Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions bulletlab/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -54,6 +55,7 @@
"Robot",
"Joint",
"Link",
"robot_contract",
"TelemetryManager",
"DataLogger",
"LivePlot",
Expand Down
3 changes: 2 additions & 1 deletion bulletlab/robot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
60 changes: 60 additions & 0 deletions bulletlab/robot/contract.py
Original file line number Diff line number Diff line change
@@ -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
],
}
49 changes: 49 additions & 0 deletions examples/10_headless_robot_contract.py
Original file line number Diff line number Diff line change
@@ -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()
28 changes: 28 additions & 0 deletions tests/test_robot_contract.py
Original file line number Diff line number Diff line change
@@ -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)