|
12 | 12 |
|
13 | 13 | pytestmark = pytest.mark.unit |
14 | 14 |
|
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)] |
20 | 34 | return SimpleNamespace( |
| 35 | + articulation_count=len(_ARTICULATIONS), |
| 36 | + articulation_label=_ARTICULATIONS, |
21 | 37 | 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, |
24 | 40 | }, |
25 | 41 | 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"), |
29 | 44 | ), |
30 | 45 | ) |
31 | 46 |
|
32 | 47 |
|
33 | | -def _view(*, world_count, count_per_world): |
| 48 | +def _view(articulation_ids, *, world_count=2, count_per_world=1): |
34 | 49 | return SimpleNamespace( |
35 | 50 | device=wp.get_device("cpu"), |
36 | 51 | world_count=world_count, |
37 | 52 | count_per_world=count_per_world, |
38 | 53 | 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"], |
41 | 58 | ) |
42 | 59 |
|
43 | 60 |
|
| 61 | +def _right_hand(model=None): |
| 62 | + return ensure_custom_frequency_api(_view([0, 3]), model or _model()) |
| 63 | + |
| 64 | + |
44 | 65 | def test_a_view_that_already_has_the_api_is_returned_unchanged(): |
45 | 66 | """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} |
48 | 69 |
|
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 |
50 | 71 |
|
51 | 72 |
|
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() |
56 | 76 |
|
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 |
59 | 80 |
|
60 | 81 |
|
61 | 82 | 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]) |
63 | 84 | 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()) |
65 | 86 |
|
66 | 87 | assert adapted.some_newton_attribute == "delegated" |
67 | 88 | assert adapted.world_count == 2 |
68 | 89 |
|
69 | 90 |
|
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"] |
75 | 93 |
|
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"] |
77 | 95 |
|
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]]) |
81 | 96 |
|
| 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) |
82 | 102 |
|
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") |
88 | 108 | ) |
89 | 109 |
|
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() |
91 | 119 |
|
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]]) |
93 | 123 |
|
94 | 124 |
|
95 | 125 | def test_a_mismatched_command_shape_is_refused(): |
96 | 126 | """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() |
100 | 128 |
|
101 | 129 | 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")) |
103 | 131 |
|
104 | 132 |
|
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 | + ] |
111 | 139 |
|
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