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,642 @@
1
+ """Physics-based robot box insertion task.
2
+
3
+ The block is a passive free rigid body. Its position changes only when contact
4
+ forces from the robot end-effector are integrated by MuJoCo; the environment
5
+ never writes the block joint ``qpos`` / ``qvel`` during ``step``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ import json
12
+ from typing import Any
13
+
14
+ import mujoco
15
+ import numpy as np
16
+ from robosuite.controllers import load_composite_controller_config
17
+ from robosuite.environments.manipulation.manipulation_env import ManipulationEnv
18
+ from robosuite.models.arenas import Arena
19
+ from react_manip.visual_task import ManipulationTask
20
+
21
+ from react_manip.core import SuccessRule, TaskSpec
22
+ from react_manip.core.scenarios import sample_scenario
23
+ from react_manip.core.visual_randomization import apply_visual_scenario, resolve_scenario
24
+
25
+ ROOT = Path(__file__).resolve().parent
26
+ SCENE_XML = ROOT / "assets" / "scene.xml"
27
+ BLOCK_FRICTION_PATCHES = ("left", "right")
28
+ MIN_SLOT_CLEARANCE_M = 0.05 # total opening margin; 2.5 cm nominal per side
29
+ MIN_INITIAL_GRIPPER_CLEARANCE_M = 0.05
30
+
31
+ SPEC = TaskSpec(
32
+ task_id="box_insertion", task_version="1.7", task_type="one_shot",
33
+ required_arms=1, required_gripper="parallel", required_eef_dof=6,
34
+ supported_robots=("dual_piperx", "panda", "sawyer", "piperx", "mobile_dual_piperx"),
35
+ horizon_sec=60.0, control_hz=20.0, done_behavior="stop",
36
+ success=SuccessRule(min_occupancy=0.0, min_events=1, max_failures=0,
37
+ require_terminal_success=True),
38
+ )
39
+
40
+
41
+ class BoxInsertionArena(Arena):
42
+ def __init__(self, scenario=None):
43
+ super().__init__(str(SCENE_XML))
44
+ self.set_origin([0, 0, 0])
45
+ resolved = resolve_scenario(scenario)
46
+ parameters = resolved.parameters if resolved is not None else {}
47
+ sampled = parameters.get("block_friction", {})
48
+ geoms = {geom.get("name"): geom for geom in self.worldbody.iter("geom")}
49
+ physics = parameters.get("physics", {})
50
+ # Priority 1 makes the wall coefficient the actual contact coefficient,
51
+ # independent of either block patch's table-contact friction.
52
+ for name, key, fallback in (
53
+ ("table", "table_friction", 0.005),
54
+ ("box_left", "slot_left_wall_friction", physics.get("slot_side_wall_friction", 3.0)),
55
+ ("box_right", "slot_right_wall_friction", physics.get("slot_side_wall_friction", 3.0)),
56
+ ("box_back", "slot_back_wall_friction", 0.02),
57
+ ):
58
+ coefficient = float(physics.get(key, fallback))
59
+ if not np.isfinite(coefficient) or coefficient <= 0.0:
60
+ raise ValueError(f"invalid {key}: {coefficient}")
61
+ friction = geoms[name].get("friction").split()
62
+ friction[0] = f"{coefficient:.12g}"
63
+ geoms[name].set("friction", " ".join(friction))
64
+ density = parameters.get("block_density")
65
+ patch_densities = dict.fromkeys(BLOCK_FRICTION_PATCHES, 650.0)
66
+ if density is not None:
67
+ light = float(density["light_kg_m3"])
68
+ heavy = float(density["heavy_kg_m3"])
69
+ heavy_side = density["heavy_side"]
70
+ if (not np.isfinite([light, heavy]).all() or not 0 < light < heavy
71
+ or heavy_side not in BLOCK_FRICTION_PATCHES):
72
+ raise ValueError(f"invalid block_density: {density}")
73
+ patch_densities = {
74
+ patch: heavy if patch == heavy_side else light
75
+ for patch in BLOCK_FRICTION_PATCHES
76
+ }
77
+ variation = resolved.metadata.get('episode_variation', {}) if resolved else {}
78
+ mass_scale = float(variation.get('mass_scale', 1.))
79
+ if not np.isfinite(mass_scale) or not .7 <= mass_scale <= 1.3:
80
+ raise ValueError('invalid Episode block mass scale')
81
+ # Uniform density scaling changes mass and inertia together, preserving COM.
82
+ patch_densities = {patch: value * mass_scale for patch, value in patch_densities.items()}
83
+ self.block_patch_density = np.array(list(patch_densities.values()))
84
+ block_size = np.asarray(geoms["block_visual"].get("size").split(), dtype=float)
85
+ block_params = parameters.get("wood_block", {})
86
+ width = float(block_params.get("width_m", 2.0 * block_size[1]))
87
+ if not np.isfinite(width) or not 0.60 <= width <= 1.00:
88
+ raise ValueError(f"wood block width must be in [0.60, 1.00] m, got {width}")
89
+ block_size[1] = width / 2.0
90
+ self.block_width = width
91
+
92
+ # The slot is derived from the sampled block width. Clearance is a
93
+ # total lateral margin (not per-side). Enforce the
94
+ # contract at construction time as well as in the Profile, so older
95
+ # saved Scenarios with the former 0.0002 m value cannot create a slot
96
+ # that is narrower than the required 5 cm margin.
97
+ box_params = parameters.get("box", {})
98
+ clearance = float(box_params.get("clearance_m", MIN_SLOT_CLEARANCE_M))
99
+ if not np.isfinite(clearance) or clearance <= 0.0:
100
+ raise ValueError(f"slot clearance must be positive, got {clearance}")
101
+ clearance = max(clearance, MIN_SLOT_CLEARANCE_M)
102
+ wall = float(box_params.get("wall", 0.02))
103
+ if not np.isfinite(wall) or wall <= 0.0:
104
+ raise ValueError(f"slot wall thickness must be positive, got {wall}")
105
+ opening = width + clearance
106
+ outer_width = opening + 2.0 * wall
107
+ self.slot_opening_width = opening
108
+ self.slot_outer_width = outer_width
109
+ self.slot_clearance = clearance
110
+ side_half_thickness = wall / 2.0
111
+ side_center = opening / 2.0 + side_half_thickness
112
+ geoms["box_left"].set("pos", f"0 {-side_center:.9g} 0.035")
113
+ geoms["box_right"].set("pos", f"0 {side_center:.9g} 0.035")
114
+ geoms["box_left"].set("size", f"0.16 {side_half_thickness:.9g} 0.035")
115
+ geoms["box_right"].set("size", f"0.16 {side_half_thickness:.9g} 0.035")
116
+ back = geoms["box_back"]
117
+ back_size = np.asarray(back.get("size").split(), dtype=float)
118
+ back_size[1] = outer_width / 2.0
119
+ back.set("size", " ".join(f"{value:.9g}" for value in back_size))
120
+ for site in self.worldbody.iter("site"):
121
+ if site.get("name") == "box_entry":
122
+ site_size = np.asarray(site.get("size").split(), dtype=float)
123
+ site_size[1] = opening / 2.0
124
+ site.set("size", " ".join(f"{value:.9g}" for value in site_size))
125
+
126
+ half_width = float(block_size[1])
127
+ full_width = 2.0 * half_width
128
+ split_from_left = sampled.get("split_from_left_m")
129
+ if split_from_left is None:
130
+ # New profiles sample a width-independent ratio. This keeps the
131
+ # historical 20--80% split rule valid for both 0.60 m and 1.00 m
132
+ # blocks. Scenarios saved before this change may still provide a
133
+ # ratio or an absolute split distance.
134
+ split_from_left = full_width * float(sampled.get("split_ratio", 0.5))
135
+ split_from_left = float(split_from_left)
136
+ if (not np.isfinite(split_from_left)
137
+ or not 0.0 < split_from_left < full_width):
138
+ raise ValueError(
139
+ "invalid block friction split distance from left edge: "
140
+ f"{split_from_left} m for width {full_width} m"
141
+ )
142
+ split_y = -half_width + split_from_left
143
+ bounds = ((-half_width, split_y), (split_y, half_width))
144
+ for patch, (lower, upper) in zip(BLOCK_FRICTION_PATCHES, bounds):
145
+ coefficient = float(sampled.get(patch, 0.10))
146
+ if not np.isfinite(coefficient) or coefficient <= 0.0:
147
+ raise ValueError(f"invalid block friction for {patch}: {coefficient}")
148
+ geom = geoms[f"block_contact_{patch}"]
149
+ geom.set("pos", f"0 {(lower + upper) / 2.0:.9g} 0")
150
+ geom.set(
151
+ "size",
152
+ f"{block_size[0]:.9g} {(upper - lower) / 2.0:.9g} {block_size[2]:.9g}",
153
+ )
154
+ geom.set("friction", f"{coefficient:.9g} 0.01 0.001")
155
+ # MuJoCo derives one body's mass, offset COM and inertia from the
156
+ # two disjoint solid regions; the visual geom has zero mass.
157
+ geom.set("density", f"{patch_densities[patch]:.12g}")
158
+ geoms["block_visual"].set("size", " ".join(f"{value:.9g}" for value in block_size))
159
+ apply_visual_scenario(self, scenario)
160
+
161
+
162
+ def _default_controller():
163
+ config = load_composite_controller_config(controller="BASIC")
164
+ config["body_parts"]["right"] = {
165
+ "type": "OSC_POSE", "input_max": 1, "input_min": -1,
166
+ "output_max": [0.015, 0.015, 0.015, 0.25, 0.25, 0.25],
167
+ "output_min": [-0.015, -0.015, -0.015, -0.25, -0.25, -0.25],
168
+ "kp": 190, "damping_ratio": 1.5, "impedance_mode": "fixed",
169
+ "kp_limits": [0, 300], "damping_ratio_limits": [0, 10],
170
+ "position_limits": None, "orientation_limits": None,
171
+ "uncouple_pos_ori": True, "input_type": "delta",
172
+ "input_ref_frame": "world", "interpolation": None,
173
+ "ramp_ratio": 0.1, "gripper": {"type": "GRIP"},
174
+ }
175
+ return config
176
+
177
+
178
+ class BoxInsertion(ManipulationEnv):
179
+ privileged_info_keys = (
180
+ 'task_seed', 'block_patch_friction', 'block_patch_density_kg_m3',
181
+ 'block_mass_kg', 'block_com_local_m', 'slot_wall_friction',
182
+ 'block_patch_width_m', 'block_width_m', 'slot_opening_width_m',
183
+ 'slot_clearance_m', 'block_friction_split_y_m', 'block_friction_split_from_left_m',
184
+ )
185
+ privileged_observation_keys = privileged_info_keys + (
186
+ 'block_pose', 'block_velocity', 'block_angular_velocity', 'eef_to_push_face',
187
+ )
188
+ """Single-arm EEF controller that physically pushes a block into a box."""
189
+
190
+ @property
191
+ def action_dim(self):
192
+ """Expose robosuite's compiled layout (7 single-arm / 14 bimanual)."""
193
+ return int(getattr(self, "_action_dim", 7))
194
+ # Pygame must not inject an additional _pre_action call: robosuite's base
195
+ # step loop already applies this action at every physics substep.
196
+ step_handles_robot_action = True
197
+ task_space_active_kp = 180.0
198
+ task_space_passive_kp = 20.0
199
+ task_space_rotation_kp = 8.0
200
+ _TARGET_DEPTH = 0.36
201
+ _BOTTOM_TOLERANCE = 0.005
202
+ # MuJoCo's resting contact permits sub-millimetre penetration into the
203
+ # tabletop. Keep this separate from the much stricter bottom-depth rule.
204
+ _CONTAINMENT_TOLERANCE = 0.0005
205
+ _STABLE_SPEED = 0.025
206
+ _STABLE_ANGULAR_SPEED = 0.08
207
+ _STABLE_TICKS = 10
208
+ # Default geometry (1.00 m block + 0.05 m total clearance). Each Scenario
209
+ # replaces this with its sampled width-derived bounds at model load.
210
+ _SLOT_BOUNDS = (0.10, 0.40, -0.5001, 0.5001)
211
+ _SLOT_Z_BOUNDS = (0.78, 0.85)
212
+
213
+ def reward(self, action=None):
214
+ """Robosuite hook; task scoring is computed in :meth:`step`.
215
+
216
+ Returning a finite scalar keeps the base environment's stepping loop
217
+ intact while avoiding any hidden state writes or legacy object-state
218
+ reward assumptions.
219
+ """
220
+ del action
221
+ return 0.0
222
+
223
+ def _task_instruction(self):
224
+ return (
225
+ "Push the free wooden block into the open box slot. "
226
+ "Block contact friction and overall mass vary between Episodes but remain fixed within each Episode. "
227
+ "The block and slot dimensions and the center-of-mass bias pattern stay the same within a Task Instance. "
228
+ "Use observed motion to adjust pushes and correct rotation. "
229
+ "The block must remain inside the slot, reach the required insertion "
230
+ "depth near the back wall, and settle without crossing the side walls. "
231
+ f"Keep it stable for {self._STABLE_TICKS / self.control_freq:g} seconds. "
232
+ "Final-position points are at most 50; success earns up to 50 time points: "
233
+ "full through 10 seconds, linearly decreasing to zero at 40 seconds. "
234
+ f"The deadline is {self.horizon / self.control_freq:g} seconds. "
235
+ "Position points use the final pose, not the maximum earlier insertion. "
236
+ "Invalid side, back or vertical containment gives zero position points; partial entry is allowed for partial credit. "
237
+ "Falling below the table ends the episode with zero. Success ends immediately; timeout retains final-position points."
238
+ )
239
+
240
+ def get_episode_language_prompt(self):
241
+ from react_manip.core.language import task_language_prompt
242
+ return task_language_prompt(
243
+ self, task_id='box_insertion',
244
+ task_instruction=self._task_instruction(),
245
+ success_condition=(f"All eight corners contained, insertion depth at least {self._TARGET_DEPTH - self._BOTTOM_TOLERANCE:g} m, "
246
+ f"linear speed <={self._STABLE_SPEED:g} m/s and angular speed <={self._STABLE_ANGULAR_SPEED:g} rad/s "
247
+ f"for {self._STABLE_TICKS} ticks"),
248
+ constraints=(),
249
+ )
250
+
251
+ def __init__(self, robots="dual_piperx", env_configuration="default",
252
+ controller_configs=None, gripper_types="default",
253
+ base_types="default", initialization_noise=None,
254
+ use_camera_obs=False, use_object_obs=False,
255
+ reward_scale=1.0, reward_shaping=False, has_renderer=False,
256
+ has_offscreen_renderer=False, render_camera="overview",
257
+ render_collision_mesh=False, render_visual_mesh=True,
258
+ render_gpu_device_id=-1, control_freq=20, lite_physics=False,
259
+ horizon=1200, ignore_done=False, hard_reset=True,
260
+ camera_names="overview", camera_heights=256,
261
+ camera_widths=256, camera_depths=False,
262
+ camera_segmentations=None, renderer="mjviewer",
263
+ renderer_config=None, seed=None, scenario=None,
264
+ **kwargs: Any):
265
+ del kwargs
266
+ if robots in ('dual_piperx', 'piperx', 'mobile_dual_piperx', 'panda', 'sawyer', 'legacy_piperx', 'aloha', 'aloha_mobile'):
267
+ from react_manip.robots.resolution import resolve_robosuite_robot
268
+ resolved = resolve_robosuite_robot(robots)
269
+ robots = resolved.robot_name
270
+ if controller_configs is None:
271
+ controller_configs = resolved.controller_configs
272
+ if scenario is None:
273
+ profile = json.loads((ROOT.parents[1] / "configs/box_insertion/profile_default.json").read_text())
274
+ scenario = sample_scenario(profile, seed=0 if seed is None else seed)
275
+ self.scenario = resolve_scenario(scenario)
276
+ self.seed = seed
277
+ self.use_object_obs = use_object_obs
278
+ self.reward_scale = float(reward_scale)
279
+ self.reward_shaping = bool(reward_shaping)
280
+ self._stable_ticks = 0
281
+ self._max_depth_seen = 0.0
282
+ self._events: list[str] = []
283
+ self._failure_reason: str | None = None
284
+ if controller_configs is None:
285
+ controller_configs = _default_controller()
286
+ super().__init__(
287
+ robots=robots, env_configuration=env_configuration,
288
+ controller_configs=controller_configs, base_types=base_types,
289
+ gripper_types=gripper_types, initialization_noise=initialization_noise,
290
+ use_camera_obs=use_camera_obs,
291
+ has_renderer=has_renderer, has_offscreen_renderer=has_offscreen_renderer,
292
+ render_camera=render_camera, render_collision_mesh=render_collision_mesh,
293
+ render_visual_mesh=render_visual_mesh,
294
+ render_gpu_device_id=render_gpu_device_id, control_freq=control_freq,
295
+ lite_physics=lite_physics, horizon=horizon, ignore_done=ignore_done,
296
+ hard_reset=hard_reset, camera_names=camera_names,
297
+ camera_heights=camera_heights, camera_widths=camera_widths,
298
+ camera_depths=camera_depths, camera_segmentations=camera_segmentations,
299
+ renderer=renderer, renderer_config=renderer_config, seed=seed,
300
+ )
301
+
302
+ def _load_model(self):
303
+ super()._load_model()
304
+ robot_model = self.robots[0].robot_model
305
+ if robot_model.__class__.__name__ in ("MobileDualPiperX", "AgileXMobileAloha"):
306
+ robot_model.set_base_xpos([-0.72, 0.0, 0.0])
307
+ elif robot_model.__class__.__name__ == "Panda":
308
+ robot_model.set_base_xpos([-0.5, 0.0, -0.1])
309
+ elif robot_model.__class__.__name__ in ("PiperX", "DualPiperX", "AgileXPiper", "AgileXAloha"):
310
+ from react_manip.robots.mounts import set_fixed_mount_pose
311
+ piper = robot_model.__class__.__name__ in ("PiperX", "AgileXPiper")
312
+ height = .62 if piper else .55
313
+ # Preserve calibrated arm world poses without sinking the stand
314
+ # root below the floor to lower the mounting plane.
315
+ set_fixed_mount_pose(robot_model, position=[-.38 if piper else -.62, 0., height],
316
+ yaw=0., height=height)
317
+ else:
318
+ robot_model.set_base_xpos([-0.62, 0.0, -0.1])
319
+ if robot_model.__class__.__name__ == "PiperX":
320
+ # The shared start pose places the fingertips inside this task's
321
+ # wide block. Raise/retract the shoulder before controller reset;
322
+ # leave the block, fixture, and robot mounting pose unchanged.
323
+ self.robots[0].init_qpos = np.array([0., .8, -1.1, 1.2, 0., 0.])
324
+ arena = BoxInsertionArena(self.scenario)
325
+ self._block_patch_density = arena.block_patch_density.copy()
326
+ self._block_width_m = arena.block_width
327
+ self._slot_opening_width_m = arena.slot_opening_width
328
+ self._slot_clearance_m = arena.slot_clearance
329
+ self._slot_bounds = (0.10, 0.40, -arena.slot_opening_width / 2.0,
330
+ arena.slot_opening_width / 2.0)
331
+ self.model = ManipulationTask(
332
+ mujoco_arena=arena,
333
+ mujoco_robots=[r.robot_model for r in self.robots],
334
+ mujoco_objects=[],
335
+ )
336
+
337
+ def _setup_references(self):
338
+ super()._setup_references()
339
+ model = self.sim.model
340
+ self.block_body_id = model.body_name2id("wood_block")
341
+ self._block_mass_kg = float(model.body_mass[self.block_body_id])
342
+ self._block_com_local_m = np.asarray(model.body_ipos[self.block_body_id]).copy()
343
+ self._slot_wall_friction = np.array([
344
+ model.geom_friction[model.geom_name2id(name), 0]
345
+ for name in ("box_left", "box_right", "box_back")
346
+ ])
347
+ self._block_geom_ids = {
348
+ model.geom_name2id(f"block_contact_{patch}")
349
+ for patch in BLOCK_FRICTION_PATCHES
350
+ }
351
+ # Kept for compatibility with external diagnostics that previously
352
+ # expected one representative collision geom.
353
+ self.block_geom_id = min(self._block_geom_ids)
354
+ visual_geom_id = model.geom_name2id("block_visual")
355
+ self.block_joint_id = model.joint_name2id("block_free")
356
+ self.block_qpos_addr = int(model.jnt_qposadr[self.block_joint_id])
357
+ self.block_qvel_addr = int(model.jnt_dofadr[self.block_joint_id])
358
+ self._block_start_pos = np.asarray(model.body_pos[self.block_body_id], dtype=float).copy()
359
+ self._block_half_length = float(model.geom_size[visual_geom_id, 0])
360
+ self._block_half_width = float(model.geom_size[visual_geom_id, 1])
361
+ self._block_half_height = float(model.geom_size[visual_geom_id, 2])
362
+ self._block_patch_friction = np.asarray([
363
+ model.geom_friction[model.geom_name2id(f"block_contact_{patch}"), 0]
364
+ for patch in BLOCK_FRICTION_PATCHES
365
+ ], dtype=float)
366
+ patch_geom_ids = [
367
+ model.geom_name2id(f"block_contact_{patch}")
368
+ for patch in BLOCK_FRICTION_PATCHES
369
+ ]
370
+ self._block_patch_width_m = np.asarray(
371
+ [2.0 * model.geom_size[geom_id, 1] for geom_id in patch_geom_ids],
372
+ dtype=float,
373
+ )
374
+ left_geom_id = patch_geom_ids[0]
375
+ self._block_friction_split_y_m = float(
376
+ model.geom_pos[left_geom_id, 1] + model.geom_size[left_geom_id, 1]
377
+ )
378
+ self._block_friction_split_from_left_m = float(
379
+ self._block_friction_split_y_m + self._block_half_width
380
+ )
381
+ self._gripper_geom_ids = set()
382
+ for gripper in self.robots[0].gripper.values():
383
+ for name in gripper.contact_geoms:
384
+ try:
385
+ self._gripper_geom_ids.add(model.geom_name2id(name))
386
+ except (ValueError, KeyError):
387
+ pass
388
+ sites = self.robots[0].eef_site_id
389
+ self.eef_site_id = sites["right"] if isinstance(sites, dict) else int(sites)
390
+ gripper_indexes = self.robots[0]._ref_gripper_joint_pos_indexes
391
+ if isinstance(gripper_indexes, dict):
392
+ gripper_indexes = next(iter(gripper_indexes.values()), [])
393
+ self._gripper_qpos = np.asarray(gripper_indexes, dtype=int)
394
+
395
+ def _block_depth(self):
396
+ displacement = self._block_pose()[0] - self._block_start_pos[0]
397
+ return float(np.clip(displacement, 0.0, self._TARGET_DEPTH))
398
+
399
+ def _block_pose(self):
400
+ return np.asarray(self.sim.data.xpos[self.block_body_id], dtype=float).copy()
401
+
402
+ def _block_velocity(self):
403
+ # cvel is angular then linear in the body frame; this is sufficient
404
+ # for the low-speed stability criterion after converting to world.
405
+ return np.asarray(self.sim.data.cvel[self.block_body_id, 3:6], dtype=float).copy()
406
+
407
+ def _block_angular_velocity(self):
408
+ return np.asarray(self.sim.data.cvel[self.block_body_id, :3], dtype=float).copy()
409
+
410
+ def _block_yaw(self):
411
+ rotation = np.asarray(self.sim.data.xmat[self.block_body_id], dtype=float).reshape(3, 3)
412
+ return float(np.arctan2(rotation[1, 0], rotation[0, 0]))
413
+
414
+ def _block_corners(self):
415
+ """World corners of the rotated visual bounding box."""
416
+ center = self._block_pose()
417
+ rotation = np.asarray(self.sim.data.xmat[self.block_body_id], dtype=float).reshape(3, 3)
418
+ local = np.array([
419
+ [sx * self._block_half_length, sy * self._block_half_width, sz * self._block_half_height]
420
+ for sx in (-1.0, 1.0) for sy in (-1.0, 1.0) for sz in (-1.0, 1.0)
421
+ ])
422
+ return center + local @ rotation.T
423
+
424
+ def _block_contained(self):
425
+ """Whether every rotated 3D corner of the block is inside the slot."""
426
+ corners = self._block_corners()
427
+ xmin, xmax, ymin, ymax = self._slot_bounds
428
+ zmin, zmax = self._SLOT_Z_BOUNDS
429
+ tolerance = self._CONTAINMENT_TOLERANCE
430
+ return bool(
431
+ np.all(corners[:, 0] >= xmin - tolerance)
432
+ and np.all(corners[:, 0] <= xmax + tolerance)
433
+ and np.all(corners[:, 1] >= ymin - tolerance)
434
+ and np.all(corners[:, 1] <= ymax + tolerance)
435
+ and np.all(corners[:, 2] >= zmin - tolerance)
436
+ and np.all(corners[:, 2] <= zmax + tolerance)
437
+ )
438
+
439
+ def _bottom_reached(self):
440
+ """Whether the block center is within tolerance of the rear wall."""
441
+ return bool(
442
+ self._block_depth() >= self._TARGET_DEPTH - self._BOTTOM_TOLERANCE
443
+ )
444
+
445
+ def _success_blockers(self, *, speed=None, angular_speed=None):
446
+ """Return explicit reasons why the current state is not terminal success.
447
+
448
+ A block can touch the rear wall while still being yawed or pressed
449
+ against a side wall. Reporting each independent condition makes that
450
+ physically meaningful distinction visible to interactive users and
451
+ benchmark logs instead of reducing it to a mysterious false result.
452
+ """
453
+ if speed is None:
454
+ speed = float(np.linalg.norm(self._block_velocity()))
455
+ if angular_speed is None:
456
+ angular_speed = float(np.linalg.norm(self._block_angular_velocity()))
457
+ blockers = []
458
+ if not self._block_contained():
459
+ blockers.append("block_not_contained")
460
+ if not self._bottom_reached():
461
+ blockers.append("slot_bottom_not_reached")
462
+ if speed > self._STABLE_SPEED:
463
+ blockers.append("linear_motion_not_stable")
464
+ if angular_speed > self._STABLE_ANGULAR_SPEED:
465
+ blockers.append("angular_motion_not_stable")
466
+ return blockers
467
+
468
+ def _contact_force(self):
469
+ peak = 0.0
470
+ for index in range(self.sim.data.ncon):
471
+ contact = self.sim.data.contact[index]
472
+ geom1, geom2 = int(contact.geom1), int(contact.geom2)
473
+ if geom1 not in self._block_geom_ids and geom2 not in self._block_geom_ids:
474
+ continue
475
+ other = geom2 if geom1 in self._block_geom_ids else geom1
476
+ if other not in self._gripper_geom_ids:
477
+ continue
478
+ force = np.zeros(6, dtype=float)
479
+ mujoco.mj_contactForce(self.sim.model._model, self.sim.data._data, index, force)
480
+ peak = max(peak, float(np.linalg.norm(force[:3])))
481
+ return peak
482
+
483
+ def _observation(self):
484
+ data = self.sim.data
485
+ eef_pos = np.asarray(data.site_xpos[self.eef_site_id], dtype=np.float32).copy()
486
+ eef_mat = np.asarray(data.site_xmat[self.eef_site_id], dtype=np.float32).copy()
487
+ block_pose = self._block_pose().astype(np.float32)
488
+ return {
489
+ "robot_qpos": data.qpos[self.robots[0]._ref_joint_pos_indexes].astype(np.float32).copy(),
490
+ "robot_qvel": data.qvel[self.robots[0]._ref_joint_vel_indexes].astype(np.float32).copy(),
491
+ "eef_pos": eef_pos, "eef_mat": eef_mat,
492
+ "gripper_qpos": data.qpos[self._gripper_qpos].astype(np.float32).copy(),
493
+ "block_pose": block_pose,
494
+ "block_velocity": self._block_velocity().astype(np.float32),
495
+ "block_angular_velocity": self._block_angular_velocity().astype(np.float32),
496
+ "block_patch_friction": self._block_patch_friction.astype(np.float32).copy(),
497
+ "block_patch_density_kg_m3": self._block_patch_density.astype(np.float32).copy(),
498
+ "block_mass_kg": np.array([self._block_mass_kg], dtype=np.float32),
499
+ "block_com_local_m": self._block_com_local_m.astype(np.float32).copy(),
500
+ "slot_wall_friction": self._slot_wall_friction.astype(np.float32).copy(),
501
+ "block_patch_width_m": self._block_patch_width_m.astype(np.float32).copy(),
502
+ "block_width_m": np.array([self._block_width_m], dtype=np.float32),
503
+ "slot_opening_width_m": np.array([self._slot_opening_width_m], dtype=np.float32),
504
+ "slot_clearance_m": np.array([self._slot_clearance_m], dtype=np.float32),
505
+ "block_friction_split_y_m": np.array(
506
+ [self._block_friction_split_y_m], dtype=np.float32
507
+ ),
508
+ "block_friction_split_from_left_m": np.array(
509
+ [self._block_friction_split_from_left_m], dtype=np.float32
510
+ ),
511
+ "block_lateral_m": np.array([block_pose[1] - self._block_start_pos[1]], dtype=np.float32),
512
+ "block_yaw_rad": np.array([self._block_yaw()], dtype=np.float32),
513
+ "block_contained": np.array([self._block_contained()], dtype=np.bool_),
514
+ "bottom_reached": np.array([self._bottom_reached()], dtype=np.bool_),
515
+ "insertion_depth_m": np.array([self._block_depth()], dtype=np.float32),
516
+ "insertion_fraction": np.array([self._block_depth() / self._TARGET_DEPTH], dtype=np.float32),
517
+ "eef_to_push_face": block_pose - eef_pos + np.array([-self._block_half_length, 0, 0], dtype=np.float32),
518
+ "contact_force_N": np.array([self._contact_force()], dtype=np.float32),
519
+ "time_remaining_sec": np.array([max(0.0, SPEC.horizon_sec - self.timestep / self.control_freq)], dtype=np.float32),
520
+ }
521
+
522
+ def _score_info(self):
523
+ from .scoring import score_state
524
+ depth = self._block_depth()
525
+ score = score_state(center=self._block_pose(), corners=self._block_corners(),
526
+ bounds=self._slot_bounds, z_bounds=self._SLOT_Z_BOUNDS,
527
+ half_length=self._block_half_length, elapsed=self.timestep/self.control_freq,
528
+ success=bool(self._events), failed=self._failure_reason is not None)
529
+ return {
530
+ **score,
531
+ "elapsed_sec": float(self.timestep / self.control_freq),
532
+ "insertion_depth_m": depth,
533
+ "max_insertion_depth_m": self._max_depth_seen,
534
+ "insertion_fraction": float(depth / self._TARGET_DEPTH),
535
+ "contact_peak_force_N": self._contact_force(),
536
+ "stable_ticks": float(self._stable_ticks),
537
+ "block_contained": self._block_contained(),
538
+ "bottom_reached": self._bottom_reached(),
539
+ "remaining_insertion_distance_m": float(
540
+ max(0.0, self._TARGET_DEPTH - depth)
541
+ ),
542
+ "block_lateral_m": float(self._block_pose()[1] - self._block_start_pos[1]),
543
+ "block_yaw_rad": self._block_yaw(),
544
+ "block_patch_friction": self._block_patch_friction.tolist(),
545
+ "task_seed": self.scenario.seed,
546
+ "block_patch_density_kg_m3": self._block_patch_density.tolist(),
547
+ "block_mass_kg": self._block_mass_kg,
548
+ "block_com_local_m": self._block_com_local_m.tolist(),
549
+ "slot_wall_friction": self._slot_wall_friction.tolist(),
550
+ "block_patch_width_m": self._block_patch_width_m.tolist(),
551
+ "block_width_m": self._block_width_m,
552
+ "slot_opening_width_m": self._slot_opening_width_m,
553
+ "slot_clearance_m": self._slot_clearance_m,
554
+ "block_friction_split_y_m": self._block_friction_split_y_m,
555
+ "block_friction_split_from_left_m": self._block_friction_split_from_left_m,
556
+ }
557
+
558
+ def _reset_internal(self):
559
+ super()._reset_internal()
560
+ self._stable_ticks = 0
561
+ self._max_depth_seen = self._block_depth()
562
+ self._events = []
563
+ self._failure_reason = None
564
+
565
+ def _initial_gripper_clearance(self):
566
+ """Minimum collision-surface gap to the block, slot, and tabletop."""
567
+ model, data = self.sim.model._model, self.sim.data._data
568
+ obstacles = [self.sim.model.geom_name2id(name) for name in (
569
+ "block_contact_left", "block_contact_right", "box_left",
570
+ "box_right", "box_back", "table",
571
+ )]
572
+ gripper_geoms = [self.sim.model.geom_name2id(name)
573
+ for gripper in self.robots[0].gripper.values()
574
+ for name in gripper.contact_geoms]
575
+ return min(mujoco.mj_geomDistance(model, data, hand, obstacle, 1., None)
576
+ for hand in gripper_geoms for obstacle in obstacles)
577
+
578
+ def reset(self, *, seed=None, **kwargs):
579
+ del kwargs
580
+ if seed is not None:
581
+ self.seed = seed
582
+ super().reset()
583
+ if self.robots[0].robot_model.__class__.__name__ == "PiperX":
584
+ clearance = self._initial_gripper_clearance()
585
+ if clearance < MIN_INITIAL_GRIPPER_CLEARANCE_M:
586
+ raise ValueError(f"Initial gripper clearance {clearance:.6f} m is below "
587
+ f"{MIN_INITIAL_GRIPPER_CLEARANCE_M:.3f} m")
588
+ return self._observation(), {
589
+ "success_state": False,
590
+ "success_blockers": self._success_blockers(),
591
+ "events": [],
592
+ **self._score_info(),
593
+ }
594
+
595
+ def step(self, action):
596
+ action = np.asarray(action, dtype=float).reshape(-1)
597
+ if action.shape != (self.action_dim,) or not np.isfinite(action).all():
598
+ raise ValueError(f"expected finite action shape {(self.action_dim,)}")
599
+ if self._failure_reason is not None or self._events:
600
+ raise RuntimeError("episode finished; call reset")
601
+ previous = self._score_info()
602
+ super().step(np.clip(action, -1.0, 1.0))
603
+ depth = self._block_depth()
604
+ self._max_depth_seen = max(self._max_depth_seen, depth)
605
+ speed = float(np.linalg.norm(self._block_velocity()))
606
+ angular_speed = float(np.linalg.norm(self._block_angular_velocity()))
607
+ completed = (
608
+ self._block_contained()
609
+ and self._bottom_reached()
610
+ and speed <= self._STABLE_SPEED
611
+ and angular_speed <= self._STABLE_ANGULAR_SPEED
612
+ )
613
+ self._stable_ticks = self._stable_ticks + 1 if completed else 0
614
+ tick_events: list[str] = []
615
+ if self._stable_ticks >= self._STABLE_TICKS:
616
+ self._events.append("block_inserted")
617
+ tick_events.append("block_inserted")
618
+ finite = bool(np.isfinite(self.sim.data.qpos).all() and np.isfinite(self.sim.data.qvel).all())
619
+ if not finite:
620
+ self._failure_reason = "non_finite_state"
621
+ elif self._block_pose()[2] < 0.65:
622
+ self._failure_reason = "block_fell_from_table"
623
+ if self._failure_reason is not None:
624
+ self._events.clear()
625
+ tick_events.clear()
626
+ current = self._score_info()
627
+ current_total = current["time_score"] + current["distance_score"]
628
+ previous_total = previous["time_score"] + previous["distance_score"]
629
+ current["total_score"] = float(current_total)
630
+ info = {
631
+ "success_state": bool(tick_events), "terminal_success": bool(tick_events),
632
+ "events": tick_events,
633
+ "failure": {"recoverable": False, "reason": self._failure_reason} if self._failure_reason else None,
634
+ "success_blockers": self._success_blockers(speed=speed, angular_speed=angular_speed),
635
+ "score_delta": float(current_total - previous_total), **current,
636
+ }
637
+ terminated = bool(tick_events or self._failure_reason)
638
+ truncated = bool(self.timestep >= self.horizon and not terminated)
639
+ return self._observation(), float(info["score_delta"]), terminated, truncated, info
640
+
641
+ def close(self):
642
+ super().close()
@@ -0,0 +1,16 @@
1
+ {
2
+ "protocol": "practice_exam",
3
+ "practice_episodes": 10,
4
+ "exam_episodes": 5,
5
+ "exam_adaptation": "online",
6
+ "scoring": {
7
+ "primary": "mean_exam_task_score",
8
+ "score_key": "total_score",
9
+ "pass_k": [
10
+ 1,
11
+ 2,
12
+ 3,
13
+ 5
14
+ ]
15
+ }
16
+ }