Skip to content

Commit 3eeab25

Browse files
committed
fix(isaac): make kinematic-tree guard fail-first, fix G1 topology
cagataycali's comment at strands_robots_sim/isaac/tests/test_procedural_kinematic_guard.py:13 on PR #51 pushed back on the opt-in env-var guard from def3c21: shipping a robot we know cannot instantiate has no good use case in this package, and the default should fail first rather than silently producing a broken robot. This commit lands the fix-first answer rather than a flag-flip: * _validate_kinematic_tree now runs unconditionally on every procedural builder + every URDF/MJCF/USD loader. The STRANDS_ISAAC_VALIDATE_KINEMATICS env-var escape hatch is removed; there is no use case for opting out of a check that only fires on robots that cannot instantiate. * _build_unitree_g1 inserts six massless intermediate '*_link' bodies (l_hip_link, r_hip_link, l_ankle_link, r_ankle_link, l_elbow_link, r_elbow_link) so each 2-DOF compound joint (hips, ankles, shoulder-yaw/elbow on each arm) splits across two unique (parent, child) edges. Actuated joint count stays at 21; only the topology gains the six fixed link bodies, so the existing 21-DOF doc-pin in test_procedural_g1_dof.py keeps passing. * test_procedural_kinematic_guard.py is rewritten to pin the fail-first contract: - all three shipped robots build cleanly under the default guard (test_g1_builds_cleanly_by_default, test_so100_and_panda_build_cleanly) - G1 topology has zero duplicate edges (test_g1_topology_has_no_duplicate_edges) - an injected duplicate edge raises with diagnostic context (test_guard_raises_on_injected_duplicate_edge) - procedural.py source contains no env-var gate or os.environ read for the guard (test_guard_has_no_env_var_escape_hatch) -- a future refactor that re-introduces the escape hatch will fail this pin. hatch run test: 44 passed, 2 skipped. hatch run lint: clean.
1 parent f8ae3a8 commit 3eeab25

3 files changed

Lines changed: 162 additions & 136 deletions

File tree

strands_robots_sim/isaac/procedural.py

Lines changed: 58 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111

1212
from __future__ import annotations
1313

14-
import os
1514
from dataclasses import dataclass, field
1615

1716

@@ -64,29 +63,34 @@ def joint_names(self) -> list[str]:
6463

6564

