Skip to content

Commit f218a0a

Browse files
committed
Build the shim fixtures from the layout Newton actually reports
The fixtures described a layout that does not exist: actuators laid out world-major with a uniform per-instance stride. MEASURED on a 2-environment handover model, Newton reports 80 actuators across 6 articulations, 20 for each of the four hands and none for the two objects, and identifies an actuator by the prim path of the joint it drives. Rebuild the fixtures around that, so the tests exercise the label join the shim performs. Cover the case the old fixtures could not express: two hands sharing a world, each writing mujoco.ctrl without touching the other.
1 parent e4912ec commit f218a0a

1 file changed

Lines changed: 80 additions & 52 deletions

File tree

source/isaaclab_newton/test/assets/test_mjc_view_compat.py

Lines changed: 80 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -12,102 +12,130 @@
1212

1313
pytestmark = pytest.mark.unit
1414

15-
16-
def _model(*, world_count, count_per_world, actuator_count, tendon_count=2):
17-
"""Build a model whose actuator rows are laid out world-major, as MuJoCo lays them out."""
18-
total = world_count * count_per_world * actuator_count
19-
worlds = np.repeat(np.arange(world_count), count_per_world * actuator_count)
15+
# Shapes below mirror what a 2-environment handover model reports: every world holds a right
16+
# hand, a left hand and an object, and only the hands own actuators.
17+
_ARTICULATIONS = [
18+
"/World/envs/env_0/RightRobot",
19+
"/World/envs/env_0/LeftRobot",
20+
"/World/envs/env_0/Object",
21+
"/World/envs/env_1/RightRobot",
22+
"/World/envs/env_1/LeftRobot",
23+
"/World/envs/env_1/Object",
24+
]
25+
26+
27+
def _model(actuators_per_hand=2, tendons_per_hand=1):
28+
"""Build a model whose actuator labels are prim paths under their articulation, as MuJoCo emits."""
29+
labels = []
30+
for path in _ARTICULATIONS:
31+
if path.endswith("Object"):
32+
continue # objects own no actuators, which is why no division recovers the count
33+
labels += [f"{path}/joint_{i}" for i in range(actuators_per_hand)]
2034
return SimpleNamespace(
35+
articulation_count=len(_ARTICULATIONS),
36+
articulation_label=_ARTICULATIONS,
2137
custom_frequency_counts={
22-
"mujoco:actuator": total,
23-
"mujoco:tendon": world_count * count_per_world * tendon_count,
38+
"mujoco:actuator": len(labels),
39+
"mujoco:tendon": tendons_per_hand * 4,
2440
},
2541
mujoco=SimpleNamespace(
26-
actuator_world=worlds,
27-
actuator_target_label=[f"act{i}" for i in range(total)],
28-
ctrl=wp.zeros(total, dtype=wp.float32, device="cpu"),
42+
actuator_target_label=labels,
43+
ctrl=wp.zeros(len(labels), dtype=wp.float32, device="cpu"),
2944
),
3045
)
3146

3247

33-
def _view(*, world_count, count_per_world):
48+
def _view(articulation_ids, *, world_count=2, count_per_world=1):
3449
return SimpleNamespace(
3550
device=wp.get_device("cpu"),
3651
world_count=world_count,
3752
count_per_world=count_per_world,
3853
count=world_count * count_per_world,
39-
tendon_count=2,
40-
tendon_names=["t0", "t1"],
54+
# Newton reports these per world, i.e. shape (world_count, count_per_world).
55+
articulation_ids=np.array(articulation_ids).reshape(world_count, count_per_world),
56+
tendon_count=1,
57+
tendon_names=["t0"],
4158
)
4259

4360

61+
def _right_hand(model=None):
62+
return ensure_custom_frequency_api(_view([0, 3]), model or _model())
63+
64+
4465
def test_a_view_that_already_has_the_api_is_returned_unchanged():
4566
"""On Newton 1.6 the wrapper must not interpose at all."""
46-
view = _view(world_count=2, count_per_world=1)
47-
view.custom_frequency_counts = {"mujoco:actuator": 3}
67+
view = _view([0, 3])
68+
view.custom_frequency_counts = {"mujoco:actuator": 2}
4869

49-
assert ensure_custom_frequency_api(view, _model(world_count=2, count_per_world=1, actuator_count=3)) is view
70+
assert ensure_custom_frequency_api(view, _model()) is view
5071

5172

52-
def test_counts_are_per_articulation_not_scene_wide():
53-
"""Newton 1.6 reports per-articulation counts; the model's are scene-wide."""
54-
model = _model(world_count=4, count_per_world=1, actuator_count=3)
55-
adapted = ensure_custom_frequency_api(_view(world_count=4, count_per_world=1), model)
73+
def test_counts_come_from_the_articulations_the_view_owns():
74+
"""8 actuators over 6 articulations divides to neither 2 nor 4; only the join gives 2."""
75+
model = _model()
5676

57-
assert model.custom_frequency_counts["mujoco:actuator"] == 12
58-
assert adapted.custom_frequency_counts["mujoco:actuator"] == 3
77+
assert model.custom_frequency_counts["mujoco:actuator"] == 8
78+
assert model.articulation_count == 6
79+
assert _right_hand(model).custom_frequency_counts["mujoco:actuator"] == 2
5980

6081

