react-manip 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. assets/materials/realistic/common/ceramic.png +0 -0
  2. assets/materials/realistic/common/cream-plaster.png +0 -0
  3. assets/materials/realistic/common/gray-felt.png +0 -0
  4. assets/materials/realistic/common/gray-plaster.png +0 -0
  5. assets/materials/realistic/common/gray-woodgrain.png +0 -0
  6. assets/materials/realistic/common/light-gray-floor-tile.png +0 -0
  7. assets/materials/realistic/common/light-gray-plaster.png +0 -0
  8. assets/materials/realistic/common/metal.png +0 -0
  9. assets/materials/realistic/common/oak-wood.png +0 -0
  10. assets/materials/realistic/common/steel-brushed.png +0 -0
  11. assets/materials/realistic/common/steel-scratched.png +0 -0
  12. assets/materials/realistic/common/varnished-wood-panels.png +0 -0
  13. assets/materials/realistic/common/walnut-wood.png +0 -0
  14. assets/materials/realistic/common/white-bricks.png +0 -0
  15. assets/materials/realistic/common/wood-tiles.png +0 -0
  16. assets/materials/realistic/tiles074/Tiles074_2K_Color.png +0 -0
  17. assets/materials/realistic/woodfloor049/WoodFloor049_2K_Color.png +0 -0
  18. configs/box_insertion/profile_default.json +204 -0
  19. react_manip/__init__.py +12 -0
  20. react_manip/asset_cli.py +18 -0
  21. react_manip/builtin.py +168 -0
  22. react_manip/cameras.py +301 -0
  23. react_manip/category_objects.py +248 -0
  24. react_manip/core/__init__.py +60 -0
  25. react_manip/core/aggregate.py +59 -0
  26. react_manip/core/compatibility.py +64 -0
  27. react_manip/core/contracts.py +42 -0
  28. react_manip/core/geometry.py +269 -0
  29. react_manip/core/language.py +116 -0
  30. react_manip/core/metrics.py +196 -0
  31. react_manip/core/observation.py +59 -0
  32. react_manip/core/protocol.py +91 -0
  33. react_manip/core/registry.py +94 -0
  34. react_manip/core/runner.py +498 -0
  35. react_manip/core/scenarios.py +110 -0
  36. react_manip/core/specs.py +203 -0
  37. react_manip/core/timeouts.py +44 -0
  38. react_manip/core/visual_randomization.py +261 -0
  39. react_manip/evaluation/__init__.py +4 -0
  40. react_manip/evaluation/metrics.py +17 -0
  41. react_manip/evaluation/protocols.py +326 -0
  42. react_manip/evaluation/viewer.py +240 -0
  43. react_manip/instances/__init__.py +6 -0
  44. react_manip/instances/candidate_bank.py +109 -0
  45. react_manip/instances/public.py +95 -0
  46. react_manip/instances/records.py +345 -0
  47. react_manip/instances/runtime.py +306 -0
  48. react_manip/instances/suite.py +231 -0
  49. react_manip/instances/trajectory.py +59 -0
  50. react_manip/instances/variation.py +118 -0
  51. react_manip/legacy.py +318 -0
  52. react_manip/policies/__init__.py +5 -0
  53. react_manip/policies/base.py +79 -0
  54. react_manip/rendering.py +58 -0
  55. react_manip/robot_registry.py +31 -0
  56. react_manip/robots/__init__.py +33 -0
  57. react_manip/robots/adapters.py +135 -0
  58. react_manip/robots/agilex_adapter.py +35 -0
  59. react_manip/robots/agilex_description.py +74 -0
  60. react_manip/robots/agilex_robosuite.py +246 -0
  61. react_manip/robots/arm_actions.py +17 -0
  62. react_manip/robots/base.py +119 -0
  63. react_manip/robots/catalog.py +18 -0
  64. react_manip/robots/legacy_agilex_robosuite.py +169 -0
  65. react_manip/robots/mobile_base.py +33 -0
  66. react_manip/robots/mounts.py +80 -0
  67. react_manip/robots/resolution.py +103 -0
  68. react_manip/robots/sawyer_robosuite.py +147 -0
  69. react_manip/robots/sensors.py +137 -0
  70. react_manip/utils/__init__.py +1 -0
  71. react_manip/visual_task.py +50 -0
  72. react_manip-0.1.0.dist-info/METADATA +446 -0
  73. react_manip-0.1.0.dist-info/RECORD +99 -0
  74. react_manip-0.1.0.dist-info/WHEEL +5 -0
  75. react_manip-0.1.0.dist-info/entry_points.txt +2 -0
  76. react_manip-0.1.0.dist-info/licenses/LICENSE +131 -0
  77. react_manip-0.1.0.dist-info/top_level.txt +5 -0
  78. scripts/add_phone_lean_to_bank.py +242 -0
  79. scripts/agilex_wheeled_builder.py +133 -0
  80. scripts/audit_task_solvability.py +150 -0
  81. scripts/build_agilex_assets.py +317 -0
  82. scripts/build_legacy_agilex_assets.py +244 -0
  83. scripts/evaluate_all_tasks.py +309 -0
  84. scripts/evaluate_policy.py +281 -0
  85. scripts/piperx_assets.lock.json +181 -0
  86. scripts/pygame_demo.py +360 -0
  87. scripts/pygame_task_debug.py +242 -0
  88. scripts/regenerate_instance_bank.py +192 -0
  89. scripts/remove_task_from_bank.py +103 -0
  90. scripts/select_task_instances.py +64 -0
  91. scripts/verify_curling_reachability.py +206 -0
  92. scripts/verify_piperx_tasks.py +81 -0
  93. tasks/box_insertion/__init__.py +3 -0
  94. tasks/box_insertion/assets/scene.xml +50 -0
  95. tasks/box_insertion/env.py +642 -0
  96. tasks/box_insertion/evaluation.json +16 -0
  97. tasks/box_insertion/instance_contract.json +321 -0
  98. tasks/box_insertion/scoring.py +23 -0
  99. tasks/box_insertion/task_spec.json +333 -0