6665
def _validate_kinematic_tree(robot: ProceduralRobot) -> None:
67-
"""Phase-1 fail-fast guard: reject duplicate (parent, child) body edges.
68-
69-
Phase-1 callers don't instantiate the articulation, so this is a no-op at
70-
builder time UNLESS ``STRANDS_ISAAC_VALIDATE_KINEMATICS=1`` is set
71-
(Phase-2 dev path). When the guard fires, it surfaces the defect with
72-
body indices + joint names rather than letting USD/MuJoCo emit a cryptic
73-
articulation error two layers down.
74-
75-
See the NOTE in ``_build_unitree_g1`` for the specific defect this is
76-
designed to catch (G1 legs/arms currently share a single ``(parent,
77-
child)`` edge across two joint axes; USD requires intermediate massless
78-
link bodies).
66+
"""Fail-fast guard: reject duplicate (parent, child) body edges.
67+
68+
A USD/MuJoCo articulation requires a tree where each non-root link has
69+
exactly one inbound joint. Two joints sharing the same ``(parent_body,
70+
child_body)`` edge violate that invariant and would surface two layers
71+
down as a cryptic articulation error at instantiation time.
72+
73+
Validation runs unconditionally on every procedural builder + every
74+
URDF / MJCF / USD loader: shipping a robot we know cannot instantiate
75+
has no good use case in this package, and silently producing one is
76+
worse than failing fast at builder time. The check raises ``ValueError``
77+
with body indices + joint names so the offender is obvious from the
78+
traceback alone.
79+
80+
For 2-DOF compound joints (e.g. a hip with both roll and pitch axes),
81+
insert an intermediate massless link body between the two joints so each
82+
joint has its own ``(parent, child)`` edge. ``_build_unitree_g1`` is the
83+
canonical example -- six ``*_link`` intermediate bodies split the
84+
hip / ankle / arm 2-DOF axes.
7985
"""
80-
if os.environ.get("STRANDS_ISAAC_VALIDATE_KINEMATICS", "").lower() not in ("1", "true", "yes"):
81-
return
8286
from collections import Counter
8387

8488
edges = [(j.parent_body, j.child_body) for j in robot.joints]
8589
dups = {edge: count for edge, count in Counter(edges).items() if count > 1}
8690
if dups:
8791
offenders = {edge: [j.name for j in robot.joints if (j.parent_body, j.child_body) == edge] for edge in dups}
8892
raise ValueError(
89-
f"{robot.name}: duplicate parent->child body edges (Phase-2 defect): {offenders}. "
93+
f"{robot.name}: duplicate parent->child body edges: {offenders}. "
9094
f"Insert intermediate massless link bodies before instantiating articulation."
9195
)
9296

@@ -170,7 +174,15 @@ def _build_panda() -> ProceduralRobot:
170174

171175

172176
def _build_unitree_g1() -> ProceduralRobot:
173-
"""Build Unitree G1 humanoid (simplified 21-DOF) procedurally."""
177+
"""Build Unitree G1 humanoid (simplified 21-DOF) procedurally.
178+
179+
The G1 has six 2-DOF compound joints (hips, ankles, shoulder/elbow on each
180+
arm). To keep the kinematic graph a valid tree -- one inbound joint per
181+
non-root link, as USD / MuJoCo articulations require -- each compound
182+
joint is split through a massless intermediate ``*_link`` body. The two
183+
axes still resolve to two actuated joints (so ``num_joints == 21``); only
184+
the topology gains the six extra fixed link bodies.
185+
"""
174186
bodies = [
175187
BodyDef(name="pelvis", position=(0.0, 0.0, 0.85), mass=10.0, shape="box", shape_size=(0.15, 0.1, 0.15)),
176188
BodyDef(name="torso", position=(0.0, 0.0, 1.1), mass=8.0, shape="box", shape_size=(0.12, 0.08, 0.3)),
@@ -193,59 +205,60 @@ def _build_unitree_g1() -> ProceduralRobot:
193205
BodyDef(name="r_shoulder", position=(0.2, 0.0, 1.2), mass=1.5, shape="sphere", shape_size=(0.04,)),
194206
BodyDef(name="r_upper_arm", position=(0.35, 0.0, 1.2), mass=1.5, shape="capsule", shape_size=(0.03, 0.12)),
195207
BodyDef(name="r_forearm", position=(0.55, 0.0, 1.2), mass=1.0, shape="capsule", shape_size=(0.025, 0.1)),
208+
# Massless intermediate link bodies so 2-DOF compound joints (hips,
209+
# ankles, shoulder-yaw/elbow) each have a unique (parent, child) edge.
210+
# Indices 17..22.
211+
BodyDef(name="l_hip_link", position=(-0.08, 0.0, 0.7), mass=0.0, shape="sphere", shape_size=(0.001,)),
212+
BodyDef(name="r_hip_link", position=(0.08, 0.0, 0.7), mass=0.0, shape="sphere", shape_size=(0.001,)),
213+
BodyDef(name="l_ankle_link", position=(-0.08, 0.0, 0.1), mass=0.0, shape="sphere", shape_size=(0.001,)),
214+
BodyDef(name="r_ankle_link", position=(0.08, 0.0, 0.1), mass=0.0, shape="sphere", shape_size=(0.001,)),
215+
BodyDef(name="l_elbow_link", position=(-0.45, 0.0, 1.2), mass=0.0, shape="sphere", shape_size=(0.001,)),
216+
BodyDef(name="r_elbow_link", position=(0.45, 0.0, 1.2), mass=0.0, shape="sphere", shape_size=(0.001,)),
196217
]
197218

198-
# Simplified joint set (21 DOF total: 1 torso + 6 left leg + 6 right leg + 4 left arm + 4 right arm).
199-
# NOTE: this kinematic graph contains duplicate (parent, child) edges on each leg/arm
200-
# (e.g. l_hip_roll and l_hip_pitch both map bodies 3 -> 4). A real USD/MuJoCo articulation
201-
# builder requires a tree where each non-root link has exactly one inbound joint, so this
202-
# topology will need intermediate massless link bodies before Phase 2 wires up the actual
203-
# USD prim chain. Tracked as Phase-2 work; the Phase-1 skeleton does not instantiate the
204-
# articulation, so the duplicate-edge defect is dormant on this branch.
205-
#
206-
# ``_validate_kinematic_tree`` (called below) is a no-op by default but raises when
207-
# ``STRANDS_ISAAC_VALIDATE_KINEMATICS=1`` is set, which is the Phase-2 dev path: it
208-
# surfaces this defect at builder time with body indices + joint names rather than
209-
# letting USD/MuJoCo emit a cryptic articulation error two layers down.
219+
# 21 DOF total: 1 torso + 6 left leg + 6 right leg + 4 left arm + 4 right arm.
220+
# Each 2-DOF compound joint is split through a massless intermediate body
221+
# (indices 17..22) so every (parent, child) edge in the kinematic tree is
222+
# unique -- the invariant ``_validate_kinematic_tree`` enforces below.
210223
joints = [
211224
# Torso
212225
JointDef(name="torso_yaw", parent_body=0, child_body=1, axis=(0, 0, 1), limit_lower=-1.0, limit_upper=1.0),
213-
# Left leg (6 DOF)
226+
# Left leg (6 DOF). Hip 2-DOF split via l_hip_link (17); ankle 2-DOF split via l_ankle_link (19).
214227
JointDef(name="l_hip_yaw", parent_body=0, child_body=3, axis=(0, 0, 1), limit_lower=-0.5, limit_upper=0.5),
215-
JointDef(name="l_hip_roll", parent_body=3, child_body=4, axis=(1, 0, 0), limit_lower=-0.5, limit_upper=0.5),
216-
JointDef(name="l_hip_pitch", parent_body=3, child_body=4, axis=(0, 1, 0), limit_lower=-1.5, limit_upper=0.5),
228+
JointDef(name="l_hip_roll", parent_body=3, child_body=17, axis=(1, 0, 0), limit_lower=-0.5, limit_upper=0.5),
229+
JointDef(name="l_hip_pitch", parent_body=17, child_body=4, axis=(0, 1, 0), limit_lower=-1.5, limit_upper=0.5),
217230
JointDef(name="l_knee", parent_body=4, child_body=5, axis=(0, 1, 0), limit_lower=-0.1, limit_upper=2.5),
218-
JointDef(name="l_ankle_pitch", parent_body=5, child_body=6, axis=(0, 1, 0), limit_lower=-0.8, limit_upper=0.5),
219-
JointDef(name="l_ankle_roll", parent_body=5, child_body=6, axis=(1, 0, 0), limit_lower=-0.3, limit_upper=0.3),
220-
# Right leg (6 DOF)
231+
JointDef(name="l_ankle_pitch", parent_body=5, child_body=19, axis=(0, 1, 0), limit_lower=-0.8, limit_upper=0.5),
232+
JointDef(name="l_ankle_roll", parent_body=19, child_body=6, axis=(1, 0, 0), limit_lower=-0.3, limit_upper=0.3),
233+
# Right leg (6 DOF). Hip 2-DOF split via r_hip_link (18); ankle 2-DOF split via r_ankle_link (20).
221234
JointDef(name="r_hip_yaw", parent_body=0, child_body=7, axis=(0, 0, 1), limit_lower=-0.5, limit_upper=0.5),
222-
JointDef(name="r_hip_roll", parent_body=7, child_body=8, axis=(1, 0, 0), limit_lower=-0.5, limit_upper=0.5),
223-
JointDef(name="r_hip_pitch", parent_body=7, child_body=8, axis=(0, 1, 0), limit_lower=-1.5, limit_upper=0.5),
235+
JointDef(name="r_hip_roll", parent_body=7, child_body=18, axis=(1, 0, 0), limit_lower=-0.5, limit_upper=0.5),
236+
JointDef(name="r_hip_pitch", parent_body=18, child_body=8, axis=(0, 1, 0), limit_lower=-1.5, limit_upper=0.5),
224237
JointDef(name="r_knee", parent_body=8, child_body=9, axis=(0, 1, 0), limit_lower=-0.1, limit_upper=2.5),
225-
JointDef(name="r_ankle_pitch", parent_body=9, child_body=10, axis=(0, 1, 0), limit_lower=-0.8, limit_upper=0.5),
226-
JointDef(name="r_ankle_roll", parent_body=9, child_body=10, axis=(1, 0, 0), limit_lower=-0.3, limit_upper=0.3),
227-
# Left arm (4 DOF simplified)
238+
JointDef(name="r_ankle_pitch", parent_body=9, child_body=20, axis=(0, 1, 0), limit_lower=-0.8, limit_upper=0.5),
239+
JointDef(name="r_ankle_roll", parent_body=20, child_body=10, axis=(1, 0, 0), limit_lower=-0.3, limit_upper=0.3),
240+
# Left arm (4 DOF simplified). Shoulder-yaw / elbow 2-DOF split via l_elbow_link (21).
228241
JointDef(
229242
name="l_shoulder_pitch", parent_body=1, child_body=11, axis=(0, 1, 0), limit_lower=-3.14, limit_upper=1.0
230243
),
231244
JointDef(
232245
name="l_shoulder_roll", parent_body=11, child_body=12, axis=(1, 0, 0), limit_lower=-0.3, limit_upper=3.14
233246
),
234247
JointDef(
235-
name="l_shoulder_yaw", parent_body=12, child_body=13, axis=(0, 0, 1), limit_lower=-1.5, limit_upper=1.5
248+
name="l_shoulder_yaw", parent_body=12, child_body=21, axis=(0, 0, 1), limit_lower=-1.5, limit_upper=1.5
236249
),
237-
JointDef(name="l_elbow", parent_body=12, child_body=13, axis=(0, 1, 0), limit_lower=-2.5, limit_upper=0.0),
238-
# Right arm (4 DOF simplified)
250+
JointDef(name="l_elbow", parent_body=21, child_body=13, axis=(0, 1, 0), limit_lower=-2.5, limit_upper=0.0),
251+
# Right arm (4 DOF simplified). Shoulder-yaw / elbow 2-DOF split via r_elbow_link (22).
239252
JointDef(
240253
name="r_shoulder_pitch", parent_body=1, child_body=14, axis=(0, 1, 0), limit_lower=-3.14, limit_upper=1.0
241254
),
242255
JointDef(
243256
name="r_shoulder_roll", parent_body=14, child_body=15, axis=(1, 0, 0), limit_lower=-3.14, limit_upper=0.3
244257
),
245258
JointDef(
246-
name="r_shoulder_yaw", parent_body=15, child_body=16, axis=(0, 0, 1), limit_lower=-1.5, limit_upper=1.5
259+
name="r_shoulder_yaw", parent_body=15, child_body=22, axis=(0, 0, 1), limit_lower=-1.5, limit_upper=1.5
247260
),
248-
JointDef(name="r_elbow", parent_body=15, child_body=16, axis=(0, 1, 0), limit_lower=-2.5, limit_upper=0.0),
261+
JointDef(name="r_elbow", parent_body=22, child_body=16, axis=(0, 1, 0), limit_lower=-2.5, limit_upper=0.0),
249262
]
250263

251264
robot = ProceduralRobot(name="unitree_g1", bodies=bodies, joints=joints, base_position=(0.0, 0.0, 0.85))

strands_robots_sim/isaac/tests/test_procedural_g1_dof.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,9 @@
1111
``test_phase1_doc_banner.py`` (lands in PR-5 alongside the docs file
1212
itself).
1313
14-
Note: this pin does NOT cover the kinematic-tree topology defect on the
15-
G1 joint graph (duplicate ``(parent_body, child_body)`` edges); that is
16-
a Phase 2 fix that needs intermediate massless link bodies, and is
17-
documented in ``procedural.py`` line 165 as deferred.
14+
Note: this pin does NOT cover the kinematic-tree topology validity (each
15+
non-root link has exactly one inbound joint); that invariant is pinned
16+
separately in ``test_procedural_kinematic_guard.py``.
1817
"""
1918

2019
from __future__ import annotations

0 commit comments

Comments
 (0)