6182
def test_unrelated_attributes_are_delegated_to_the_wrapped_view():
62-
view = _view(world_count=2, count_per_world=1)
83+
view = _view([0, 3])
6384
view.some_newton_attribute = "delegated"
64-
adapted = ensure_custom_frequency_api(view, _model(world_count=2, count_per_world=1, actuator_count=3))
85+
adapted = ensure_custom_frequency_api(view, _model())
6586

6687
assert adapted.some_newton_attribute == "delegated"
6788
assert adapted.world_count == 2
6889

6990

70-
def test_actuator_rows_are_read_back_per_world():
71-
"""``get_attribute`` reshapes the flat rows into the view's (world, instance, value) shape."""
72-
model = _model(world_count=3, count_per_world=1, actuator_count=2)
73-
model.mujoco.actuator_trntype = np.arange(6, dtype=np.int32)
74-
adapted = ensure_custom_frequency_api(_view(world_count=3, count_per_world=1), model)
91+
def test_labels_name_the_actuators_this_view_owns():
92+
labels = _right_hand().custom_frequency_labels["mujoco:actuator"]
7593

76-
got = adapted.get_attribute("mujoco.actuator_trntype", model).numpy()
94+
assert labels == ["/World/envs/env_0/RightRobot/joint_0", "/World/envs/env_0/RightRobot/joint_1"]
7795

78-
assert got.shape == (3, 1, 2)
79-
# World-major layout: world w owns rows [2w, 2w+1].
80-
np.testing.assert_array_equal(got[:, 0, :], [[0, 1], [2, 3], [4, 5]])
8196

97+
def test_two_articulations_in_one_world_do_not_claim_each_others_actuators():
98+
"""The handover scene puts two hands in each world, and each view must take only its own."""
99+
model = _model()
100+
right = ensure_custom_frequency_api(_view([0, 3]), model)
101+
left = ensure_custom_frequency_api(_view([1, 4]), model)
82102

83-
def test_ctrl_writes_land_on_the_rows_that_world_owns():
84-
model = _model(world_count=3, count_per_world=1, actuator_count=2)
85-
adapted = ensure_custom_frequency_api(_view(world_count=3, count_per_world=1), model)
86-
values = wp.array(
87-
np.array([[[1.0, 2.0]], [[3.0, 4.0]], [[5.0, 6.0]]], dtype=np.float32), dtype=wp.float32, device="cpu"
103+
right.set_attribute(
104+
"mujoco.ctrl", model, wp.array(np.array([[[1.0, 2.0]], [[5.0, 6.0]]], dtype=np.float32), device="cpu")
105+
)
106+
left.set_attribute(
107+
"mujoco.ctrl", model, wp.array(np.array([[[3.0, 4.0]], [[7.0, 8.0]]], dtype=np.float32), device="cpu")
88108
)
89109

90-
adapted.set_attribute("mujoco.ctrl", model, values)
110+
# Right owns rows 0-1 and 4-5, left owns 2-3 and 6-7; neither overwrote the other.
111+
np.testing.assert_array_equal(model.mujoco.ctrl.numpy(), [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0])
112+
113+
114+
def test_actuator_rows_are_read_back_in_view_shape():
115+
model = _model()
116+
model.mujoco.actuator_trntype = np.arange(8, dtype=np.int32)
117+
118+
got = _right_hand(model).get_attribute("mujoco.actuator_trntype", model).numpy()
91119

92-
np.testing.assert_array_equal(model.mujoco.ctrl.numpy(), [1.0, 2.0, 3.0, 4.0, 5.0, 6.0])
120+
assert got.shape == (2, 1, 2)
121+
# The right hand owns rows 0-1 in world 0 and 4-5 in world 1.
122+
np.testing.assert_array_equal(got[:, 0, :], [[0, 1], [4, 5]])
93123

94124

95125
def test_a_mismatched_command_shape_is_refused():
96126
"""Silently broadcasting the wrong shape would drive arbitrary actuators."""
97-
model = _model(world_count=2, count_per_world=1, actuator_count=2)
98-
adapted = ensure_custom_frequency_api(_view(world_count=2, count_per_world=1), model)
99-
values = wp.zeros((2, 1, 3), dtype=wp.float32, device="cpu")
127+
model = _model()
100128

101129
with pytest.raises(ValueError, match="Expected values shaped"):
102-
adapted.set_attribute("mujoco.ctrl", model, values)
130+
_right_hand(model).set_attribute("mujoco.ctrl", model, wp.zeros((2, 1, 3), dtype=wp.float32, device="cpu"))
103131

104132

105-
def test_a_world_short_of_actuator_rows_is_refused():
106-
"""Deriving a layout from too few rows would alias instances onto each other."""
107-
model = _model(world_count=2, count_per_world=1, actuator_count=2)
108-
# World 1 is one row short of the 2 actuators its instance needs.
109-
model.mujoco.actuator_world = np.array([0, 0, 0, 1])
110-
adapted = ensure_custom_frequency_api(_view(world_count=2, count_per_world=1), model)
133+
def test_articulations_owning_different_actuator_counts_are_refused():
134+
"""No view-shaped buffer can represent a ragged selection, so say so rather than truncate."""
135+
model = _model()
136+
model.mujoco.actuator_target_label = model.mujoco.actuator_target_label + [
137+
"/World/envs/env_0/RightRobot/joint_extra"
138+
]
111139

112-
with pytest.raises(ValueError, match="actuator rows"):
113-
adapted.get_attribute("mujoco.ctrl", model)
140+
with pytest.raises(ValueError, match="differing actuator counts"):
141+
ensure_custom_frequency_api(_view([0, 3]), model)

0 commit comments

Comments
 (0)