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,169 @@
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
+ return Path(os.environ.get("LEGACY_AGILEX_ASSET_DIR", Path(__file__).resolve().parents[2] / "assets/robots/agilex/legacy"))
9
+
10
+
11
+ def validate_bundle():
12
+ import hashlib, json
13
+ directory = asset_dir()
14
+ manifest = directory / 'provenance.json'
15
+ if not manifest.exists():
16
+ raise FileNotFoundError('Run python scripts/build_agilex_assets.py --family legacy')
17
+ data = json.loads(manifest.read_text())
18
+ lock = Path(__file__).resolve().parents[2] / 'scripts/legacy_agilex_assets.lock.json'
19
+ if data.get('source_lock_sha256') != hashlib.sha256(lock.read_bytes()).hexdigest():
20
+ raise ValueError('Legacy asset lock mismatch; rebuild assets')
21
+ for name, expected in data['generated_sha256'].items():
22
+ if hashlib.sha256((directory / name).read_bytes()).hexdigest() != expected:
23
+ raise ValueError(f'Modified legacy asset: {name}')
24
+
25
+
26
+ def controller_config(arms=("right",)):
27
+ return {"type": "BASIC", "body_parts": {arm: {
28
+ "type": "OSC_POSE", "input_max": 1, "input_min": -1,
29
+ "output_max": [0.015]*3 + [0.08]*3, "output_min": [-0.015]*3 + [-0.08]*3,
30
+ "kp": 60, "damping_ratio": 1.5, "impedance_mode": "fixed",
31
+ "input_type": "delta", "input_ref_frame": "world", "uncouple_pos_ori": True,
32
+ "goal_update_mode": "achieved", "interpolation": None,
33
+ "gripper": {"type": "GRIP"},
34
+ } for arm in arms}}
35
+
36
+
37
+ def register_agilex():
38
+ """Register classes without importing robosuite during registry discovery."""
39
+ validate_bundle()
40
+ from robosuite.models.robots.robot_model import REGISTERED_ROBOTS
41
+ if "AgileXPiper" in REGISTERED_ROBOTS:
42
+ return
43
+ from robosuite.models.robots.manipulators.manipulator_model import ManipulatorModel
44
+ from robosuite.models.grippers.gripper_model import GripperModel
45
+ from robosuite.models.grippers import GRIPPER_MAPPING
46
+ from robosuite.robots import ROBOT_CLASS_MAPPING, FixedBaseRobot
47
+
48
+ class AgileXPiperGripper(GripperModel):
49
+ def __init__(self, idn=0):
50
+ super().__init__(str(asset_dir() / "gripper.xml"), idn=idn)
51
+
52
+ @property
53
+ def dof(self):
54
+ return 1
55
+
56
+ @property
57
+ def init_qpos(self):
58
+ return np.array([0.035, -0.035])
59
+
60
+ def format_action(self, action):
61
+ # +1 closes both opposing slides; -1 opens. The keyboard demo
62
+ # sends a persistent toggle state, so map that state directly to
63
+ # the normalized gripper target instead of accumulating only a
64
+ # small increment on every simulation step. The downstream
65
+ # position actuators still provide the physical smoothing.
66
+ self.current_action = np.clip(np.asarray(action, dtype=float), -1, 1)
67
+ return np.array([-self.current_action[0], self.current_action[0]])
68
+
69
+ @property
70
+ def _important_geoms(self):
71
+ import json
72
+ names = json.loads((asset_dir() / 'provenance.json').read_text())['gripper_collision_names']
73
+ return {'left_finger': names['finger7'], 'right_finger': names['finger8'],
74
+ 'left_fingerpad': names['finger7'], 'right_fingerpad': names['finger8']}
75
+
76
+ class AgileXPiper(ManipulatorModel):
77
+ arms = ["right"]
78
+ asset_name = "legacy_piperx.xml"
79
+ fixed_stand = dict(height=.80, half_depth=.09, half_width=.10)
80
+
81
+ def __init__(self, idn=0):
82
+ super().__init__(str(asset_dir() / self.asset_name), idn=idn)
83
+ if self.fixed_stand is not None:
84
+ from .mounts import install_fixed_stand
85
+ install_fixed_stand(self, **self.fixed_stand)
86
+
87
+ def add_gripper(self, gripper, arm_name=None):
88
+ super().add_gripper(gripper, arm_name)
89
+ from ..cameras import ensure_robot_cameras
90
+ ensure_robot_cameras(self)
91
+ self.cameras = self.get_element_names(self.worldbody, 'camera')
92
+
93
+ @property
94
+ def default_base(self):
95
+ return "NullMount"
96
+
97
+ @property
98
+ def default_gripper(self):
99
+ return {arm: "AgileXPiperGripper" for arm in self.arms}
100
+
101
+ @property
102
+ def default_controller_config(self):
103
+ return {arm: "osc_pose" for arm in self.arms}
104
+
105
+ @property
106
+ def init_qpos(self):
107
+ # Calibrated tabletop starting posture used by the physical
108
+ # manipulation tasks. A zero posture places the wrist high and
109
+ # causes the controller's first goal update to sweep through
110
+ # nearby objects.
111
+ return np.tile([0, 0.8, -1.1, 0, 0.3, 0], len(self.arms))
112
+
113
+ @property
114
+ def base_xpos_offset(self):
115
+ return {"empty": (-0.45, 0, 0), "bins": (-0.45, 0, 0),
116
+ "table": lambda length: (-0.1-length/2, 0, 0)}
117
+
118
+ @property
119
+ def top_offset(self):
120
+ return np.array([0, 0, 0.7])
121
+
122
+ @property
123
+ def _horizontal_radius(self):
124
+ return 0.7
125
+
126
+ @property
127
+ def arm_type(self):
128
+ return "single" if len(self.arms) == 1 else "bimanual"
129
+
130
+ @property
131
+ def _eef_name(self):
132
+ return {arm: arm + "_hand" for arm in self.arms}
133
+
134
+ class AgileXAloha(AgileXPiper):
135
+ arms = ["right", "left"]
136
+ asset_name = "aloha.xml"
137
+ # Imported dual-arm bases are at x=.23875, y=+/- .30.
138
+ # Support their mounting plane, not the empty asset origin.
139
+ fixed_stand = dict(height=.65, half_depth=.09, half_width=.375, center_xy=(.23875, 0.))
140
+
141
+ class AgileXMobileAloha(AgileXAloha):
142
+ asset_name = "aloha_mobile.xml"
143
+ fixed_stand = None
144
+
145
+ # FixedBaseRobot is used only for the two arm controllers. The XML
146
+ # still contains the physical free root and ten wheel/caster joints;
147
+ # they must not enter robosuite's scalar arm-qpos reset/indexing.
148
+ @property
149
+ def joints(self):
150
+ return [name for name in super().joints if "mobile_" not in name]
151
+
152
+ @property
153
+ def actuators(self):
154
+ return [name for name in super().actuators if "mobile_" not in name]
155
+
156
+ GRIPPER_MAPPING["AgileXPiperGripper"] = AgileXPiperGripper
157
+ for cls in (AgileXPiper, AgileXAloha, AgileXMobileAloha):
158
+ ROBOT_CLASS_MAPPING[cls.__name__] = FixedBaseRobot
159
+
160
+
161
+ def resolve_robot(robot_id):
162
+ if robot_id not in ("legacy_piperx", "aloha", "aloha_mobile"):
163
+ raise ValueError(f"not an AgileX robot: {robot_id}")
164
+ register_agilex()
165
+ if robot_id == "aloha_mobile":
166
+ import xml.etree.ElementTree as ET
167
+ if ET.parse(asset_dir() / "aloha_mobile.xml").find(".//freejoint[@name='mobile_free']") is None:
168
+ raise ValueError("Stale planar ALOHA assets: run python scripts/build_agilex_assets.py")
169
+ return "AgileXPiper" if robot_id == "legacy_piperx" else ("AgileXMobileAloha" if robot_id == "aloha_mobile" else "AgileXAloha")
@@ -0,0 +1,33 @@
1
+ """Shared wheel-drive helpers for the custom Mobile Dual PiPER-X asset."""
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+
6
+
7
+ def set_wheel_command(env, vx: float, wz: float) -> None:
8
+ """Write normalized differential-drive wheel targets into ``data.ctrl``.
9
+
10
+ This only sets actuator targets; MuJoCo integrates wheel/body dynamics on
11
+ the subsequent physical step. Tasks that expose ``set_mobile_base_command``
12
+ may keep their own wrapper, while generic frontends can use this helper.
13
+ """
14
+ if not np.all(np.isfinite([vx, wz])):
15
+ raise ValueError("mobile command must be finite")
16
+ robot = getattr(env, "robots", ())[0]
17
+ if getattr(robot, "name", "") not in ("MobileDualPiperX", "AgileXMobileAloha"):
18
+ return
19
+ model, data = env.sim.model, env.sim.data
20
+ prefix = robot.robot_model.naming_prefix
21
+ values = (float(np.clip(vx, -1, 1)), float(np.clip(wz, -1, 1)))
22
+ try:
23
+ radius_id = model.geom_name2id(prefix + "mobile_left_wheel_link_tire")
24
+ radius = float(model.geom_size[radius_id, 0])
25
+ except (ValueError, KeyError, IndexError):
26
+ return
27
+ for side, sign in (("left", -1.0), ("right", 1.0)):
28
+ try:
29
+ actuator = model.actuator_name2id(prefix + "mobile_" + side + "_wheel_motor")
30
+ except (ValueError, KeyError):
31
+ continue
32
+ target = (0.25 * values[0] + sign * 0.5 * values[1] * 0.17) / radius
33
+ data.ctrl[actuator] = float(np.clip(target, -6, 6))
@@ -0,0 +1,80 @@
1
+ """Robot-owned fixed stands. Never add these as task arena geometry."""
2
+ import xml.etree.ElementTree as ET
3
+
4
+
5
+ def use_existing_mount(robot):
6
+ """Remove an automatic floor stand for tasks with a table/car mounting point.
7
+
8
+ Call before task-specific set_base_xpos. Preserve all original arm bodies,
9
+ sites and cameras; only undo the wrapper installed by install_fixed_stand.
10
+ """
11
+ root = robot.worldbody.find('body')
12
+ mount = root.find(f"body[@name='{robot.naming_prefix}fixed_stand_mount']")
13
+ if mount is None:
14
+ return
15
+ root.remove(mount)
16
+ for child in list(mount):
17
+ root.append(child)
18
+ for suffix in ('fixed_stand_collision', 'fixed_stand_visual'):
19
+ geom = root.find(f"geom[@name='{robot.naming_prefix}{suffix}']")
20
+ if geom is not None:
21
+ root.remove(geom)
22
+
23
+
24
+ def install_fixed_stand(robot, *, height, half_width, half_depth, center_xy=(0., 0.)):
25
+ """Raise the complete fixed model once, keeping the root on the floor.
26
+
27
+ All imported bodies/sites/cameras move together. A collision geom and a
28
+ non-colliding visual geom share the same dimensions. Mobile models must
29
+ not use this helper: their chassis already defines the mounting height.
30
+ """
31
+ if min(height, half_width, half_depth) <= 0:
32
+ raise ValueError('stand dimensions must be positive')
33
+ root = robot.worldbody.find('body')
34
+ if root is None:
35
+ raise ValueError('robot has no root body')
36
+ if root.find('.//freejoint') is not None or root.find(".//joint[@type='free']") is not None:
37
+ raise ValueError('cannot add a fixed stand to a free-root robot')
38
+ name = robot.naming_prefix + 'fixed_stand_mount'
39
+ if root.find(f"body[@name='{name}']") is not None:
40
+ return
41
+ mount = ET.Element('body', name=name, pos=f'0 0 {height}')
42
+ for child in list(root):
43
+ root.remove(child)
44
+ mount.append(child)
45
+ root.append(mount)
46
+ attrs = dict(type='box', pos=f'{center_xy[0]} {center_xy[1]} {height / 2}',
47
+ size=f'{half_depth} {half_width} {height / 2}', rgba='.25 .28 .32 1',
48
+ friction='.8 .01 .001', solref='.005 1', condim='6')
49
+ ET.SubElement(root, 'geom', name=robot.naming_prefix+'fixed_stand_collision', group='0', **attrs)
50
+ ET.SubElement(root, 'geom', name=robot.naming_prefix+'fixed_stand_visual', group='1', contype='0', conaffinity='0', density='0', **attrs)
51
+
52
+
53
+ def set_fixed_mount_pose(robot, *, position, yaw, height):
54
+ """Place a complete floor stand using its arm-mount frame, not its floor root.
55
+
56
+ position is the world position of the imported asset origin at mounting
57
+ height. Only yaw is allowed: the stand stays vertical and reaches z=0.
58
+ Both arms, sites and cameras keep their asset-relative transforms.
59
+ """
60
+ import math
61
+ import numpy as np
62
+ position = np.asarray(position, dtype=float)
63
+ if position.shape != (3,) or not np.isfinite(position).all() or not math.isfinite(yaw):
64
+ raise ValueError('mount pose must be finite')
65
+ if not math.isfinite(height) or height <= 0 or abs(position[2] - height) > 1e-12:
66
+ raise ValueError('mount height must equal its world z above the floor')
67
+ root = robot.worldbody.find('body')
68
+ mount = root.find(f"body[@name='{robot.naming_prefix}fixed_stand_mount']")
69
+ if mount is None:
70
+ raise ValueError('fixed mount pose requires an installed floor stand')
71
+ mount.set('pos', f'0 0 {height}')
72
+ for suffix in ('fixed_stand_collision', 'fixed_stand_visual'):
73
+ geom = root.find(f"geom[@name='{robot.naming_prefix}{suffix}']")
74
+ pos = np.fromstring(geom.get('pos'), sep=' ')
75
+ size = np.fromstring(geom.get('size'), sep=' ')
76
+ pos[2] = size[2] = height / 2
77
+ geom.set('pos', ' '.join(map(str, pos)))
78
+ geom.set('size', ' '.join(map(str, size)))
79
+ robot.set_base_xpos([position[0], position[1], 0.])
80
+ robot.set_base_ori(np.array([0., 0., yaw]))
@@ -0,0 +1,103 @@
1
+ """Canonical robot-id to robosuite construction resolution.
2
+
3
+ Keeping this mapping in one place prevents task factories from silently
4
+ falling back to Panda and ensures every physical task uses the same arm order,
5
+ controller and gripper configuration.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class RobosuiteResolution:
16
+ robot_id: str
17
+ robot_name: str
18
+ controller_configs: Any = None
19
+ gripper_types: str = "default"
20
+ initialization_noise: Any = None
21
+ arms: tuple[str, ...] = ("right",)
22
+ mobile_base: bool = False
23
+
24
+
25
+ def _builtin_resolution(key: str, *, include_controller: bool = True) -> RobosuiteResolution:
26
+ if key in ('panda_libero','aloha_agilex_robotwin','dual_arx_x5_robodojo','panda_omron_robocasa'):
27
+ from integrations.rpent_embodiments.models import resolve
28
+ return resolve(key, include_controller=include_controller)
29
+ if key == "panda":
30
+ return RobosuiteResolution(key, "Panda", gripper_types="PandaGripper", initialization_noise="default", arms=("right",))
31
+ if key == "sawyer":
32
+ from .sawyer_robosuite import register_sawyer_with_base
33
+ register_sawyer_with_base()
34
+ return RobosuiteResolution(key, "CLSawyer", arms=("right",))
35
+ from .agilex_robosuite import controller_config, resolve_robot
36
+ arms = ("right",) if key in ("piperx", "legacy_piperx") else ("right", "left")
37
+ return RobosuiteResolution(
38
+ key, resolve_robot(key),
39
+ controller_configs=controller_config(arms) if include_controller else None,
40
+ initialization_noise=None, arms=arms, mobile_base=key in ("mobile_dual_piperx", "aloha_mobile"),
41
+ )
42
+
43
+
44
+ _PROVIDERS = {key: (lambda *, include_controller=True, key=key:
45
+ _builtin_resolution(key, include_controller=include_controller))
46
+ for key in ('panda', 'sawyer', 'piperx', 'dual_piperx', 'mobile_dual_piperx', 'legacy_piperx', 'aloha', 'aloha_mobile', 'panda_libero', 'aloha_agilex_robotwin', 'dual_arx_x5_robodojo', 'panda_omron_robocasa')}
47
+ _MODEL_IDS = {'liberopanda':'panda_libero', 'robotwinaloha':'aloha_agilex_robotwin', 'robodojox5':'dual_arx_x5_robodojo', 'robocasapandaomron':'panda_omron_robocasa', 'panda': 'panda', 'sawyer': 'sawyer', 'clsawyer': 'sawyer',
48
+ 'agilexpiper': 'legacy_piperx', 'agilexaloha': 'aloha', 'agilexmobilealoha': 'aloha_mobile',
49
+ 'piperx': 'piperx', 'dualpiperx': 'dual_piperx', 'mobiledualpiperx': 'mobile_dual_piperx'}
50
+
51
+
52
+ def robot_id_from_model(name):
53
+ """Resolve a canonical ID or a registered simulator-model alias."""
54
+ key = str(name).strip().lower()
55
+ if key in _PROVIDERS:
56
+ return key
57
+ if key in _MODEL_IDS:
58
+ return _MODEL_IDS[key]
59
+ raise ValueError(f'unknown robot/model {name!r}; register its provider first')
60
+
61
+
62
+ def default_controller_config(robot):
63
+ """Resolve embodiment controller defaults for direct environment creation."""
64
+ resolved = resolve_robosuite_robot(robot_id_from_model(robot))
65
+ if resolved.controller_configs is not None:
66
+ return resolved.controller_configs
67
+ from robosuite.controllers import load_composite_controller_config
68
+ return load_composite_controller_config(robot='Sawyer' if resolved.robot_id == 'sawyer' else resolved.robot_name)
69
+
70
+
71
+ def registered_robot_ids():
72
+ """Live catalog; registration does not import simulation dependencies."""
73
+ return tuple(_PROVIDERS)
74
+
75
+
76
+ def register_robosuite_robot(robot_id, provider):
77
+ """Register a lazy provider accepting ``include_controller=bool``.
78
+
79
+ The provider registers its model with robosuite and returns a
80
+ RobosuiteResolution. This does not assert physical task compatibility.
81
+ """
82
+ if not isinstance(robot_id, str) or not robot_id or robot_id != robot_id.strip().lower() or any(c.isspace() for c in robot_id):
83
+ raise ValueError('robot_id must be a nonempty lowercase identifier')
84
+ if robot_id in _PROVIDERS:
85
+ raise ValueError(f'robot already registered: {robot_id}')
86
+ if robot_id in _MODEL_IDS and _MODEL_IDS[robot_id] != robot_id:
87
+ raise ValueError(f'robot id conflicts with model alias: {robot_id}')
88
+ if not callable(provider):
89
+ raise TypeError('provider must be callable')
90
+ _PROVIDERS[robot_id] = provider
91
+
92
+
93
+ def resolve_robosuite_robot(robot_id: str, *, include_controller: bool = True) -> RobosuiteResolution:
94
+ from .catalog import normalize_robot_id
95
+ key = normalize_robot_id(robot_id)
96
+ result = _PROVIDERS[key](include_controller=include_controller)
97
+ if not isinstance(result, RobosuiteResolution) or result.robot_id != key:
98
+ raise ValueError(f'invalid robot provider result for {key}')
99
+ alias = result.robot_name.lower()
100
+ if (alias in _MODEL_IDS and _MODEL_IDS[alias] != key) or (alias in _PROVIDERS and alias != key):
101
+ raise ValueError(f'robot model alias already owned: {result.robot_name}')
102
+ _MODEL_IDS[alias] = key
103
+ return result
@@ -0,0 +1,147 @@
1
+ """Sawyer model with its physical pedestal integrated into the robot asset.
2
+
3
+ The upstream robosuite Sawyer is normally composed with ``RethinkMount``.
4
+ That mount is an environment-time support model whose root is offset below
5
+ the arm by roughly 0.912 m. This is convenient for table arenas, but makes a
6
+ stand-alone robot appear to float when the environment does not provide that
7
+ mount. ``SawyerWithBase`` keeps the arm at the calibrated height and adds a
8
+ simple collision/visual pedestal to the Sawyer MJCF itself. It is therefore
9
+ part of the robot embodiment and follows the robot whenever its root is
10
+ placed or moved.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import xml.etree.ElementTree as ET
16
+
17
+ import numpy as np
18
+
19
+
20
+ # Height of the upstream Rethink pedestal from its floor contact to the
21
+ # Sawyer arm root. The original RethinkMount XML has a child root at -0.922 m
22
+ # and a top offset of -0.01 m, yielding -0.912 m as the model bottom offset.
23
+ SAWYER_ARM_ROOT_Z = 0.912
24
+ SAWYER_BASE_RADIUS = 0.31
25
+ SAWYER_BASE_HEIGHT = SAWYER_ARM_ROOT_Z
26
+
27
+
28
+ def register_sawyer_with_base() -> str:
29
+ """Register and return the robosuite name for the integrated-base Sawyer.
30
+
31
+ Imports robosuite lazily so task discovery and schema checks remain usable
32
+ without simulator dependencies.
33
+ """
34
+
35
+ from robosuite.models.robots.manipulators.sawyer_robot import Sawyer
36
+ from robosuite.models.robots.robot_model import REGISTERED_ROBOTS
37
+ from robosuite.robots import ROBOT_CLASS_MAPPING, FixedBaseRobot
38
+ from robosuite.utils.mjcf_utils import find_elements
39
+
40
+ name = "CLSawyer"
41
+ if name not in REGISTERED_ROBOTS:
42
+
43
+ class CLSawyer(Sawyer):
44
+ """Sawyer with a floor-contacting pedestal in its own MJCF."""
45
+
46
+ def __init__(self, idn=0):
47
+ super().__init__(idn=idn)
48
+
49
+ # The arm link in robosuite's Sawyer XML is rooted at z=0 and
50
+ # relies on RethinkMount to lift it. Move only the arm branch;
51
+ # the robot root remains at the floor for normal placement.
52
+ arm_root = find_elements(
53
+ root=self.worldbody,
54
+ tags="body",
55
+ attribs={"name": self.correct_naming("right_arm_base_link")},
56
+ return_first=True,
57
+ )
58
+ if arm_root is None:
59
+ raise ValueError("Sawyer asset is missing right_arm_base_link")
60
+ arm_root.set("pos", f"0 0 {SAWYER_ARM_ROOT_Z:.6f}")
61
+
62
+ # The upstream robotview is attached directly to the XML
63
+ # root at z=0.4 because RethinkMount lifts that root by
64
+ # 0.912 m. With the integrated pedestal the root is on the
65
+ # floor, so preserve the original world camera height.
66
+ robotview = find_elements(
67
+ root=self.worldbody,
68
+ tags="camera",
69
+ attribs={"name": self.correct_naming("robotview")},
70
+ return_first=True,
71
+ )
72
+ if robotview is not None:
73
+ robotview.set("pos", f"1.0 0 {0.4 + SAWYER_ARM_ROOT_Z:.6f}")
74
+
75
+ root = self._elements["root_body"]
76
+ base = ET.Element(
77
+ "body",
78
+ {
79
+ "name": self.correct_naming("sawyer_integrated_base"),
80
+ "pos": "0 0 0",
81
+ },
82
+ )
83
+ # Static root-body geometry is physically attached to the
84
+ # robot and therefore moves with it, while the visual copy is
85
+ # excluded from contacts to avoid duplicate collision forces.
86
+ ET.SubElement(
87
+ base,
88
+ "geom",
89
+ {
90
+ "name": self.correct_naming("sawyer_base_collision"),
91
+ "type": "cylinder",
92
+ "size": f"{SAWYER_BASE_RADIUS:.4f} {SAWYER_BASE_HEIGHT / 2:.4f}",
93
+ "pos": f"0 0 {SAWYER_BASE_HEIGHT / 2:.4f}",
94
+ "rgba": "0.12 0.14 0.16 1",
95
+ "density": "450",
96
+ "friction": "0.8 0.01 0.001",
97
+ "condim": "6",
98
+ },
99
+ )
100
+ ET.SubElement(
101
+ base,
102
+ "geom",
103
+ {
104
+ "name": self.correct_naming("sawyer_base_visual"),
105
+ "type": "cylinder",
106
+ # Keep the underside exactly on the floor; only the
107
+ # radius is enlarged for a small visual lip.
108
+ "size": f"{SAWYER_BASE_RADIUS + 0.006:.4f} {SAWYER_BASE_HEIGHT / 2:.4f}",
109
+ "pos": f"0 0 {SAWYER_BASE_HEIGHT / 2:.4f}",
110
+ "rgba": "0.18 0.20 0.23 1",
111
+ "contype": "0",
112
+ "conaffinity": "0",
113
+ "group": "1",
114
+ },
115
+ )
116
+ root.append(base)
117
+
118
+ @property
119
+ def default_base(self):
120
+ # NullMount contributes no environment-specific pedestal or
121
+ # vertical offset. The pedestal above is the robot asset.
122
+ return "NullMount"
123
+
124
+ @property
125
+ def top_offset(self):
126
+ # Preserve the upstream top envelope (1.0 m above the arm
127
+ # root) while the arm root itself is lifted by the integrated
128
+ # pedestal.
129
+ return np.array((0.0, 0.0, SAWYER_ARM_ROOT_Z + 1.0))
130
+
131
+ @property
132
+ def _horizontal_radius(self):
133
+ return max(float(super()._horizontal_radius), SAWYER_BASE_RADIUS)
134
+
135
+ # RobotModelMeta registers the class in REGISTERED_ROBOTS. The
136
+ # wrapper map is what RobotEnv uses when creating the runtime robot.
137
+ ROBOT_CLASS_MAPPING[name] = FixedBaseRobot
138
+ # Keep the public robosuite robot id stable. Existing task code often
139
+ # passes ``robots="Sawyer"`` directly; that path must receive the same
140
+ # integrated-base embodiment as the benchmark factory's ``sawyer`` ID.
141
+ REGISTERED_ROBOTS["Sawyer"] = CLSawyer
142
+ ROBOT_CLASS_MAPPING["Sawyer"] = FixedBaseRobot
143
+
144
+ return name
145
+
146
+
147
+ __all__ = ["register_sawyer_with_base", "SAWYER_ARM_ROOT_Z"]
@@ -0,0 +1,137 @@
1
+ """Embodiment-owned sensor profiles; no task or scene dependencies.
2
+
3
+ Third-party robot models may expose ``sensor_profile`` or register their
4
+ exact model class name. Unknown models retain imported camera extrinsics.
5
+ """
6
+ from dataclasses import dataclass
7
+ import xml.etree.ElementTree as ET
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class MountedCamera:
12
+ """Body and role names are unprefixed, in the robot model namespace."""
13
+ role: str
14
+ body: str
15
+ position: tuple[float, float, float]
16
+ xyaxes: tuple[float, float, float, float, float, float]
17
+ fovy: float = 65.
18
+
19
+ def __post_init__(self):
20
+ if self.role not in ('head', 'wrist_right', 'wrist_left', 'overhead_debug'):
21
+ raise ValueError(f'unknown mounted camera role: {self.role}')
22
+ if not 0 < self.fovy < 180:
23
+ raise ValueError('fovy must be between 0 and 180 degrees')
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class SensorProfile:
28
+ overhead_height: float = 1.8
29
+ head_min_x: float | None = None
30
+ head_xyaxes: str | None = None
31
+ # Wrist camera mount in the gripper/eef local frame. ``None`` preserves
32
+ # an imported asset offset for models that do not have a canonical mount.
33
+ wrist_position: tuple[float, float, float] | None = None
34
+ wrist_target: tuple[float, float, float] | None = None
35
+ wrist_up: tuple[float, float, float] = (0., 1., 0.)
36
+ cameras: tuple[MountedCamera, ...] = ()
37
+
38
+
39
+ _PROFILES = {
40
+ 'Panda': SensorProfile(overhead_height=.65, cameras=(MountedCamera('head', 'base', (-.12, -.20, .80), (0., -1., 0., .866025403784, 0., .5), 80.),)),
41
+ 'CLSawyer': SensorProfile(cameras=(MountedCamera('head', 'base', (-.12, -.20, 1.70), (0., -1., 0., .866025403784, 0., .5), 80.),)),
42
+ # First physical-fit trial: lift the wrist sensor 10 cm in the eef local
43
+ # ``-X`` direction (the neutral image-up direction) to reduce gripper-root
44
+ # occlusion while keeping the same TCP aiming point.
45
+ 'PiperX': SensorProfile(wrist_position=(-.1, 0., -.08), wrist_target=(0., 0., .06), wrist_up=(-1., 0., 0.), cameras=(
46
+ MountedCamera('head', 'right_base', (-.12, -.20, .80),
47
+ (0., -1., 0., .866025403784, 0., .5), 80.),
48
+ )),
49
+ 'DualPiperX': SensorProfile(head_min_x=.24, head_xyaxes='0 -1 0 .5 0 .866025403784', wrist_position=(-.1, 0., -.08), wrist_target=(0., 0., .06), wrist_up=(-1., 0., 0.)),
50
+ 'MobileDualPiperX': SensorProfile(head_min_x=.24, head_xyaxes='0 -1 0 .5 0 .866025403784', wrist_position=(-.1, 0., -.08), wrist_target=(0., 0., .06), wrist_up=(-1., 0., 0.)),
51
+ }
52
+
53
+
54
+ _PROFILES['AgileXPiper'] = _PROFILES['PiperX']
55
+ _PROFILES['AgileXAloha'] = _PROFILES['DualPiperX']
56
+ _PROFILES['AgileXMobileAloha'] = _PROFILES['MobileDualPiperX']
57
+
58
+
59
+ def register_sensor_profile(model_class_name, profile):
60
+ if not isinstance(profile, SensorProfile):
61
+ raise TypeError('profile must be a SensorProfile')
62
+ if model_class_name in _PROFILES:
63
+ raise ValueError(f'sensor profile already registered: {model_class_name}')
64
+ _PROFILES[model_class_name] = profile
65
+
66
+
67
+ def sensor_profile(robot):
68
+ profile = getattr(robot, 'sensor_profile', None)
69
+ if profile is None:
70
+ profile = _PROFILES.get(type(robot).__name__, SensorProfile())
71
+ if not isinstance(profile, SensorProfile):
72
+ raise TypeError('robot.sensor_profile must be a SensorProfile')
73
+ return profile
74
+
75
+
76
+ def install_declared_cameras(robot):
77
+ """New models specify mounts explicitly, without legacy name inference."""
78
+ profile = sensor_profile(robot)
79
+ roles = [c.role for c in profile.cameras]
80
+ if len(set(roles)) != len(roles):
81
+ raise ValueError('duplicate mounted camera roles')
82
+ for spec in profile.cameras:
83
+ name = robot.naming_prefix + spec.role
84
+ body_name = robot.correct_naming(spec.body)
85
+ body = next((b for b in robot.worldbody.iter('body') if b.get('name') == body_name), None)
86
+ if body is None:
87
+ raise ValueError(f'{name}: camera mount body not found: {body_name}')
88
+ matches = [(b, c) for b in robot.worldbody.iter('body') for c in b.findall('camera') if c.get('name') == name]
89
+ if len(matches) > 1 or (matches and matches[0][0] is not body):
90
+ raise ValueError(f'{name}: conflicting camera mount')
91
+ camera = matches[0][1] if matches else ET.SubElement(body, 'camera', name=name)
92
+ for attr in ('quat', 'euler', 'axisangle', 'zaxis', 'target', 'mode'):
93
+ camera.attrib.pop(attr, None)
94
+ camera.set('pos', ' '.join(map(str, spec.position)))
95
+ camera.set('xyaxes', ' '.join(map(str, spec.xyaxes)))
96
+ camera.set('fovy', str(spec.fovy))
97
+ if spec.role == 'head':
98
+ # Keep the existing public main-camera ID for all three models.
99
+ from copy import deepcopy
100
+ alias = robot.naming_prefix + 'head_camera'
101
+ if not any(c.get('name') == alias for c in robot.worldbody.iter('camera')):
102
+ clone = deepcopy(camera); clone.set('name', alias); body.append(clone)
103
+
104
+
105
+ def ensure_robot_overhead_camera(robot_model, *, suffix='overhead_camera'):
106
+ """Attach virtual debug camera to moving root, independent of scene."""
107
+ world = getattr(robot_model, 'worldbody', None)
108
+ if world is None:
109
+ return None
110
+ naming = getattr(robot_model, 'correct_naming', None)
111
+ name = naming(suffix) if callable(naming) else suffix
112
+ if any(c.get('name') == name for c in world.iter('camera')):
113
+ return name
114
+ root = world.find('body')
115
+ if root is None:
116
+ return None
117
+ for body in root.iter('body'):
118
+ if body.find('freejoint') is not None or body.find("joint[@type='free']") is not None:
119
+ root = body
120
+ break
121
+ height = sensor_profile(robot_model).overhead_height
122
+ ET.SubElement(root, 'camera', name=name, pos=f'0.20 0 {height}',
123
+ xyaxes='0 1 0 -1 0 0', fovy='65')
124
+ return name
125
+
126
+
127
+ PIPERX_ROBOTS = ('piperx', 'dual_piperx', 'mobile_dual_piperx')
128
+ CAMERA_CONTRACT_VERSION = '3.0'
129
+
130
+
131
+ def policy_camera_ids(robot_id):
132
+ """Ordered public views; left/right names follow robot arms, not screen sides."""
133
+ main = ('robot0_head_camera',)
134
+ if robot_id in PIPERX_ROBOTS:
135
+ return main + ('robot0_wrist_right',) + (() if robot_id == 'piperx' else ('robot0_wrist_left',))
136
+ from integrations.rpent_embodiments.catalog import EMBODIMENTS, camera_ids
137
+ return camera_ids(robot_id) if robot_id in EMBODIMENTS else main
@@ -0,0 +1 @@
1
+ """Small, reusable utilities shared by benchmark environments and rollouts."""