Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
- Show prismatic joints in the GL viewer when "Show Joints" is enabled
- Fix connect constraint anchor computation to account for joint reference positions when `SolverMuJoCo` is the chosen solver.
- Fix `SolverMuJoCo` passing non-zero geom/pair margins to `mujoco_warp.put_model()`, which fails when NATIVECCD is enabled. Margins are forced to zero when MuJoCo handles collisions (`use_mujoco_contacts=True`); the Newton collision pipeline (`use_mujoco_contacts=False`) is unchanged
- Fix `State.assign` not copying namespaced extended attributes such as `state.mujoco.qfrc_actuator`
Comment thread
camevor marked this conversation as resolved.
Outdated
- Fix mesh-convex back-face contacts generating inverted normals that trap shapes inside meshes and cause solver divergence (NaN)
- Fix finite plane geometry 2x too large in collision, bounding sphere, and raytrace sensor
- Fix MPR convergence failure on large and extreme-aspect-ratio mesh triangles by projecting the starting point onto the triangle nearest the convex center
Expand Down
86 changes: 67 additions & 19 deletions newton/_src/sim/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,44 @@
import warp as wp


def _copy_arrays(dst: object, src: object, prefix: str = "") -> None:
"""Copy ``wp.array`` attributes from ``src`` into ``dst``.

Walks both objects' ``__dict__``, matches attributes by name, and copies
``wp.array`` values via ``dst_array.assign(src_array)``. Raises
:class:`ValueError` on presence mismatch (one side has an array where
the other does not).

Args:
dst: Destination object (its ``wp.array`` attributes will be overwritten).
src: Source object.
prefix: Prefix prepended to attribute names in error messages
(e.g. ``"mujoco."`` for namespaced attributes).
"""
attributes = set(dst.__dict__).union(src.__dict__)
for attr in attributes:
val_dst = getattr(dst, attr, None)
val_src = getattr(src, attr, None)

if val_dst is None and val_src is None:
continue

array_dst = isinstance(val_dst, wp.array)
array_src = isinstance(val_src, wp.array)

if not array_dst and not array_src:
continue

qualified = f"{prefix}{attr}"
if val_dst is None or not array_dst:
raise ValueError(f"State is missing array for '{qualified}' which is present in the other state.")

if val_src is None or not array_src:
raise ValueError(f"Other state is missing array for '{qualified}' which is present in this state.")

val_dst.assign(val_src)


class State:
"""
Represents the time-varying state of a :class:`Model` in a simulation.
Expand Down Expand Up @@ -157,28 +195,38 @@ def assign(self, other: State) -> None:
Raises:
ValueError: If the states have mismatched attributes (one has an array allocated where the other is None).
"""
attributes = set(self.__dict__).union(other.__dict__)

for attr in attributes:
val_self = getattr(self, attr, None)
val_other = getattr(other, attr, None)

if val_self is None and val_other is None:
from .model import Model # noqa: PLC0415

# Top-level array attributes.
_copy_arrays(self, other)

# Discover all AttributeNamespace containers on either state and
# descend into each. This uniformly covers both EXTENDED_ATTRIBUTES
# (e.g. ``mujoco.qfrc_actuator``) and custom namespaced attributes
# registered via ``ModelBuilder.add_custom_attribute``.
ns_self = {k: v for k, v in self.__dict__.items() if isinstance(v, Model.AttributeNamespace)}
ns_other = {k: v for k, v in other.__dict__.items() if isinstance(v, Model.AttributeNamespace)}

for ns_name in ns_self.keys() | ns_other.keys():
dst_ns = ns_self.get(ns_name)
src_ns = ns_other.get(ns_name)

# If the namespace container is missing on one side, only raise
# when the other side actually holds arrays inside it.
if dst_ns is None:
if any(isinstance(v, wp.array) for v in src_ns.__dict__.values()):
raise ValueError(
f"State is missing namespace '{ns_name}' which contains arrays in the other state."
)
continue

array_self = isinstance(val_self, wp.array)
array_other = isinstance(val_other, wp.array)

if not array_self and not array_other:
if src_ns is None:
if any(isinstance(v, wp.array) for v in dst_ns.__dict__.values()):
raise ValueError(
f"Other state is missing namespace '{ns_name}' which contains arrays in this state."
)
continue

if val_self is None or not array_self:
raise ValueError(f"State is missing array for '{attr}' which is present in the other state.")

if val_other is None or not array_other:
raise ValueError(f"Other state is missing array for '{attr}' which is present in this state.")

val_self.assign(val_other)
_copy_arrays(dst_ns, src_ns, prefix=f"{ns_name}.")

