Skip to content

Commit ea456bc

Browse files
author
Horde
committed
Fix MuJoCo tendon duplication in Newton physics replication
add_builder is called once per environment in _build_newton_builder_from_mapping. Each call appends T tendon custom-attribute entries (from the prototype), so after N environments the builder has N×T entries instead of the T entries that Newton's SolverMuJoCo expects (it only reads tendons from world 0 and replicates them internally). Introduce _deduplicate_tendon_attributes, which trims all mujoco:tendon*, mujoco:tendon_joint*, and mujoco:tendon_wrap* custom attribute lists back to the template-world (world-0) count after the build loop completes. The trim boundary is captured from _custom_frequency_counts after the first world is processed. Add 8 unit tests in test/cloner/test_tendon_deduplication.py that exercise the helper directly using Newton's ModelBuilder API without requiring Isaac Sim or MJCF XML parsing. Bump version to 0.5.16.
1 parent 3d7a2ed commit ea456bc

4 files changed

Lines changed: 331 additions & 1 deletion

File tree

source/isaaclab_newton/config/extension.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[package]
22

33
# Note: Semantic Versioning is used: https://semver.org/
4-
version = "0.5.15"
4+
version = "0.5.16"
55

66
# Description
77
title = "Newton simulation interfaces for IsaacLab core package"

source/isaaclab_newton/docs/CHANGELOG.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
Changelog
22
---------
33

4+
0.5.16 (2026-04-28)
5+
~~~~~~~~~~~~~~~~~~~
6+
7+
Fixed
8+
^^^^^
9+
10+
* Fixed MuJoCo tendon duplication in Newton physics replication.
11+
:func:`~isaaclab_newton.cloner.newton_replicate._build_newton_builder_from_mapping`
12+
called ``add_builder`` once per environment, which appended T tendon custom-attribute
13+
entries each time and produced N×T entries after N environments instead of the T
14+
template-world entries that Newton's SolverMuJoCo expects. A new
15+
``_deduplicate_tendon_attributes`` helper now trims all ``mujoco:tendon*``
16+
custom-attribute lists back to the world-0 (template) count after the build loop.
17+
18+
419
0.5.15 (2026-04-28)
520
~~~~~~~~~~~~~~~~~~~
621

source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,81 @@
1919
from isaaclab_newton.physics import NewtonManager
2020

2121

