Skip to content

Commit c5bb160

Browse files
committed
Harden batched Newton replication
1 parent 22a38d3 commit c5bb160

2 files changed

Lines changed: 96 additions & 16 deletions

File tree

source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -246,26 +246,11 @@ def _collect_replication_preparers(
246246
Returns:
247247
One preparer per hook, in registration order, or ``None`` if any hook does not support
248248
replication or declines these transforms.
249-
250-
Raises:
251-
TypeError: If a hook carries one opt-in attribute as a callable but not the other.
252249
"""
253-
opted_in = []
250+
preparers = []
254251
for hook in hooks:
255252
can_replicate = getattr(hook, "_can_replicate_builder", None)
256253
prepare = getattr(hook, "_prepare_builder_replication", None)
257-
# Checked for every hook before any is consulted, so a half-implemented hook is rejected
258-
# regardless of registration order.
259-
if callable(can_replicate) != callable(prepare):
260-
missing = "_prepare_builder_replication" if callable(can_replicate) else "_can_replicate_builder"
261-
raise TypeError(
262-
f"Newton world-builder hook '{getattr(hook, '__qualname__', repr(hook))}' opts into replication"
263-
f" but has no callable '{missing}'."
264-
)
265-
opted_in.append((hook, can_replicate, prepare))
266-
267-
preparers = []
268-
for hook, can_replicate, prepare in opted_in:
269254
name = getattr(hook, "__qualname__", repr(hook))
270255
if not callable(can_replicate):
271256
logger.debug("Hook '%s' does not support replication; building each Newton world separately.", name)
@@ -277,6 +262,21 @@ def _collect_replication_preparers(
277262
return preparers
278263

279264

265+
def _validate_replication_hook_attributes(
266+
hooks: Sequence[Callable[[ModelBuilder, int, np.ndarray, np.ndarray], None]],
267+
) -> None:
268+
"""Reject hooks that define only half of the replication opt-in contract."""
269+
for hook in hooks:
270+
can_replicate = getattr(hook, "_can_replicate_builder", None)
271+
prepare = getattr(hook, "_prepare_builder_replication", None)
272+
if callable(can_replicate) != callable(prepare):
273+
missing = "_prepare_builder_replication" if callable(can_replicate) else "_can_replicate_builder"
274+
raise TypeError(
275+
f"Newton world-builder hook '{getattr(hook, '__qualname__', repr(hook))}' opts into replication"
276+
f" but has no callable '{missing}'."
277+
)
278+
279+
280280
def replicate_builder_mapping(
281281
builder: ModelBuilder,
282282
sources: Sequence[str],
@@ -306,6 +306,7 @@ def replicate_builder_mapping(
306306
quaternions = quaternions.astype(np.float32, copy=False)
307307
xforms_np = np.concatenate((positions, quaternions), axis=1)
308308
world_xforms = [wp.transform(*row) for row in xforms_np]
309+
_validate_replication_hook_attributes(per_world_builder_hooks)
309310

310311
if (
311312
len(sources) == 1
@@ -330,6 +331,11 @@ def replicate_builder_mapping(
330331
for label, indices in source_site_indices.get(id(source_builder), {}).items():
331332
site_local_indices.setdefault(label, []).extend(indices)
332333

334+
# Hook-added particles inherit the aggregate builder's model-wide velocity limit.
335+
# A source that already owns particles keeps Newton's source-wins behavior.
336+
if source_builder.particle_count == 0:
337+
source_builder.particle_max_velocity = builder.particle_max_velocity
338+
333339
finish_callbacks = [
334340
prepare(builder, source_builder, num_worlds, xforms_np[0, :3].copy(), xforms_np[0, 3:].copy())
335341
for prepare in hook_preparers

source/isaaclab_newton/test/cloner/test_rename_builder_labels.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,80 @@ def hook(builder, *_):
556556
[f"/World/envs/env_{env_id}/Robot/{label}" for env_id in env_ids for label in ("base", "hook")],
557557
)
558558

559+
def test_half_replication_hook_is_rejected_on_non_homogeneous_plan(self):
560+
def hook(*_):
561+
pass
562+
563+
hook._can_replicate_builder = lambda _: True
564+
sources = (f"{self._SRC}/a", f"{self._SRC}/b")
565+
with self.assertRaisesRegex(TypeError, "has no callable '_prepare_builder_replication'"):
566+
replicate_builder_mapping(
567+
newton.ModelBuilder(),
568+
sources,
569+
np.eye(2, dtype=np.bool_),
570+
np.zeros((2, 3), dtype=np.float32),
571+
np.array([[0.0, 0.0, 0.0, 1.0]] * 2, dtype=np.float32),
572+
{source: newton.ModelBuilder() for source in sources},
573+
destinations=(f"{self._ENV}/a", f"{self._ENV}/b"),
574+
env_ids=np.arange(2, dtype=np.int64),
575+
per_world_builder_hooks=(hook,),
576+
)
577+
578+
def test_hook_added_particles_inherit_destination_max_velocity(self):
579+
source = newton.ModelBuilder()
580+
builder = newton.ModelBuilder()
581+
builder.particle_max_velocity = 17.0
582+
583+
def hook(*_):
584+
pass
585+
586+
def prepare(_builder, source_builder, *_):
587+
self.assertEqual(source_builder.particle_max_velocity, 17.0)
588+
source_builder.add_particle(pos=(0.0, 0.0, 0.0), vel=(0.0, 0.0, 0.0), mass=1.0, radius=0.1)
589+
return lambda: None
590+
591+
hook._can_replicate_builder = lambda _: True
592+
hook._prepare_builder_replication = prepare
593+
replicate_builder_mapping(
594+
builder,
595+
(self._SRC,),
596+
np.ones((1, 2), dtype=np.bool_),
597+
np.zeros((2, 3), dtype=np.float32),
598+
np.array([[0.0, 0.0, 0.0, 1.0]] * 2, dtype=np.float32),
599+
{self._SRC: source},
600+
destinations=(f"{self._ENV}/Robot",),
601+
env_ids=np.arange(2, dtype=np.int64),
602+
per_world_builder_hooks=(hook,),
603+
)
604+
self.assertEqual(source.particle_max_velocity, 17.0)
605+
self.assertEqual(builder.particle_max_velocity, 17.0)
606+
607+
def test_particle_source_keeps_own_max_velocity_with_replication_hook(self):
608+
source = newton.ModelBuilder()
609+
source.add_particle(pos=(0.0, 0.0, 0.0), vel=(0.0, 0.0, 0.0), mass=1.0, radius=0.1)
610+
source.particle_max_velocity = 23.0
611+
builder = newton.ModelBuilder()
612+
builder.particle_max_velocity = 17.0
613+
614+
def hook(*_):
615+
pass
616+
617+
hook._can_replicate_builder = lambda _: True
618+
hook._prepare_builder_replication = lambda *_: lambda: None
619+
replicate_builder_mapping(
620+
builder,
621+
(self._SRC,),
622+
np.ones((1, 2), dtype=np.bool_),
623+
np.zeros((2, 3), dtype=np.float32),
624+
np.array([[0.0, 0.0, 0.0, 1.0]] * 2, dtype=np.float32),
625+
{self._SRC: source},
626+
destinations=(f"{self._ENV}/Robot",),
627+
env_ids=np.arange(2, dtype=np.int64),
628+
per_world_builder_hooks=(hook,),
629+
)
630+
self.assertEqual(source.particle_max_velocity, 23.0)
631+
self.assertEqual(builder.particle_max_velocity, 23.0)
632+
559633

560634
class TestRootJointNaming(unittest.TestCase):
561635
"""The importer leaves a floating base's root joint unnamed; every other entity is named."""

0 commit comments

Comments
 (0)