@property
def requires_grad(self) -> bool:
Expand Down
135 changes: 135 additions & 0 deletions newton/tests/test_state_assign.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 The Newton Developers
# SPDX-License-Identifier: Apache-2.0

import unittest

import numpy as np
import warp as wp

import newton


def _build_model(*, custom_attrs: tuple[str, ...] = ()):
builder = newton.ModelBuilder(up_axis=newton.Axis.Y, gravity=0.0)
inertia = wp.mat33((0.1, 0.0, 0.0), (0.0, 0.1, 0.0), (0.0, 0.0, 0.1))
body = builder.add_link(armature=0.0, inertia=inertia, mass=1.0)
joint = builder.add_joint_revolute(
parent=-1,
child=body,
parent_xform=wp.transform(wp.vec3(0.0, 0.0, 0.0), wp.quat_identity()),
child_xform=wp.transform(wp.vec3(0.0, 0.0, 0.0), wp.quat_identity()),
axis=wp.vec3(0.0, 0.0, 1.0),
target_pos=0.0,
target_ke=100.0,
target_kd=10.0,
effort_limit=5.0,
actuator_mode=newton.JointTargetMode.POSITION_VELOCITY,
)
builder.add_articulation([joint])
builder.request_state_attributes("mujoco:qfrc_actuator")
for name in custom_attrs:
builder.add_custom_attribute(
newton.ModelBuilder.CustomAttribute(
name=name,
frequency=newton.Model.AttributeFrequency.BODY,
dtype=wp.float32,
default=0.0,
assignment=newton.Model.AttributeAssignment.STATE,
namespace="my_namespace",
)
)
model = builder.finalize()
model.ground = False
return model


class TestStateAssignNamespacedAttributes(unittest.TestCase):
def test_copies_namespaced_attribute(self):
model = _build_model()
state_0 = model.state()
state_1 = model.state()

sentinel = np.array([3.14], dtype=np.float32)
state_1.mujoco.qfrc_actuator.assign(sentinel)

state_0.assign(state_1)

np.testing.assert_allclose(state_0.mujoco.qfrc_actuator.numpy(), sentinel)

def test_raises_when_src_missing_namespaced_attribute(self):
model = _build_model()
state_0 = model.state()
state_1 = model.state()
delattr(state_1, "mujoco")

with self.assertRaises(ValueError):
state_0.assign(state_1)

def test_raises_when_dst_missing_namespaced_attribute(self):
model = _build_model()
state_0 = model.state()
state_1 = model.state()
delattr(state_0, "mujoco")

with self.assertRaises(ValueError):
state_0.assign(state_1)

def test_copies_custom_namespaced_attribute(self):
model = _build_model(custom_attrs=("my_attribute",))
state_0 = model.state()
state_1 = model.state()

sentinel = np.array([2.71], dtype=np.float32)
state_1.my_namespace.my_attribute.assign(sentinel)

state_0.assign(state_1)

np.testing.assert_allclose(state_0.my_namespace.my_attribute.numpy(), sentinel)

def test_raises_when_src_missing_custom_namespaced_attribute(self):
model = _build_model(custom_attrs=("my_attribute",))
state_0 = model.state()
state_1 = model.state()
delattr(state_1, "my_namespace")

with self.assertRaises(ValueError):
state_0.assign(state_1)

def test_raises_when_dst_missing_custom_namespaced_attribute(self):
model = _build_model(custom_attrs=("my_attribute",))
state_0 = model.state()
state_1 = model.state()
delattr(state_0, "my_namespace")

with self.assertRaises(ValueError):
state_0.assign(state_1)

def test_copies_multiple_custom_namespaced_attributes(self):
model = _build_model(custom_attrs=("attr_one", "attr_two"))
state_0 = model.state()
state_1 = model.state()

sentinel_one = np.array([1.23], dtype=np.float32)
sentinel_two = np.array([4.56], dtype=np.float32)
state_1.my_namespace.attr_one.assign(sentinel_one)
state_1.my_namespace.attr_two.assign(sentinel_two)

state_0.assign(state_1)

np.testing.assert_allclose(state_0.my_namespace.attr_one.numpy(), sentinel_one)
np.testing.assert_allclose(state_0.my_namespace.attr_two.numpy(), sentinel_two)

def test_raises_when_one_of_multiple_custom_attributes_missing(self):
model = _build_model(custom_attrs=("attr_one", "attr_two"))
state_0 = model.state()
state_1 = model.state()
# Remove a single attribute inside the namespace container (not the
# container itself) to exercise per-attribute presence checks.
delattr(state_1.my_namespace, "attr_two")

with self.assertRaises(ValueError):
state_0.assign(state_1)


if __name__ == "__main__":
unittest.main(verbosity=2)
Loading