22+
def _deduplicate_tendon_attributes(
23+
builder: ModelBuilder,
24+
tendon_count: int,
25+
tendon_joint_count: int,
26+
tendon_wrap_count: int,
27+
) -> None:
28+
"""Trim replicated MuJoCo tendon custom attributes to the template-world entries only.
29+
30+
Newton's SolverMuJoCo only reads tendons from the template world (world 0) and
31+
replicates them across all worlds internally. Because the cloner calls
32+
``add_builder`` once per environment, each call appends T tendon entries, yielding
33+
N×T total after N environments. This function restores the correct T count so the
34+
solver receives exactly the template entries.
35+
36+
Args:
37+
builder: Newton model builder to update in-place.
38+
tendon_count: Number of ``mujoco:tendon``-frequency entries to keep
39+
(global entries + template-world entries).
40+
tendon_joint_count: Number of ``mujoco:tendon_joint``-frequency entries to keep.
41+
tendon_wrap_count: Number of ``mujoco:tendon_wrap``-frequency entries to keep.
42+
"""
43+
_TENDON_ATTRS = [
44+
"mujoco:tendon_world",
45+
"mujoco:tendon_stiffness",
46+
"mujoco:tendon_damping",
47+
"mujoco:tendon_frictionloss",
48+
"mujoco:tendon_limited",
49+
"mujoco:tendon_range",
50+
"mujoco:tendon_margin",
51+
"mujoco:tendon_solref_limit",
52+
"mujoco:tendon_solimp_limit",
53+
"mujoco:tendon_solref_friction",
54+
"mujoco:tendon_solimp_friction",
55+
"mujoco:tendon_armature",
56+
"mujoco:tendon_springlength",
57+
"mujoco:tendon_joint_adr",
58+
"mujoco:tendon_joint_num",
59+
"mujoco:tendon_actuator_force_range",
60+
"mujoco:tendon_actuator_force_limited",
61+
"mujoco:tendon_label",
62+
"mujoco:tendon_type",
63+
"mujoco:tendon_wrap_adr",
64+
"mujoco:tendon_wrap_num",
65+
]
66+
_TENDON_JOINT_ATTRS = ["mujoco:tendon_joint", "mujoco:tendon_coef"]
67+
_TENDON_WRAP_ATTRS = [
68+
"mujoco:tendon_wrap_type",
69+
"mujoco:tendon_wrap_shape",
70+
"mujoco:tendon_wrap_sidesite",
71+
"mujoco:tendon_wrap_prm",
72+
]
73+
74+
for key in _TENDON_ATTRS:
75+
attr = builder.custom_attributes.get(key)
76+
if attr is not None and isinstance(attr.values, list):
77+
attr.values = attr.values[:tendon_count]
78+
79+
for key in _TENDON_JOINT_ATTRS:
80+
attr = builder.custom_attributes.get(key)
81+
if attr is not None and isinstance(attr.values, list):
82+
attr.values = attr.values[:tendon_joint_count]
83+
84+
for key in _TENDON_WRAP_ATTRS:
85+
attr = builder.custom_attributes.get(key)
86+
if attr is not None and isinstance(attr.values, list):
87+
attr.values = attr.values[:tendon_wrap_count]
88+
89+
if "mujoco:tendon" in builder._custom_frequency_counts:
90+
builder._custom_frequency_counts["mujoco:tendon"] = tendon_count
91+
if "mujoco:tendon_joint" in builder._custom_frequency_counts:
92+
builder._custom_frequency_counts["mujoco:tendon_joint"] = tendon_joint_count
93+
if "mujoco:tendon_wrap" in builder._custom_frequency_counts:
94+
builder._custom_frequency_counts["mujoco:tendon_wrap"] = tendon_wrap_count
95+
96+
2297
def _build_newton_builder_from_mapping(
2398
stage: Usd.Stage,
2499
sources: list[str],
@@ -92,6 +167,13 @@ def _build_newton_builder_from_mapping(
92167
num_worlds = mapping.size(1)
93168
local_site_map: dict[str, list[list[int]]] = {}
94169

170+
# Snapshot tendon counts before the world loop. add_builder appends T tendon
171+
# entries per environment, yielding N×T after N worlds. We capture the counts
172+
# after world 0 (the template) and deduplicate once the loop is done.
173+
_template_tendon_count: int = -1
174+
_template_tendon_joint_count: int = 0
175+
_template_tendon_wrap_count: int = 0
176+
95177
# create a separate world for each environment (heterogeneous spawning)
96178
# Newton assigns sequential world IDs (0, 1, 2, ...), so we need to track the mapping
97179
for col, _ in enumerate(env_ids.tolist()):
@@ -115,6 +197,21 @@ def _build_newton_builder_from_mapping(
115197
# end the world context
116198
builder.end_world()
117199

200+
# Capture counts after world 0 — these are the template (global + env-0) entries.
201+
if col == 0:
202+
_template_tendon_count = builder._custom_frequency_counts.get("mujoco:tendon", 0)
203+
_template_tendon_joint_count = builder._custom_frequency_counts.get("mujoco:tendon_joint", 0)
204+
_template_tendon_wrap_count = builder._custom_frequency_counts.get("mujoco:tendon_wrap", 0)
205+
206+
# Remove duplicate tendon entries appended by subsequent add_builder calls.
207+
# Newton's SolverMuJoCo only reads world-0 tendons and replicates them internally.
208+
if _template_tendon_count >= 0 and (
209+
builder._custom_frequency_counts.get("mujoco:tendon", 0) > _template_tendon_count
210+
):
211+
_deduplicate_tendon_attributes(
212+
builder, _template_tendon_count, _template_tendon_joint_count, _template_tendon_wrap_count
213+
)
214+
118215
site_index_map = {
119216
**global_site_map,
120217
**{label: (None, per_world) for label, per_world in local_site_map.items()},
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
2+
# All rights reserved.
3+
#
4+
# SPDX-License-Identifier: BSD-3-Clause
5+
6+
"""Unit tests for MuJoCo tendon deduplication in the Newton cloner.
7+
8+
:func:`_deduplicate_tendon_attributes` corrects the N×T tendon-entry duplication
9+
that arises when IsaacLab's Newton cloner calls ``add_builder`` once per environment
10+
(N times) with a prototype that carries T tendon entries. Newton's SolverMuJoCo
11+
expects only the template-world (world 0) entries and replicates them internally.
12+
13+
Tests construct builder state directly via Newton's ``ModelBuilder`` API so that
14+
no Isaac Sim or MJCF XML parsing is required.
15+
"""
16+
17+
import unittest
18+
19+
import newton
20+
from isaaclab_newton.cloner.newton_replicate import _deduplicate_tendon_attributes
21+
from newton.solvers import SolverMuJoCo
22+
23+
# ─── helpers ────────────────────────────────────────────────────────────────
24+
25+
_TENDON_FREQ = "mujoco:tendon"
26+
_TENDON_JOINT_FREQ = "mujoco:tendon_joint"
27+
_TENDON_WRAP_FREQ = "mujoco:tendon_wrap"
28+
29+
30+
def _registered_builder() -> newton.ModelBuilder:
31+
"""Return a ModelBuilder with MuJoCo custom attributes registered."""
32+
b = newton.ModelBuilder()
33+
SolverMuJoCo.register_custom_attributes(b)
34+
return b
35+
36+
37+
def _inject_tendon_entries(
38+
builder: newton.ModelBuilder,
39+
world: int,
40+
names: list[str],
41+
stiffnesses: list[float],
42+
joint_entries_per_tendon: int,
43+
) -> None:
44+
"""Append synthetic tendon entries to *builder* for the given *world*.
45+
46+
Directly manipulates custom attribute lists and frequency counts, mimicking
47+
what ``add_builder`` does when copying a prototype with tendon data.
48+
"""
49+
t = len(names)
50+
j = t * joint_entries_per_tendon
51+
world_attr = builder.custom_attributes.get("mujoco:tendon_world")
52+
label_attr = builder.custom_attributes.get("mujoco:tendon_label")
53+
stiff_attr = builder.custom_attributes.get("mujoco:tendon_stiffness")
54+
joint_adr_attr = builder.custom_attributes.get("mujoco:tendon_joint_adr")
55+
joint_num_attr = builder.custom_attributes.get("mujoco:tendon_joint_num")
56+
joint_attr = builder.custom_attributes.get("mujoco:tendon_joint")
57+
coef_attr = builder.custom_attributes.get("mujoco:tendon_coef")
58+
59+
for attr in (world_attr, label_attr, stiff_attr, joint_adr_attr, joint_num_attr):
60+
if attr is not None and attr.values is None:
61+
attr.values = []
62+
for attr in (joint_attr, coef_attr):
63+
if attr is not None and attr.values is None:
64+
attr.values = []
65+
66+
current_joint_offset = builder._custom_frequency_counts.get(_TENDON_JOINT_FREQ, 0)
67+
for i, (name, stiff) in enumerate(zip(names, stiffnesses)):
68+
if world_attr is not None:
69+
world_attr.values.append(world)
70+
if label_attr is not None:
71+
label_attr.values.append(name)
72+
if stiff_attr is not None:
73+
stiff_attr.values.append(stiff)
74+
if joint_adr_attr is not None:
75+
joint_adr_attr.values.append(current_joint_offset + i * joint_entries_per_tendon)
76+
if joint_num_attr is not None:
77+
joint_num_attr.values.append(joint_entries_per_tendon)
78+
for _ in range(j):
79+
if joint_attr is not None:
80+
joint_attr.values.append(0)
81+
if coef_attr is not None:
82+
coef_attr.values.append(1.0)
83+
84+
builder._custom_frequency_counts[_TENDON_FREQ] = builder._custom_frequency_counts.get(_TENDON_FREQ, 0) + t
85+
builder._custom_frequency_counts[_TENDON_JOINT_FREQ] = (
86+
builder._custom_frequency_counts.get(_TENDON_JOINT_FREQ, 0) + j
87+
)
88+
89+
90+
def _tendon_count(b: newton.ModelBuilder) -> int:
91+
return b._custom_frequency_counts.get(_TENDON_FREQ, 0)
92+
93+
94+
def _tendon_joint_count(b: newton.ModelBuilder) -> int:
95+
return b._custom_frequency_counts.get(_TENDON_JOINT_FREQ, 0)
96+
97+
98+
def _world_values(b: newton.ModelBuilder) -> list[int]:
99+
attr = b.custom_attributes.get("mujoco:tendon_world")
100+
if attr is None or not isinstance(attr.values, list):
101+
return []
102+
return [int(v) for v in attr.values]
103+
104+
105+
def _label_values(b: newton.ModelBuilder) -> list[str]:
106+
attr = b.custom_attributes.get("mujoco:tendon_label")
107+
if attr is None or not isinstance(attr.values, list):
108+
return []
109+
return list(attr.values)
110+
111+
112+
# ─── test cases ─────────────────────────────────────────────────────────────
113+
114+
115+
class TestTendonDeduplication(unittest.TestCase):
116+
"""Verifies that _deduplicate_tendon_attributes trims N×T entries to T."""
117+
118+
# Test parameters: 2 tendons, 2 joint entries each → 4 joint entries total per world.
119+
NAMES = ["tendon_coupling", "tendon_sum"]
120+
STIFFNESSES = [2.0, 1.0]
121+
JOINT_ENTRIES_PER_TENDON = 2
122+
T = len(NAMES)
123+
T_JOINT = T * JOINT_ENTRIES_PER_TENDON
124+
125+
def _build_duplicated(self, n_envs: int) -> tuple[newton.ModelBuilder, int, int]:
126+
"""Return a builder with N×T entries and the template counts (T, T_joint)."""
127+
main = _registered_builder()
128+
template_tendon = 0
129+
template_joint = 0
130+
for world in range(n_envs):
131+
_inject_tendon_entries(main, world, self.NAMES, self.STIFFNESSES, self.JOINT_ENTRIES_PER_TENDON)
132+
if world == 0:
133+
template_tendon = _tendon_count(main)
134+
template_joint = _tendon_joint_count(main)
135+
return main, template_tendon, template_joint
136+
137+
def test_duplication_exists_before_fix(self):
138+
"""Without fix, N add_builder calls produce N×T tendon entries."""
139+
n = 4
140+
main, _, _ = self._build_duplicated(n)
141+
self.assertEqual(_tendon_count(main), n * self.T)
142+
self.assertEqual(_tendon_joint_count(main), n * self.T_JOINT)
143+
144+
def test_deduplication_restores_template_count(self):
145+
"""After fix, exactly T tendon entries remain."""
146+
n = 4
147+
main, tmpl_t, tmpl_j = self._build_duplicated(n)
148+
_deduplicate_tendon_attributes(main, tmpl_t, tmpl_j, 0)
149+
self.assertEqual(_tendon_count(main), self.T)
150+
self.assertEqual(_tendon_joint_count(main), self.T_JOINT)
151+
152+
def test_deduplication_all_entries_are_world_0(self):
153+
"""After fix, every remaining tendon_world entry equals 0."""
154+
n = 5
155+
main, tmpl_t, tmpl_j = self._build_duplicated(n)
156+
_deduplicate_tendon_attributes(main, tmpl_t, tmpl_j, 0)
157+
worlds = _world_values(main)
158+
self.assertEqual(len(worlds), self.T)
159+
self.assertTrue(all(w == 0 for w in worlds), f"Expected world=0, got {worlds}")
160+
161+
def test_deduplication_preserves_template_labels(self):
162+
"""After fix, the surviving labels are the template world's names."""
163+
n = 3
164+
main, tmpl_t, tmpl_j = self._build_duplicated(n)
165+
_deduplicate_tendon_attributes(main, tmpl_t, tmpl_j, 0)
166+
labels = _label_values(main)
167+
self.assertEqual(sorted(labels), sorted(self.NAMES))
168+
169+
def test_deduplication_frequency_counts_match_list_lengths(self):
170+
"""_custom_frequency_counts must stay consistent with attribute list lengths."""
171+
n = 6
172+
main, tmpl_t, tmpl_j = self._build_duplicated(n)
173+
_deduplicate_tendon_attributes(main, tmpl_t, tmpl_j, 0)
174+
175+
reported_t = main._custom_frequency_counts.get(_TENDON_FREQ, 0)
176+
actual_t = len(main.custom_attributes["mujoco:tendon_world"].values)
177+
self.assertEqual(reported_t, actual_t)
178+
179+
reported_j = main._custom_frequency_counts.get(_TENDON_JOINT_FREQ, 0)
180+
actual_j = len(main.custom_attributes["mujoco:tendon_joint"].values)
181+
self.assertEqual(reported_j, actual_j)
182+
183+
def test_no_change_for_single_env(self):
184+
"""For N=1 no duplication occurs; counts must stay T after a no-op trim."""
185+
main, tmpl_t, tmpl_j = self._build_duplicated(1)
186+
self.assertEqual(_tendon_count(main), self.T)
187+
# No trimming needed (caller guards on count > template), but the function
188+
# should be safe to call anyway.
189+
_deduplicate_tendon_attributes(main, tmpl_t, tmpl_j, 0)
190+
self.assertEqual(_tendon_count(main), self.T)
191+
self.assertEqual(_tendon_joint_count(main), self.T_JOINT)
192+
193+
def test_deduplication_is_idempotent(self):
194+
"""Calling _deduplicate_tendon_attributes twice must not trim further."""
195+
n = 4
196+
main, tmpl_t, tmpl_j = self._build_duplicated(n)
197+
_deduplicate_tendon_attributes(main, tmpl_t, tmpl_j, 0)
198+
_deduplicate_tendon_attributes(main, tmpl_t, tmpl_j, 0)
199+
self.assertEqual(_tendon_count(main), self.T)
200+
self.assertEqual(_tendon_joint_count(main), self.T_JOINT)
201+
202+
def test_regression_bug_n_times_t_entries(self):
203+
"""Regression: verify the reported bug (N×T instead of T) is fixed."""
204+
n = 8
205+
main, tmpl_t, tmpl_j = self._build_duplicated(n)
206+
207+
# BUG: before fix, count == n * T
208+
self.assertEqual(_tendon_count(main), n * self.T, "pre-condition: duplication present")
209+
210+
_deduplicate_tendon_attributes(main, tmpl_t, tmpl_j, 0)
211+
212+
# FIX: after deduplication, count == T
213+
self.assertEqual(_tendon_count(main), self.T, "post-condition: deduplication applied")
214+
self.assertNotEqual(_tendon_count(main), n * self.T, "count must not be N×T after fix")
215+
216+
217+
if __name__ == "__main__":
218+
unittest.main()

0 commit comments

Comments
 (0)