@@ -0,0 +1,135 @@
1
+ """Built-in and custom robot embodiment adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Mapping
6
+
7
+ from .base import ModelBackedAdapter, RobotSpec
8
+
9
+
10
+ class RobosuiteRobotAdapter:
11
+ """Metadata adapter for a robot already supported by robosuite."""
12
+
13
+ def __init__(self, robot_name: str):
14
+ self.robot_name = robot_name
15
+ bimanual = robot_name in {"Baxter", "GR1ArmsOnly"}
16
+ self.spec = RobotSpec(
17
+ robot_id=robot_name.lower(),
18
+ arm_names=("left", "right") if bimanual else ("right",),
19
+ gripper_type="robosuite",
20
+ metadata={"robosuite_name": robot_name},
21
+ )
22
+
23
+ def validate_assets(self) -> None:
24
+ # The robosuite installation performs the authoritative asset check.
25
+ return None
26
+
27
+ def make(self, **kwargs: Any) -> str:
28
+ del kwargs
29
+ return self.robot_name
30
+
31
+ def load_mujoco_model(self) -> Any:
32
+ raise NotImplementedError("robosuite robots are loaded through suite.make")
33
+
34
+
35
+ class PiperXAdapter(ModelBackedAdapter):
36
+ """PiperX single-arm adapter.
37
+
38
+ ``spec`` is configurable because Piper/PiperX hardware revisions and
39
+ grippers may differ. No default joint list is guessed here; the adapter
40
+ must be constructed with the exact MJCF and calibrated metadata.
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ model_path: str | None = None,
46
+ *,
47
+ arm_dof: int | None = None,
48
+ action_dim: int | None = None,
49
+ eef_site: str = "piperx_gripper_site",
50
+ gripper_type: str = "parallel",
51
+ metadata: Mapping[str, Any] | None = None,
52
+ ):
53
+ super().__init__(
54
+ RobotSpec(
55
+ robot_id="piperx",
56
+ arm_names=("right",),
57
+ arm_dof=() if arm_dof is None else (int(arm_dof),),
58
+ gripper_type=gripper_type,
59
+ action_dim=action_dim,
60
+ eef_sites=(eef_site,),
61
+ model_path=model_path,
62
+ capabilities=frozenset({"single_arm", "parallel_gripper", "custom_mjcf"}),
63
+ metadata=dict(metadata or {}),
64
+ )
65
+ )
66
+
67
+
68
+ class AlohaAdapter(ModelBackedAdapter):
69
+ """Adapter for a split, fixed-base bimanual ALOHA embodiment.
70
+
71
+ The two arm model paths may point to one combined MJCF or two independent
72
+ arm MJCF files. A later loader can choose the representation without
73
+ changing task or policy APIs.
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ model_path: str | None = None,
79
+ *,
80
+ left_model_path: str | None = None,
81
+ right_model_path: str | None = None,
82
+ arm_dof: int | None = None,
83
+ gripper_type: str = "parallel",
84
+ eef_sites: tuple[str, str] = ("aloha_left_gripper_site", "aloha_right_gripper_site"),
85
+ metadata: Mapping[str, Any] | None = None,
86
+ ):
87
+ paths = {"left_model_path": left_model_path, "right_model_path": right_model_path}
88
+ super().__init__(
89
+ RobotSpec(
90
+ robot_id="aloha",
91
+ arm_names=("left", "right"),
92
+ arm_dof=() if arm_dof is None else (int(arm_dof), int(arm_dof)),
93
+ gripper_type=gripper_type,
94
+ eef_sites=eef_sites,
95
+ model_path=model_path,
96
+ capabilities=frozenset({"bimanual", "parallel_gripper", "custom_mjcf"}),
97
+ metadata={**paths, **dict(metadata or {})},
98
+ )
99
+ )
100
+
101
+ def validate_assets(self) -> None:
102
+ if self.model_path is not None:
103
+ return super().validate_assets()
104
+ missing = []
105
+ for key in ("left_model_path", "right_model_path"):
106
+ value = self.spec.metadata.get(key)
107
+ if not value:
108
+ missing.append(key)
109
+ else:
110
+ from pathlib import Path
111
+
112
+ if not Path(str(value)).expanduser().is_file():
113
+ raise FileNotFoundError(f"robot model does not exist: {value}")
114
+ if missing:
115
+ raise FileNotFoundError(
116
+ "aloha requires model_path or both left_model_path/right_model_path; missing "
117
+ + ", ".join(missing)
118
+ )
119
+
120
+ def load_mujoco_model(self) -> Any:
121
+ """Load one combined model or both split arm models.
122
+
123
+ A split return value is a mapping so a higher-level bimanual scene
124
+ builder can place and namespace the two arms explicitly.
125
+ """
126
+
127
+ if self.model_path is not None:
128
+ return super().load_mujoco_model()
129
+ self.validate_assets()
130
+ import mujoco
131
+
132
+ return {
133
+ "left": mujoco.MjModel.from_xml_path(str(self.spec.metadata["left_model_path"])),
134
+ "right": mujoco.MjModel.from_xml_path(str(self.spec.metadata["right_model_path"])),
135
+ }
@@ -0,0 +1,35 @@
1
+ """Metadata facade for the generated official PiPER-X and custom dual assemblies."""
2
+ from .base import RobotSpec
3
+
4
+
5
+ class AgileXAdapter:
6
+ def __init__(self, robot_id):
7
+ if robot_id not in ("piperx", "dual_piperx", "mobile_dual_piperx", "legacy_piperx", "aloha", "aloha_mobile"):
8
+ raise ValueError(robot_id)
9
+ arms = ("right", "left") if robot_id not in ("piperx", "legacy_piperx") else ("right",)
10
+ self.spec = RobotSpec(
11
+ robot_id=robot_id, arm_names=arms, arm_dof=(6,)*len(arms),
12
+ gripper_type="parallel", action_dim=7*len(arms), control_modes=("osc_pose",),
13
+ eef_sites=tuple(f"gripper0_{arm}_grip_site" for arm in arms),
14
+ capabilities=frozenset({"custom_mjcf", "parallel_gripper", "bimanual" if len(arms)==2 else "single_arm"}),
15
+ metadata={"manufacturer": "AgileX", "arm_model": "legacy follower" if robot_id in ("legacy_piperx", "aloha", "aloha_mobile") else "PiPER-X", "asset_version": "legacy-agilex-family.v1" if robot_id in ("legacy_piperx", "aloha", "aloha_mobile") else "piperx-family.v1", "assembly": "custom" if len(arms) == 2 else "single", "mobile_base": robot_id in ("mobile_dual_piperx", "aloha_mobile"), "base_command_dim": 2 if robot_id in ("mobile_dual_piperx", "aloha_mobile") else 0, "calibrated": False,
16
+ "action_layout": "right_pose,left_pose,right_gripper,left_gripper" if len(arms)==2 else "right_pose,right_gripper"},
17
+ )
18
+
19
+ def validate_assets(self):
20
+ from .agilex_robosuite import validate_bundle
21
+ if self.spec.robot_id in ("legacy_piperx", "aloha", "aloha_mobile"):
22
+ from .legacy_agilex_robosuite import validate_bundle
23
+ validate_bundle()
24
+
25
+ def make(self, **kwargs):
26
+ from .agilex_robosuite import resolve_robot
27
+ return resolve_robot(self.spec.robot_id)
28
+
29
+ def load_mujoco_model(self):
30
+ from robosuite.models.robots import create_robot
31
+ from robosuite.models.grippers import gripper_factory
32
+ robot = create_robot(self.make())
33
+ for arm in robot.arms:
34
+ robot.add_gripper(gripper_factory(robot.default_gripper[arm], idn=f"0_{arm}"), robot.eef_name[arm])
35
+ return robot.get_model()
@@ -0,0 +1,74 @@
1
+ """Legacy extractor for the archived mobile_aloha_sim follower model.
2
+
3
+ This prepares URDF input; it does not implement a runnable task or convert DAE
4
+ meshes. Source: agilexrobotics/mobile_aloha_sim, aloha_new.urdf.
5
+ """
6
+ from copy import deepcopy
7
+ import xml.etree.ElementTree as ET
8
+
9
+
10
+ def extract_followers(source: ET.Element, embodiment: str) -> ET.Element:
11
+ """Keep front follower subtrees, excluding leaders, chassis and cameras.
12
+
13
+ Single PiPER is rooted at its base origin. Dual-arm mounting transforms are
14
+ preserved relative to the upstream body_Link frame (not the world frame).
15
+ This legacy model is not the official PiPER-X asset.
16
+ """
17
+ if embodiment in ("piperx", "dual_piperx", "mobile_dual_piperx"):
18
+ raise ValueError("PiPER-X requires scripts/build_agilex_assets.py and official agx_arm_urdf assets")
19
+ selections = {"piper": ("fr",), "agilex_aloha": ("fl", "fr")}
20
+ if embodiment not in selections:
21
+ raise ValueError("embodiment must be piper or agilex_aloha")
22
+ links = {link.attrib["name"]: link for link in source.findall("link")}
23
+ joints = source.findall("joint")
24
+ output = ET.Element("robot", name=embodiment + "_followers")
25
+ ET.SubElement(output, "link", name="world")
26
+ for material in source.findall("material"):
27
+ output.append(deepcopy(material))
28
+ for prefix in selections[embodiment]:
29
+ base = prefix + "_base_link"
30
+ if base not in links:
31
+ raise ValueError(f"missing follower base: {base}")
32
+ selected = {base}
33
+ chosen = []
34
+ pending = list(joints)
35
+ while True:
36
+ additions = [j for j in pending if j.find("parent").get("link") in selected]
37
+ if not additions:
38
+ break
39
+ for joint in additions:
40
+ child = joint.find("child").get("link")
41
+ if child in selected or child not in links:
42
+ raise ValueError(f"invalid follower tree at {child}")
43
+ selected.add(child)
44
+ chosen.append(joint)
45
+ pending.remove(joint)
46
+ revolute = [j for j in chosen if j.get("type") == "revolute"]
47
+ fingers = [j for j in chosen if j.get("type") == "prismatic"]
48
+ if len(revolute) != 6 or len(fingers) != 2:
49
+ raise ValueError(f"{prefix}: expected six arm and two finger joints")
50
+ for joint in revolute + fingers:
51
+ limit = joint.find("limit")
52
+ if limit is None or not {"lower", "upper", "effort", "velocity"} <= set(limit.keys()):
53
+ raise ValueError(f"missing limits: {joint.get('name')}")
54
+ child = links[joint.find("child").get("link")]
55
+ if child.find("inertial") is None or child.find("collision") is None:
56
+ raise ValueError(f"missing dynamics/collision: {child.get('name')}")
57
+ mounts = [j for j in joints if j.find("child").get("link") == base]
58
+ if len(mounts) != 1 or mounts[0].get("type") != "fixed":
59
+ raise ValueError(f"expected fixed mount for {base}")
60
+ mount = deepcopy(mounts[0])
61
+ if mount.find("parent").get("link") != "body_Link":
62
+ raise ValueError("unexpected mounting frame; inspect upstream model")
63
+ mount.find("parent").set("link", "world")
64
+ if embodiment == "piper":
65
+ origin = mount.find("origin")
66
+ if origin is None:
67
+ origin = ET.SubElement(mount, "origin")
68
+ origin.set("xyz", "0 0 0")
69
+ origin.set("rpy", "0 0 0")
70
+ for name in sorted(selected):
71
+ output.append(deepcopy(links[name]))
72
+ output.append(mount)
73
+ output.extend(deepcopy(chosen))
74
+ return output
@@ -0,0 +1,246 @@
1
+ """Lazy robosuite integration of pinned official AgileX follower models."""
2
+ from pathlib import Path
3
+ import os
4
+ import numpy as np
5
+
6
+
7
+ def asset_dir():
8
+ override = os.environ.get("AGILEX_ASSET_DIR")
9
+ if override:
10
+ return Path(override)
11
+ root = Path(__file__).resolve().parents[2]
12
+ if (root / "pyproject.toml").is_file() and (root / "scripts/build_agilex_assets.py").is_file():
13
+ return root / "assets/robots/agilex"
14
+ return Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "react-manip/robots/agilex"
15
+
16
+
17
+ def validate_bundle():
18
+ """Reject stale or modified generated bundles, including old grasp aids."""
19
+ import hashlib
20
+ import json
21
+ import xml.etree.ElementTree as ET
22
+ directory = asset_dir()
23
+ manifest = directory / "provenance.json"
24
+ if not manifest.exists():
25
+ raise FileNotFoundError("Run react-manip-assets prepare to build PiPER-X assets")
26
+ data = json.loads(manifest.read_text())
27
+ if data.get("schema") != "piperx-family.v1":
28
+ raise ValueError("Legacy AgileX assets: rebuild with scripts/build_agilex_assets.py")
29
+ lock = Path(__file__).resolve().parents[2] / "scripts/piperx_assets.lock.json"
30
+ if data.get("source_lock_sha256") != hashlib.sha256(lock.read_bytes()).hexdigest():
31
+ raise ValueError("PiPER-X source lock changed; rebuild the asset bundle")
32
+ for name in ("piperx.xml", "dual_piperx.xml", "mobile_dual_piperx.xml", "gripper.xml"):
33
+ if name not in data.get("generated_sha256", {}):
34
+ raise ValueError(f"Incomplete PiPER-X manifest: {name}")
35
+ for name, expected in data["generated_sha256"].items():
36
+ path = directory / name
37
+ if not path.is_file() or hashlib.sha256(path.read_bytes()).hexdigest() != expected:
38
+ raise ValueError(f"Stale or modified PiPER-X asset {path}; rebuild the bundle")
39
+ gripper = ET.parse(directory / "gripper.xml")
40
+ for geom in gripper.iter("geom"):
41
+ if geom.get("name", "").endswith(("_pad", "_flat_pad")):
42
+ raise ValueError("Unexpected legacy grasp-helper geometry")
43
+ if "_collision" in geom.get("name", "") and (geom.get("contype", "1") == "0" or geom.get("conaffinity", "1") == "0"):
44
+ raise ValueError("Disabled physical gripper surface")
45
+
46
+
47
+ def controller_config(arms=("right",)):
48
+ return {"type": "BASIC", "body_parts": {arm: {
49
+ "type": "OSC_POSE", "input_max": 1, "input_min": -1,
50
+ "output_max": [0.015]*3 + [0.08]*3, "output_min": [-0.015]*3 + [-0.08]*3,
51
+ "kp": 60, "damping_ratio": 1.5, "impedance_mode": "fixed",
52
+ "input_type": "delta", "input_ref_frame": "world", "uncouple_pos_ori": True,
53
+ "goal_update_mode": "achieved", "interpolation": None,
54
+ "gripper": {"type": "GRIP"},
55
+ } for arm in arms}}
56
+
57
+
58
+ def register_agilex():
59
+ """Register classes without importing robosuite during registry discovery."""
60
+ validate_bundle()
61
+ from robosuite.models.robots.robot_model import REGISTERED_ROBOTS
62
+ if "PiperX" in REGISTERED_ROBOTS:
63
+ return
64
+ from robosuite.models.robots.manipulators.manipulator_model import ManipulatorModel
65
+ from robosuite.models.grippers.gripper_model import GripperModel
66
+ from robosuite.models.grippers import GRIPPER_MAPPING
67
+ from robosuite.robots import ROBOT_CLASS_MAPPING, FixedBaseRobot
68
+
69
+ class PiperXGripper(GripperModel):
70
+ def __init__(self, idn=0):
71
+ super().__init__(str(asset_dir() / "gripper.xml"), idn=idn)
72
+ # Simulation tuning, independent of the checksum-locked geometry.
73
+ # The generated 1 N s/m damping lets a full-stroke position command
74
+ # overshoot the slide stops and interlock the opposing finger meshes.
75
+ # Dampen the physical joints, without clipping positions or removing
76
+ # contact surfaces. Tasks can still apply their validated settings.
77
+ for joint in self.worldbody.iter("joint"):
78
+ if joint.get("type") == "slide":
79
+ joint.set("damping", "8")
80
+
81
+ @property
82
+ def dof(self):
83
+ return 1
84
+
85
+ @property
86
+ def init_qpos(self):
87
+ return np.array([0.04, -0.04])
88
+
89
+ def format_action(self, action):
90
+ # +1 closes both opposing slides; -1 opens. The keyboard demo
91
+ # sends a persistent toggle state, so map that state directly to
92
+ # the normalized gripper target instead of accumulating only a
93
+ # small increment on every simulation step. The downstream
94
+ # position actuators still provide the physical smoothing.
95
+ self.current_action = np.clip(np.asarray(action, dtype=float), -1, 1)
96
+ return np.array([-self.current_action[0], self.current_action[0]])
97
+
98
+ @property
99
+ def _important_geoms(self):
100
+ # Every convex part participates in robosuite contact detection.
101
+ import json
102
+ names = json.loads((asset_dir() / "provenance.json").read_text())["gripper_collision_names"]
103
+ return {"left_finger": names["finger7"], "right_finger": names["finger8"],
104
+ "left_fingerpad": names["finger7"], "right_fingerpad": names["finger8"]}
105
+
106
+
107
+ class PiperX(ManipulatorModel):
108
+ arms = ["right"]
109
+ asset_name = "piperx.xml"
110
+ fixed_stand = dict(height=.80, half_depth=.09, half_width=.10)
111
+
112
+ def __init__(self, idn=0):
113
+ super().__init__(str(asset_dir() / self.asset_name), idn=idn)
114
+ if self.fixed_stand is not None:
115
+ from .mounts import install_fixed_stand
116
+ install_fixed_stand(self, **self.fixed_stand)
117
+
118
+ def add_gripper(self, gripper, arm_name=None):
119
+ super().add_gripper(gripper, arm_name)
120
+ # Install cameras on the embodiment before any task merges XML.
121
+ # Covers tasks that use robosuite's own ManipulationTask as well.
122
+ from ..cameras import ensure_robot_cameras
123
+ ensure_robot_cameras(self)
124
+ self.cameras = self.get_element_names(self.worldbody, 'camera')
125
+
126
+ @property
127
+ def default_base(self):
128
+ return "NullMount"
129
+
130
+ @property
131
+ def default_gripper(self):
132
+ return {arm: "PiperXGripper" for arm in self.arms}
133
+
134
+ @property
135
+ def default_controller_config(self):
136
+ return {arm: "osc_pose" for arm in self.arms}
137
+
138
+ @property
139
+ def init_qpos(self):
140
+ # Official PiPER-X joint frames differ from the old extracted arm.
141
+ # Start with the fingers pointing toward the tabletop, within the
142
+ # official limits and with margin to the wrist limit.
143
+ return np.tile([0, 1.2, -1.1, 1.2, 0, 0], len(self.arms))
144
+
145
+ @property
146
+ def base_xpos_offset(self):
147
+ return {"empty": (-0.45, 0, 0), "bins": (-0.45, 0, 0),
148
+ "table": lambda length: (-0.1-length/2, 0, 0)}
149
+
150
+ @property
151
+ def top_offset(self):
152
+ return np.array([0, 0, 0.7])
153
+
154
+ @property
155
+ def _horizontal_radius(self):
156
+ return 0.7
157
+
158
+ @property
159
+ def arm_type(self):
160
+ return "single" if len(self.arms) == 1 else "bimanual"
161
+
162
+ @property
163
+ def _eef_name(self):
164
+ return {arm: arm + "_hand" for arm in self.arms}
165
+
166
+ class DualPiperX(PiperX):
167
+ arms = ["right", "left"]
168
+ asset_name = "dual_piperx.xml"
169
+ # Imported dual-arm bases are at x=.23875, y=+/- .30.
170
+ # Support their mounting plane, not the empty asset origin.
171
+ fixed_stand = dict(height=.65, half_depth=.09, half_width=.375, center_xy=(.23875, 0.))
172
+
173
+ class MobileDualPiperX(DualPiperX):
174
+ asset_name = "mobile_dual_piperx.xml"
175
+ fixed_stand = None
176
+
177
+ # FixedBaseRobot is used only for the two arm controllers. The XML
178
+ # still contains the physical free root and ten wheel/caster joints;
179
+ # they must not enter robosuite's scalar arm-qpos reset/indexing.
180
+ @property
181
+ def joints(self):
182
+ return [name for name in super().joints if "mobile_" not in name]
183
+
184
+ @property
185
+ def actuators(self):
186
+ return [name for name in super().actuators if "mobile_" not in name]
187
+
188
+ GRIPPER_MAPPING["PiperXGripper"] = PiperXGripper
189
+ for cls in (PiperX, DualPiperX, MobileDualPiperX):
190
+ ROBOT_CLASS_MAPPING[cls.__name__] = FixedBaseRobot
191
+
192
+
193
+ def resolve_robot(robot_id):
194
+ if robot_id in ('legacy_piperx', 'aloha', 'aloha_mobile'):
195
+ from .legacy_agilex_robosuite import resolve_robot as resolve_legacy
196
+ return resolve_legacy(robot_id)
197
+ if robot_id not in ("piperx", "dual_piperx", "mobile_dual_piperx"):
198
+ raise ValueError(f"not an AgileX robot: {robot_id}")
199
+ register_agilex()
200
+ if robot_id == "mobile_dual_piperx":
201
+ import xml.etree.ElementTree as ET
202
+ if ET.parse(asset_dir() / "mobile_dual_piperx.xml").find(".//freejoint[@name='mobile_free']") is None:
203
+ raise ValueError("Stale planar ALOHA assets: run python scripts/build_agilex_assets.py")
204
+ return "PiperX" if robot_id == "piperx" else ("MobileDualPiperX" if robot_id == "mobile_dual_piperx" else "DualPiperX")
205
+
206
+
207
+ def validate_compiled_grippers(env):
208
+ """Task composition may tune materials, but cannot replace contact shapes."""
209
+ import json
210
+ raw = getattr(env, 'env', env)
211
+ robots = getattr(raw, 'robots', ())
212
+ if not any(getattr(robot, 'name', '') in ('PiperX','DualPiperX','MobileDualPiperX','AgileXPiper','AgileXAloha','AgileXMobileAloha') for robot in robots):
213
+ return
214
+ model = raw.sim.model
215
+ for robot in robots:
216
+ if getattr(robot, 'name', '') not in ('PiperX','DualPiperX','MobileDualPiperX','AgileXPiper','AgileXAloha','AgileXMobileAloha'):
217
+ continue
218
+ legacy = getattr(robot, 'name', '').startswith('AgileX')
219
+ directory = asset_dir()
220
+ if legacy:
221
+ from .legacy_agilex_robosuite import asset_dir as legacy_asset_dir
222
+ directory = legacy_asset_dir()
223
+ manifest = json.loads((directory / 'provenance.json').read_text())
224
+ for gripper in robot.gripper.values():
225
+ if type(gripper).__name__ != ('AgileXPiperGripper' if legacy else 'PiperXGripper'):
226
+ raise ValueError('PiPER-X must use the central PiperXGripper asset')
227
+ prefix = gripper.naming_prefix
228
+ expected = {prefix + name for parts in manifest['gripper_collision_names'].values() for name in parts}
229
+ finger_bodies = {model.body_name2id(prefix + name) for name in ('finger7', 'finger8')}
230
+ for geom_id in range(model.ngeom):
231
+ if model.geom_bodyid[geom_id] in finger_bodies and (model.geom_contype[geom_id] or model.geom_conaffinity[geom_id]):
232
+ if model.geom_id2name(geom_id) not in expected:
233
+ raise ValueError('Task added an unapproved PiPER-X finger collision shape')
234
+ for parts in manifest['gripper_collision_names'].values():
235
+ for name in parts:
236
+ c = model.geom_name2id(prefix + name)
237
+ v = model.geom_name2id(prefix + name.replace('_collision','_visual'))
238
+ if (model.geom_type[c] != model.geom_type[v]
239
+ or not np.allclose(model.geom_size[c], model.geom_size[v], atol=1e-10)
240
+ or model.geom_dataid[c] != model.geom_dataid[v]
241
+ or not np.allclose(model.geom_pos[c],model.geom_pos[v],atol=1e-10)
242
+ or not np.allclose(model.geom_quat[c],model.geom_quat[v],atol=1e-10)
243
+ or not model.geom_contype[c] or not model.geom_conaffinity[c]
244
+ or model.geom_contype[v] or model.geom_conaffinity[v]
245
+ or model.geom_margin[c] != 0):
246
+ raise ValueError(f'Task changed PiPER-X contact surface: {prefix + name}')
@@ -0,0 +1,17 @@
1
+ """Build multi-arm actions using controller-advertised slices, not 7D chunks."""
2
+ import numpy as np
3
+
4
+
5
+ def arm_action(action_dim, layout, active_arm, pose, grippers):
6
+ action = np.zeros(action_dim, dtype=np.float32)
7
+ start, end = layout[active_arm]
8
+ if end - start != 6:
9
+ raise ValueError("keyboard pose control requires six Cartesian inputs per arm")
10
+ action[start:end] = np.clip(np.asarray(pose), -1, 1)
11
+ for arm, value in grippers.items():
12
+ start, end = layout[arm + "_gripper"]
13
+ if end - start != 1:
14
+ raise ValueError("keyboard gripper control requires one input per gripper")
15
+ action[start:end] = np.clip(value, -1, 1)
16
+ return action
17
+
@@ -0,0 +1,119 @@
1
+ """Embodiment-neutral robot metadata and adapter contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any, Mapping, Protocol
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class RobotSpec:
12
+ robot_id: str
13
+ arm_names: tuple[str, ...] = ("right",)
14
+ arm_dof: tuple[int, ...] = ()
15
+ gripper_type: str | None = None
16
+ control_modes: tuple[str, ...] = ("joint_position",)
17
+ action_dim: int | None = None
18
+ eef_sites: tuple[str, ...] = ()
19
+ model_path: str | None = None
20
+ capabilities: frozenset[str] = field(default_factory=frozenset)
21
+ metadata: Mapping[str, Any] = field(default_factory=dict)
22
+
23
+ def __post_init__(self) -> None:
24
+ if not self.robot_id or any(ch.isspace() for ch in self.robot_id):
25
+ raise ValueError("robot_id must be a non-empty, whitespace-free string")
26
+ if not self.arm_names and 'task_level_proxy' not in self.capabilities:
27
+ raise ValueError("arm_names must contain at least one arm")
28
+ if self.arm_dof and len(self.arm_dof) != len(self.arm_names):
29
+ raise ValueError("arm_dof must have one entry per arm")
30
+ if self.action_dim is not None and self.action_dim < 1:
31
+ raise ValueError("action_dim must be positive")
32
+
33
+ @property
34
+ def required_arms(self) -> int:
35
+ return len(self.arm_names)
36
+
37
+ @classmethod
38
+ def from_mapping(cls, value: Mapping[str, Any]) -> "RobotSpec":
39
+ arm_names = value.get("arm_names", ("right",))
40
+ if isinstance(arm_names, str):
41
+ arm_names = (arm_names,)
42
+ control_modes = value.get("control_modes", ("joint_position",))
43
+ if isinstance(control_modes, str):
44
+ control_modes = (control_modes,)
45
+ eef_sites = value.get("eef_sites", ())
46
+ if isinstance(eef_sites, str):
47
+ eef_sites = (eef_sites,)
48
+ arm_dof = value.get("arm_dof", ())
49
+ if isinstance(arm_dof, int):
50
+ arm_dof = (arm_dof,)
51
+ return cls(
52
+ robot_id=str(value["robot_id"]),
53
+ arm_names=tuple(arm_names),
54
+ arm_dof=tuple(int(v) for v in arm_dof),
55
+ gripper_type=value.get("gripper_type"),
56
+ control_modes=tuple(control_modes),
57
+ action_dim=None if value.get("action_dim") is None else int(value["action_dim"]),
58
+ eef_sites=tuple(eef_sites),
59
+ model_path=value.get("model_path"),
60
+ capabilities=frozenset(value.get("capabilities", ())),
61
+ metadata=dict(value.get("metadata", {})),
62
+ )
63
+
64
+
65
+ class RobotAdapter(Protocol):
66
+ """Protocol implemented by a robot embodiment integration."""
67
+
68
+ spec: RobotSpec
69
+
70
+ def validate_assets(self) -> None:
71
+ ...
72
+
73
+ def make(self, **kwargs: Any) -> Any:
74
+ ...
75
+
76
+ def load_mujoco_model(self) -> Any:
77
+ ...
78
+
79
+
80
+ class ModelBackedAdapter:
81
+ """Common asset validation for custom MJCF-backed robots."""
82
+
83
+ spec: RobotSpec
84
+
85
+ def __init__(self, spec: RobotSpec):
86
+ self.spec = spec
87
+
88
+ @property
89
+ def model_path(self) -> Path | None:
90
+ return Path(self.spec.model_path).expanduser() if self.spec.model_path else None
91
+
92
+ def validate_assets(self) -> None:
93
+ path = self.model_path
94
+ if path is None:
95
+ raise FileNotFoundError(
96
+ f"{self.spec.robot_id} has no model_path; provide an MJCF asset before simulation"
97
+ )
98
+ if not path.is_file():
99
+ raise FileNotFoundError(f"robot model does not exist: {path}")
100
+
101
+ def load_mujoco_model(self) -> Any:
102
+ """Load the configured MJCF for lightweight asset/control smoke tests."""
103
+
104
+ self.validate_assets()
105
+ import mujoco # imported only when a simulator is actually requested
106
+
107
+ return mujoco.MjModel.from_xml_path(str(self.model_path))
108
+
109
+ def make(self, **kwargs: Any) -> Any:
110
+ """Return integration-specific robot object.
111
+
112
+ Subclasses should implement this once the exact robosuite/MuJoCo
113
+ model is available. Keeping the method explicit prevents a missing
114
+ asset from silently falling back to Panda.
115
+ """
116
+
117
+ del kwargs
118
+ self.validate_assets()
119
+ raise NotImplementedError(f"{self.spec.robot_id} model loader is not configured")
@@ -0,0 +1,18 @@
1
+ """Built-in ID snapshots plus live registry-based normalization.
2
+
3
+ Use registered_robot_ids() for extension-aware enumeration. Task compatibility
4
+ is separate from registration and may still narrow the available combinations.
5
+ """
6
+
7
+ ALL_ROBOT_IDS = ("panda", "sawyer", "piperx", "dual_piperx", "mobile_dual_piperx", "legacy_piperx", "aloha", "aloha_mobile")
8
+ SINGLE_ARM_ROBOT_IDS = ("panda", "sawyer", "piperx", "legacy_piperx")
9
+
10
+
11
+ def normalize_robot_id(robot_id: str) -> str:
12
+ from .resolution import registered_robot_ids
13
+ value = str(robot_id).strip().lower()
14
+ if value not in registered_robot_ids():
15
+ raise ValueError(
16
+ f"unknown robot {robot_id!r}; supported IDs: {', '.join(registered_robot_ids())}"
17
+ )
18
+ return value