You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
MuJoCo-based pipeline for training a π0 flow-matching policy to pick a
blue cube, weave it through a field of red obstacles, and place it on
a goal patch — with collision avoidance learned in the policy weights
rather than enforced post-hoc (see project_summary.md).
Includes:
- SafeCubeEnv (Gymnasium-style env) with privileged loss-only state
(cube positions, half-extents, ee_pos, grasped flag)
- Scene composition via MjSpec on top of the SO-101 URDF, with the
SO-101 model assets and 10 procedurally placed 1-inch red cubes
inside a 12×12 inch obstacle field
- Scripted expert (APPROACH → DESCEND → CLOSE → LIFT → CARRY →
DESCEND2 → OPEN) that follows BFS-derived waypoints across the
field, plus a magnetic-grip workaround for the SO-101 gripper's
inability to physically pinch 1-inch cubes
- LeRobotDataset writer with privileged keys flattened to 1D
- Eval harness reporting success / red_contact / min_clearance
- CLI scripts: record_rollout (agentview + wrist_cam + composite MP4),
collect_demos (LeRobotDataset format), view_env (interactive viewer)
- Wrist camera (gripper-mounted, targetbody-tracked at the moving jaw)
CLAUDE.md / AGENTS.md updated with the sim architecture and the
hard-won MuJoCo / URDF lessons (URDF parser flattening, MjSpec compile
pruning of dead geoms, joint-damping injection, self-collision mask
trick, etc.) so future agents can pick up cleanly.
Copy file name to clipboardExpand all lines: AGENTS.md
+142Lines changed: 142 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -54,3 +54,145 @@ pre-commit run --all-files # Lint + format (ruff, typo
54
54
-**Optional dependencies**: many policies, envs, and robots are behind extras (e.g., `lerobot[aloha]`). New imports for optional packages must be guarded or lazy. See `pyproject.toml [project.optional-dependencies]`.
55
55
-**Video decoding**: datasets can store observations as video files. `LeRobotDataset` handles frame extraction, but tests need ffmpeg installed.
56
56
-**Prioritize use of `uv run`** to execute Python commands (not raw `python` or `pip`).
A research-style sub-project lives at **`sim/`** (top of repo, *not* inside `src/lerobot/`). It is the data-collection half of the project described in [`project_summary.md`](./project_summary.md): train a π0 flow-matching VLA to pick a blue cube, weave it low through a field of red cubes, and drop it on a goal patch — with collision avoidance baked into the policy weights, not enforced post-hoc.
63
+
64
+
The sim is the source of (image, state, action, privileged.*) episodes that the future `SafePI0Policy` and DAgger driver will consume. **None of the policy / training code is in this repo yet.**
Action is `(n_arm + 1,) = (6,)` — absolute joint position targets (position-controlled actuators), gripper last. Chunked actions `(T, 6)` are accepted by `.step()` for the policy's open-loop chunk inference.
93
+
94
+
## Scene & layout (current tuning)
95
+
96
+
-**SO-101 URDF** loaded via `mujoco.MjSpec.from_file(...)` — actuators / cameras / damping are added procedurally on top.
97
+
-**Red field**: 12×12 inch (0.305 m) square, centered at `(0.225, 0.0)` on the table. Visualized as a flat black tape border (`zone_edge_*`).
98
+
-**10 red cubes**, 1 inch each (`half = 0.0127 m`), reject-sampled with `min_cube_separation = 0.055 m`.
99
+
-**Blue cube**: 1 inch, sampled in `x ∈ [0.195, 0.255]`, `y ∈ [0.17, 0.21]` (left side, +y). `blue_safety_radius = 0.085 m` from any red cube.
100
+
-**Goal patch**: FIXED at `(0.225, -0.20)` (right side, -y). Drawn as a 3×3 inch blue tape square (`goal_tape_*`). `goal_safety_radius = 0.07 m` from any red cube. Success uses L-inf check `|dx| < goal_size/2 AND |dy| < goal_size/2`.
101
+
-**BFS path-connectivity check** in `_find_path` runs at sample time — if no collision-free route from blue→goal exists, the whole layout is rejected and resampled (up to `max_layout_attempts = 100`). The resulting waypoint list is stored on `Layout.carry_waypoints` and consumed directly by the expert's CARRY phase.
102
+
-**Cameras**:
103
+
-`agentview`: fixed, behind the arm (`pos = (-0.22, 0, 0.55)`, `lookat = (0.23, 0, 0.02)`, fovy 50°). World +y appears on the LEFT of the rendered image — that's why blue is on +y.
104
+
-`wrist_cam`: attached to `gripper_link`, `pos = (0.05, 0, 0.06)` in local frame, `mode=mjCAMLIGHT_TARGETBODY` targeting `moving_jaw_so101_v1_link` so it auto-reorients with the arm. fovy 70°.
105
+
-**Home pose** (critical — URDF zero is "arm pointing straight up"): `home_qpos = (0.0, -1.2, 1.4, 0.3, 0.0)` (pan, lift, elbow, wrist_flex, wrist_roll) + `home_gripper = -0.1` (jaws closed at start). Settle 20 sim steps after seeding qpos+ctrl; `mj_forward` once before that.
106
+
107
+
## Hard-won MuJoCo / URDF lessons (read before changing the scene)
108
+
109
+
1.**MuJoCo URDF parser flattens fixed-joint links.** The `base_link` body's geoms end up attached *directly to the world body* (id 0) with no name. `gripper_frame_link` is flattened away entirely. Code that wants to identify "arm parts" must either look up bodies by name (for the surviving links) OR filter unnamed world-body geoms via a hardcoded exclusion list (`_NON_ARM_WORLD_GEOMS` in `scene.py`).
110
+
111
+
2.**`MjSpec.compile()` silently prunes geoms with `contype=0, conaffinity=0`** (treats them as dead). For visual-only decoration (border tape, etc.) use a unique bit (we use `contype=4, conaffinity=4`) — collisions only fire when bits overlap, so 4∩(1,2) = 0 ⇒ no collisions, but the geom survives compile and renders.
112
+
113
+
3.**URDFs ship NO joint damping / armature.** Without injecting them, position-controlled SO-101 oscillates wildly. We set `damping=2.0`, `armature=0.01` on every arm joint in `build_scene()`. `MjsJoint.damping` is a length-3 vector (per-axis), so pass `[d, 0, 0]` for a hinge.
114
+
115
+
4.**Arm self-collision pins joints.** Adjacent URDF link collision geoms slightly overlap → contact reaction torques cancel actuator force → `shoulder_pan` literally cannot move. Fix is a `contype` / `conaffinity` bitmask: arm-link + flattened-base geoms = `(2, 1)`, everything else (floor, table, cubes) = `(1, 1)`. arm-vs-arm masks intersect to 0 = no collision; arm-vs-cube/table = 1 = collides.
116
+
117
+
5.**`mj_kinematics` is NOT enough to compute the Jacobian.**`mj_jacSite` returns all zeros after `mj_kinematics`. Use `mj_forward` instead — it does the prereq inertia / com pass.
118
+
119
+
6.**Position controllers can't chase a "moving waypoint."** An expert that computes `target = ee + step` each tick advances the target faster than the controller can track → arm lags forever and never converges. Target *absolute* poses (the actual goal of each phase) instead and let the controller smooth.
120
+
121
+
7.**The SO-101 gripper physically can't pinch a 1-inch cube.** Minimum jaw spacing at fully closed is ~55 mm vs the 25 mm cube. We solve it with a **magnetic grip** in `env._update_grasp()`: when (a) the gripper *qpos* (NOT ctrl) is below `_grip_qpos_attach_threshold = 0.20` AND (b) the cube is within `_grip_attach_radius = 0.04 m` of the ee_site, the cube is kinematically locked to `ee_pos + (0, 0, -0.012)` each substep. Released when qpos rises above `_grip_qpos_release_threshold = 0.40`. The policy still sees the natural pixel-level cause-and-effect.
122
+
123
+
8.**Grasp must trigger on qpos, not ctrl.** Triggering on ctrl makes the cube snap to the gripper the instant the CLOSE phase begins, before the jaws have physically moved — looks awful and is unphysical. Trigger on actual `data.qpos[gripper]`.
124
+
125
+
9.**Once grasped, `blue_z` tracks the gripper.** So `LIFT/CARRY/DESCEND2` targets must be in *absolute* world-frame z, not relative to `blue.z` — otherwise the target ramps with the gripper and the arm flies off (and through the ceiling).
126
+
127
+
10.**Potential-field navigation has local minima.** Original CARRY used attractive-toward-goal + repulsive-from-reds and routinely deadlocked. Replaced with BFS waypoints from the path-connectivity check — no minima possible.
128
+
129
+
11.**macOS interactive viewer needs `mjpython`.**`mujoco.viewer.launch_passive` requires the main thread; on Darwin the entry point is `.venv/bin/mjpython`, not `python`. For headless rendering use a separate `mujoco.Renderer` — no thread issue.
130
+
131
+
12.**Gripper open/closed convention** (verified by probing the moving_jaw geom): **low qpos = closed, high qpos = open**. URDF limits are `[-0.174, 1.745]`. Current config uses `grip_closed = -0.10`, `grip_open = 0.60` (~36% of ROM). Don't open all the way — there's no need and it slows the sequence.
132
+
133
+
## Expert FSM phases (`sim/expert.py`)
134
+
135
+
```
136
+
APPROACH pan over the blue cube at z = blue.z + pre_grasp_height (0.07 m)
137
+
DESCEND drop to z = blue.z + descend_clearance (0.004 m); reach_tol 0.018 m
138
+
— contact stops the arm ~8 mm above the cube, gripper at cube edge
139
+
CLOSE ctrl = grip_closed; advance only after jaws actually close
140
+
(qpos < grasp_close_qpos_threshold) AND post_grasp_hold_steps (15 ≈ 0.5s)
141
+
LIFT z = carry_z (0.040 m, absolute, well under the 0.060 m ceiling)
142
+
CARRY iterate through Layout.carry_waypoints in XY at carry_z
143
+
DESCEND2 z = pre_release_z (0.028 m); tight XY tolerance (0.012 m) for centering
144
+
OPEN ctrl = grip_open; advance only after jaws actually open
145
+
(qpos > grasp_open_qpos_threshold = 0.45) AND release_settle_steps
146
+
DONE (post-loop hold of grip_open held by the rollout scripts)
147
+
```
148
+
149
+
Each phase targets a Cartesian pose; IK is solved via `env.ik_solve()` (damped LS on an `MjData` copy with `mj_forward` per iteration).
150
+
151
+
## Tuned constants worth knowing
152
+
153
+
| Knob | Value | Where | Why |
154
+
|---|---|---|---|
155
+
|`_KP` (position ctrl) | 75 N·m/rad |`scene.py`| Halved from 150 for smoother motion |
156
+
|`_KD`| 4 |`scene.py`| Damped enough to avoid overshoot |
157
+
|`_FORCE_LIMIT`| 30 N·m |`scene.py`| Generous for sim (real STS3215 is ~2–3) |
# Interactive viewer (macOS — note mjpython, not python)
181
+
.venv/bin/mjpython -m sim.scripts.view_env
182
+
```
183
+
184
+
## What's NOT done yet (for the next agent)
185
+
186
+
-**`SafePI0Policy`** — the LeRobot subclass with `L = L_flow_matching + λ·L_safety` per `project_summary.md` §5. Privileged keys are already in the recorded datasets (`privileged.cube_positions`, `privileged.cube_half_extents`, `privileged.blue_cube_pos`, `privileged.goal_pos`, `privileged.ee_pos`, `privileged.grasped`) — the policy/loss just needs to read them from the batch and ignore them at inference. Per-cube arrays are flattened to 1D; reshape on load.
187
+
-**DAgger driver** — sketched in `sim/README.md`. The primitives it needs are already in place (`SafeCubeEnv`, `ScriptedExpert`, `EpisodeRecorder`, `evaluate.evaluate()`).
188
+
-**Domain randomization not yet applied.**`sim/randomize.py` defines `apply_dr(model, rng, cfg)` but `env.reset()` doesn't call it. Wire it in after the scene compiles, before the first physics step, when ready.
189
+
-**Real-world fine-tune / hardware deployment** — out of scope for the sim.
190
+
191
+
## Gotchas for future edits
192
+
193
+
- If you add a new visual-only geom to the world, add its name to `_NON_ARM_WORLD_GEOMS` in `scene.py` so the collision-mask rewrite doesn't flag it as a flattened-base arm geom.
194
+
- If you change `grip_open` or `grip_closed`, double-check the four thresholds: `_grip_qpos_attach_threshold` / `_grip_qpos_release_threshold` (env.py), and `grasp_close_qpos_threshold` / `grasp_open_qpos_threshold` (configs.py). They need to sit between the new closed and open values with hysteresis.
195
+
- After `expert.done()`, the rollout scripts must send `grip_open` (not `home_gripper`) as the gripper action — otherwise the gripper closes back on the released cube and re-attaches it via the magnetic grip. Already wired in `record_rollout.py` and `collect_demos.py`; replicate the pattern if you write new drivers.
196
+
-`success` only fires after `success_dwell_steps = 5` frames of the cube resting in the goal zone *with the gripper released*. Don't exit the env loop the instant `expert.done()` is True — keep stepping for ~1 s with a held-open pose so the dwell window can register.
197
+
- The wrist camera is mounted on `gripper_link` (rotates with the gripper's `wrist_roll`). If you ever change the home pose so the wrist is rolled, expect the wrist-cam horizon to tilt accordingly.
198
+
- Sample-time path BFS uses `path_clearance_radius = 0.045 m`. If you shrink `min_cube_separation` or pack the field tighter, this BFS will start rejecting layouts and `sample_layout` will raise after 100 attempts.
0 commit comments