From 7b0bd3351c05a4ba8186c111759bb577c6b341c0 Mon Sep 17 00:00:00 2001 From: OmniLink <234818255+omnilink-tech@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:03:34 +0200 Subject: [PATCH] feat(robot): add headless JSON contract export --- README.md | 1 + bulletlab/__init__.py | 2 + bulletlab/robot/__init__.py | 3 +- bulletlab/robot/contract.py | 60 ++++++++++++++++++++++++++ examples/10_headless_robot_contract.py | 49 +++++++++++++++++++++ tests/test_robot_contract.py | 28 ++++++++++++ 6 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 bulletlab/robot/contract.py create mode 100644 examples/10_headless_robot_contract.py create mode 100644 tests/test_robot_contract.py diff --git a/README.md b/README.md index 6f514a9..758b05b 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,7 @@ robot.apply_action(action) # → updates joints | `examples/07_arsenal_loading.py` | Direct loading of models from BulletLab Arsenal | | `examples/08_loading_humanoid.py` | Loading complex humanoid robotics models | | `examples/09_quick_launch.py` | One-liner instant model inspection and deployment | +| `examples/10_headless_robot_contract.py` | Export any URDF's action, observation, joint, and link contract as JSON | Run any example: ```bash diff --git a/bulletlab/__init__.py b/bulletlab/__init__.py index 1695dd8..129da2c 100644 --- a/bulletlab/__init__.py +++ b/bulletlab/__init__.py @@ -29,6 +29,7 @@ from bulletlab.robot.robot import Robot from bulletlab.robot.joint import Joint from bulletlab.robot.link import Link +from bulletlab.robot.contract import robot_contract from bulletlab.telemetry.manager import TelemetryManager from bulletlab.logging.logger import DataLogger from bulletlab.plotting.live_plot import LivePlot @@ -54,6 +55,7 @@ "Robot", "Joint", "Link", + "robot_contract", "TelemetryManager", "DataLogger", "LivePlot", diff --git a/bulletlab/robot/__init__.py b/bulletlab/robot/__init__.py index b24b9b1..9765736 100644 --- a/bulletlab/robot/__init__.py +++ b/bulletlab/robot/__init__.py @@ -8,5 +8,6 @@ from bulletlab.robot.robot import Robot from bulletlab.robot.joint import Joint, JointType from bulletlab.robot.link import Link +from bulletlab.robot.contract import robot_contract -__all__ = ["Robot", "Joint", "JointType", "Link"] +__all__ = ["Robot", "Joint", "JointType", "Link", "robot_contract"] diff --git a/bulletlab/robot/contract.py b/bulletlab/robot/contract.py new file mode 100644 index 0000000..bc8b554 --- /dev/null +++ b/bulletlab/robot/contract.py @@ -0,0 +1,60 @@ +"""Machine-readable robot descriptions built from BulletLab's named API.""" + +from __future__ import annotations + +from typing import Any + +from bulletlab.robot.robot import Robot + + +def robot_contract(robot: Robot) -> dict[str, Any]: + """Return a JSON-serializable description of a loaded robot. + + The action order matches :meth:`Robot.apply_action`, while ``state_size`` + matches :meth:`Robot.get_state`. This makes the result useful for headless + smoke checks and for tools that need to inspect an unfamiliar URDF without + relying on PyBullet's integer identifiers. + """ + joints = sorted(robot.joints.values(), key=lambda joint: joint.index) + links = sorted(robot.links.values(), key=lambda link: link.index) + controllable = robot.controllable_joints + + return { + "schema_version": 1, + "name": robot.name, + "action": { + "size": robot.num_controllable_joints, + "joint_order": [joint.name for joint in controllable], + }, + "observation": { + "state_size": int(robot.get_state().size), + "layout": [ + "base_position[3]", + "base_orientation_xyzw[4]", + "base_linear_velocity[3]", + "base_angular_velocity[3]", + "joint_positions[action.size]", + "joint_velocities[action.size]", + ], + }, + "joints": [ + { + "name": joint.name, + "index": joint.index, + "type": getattr(joint.joint_type, "name", str(joint.joint_type)), + "controllable": not joint.is_fixed, + "limits": list(joint.limits), + "max_force": joint.max_force, + "max_velocity": joint.max_velocity, + } + for joint in joints + ], + "links": [ + { + "name": link.name, + "index": link.index, + "mass": link.mass, + } + for link in links + ], + } diff --git a/examples/10_headless_robot_contract.py b/examples/10_headless_robot_contract.py new file mode 100644 index 0000000..be8a8fd --- /dev/null +++ b/examples/10_headless_robot_contract.py @@ -0,0 +1,49 @@ +""" +Example 10: Headless Robot Contract +=================================== +Load any URDF without a display and print its action, observation, joint, and +link contract as JSON. + +Usage:: + + python examples/10_headless_robot_contract.py + python examples/10_headless_robot_contract.py r2d2.urdf + python examples/10_headless_robot_contract.py kuka_iiwa/model.urdf --output robot.json +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from bulletlab import Robot, Simulation, robot_contract +from bulletlab.utils.urdf_utils import find_urdf + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("urdf", nargs="?", default="kuka_iiwa/model.urdf") + parser.add_argument("--output", type=Path, help="write JSON to this path instead of stdout") + args = parser.parse_args() + + sim = Simulation(mode="direct") + sim.start() + try: + urdf_path = find_urdf(args.urdf) + robot = Robot.load(str(urdf_path), sim=sim, fixed_base=True) + payload = json.dumps(robot_contract(robot), indent=2) + if args.output is None: + print(payload) + else: + args.output.write_text(f"{payload}\n", encoding="utf-8") + print(f"Wrote robot contract to {args.output}") + finally: + sim.stop() + + +if __name__ == "__main__": + main() diff --git a/tests/test_robot_contract.py b/tests/test_robot_contract.py new file mode 100644 index 0000000..0b642ab --- /dev/null +++ b/tests/test_robot_contract.py @@ -0,0 +1,28 @@ +"""Tests for the machine-readable robot contract.""" + +import json + +from bulletlab import robot_contract + + +def test_robot_contract_matches_action_and_state_interfaces(kuka_robot): + contract = robot_contract(kuka_robot) + + assert contract["schema_version"] == 1 + assert contract["name"] == "TestKuka" + assert contract["action"] == { + "size": kuka_robot.num_controllable_joints, + "joint_order": [joint.name for joint in kuka_robot.controllable_joints], + } + assert contract["observation"]["state_size"] == kuka_robot.get_state().size + assert [joint["index"] for joint in contract["joints"]] == sorted( + joint.index for joint in kuka_robot.joints.values() + ) + assert [link["index"] for link in contract["links"]] == sorted( + link.index for link in kuka_robot.links.values() + ) + controllable_count = sum(joint["controllable"] for joint in contract["joints"]) + assert controllable_count == contract["action"]["size"] + assert all(len(joint["limits"]) == 2 for joint in contract["joints"]) + + json.dumps(contract, allow_nan=False)