mjlab/tests/test_events.py
Upstream Snapshot 32a241c28f
Some checks failed
nightly / Test against latest dependencies (py3.10) (push) Has been cancelled
nightly / Test against latest dependencies (py3.13) (push) Has been cancelled
tests / tests (3.13, locked) (push) Has been cancelled
tests / tests (3.13, unlocked) (push) Has been cancelled
tests / pyright (3.10) (push) Has been cancelled
tests / lint-format (push) Has been cancelled
tests / tests (3.10, locked) (push) Has been cancelled
tests / tests (3.11, locked) (push) Has been cancelled
tests / tests (3.12, locked) (push) Has been cancelled
tests / pyright (3.11) (push) Has been cancelled
tests / pyright (3.12) (push) Has been cancelled
tests / pyright (3.13) (push) Has been cancelled
tests / ty-check (3.10) (push) Has been cancelled
tests / ty-check (3.11) (push) Has been cancelled
tests / ty-check (3.12) (push) Has been cancelled
tests / ty-check (3.13) (push) Has been cancelled
tests / stubs (push) Has been cancelled
tests / smoke-test (push) Has been cancelled
Docker / check_paths (push) Has been cancelled
docs / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Import upstream snapshot c19f713c415a699a79d71cd96aa13c3104a05047
Upstream: https://github.com/michaelgillett/mjlab
Upstream-Commit: c19f713c415a699a79d71cd96aa13c3104a05047
Upstream-Branch: main
2026-08-28 15:42:17 +08:00

1064 lines
32 KiB
Python

"""Tests for EventManager, special dr.* functions, and recomputation."""
from unittest.mock import Mock
import mujoco
import pytest
import torch
from conftest import get_test_device
from mjlab import actuator
from mjlab.entity import EntityCfg
from mjlab.envs.mdp import dr, events
from mjlab.managers.event_manager import (
EventManager,
EventTermCfg,
RecomputeLevel,
)
from mjlab.managers.scene_entity_config import SceneEntityCfg
from mjlab.scene import Scene, SceneCfg
from mjlab.sim.sim import Simulation, SimulationCfg
pytestmark = pytest.mark.filterwarnings(
"ignore:Use of index_put_ on expanded tensors is deprecated:UserWarning"
)
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
ROBOT_XML = """
<mujoco>
<worldbody>
<body name="base" pos="0 0 1">
<freejoint name="free_joint"/>
<geom name="base_geom" type="box" size="0.1 0.1 0.1" mass="5.0"/>
<body name="link1" pos="0.2 0 0">
<joint name="joint1" type="hinge" axis="0 0 1" range="0 1.57"
armature="0.1"/>
<geom name="link1_geom" type="box" size="0.05 0.05 0.2" mass="1.0"/>
</body>
<body name="link2" pos="-0.2 0 0">
<joint name="joint2" type="hinge" axis="0 0 1" range="0 1.57"
armature="0.1"/>
<geom name="link2_geom" type="box" size="0.05 0.05 0.2" mass="1.0"/>
</body>
</body>
</worldbody>
</mujoco>
"""
NUM_ENVS = 2
@pytest.fixture(scope="module")
def device():
return get_test_device()
class Env:
def __init__(self, scene, sim, device):
self.scene = scene
self.sim = sim
self.num_envs = scene.num_envs
self.device = device
def create_env(device, fields, num_envs=NUM_ENVS):
entity_cfg = EntityCfg(spec_fn=lambda: mujoco.MjSpec.from_string(ROBOT_XML))
scene_cfg = SceneCfg(num_envs=num_envs, entities={"robot": entity_cfg})
scene = Scene(scene_cfg, device)
model = scene.compile()
sim = Simulation(num_envs=num_envs, cfg=SimulationCfg(), model=model, device=device)
scene.initialize(model, sim.model, sim.data)
sim.expand_model_fields(fields)
return Env(scene, sim, device)
# ===========================================================================
# Section 1: EventManager
# ===========================================================================
def test_dr_fields_registered_in_event_manager(device):
"""@requires_model_fields functions populate manager.domain_randomization_fields."""
env = Mock()
env.num_envs = 4
env.device = device
env.scene = {}
env.sim = Mock()
cfg = {
"friction_dr": EventTermCfg(
mode="startup",
func=dr.geom_friction,
params={"ranges": (0.3, 1.2)},
),
"damping_dr": EventTermCfg(
mode="reset",
func=dr.joint_damping,
params={"ranges": (0.1, 0.5)},
),
"pd_gains": EventTermCfg(
mode="reset",
func=dr.pd_gains,
params={"kp_range": (0.8, 1.2), "kd_range": (0.8, 1.2)},
),
"effort_limits": EventTermCfg(
mode="reset",
func=dr.effort_limits,
params={"effort_limit_range": (0.8, 1.2)},
),
"regular_event": EventTermCfg(
mode="reset",
func=events.reset_joints_by_offset,
params={"position_range": (-0.1, 0.1), "velocity_range": (0.0, 0.0)},
),
}
manager = EventManager(cfg, env)
assert "geom_friction" in manager.domain_randomization_fields
assert "dof_damping" in manager.domain_randomization_fields
assert "actuator_gainprm" in manager.domain_randomization_fields
assert "actuator_biasprm" in manager.domain_randomization_fields
assert "actuator_forcerange" in manager.domain_randomization_fields
assert "jnt_actfrcrange" in manager.domain_randomization_fields
assert "tendon_actfrcrange" in manager.domain_randomization_fields
assert len(manager.domain_randomization_fields) == 7
def test_recompute_fields_registered_in_event_manager(device):
"""Recomputed model fields are expanded per environment."""
env = Mock()
env.num_envs = 4
env.device = device
env.scene = {}
env.sim = Mock()
cfg = {
"body_com": EventTermCfg(
mode="startup",
func=dr.body_com_offset,
params={"ranges": (-0.01, 0.01)},
),
}
manager = EventManager(cfg, env)
assert manager.domain_randomization_fields == (
"body_ipos",
"body_subtreemass",
"dof_invweight0",
"body_invweight0",
"tendon_length0",
"tendon_invweight0",
"actuator_acc0",
)
def test_recompute_level_ordering():
"""IntEnum max() gives strongest level."""
L = RecomputeLevel
assert max(L.none, L.set_const_0) == L.set_const_0
assert max(L.set_const_0, L.set_const) == L.set_const
assert max(L.set_const, L.set_const_fixed) == L.set_const
assert max(L.none, L.none) == L.none
def test_recompute_not_called_when_no_dr_fired(device):
"""Non-DR events don't trigger recompute."""
env = Mock()
env.num_envs = 2
env.device = device
env.scene = {}
env.sim = Mock()
env.sim.recompute_constants = Mock()
cfg = {
"regular_event": EventTermCfg(
mode="reset",
func=lambda env, env_ids: None,
params={},
),
}
manager = EventManager(cfg, env)
manager.apply(
"reset",
env_ids=torch.tensor([0], device=device),
global_env_step_count=1,
)
env.sim.recompute_constants.assert_not_called()
# ===========================================================================
# Section 2: PD gains & effort limits
# ===========================================================================
def _make_pd_env(device, num_envs=2):
"""Create a mock env with Builtin, Xml, and Ideal actuators for PD tests."""
env = Mock()
env.num_envs = num_envs
env.device = device
mock_entity = Mock()
builtin = Mock(spec=actuator.BuiltinPositionActuator)
builtin.ctrl_ids = torch.tensor([0, 1], device=device)
builtin.global_ctrl_ids = torch.tensor([0, 1], device=device)
xml = Mock(spec=actuator.XmlActuator)
xml.command_field = "position"
xml.ctrl_ids = torch.tensor([2, 3], device=device)
xml.global_ctrl_ids = torch.tensor([2, 3], device=device)
ideal = Mock(spec=actuator.IdealPdActuator)
ideal.ctrl_ids = torch.tensor([4, 5], device=device)
ideal.global_ctrl_ids = torch.tensor([4, 5], device=device)
ideal.stiffness = torch.tensor([[100.0, 100.0]] * num_envs, device=device)
ideal.damping = torch.tensor([[10.0, 10.0]] * num_envs, device=device)
ideal.default_stiffness = torch.tensor([[100.0, 100.0]] * num_envs, device=device)
ideal.default_damping = torch.tensor([[10.0, 10.0]] * num_envs, device=device)
ideal.set_gains = actuator.IdealPdActuator.set_gains.__get__(ideal)
mock_entity.actuators = [builtin, xml, ideal]
env.scene = {"robot": mock_entity}
env.sim = Mock()
env.sim.model = Mock()
env.sim.model.actuator_gainprm = torch.ones((num_envs, 6, 10), device=device) * 50.0
env.sim.model.actuator_biasprm = torch.zeros((num_envs, 6, 10), device=device)
env.sim.model.actuator_biasprm[:, :, 1] = -50.0
env.sim.model.actuator_biasprm[:, :, 2] = -5.0
default_gainprm = torch.ones((6, 10), device=device) * 50.0
default_biasprm = torch.zeros((6, 10), device=device)
default_biasprm[:, 1] = -50.0
default_biasprm[:, 2] = -5.0
defaults = {
"actuator_gainprm": default_gainprm,
"actuator_biasprm": default_biasprm,
}
env.sim.get_default_field = lambda f: defaults[f]
return env, ideal
def _make_effort_env(device, num_envs=2):
"""Create a mock env with Builtin, Xml, and Ideal actuators for effort tests."""
env = Mock()
env.num_envs = num_envs
env.device = device
mock_entity = Mock()
builtin = Mock(spec=actuator.BuiltinPositionActuator)
builtin.ctrl_ids = torch.tensor([0, 1], device=device)
builtin.global_ctrl_ids = torch.tensor([0, 1], device=device)
xml = Mock(spec=actuator.XmlActuator)
xml.command_field = "position"
xml.ctrl_ids = torch.tensor([2, 3], device=device)
xml.global_ctrl_ids = torch.tensor([2, 3], device=device)
ideal = Mock(spec=actuator.IdealPdActuator)
ideal.ctrl_ids = torch.tensor([4, 5], device=device)
ideal.global_ctrl_ids = torch.tensor([4, 5], device=device)
ideal.force_limit = torch.tensor([[50.0, 50.0]] * num_envs, device=device)
ideal.default_force_limit = torch.tensor([[50.0, 50.0]] * num_envs, device=device)
ideal.set_effort_limit = actuator.IdealPdActuator.set_effort_limit.__get__(ideal)
mock_entity.actuators = [builtin, xml, ideal]
env.scene = {"robot": mock_entity}
env.sim = Mock()
env.sim.model = Mock()
env.sim.model.actuator_forcerange = torch.zeros((num_envs, 6, 2), device=device)
env.sim.model.actuator_forcerange[:, :, 0] = -100.0
env.sim.model.actuator_forcerange[:, :, 1] = 100.0
default_forcerange = torch.zeros((6, 2), device=device)
default_forcerange[:, 0] = -100.0
default_forcerange[:, 1] = 100.0
env.sim.get_default_field = lambda field: default_forcerange
return env, ideal
@pytest.mark.parametrize(
"operation, env_id, kp, kd, expected_gainprm, expected_biasprm_1,"
" expected_biasprm_2, expected_stiffness, expected_damping",
[
("scale", 0, 1.5, 2.0, 75.0, -75.0, -10.0, 150.0, 20.0),
("abs", 1, 200.0, 25.0, 200.0, -200.0, -25.0, 200.0, 25.0),
],
)
def test_pd_gains(
device,
operation,
env_id,
kp,
kd,
expected_gainprm,
expected_biasprm_1,
expected_biasprm_2,
expected_stiffness,
expected_damping,
):
"""PD gains scale/abs on Builtin, Xml, and Ideal actuators."""
env, ideal = _make_pd_env(device)
dr.pd_gains(
env,
torch.tensor([env_id], device=device),
kp_range=(kp, kp),
kd_range=(kd, kd),
asset_cfg=SceneEntityCfg("robot"),
operation=operation,
)
d = device
# Builtin actuator.
assert torch.allclose(
env.sim.model.actuator_gainprm[env_id, [0, 1], 0],
torch.tensor([expected_gainprm] * 2, device=d),
)
assert torch.allclose(
env.sim.model.actuator_biasprm[env_id, [0, 1], 1],
torch.tensor([expected_biasprm_1] * 2, device=d),
)
assert torch.allclose(
env.sim.model.actuator_biasprm[env_id, [0, 1], 2],
torch.tensor([expected_biasprm_2] * 2, device=d),
)
# Xml actuator.
assert torch.allclose(
env.sim.model.actuator_gainprm[env_id, [2, 3], 0],
torch.tensor([expected_gainprm] * 2, device=d),
)
# Ideal actuator.
assert torch.allclose(
ideal.stiffness[env_id],
torch.tensor([expected_stiffness] * 2, device=d),
)
assert torch.allclose(
ideal.damping[env_id],
torch.tensor([expected_damping] * 2, device=d),
)
def test_pd_gains_multi_env(device):
"""Independent values per environment."""
env, _ = _make_pd_env(device)
torch.manual_seed(42)
dr.pd_gains(
env,
torch.tensor([0, 1], device=device),
kp_range=(0.5, 2.0),
kd_range=(0.5, 2.0),
asset_cfg=SceneEntityCfg("robot"),
operation="scale",
)
gains = env.sim.model.actuator_gainprm[:, :2, 0]
assert (gains != 50.0).all()
assert not torch.allclose(gains[0], gains[1])
@pytest.mark.parametrize(
"operation, env_id, limit, expected_lower, expected_upper, expected_ideal",
[
("scale", 0, 2.0, -200.0, 200.0, 100.0),
("abs", 1, 150.0, -150.0, 150.0, 150.0),
],
)
def test_effort_limits(
device, operation, env_id, limit, expected_lower, expected_upper, expected_ideal
):
"""Effort limits scale/abs on Builtin, Xml, and Ideal actuators."""
env, ideal = _make_effort_env(device)
dr.effort_limits(
env,
torch.tensor([env_id], device=device),
effort_limit_range=(limit, limit),
asset_cfg=SceneEntityCfg("robot"),
operation=operation,
)
d = device
# Builtin.
assert torch.allclose(
env.sim.model.actuator_forcerange[env_id, [0, 1], 0],
torch.tensor([expected_lower] * 2, device=d),
)
assert torch.allclose(
env.sim.model.actuator_forcerange[env_id, [0, 1], 1],
torch.tensor([expected_upper] * 2, device=d),
)
# Xml.
assert torch.allclose(
env.sim.model.actuator_forcerange[env_id, [2, 3], 0],
torch.tensor([expected_lower] * 2, device=d),
)
# Ideal.
assert torch.allclose(
ideal.force_limit[env_id],
torch.tensor([expected_ideal] * 2, device=d),
)
def test_effort_limits_multi_env(device):
"""Independent values per environment."""
env, _ = _make_effort_env(device)
torch.manual_seed(42)
dr.effort_limits(
env,
torch.tensor([0, 1], device=device),
effort_limit_range=(0.5, 2.0),
asset_cfg=SceneEntityCfg("robot"),
operation="scale",
)
upper = env.sim.model.actuator_forcerange[:, :2, 1]
assert (upper != 100.0).all()
assert not torch.allclose(upper[0], upper[1])
def test_effort_limits_scale_no_accumulation(device):
"""Scale operation uses defaults (no accumulation)."""
env, _ = _make_effort_env(device)
for _ in range(3):
dr.effort_limits(
env,
env_ids=None,
effort_limit_range=(2.0, 2.0),
asset_cfg=SceneEntityCfg("robot"),
operation="scale",
)
actual_upper = env.sim.model.actuator_forcerange[0, 0, 1].item()
assert abs(actual_upper - 200.0) < 1e-5
def test_pd_gains_accepts_operation_object(device):
"""dr.scale / dr.abs Operation objects produce the same result as strings."""
env_str, ideal_str = _make_pd_env(device)
env_obj, ideal_obj = _make_pd_env(device)
ids = torch.tensor([0], device=device)
kwargs = dict(
kp_range=(1.5, 1.5), kd_range=(2.0, 2.0), asset_cfg=SceneEntityCfg("robot")
)
torch.manual_seed(0)
dr.pd_gains(env_str, ids, operation="scale", **kwargs)
torch.manual_seed(0)
dr.pd_gains(env_obj, ids, operation=dr.scale, **kwargs)
assert torch.allclose(
env_str.sim.model.actuator_gainprm[0], env_obj.sim.model.actuator_gainprm[0]
)
assert torch.allclose(ideal_str.stiffness, ideal_obj.stiffness)
def test_effort_limits_accepts_operation_object(device):
"""dr.abs Operation object produces the same result as the string."""
env_str, ideal_str = _make_effort_env(device)
env_obj, ideal_obj = _make_effort_env(device)
ids = torch.tensor([0], device=device)
kwargs = dict(effort_limit_range=(150.0, 150.0), asset_cfg=SceneEntityCfg("robot"))
dr.effort_limits(env_str, ids, operation="abs", **kwargs)
dr.effort_limits(env_obj, ids, operation=dr.abs, **kwargs)
assert torch.allclose(
env_str.sim.model.actuator_forcerange[0], env_obj.sim.model.actuator_forcerange[0]
)
assert torch.allclose(ideal_str.force_limit, ideal_obj.force_limit)
def test_pd_gains_rejects_unsupported_operation(device):
"""Operations other than scale/abs raise ValueError."""
env, _ = _make_pd_env(device)
ids = torch.tensor([0], device=device)
with pytest.raises(ValueError, match="only supports 'scale' and 'abs'"):
dr.pd_gains(env, ids, kp_range=(1.0, 1.0), kd_range=(1.0, 1.0), operation=dr.add)
def test_effort_limits_rejects_unsupported_operation(device):
"""Operations other than scale/abs raise ValueError."""
env, _ = _make_effort_env(device)
ids = torch.tensor([0], device=device)
with pytest.raises(ValueError, match="only supports 'scale' and 'abs'"):
dr.effort_limits(env, ids, effort_limit_range=(1.0, 1.0), operation=dr.add)
# ===========================================================================
# Section 3: Other events
# ===========================================================================
def test_reset_joints_by_offset(device):
"""reset_joints_by_offset applies offsets and respects joint limits."""
env = Mock()
env.num_envs = 2
env.device = device
mock_entity = Mock()
mock_entity.data.default_joint_pos = torch.zeros((2, 3), device=device)
mock_entity.data.default_joint_vel = torch.zeros((2, 3), device=device)
mock_entity.data.soft_joint_pos_limits = torch.tensor(
[
[[-0.5, 0.5], [-0.5, 0.5], [-0.5, 0.5]],
[[-0.5, 0.5], [-0.5, 0.5], [-0.5, 0.5]],
],
device=device,
)
mock_entity.write_joint_state_to_sim = Mock()
env.scene = {"robot": mock_entity}
# Normal offset.
events.reset_joints_by_offset(
env,
torch.tensor([0], device=device),
position_range=(0.3, 0.3),
velocity_range=(0.2, 0.2),
asset_cfg=SceneEntityCfg("robot", joint_ids=slice(None)),
)
call_args = mock_entity.write_joint_state_to_sim.call_args
joint_pos, joint_vel = call_args[0][0], call_args[0][1]
assert torch.allclose(joint_pos, torch.ones_like(joint_pos) * 0.3)
assert torch.allclose(joint_vel, torch.ones_like(joint_vel) * 0.2)
# Clamping when offset exceeds limits.
events.reset_joints_by_offset(
env,
torch.tensor([1], device=device),
position_range=(1.0, 1.0),
velocity_range=(0.0, 0.0),
asset_cfg=SceneEntityCfg("robot", joint_ids=slice(None)),
)
call_args = mock_entity.write_joint_state_to_sim.call_args
joint_pos = call_args[0][0]
assert torch.allclose(joint_pos, torch.ones_like(joint_pos) * 0.5)
# ===========================================================================
# Section 4: Step mode and apply_body_impulse
# ===========================================================================
def test_step_mode_fires_every_call(device):
"""Step-mode events fire unconditionally on every apply() call."""
call_count = [0]
def counter(env, env_ids):
call_count[0] += 1
env = Mock()
env.num_envs = 2
env.device = device
env.scene = {}
env.sim = Mock()
cfg = {
"step_counter": EventTermCfg(
mode="step",
func=counter,
params={},
),
}
manager = EventManager(cfg, env)
for _ in range(5):
manager.apply(mode="step", dt=0.02)
assert call_count[0] == 5
def _make_impulse_env(
device, num_envs=2, num_bodies=1, body_ids=None, cooldown_s=(0.0, 0.0)
):
"""Create a mock env for apply_body_impulse tests."""
if body_ids is None:
body_ids = [0]
env = Mock()
env.num_envs = num_envs
env.device = device
env.step_dt = 0.02
mock_entity = Mock()
mock_entity.num_bodies = num_bodies
mock_entity.data = Mock()
mock_entity.data.body_com_quat_w = torch.zeros(
(num_envs, num_bodies, 4), device=device
)
mock_entity.data.body_com_quat_w[..., 0] = 1.0
env.scene = {"robot": mock_entity}
asset_cfg = SceneEntityCfg("robot", body_ids=body_ids)
term_cfg = Mock()
term_cfg.params = {"asset_cfg": asset_cfg, "cooldown_s": cooldown_s}
impulse = events.apply_body_impulse(cfg=term_cfg, env=env)
return env, mock_entity, asset_cfg, impulse
def test_apply_body_impulse_basic(device):
"""Impulse is applied and cleared after duration expires."""
env, mock_entity, asset_cfg, impulse = _make_impulse_env(
device, num_envs=2, num_bodies=3, body_ids=[1], cooldown_s=(10.0, 10.0)
)
# Skip the initial cooldown so the first call triggers immediately;
# the trigger/sustain/expire cycle is what's under test here.
impulse._interval_time_left[:] = 0.0
impulse(
env,
None,
force_range=(10.0, 10.0),
torque_range=(0.0, 0.0),
duration_s=(0.05, 0.05),
cooldown_s=(10.0, 10.0),
asset_cfg=asset_cfg,
)
assert impulse._active.any()
mock_entity.write_external_wrench_to_sim.assert_called()
def step():
impulse(
env,
None,
force_range=(10.0, 10.0),
torque_range=(0.0, 0.0),
duration_s=(0.05, 0.05),
cooldown_s=(10.0, 10.0),
asset_cfg=asset_cfg,
)
# Step a few times (duration is 0.05s, step_dt is 0.02s).
mock_entity.write_external_wrench_to_sim.reset_mock()
step() # t=0.02, remaining=0.03
assert impulse._active.all()
step() # t=0.04, remaining=0.01
assert impulse._active.all()
step() # t=0.06, remaining=-0.01 -> cleared
assert not impulse._active.any()
# Verify write_external_wrench_to_sim was called with zeros to clear.
clear_call = None
for call in mock_entity.write_external_wrench_to_sim.call_args_list:
forces = call[0][0]
if torch.all(forces == 0):
clear_call = call
assert clear_call is not None
def test_apply_body_impulse_with_offset(device):
"""body_point_offset adds cross-product contribution to torque."""
env, mock_entity, asset_cfg, impulse = _make_impulse_env(
device, num_envs=1, num_bodies=1, body_ids=[0]
)
impulse(
env,
None,
force_range=(1.0, 1.0),
torque_range=(0.0, 0.0),
duration_s=(1.0, 1.0),
cooldown_s=(0.0, 0.0),
asset_cfg=asset_cfg,
body_point_offset=(0.0, 0.0, 0.5),
)
call_args = mock_entity.write_external_wrench_to_sim.call_args
forces = call_args[0][0]
torques = call_args[0][1]
# force is (1,1,1) applied at offset (0,0,0.5).
# cross((0,0,0.5), (1,1,1)) = (-0.5, 0.5, 0)
offset = torch.tensor([0.0, 0.0, 0.5], device=device)
expected_extra_torque = torch.cross(offset.unsqueeze(0), forces.squeeze(0), dim=-1)
torch.testing.assert_close(
torques.squeeze(0), expected_extra_torque, atol=1e-5, rtol=1e-5
)
def test_apply_body_impulse_reset_clears(device):
"""reset() zeros forces and resets internal state."""
env, mock_entity, asset_cfg, impulse = _make_impulse_env(
device, num_envs=2, num_bodies=1, body_ids=[0]
)
# Trigger impulse.
impulse(
env,
None,
force_range=(10.0, 10.0),
torque_range=(0.0, 0.0),
duration_s=(1.0, 1.0),
cooldown_s=(0.0, 0.0),
asset_cfg=asset_cfg,
)
assert impulse._active.all()
# Reset env 0.
mock_entity.write_external_wrench_to_sim.reset_mock()
impulse.reset(env_ids=torch.tensor([0], device=device))
assert not impulse._active[0]
assert impulse._active[1]
assert impulse._time_remaining[0] == 0.0
# Verify clearing write was called for env 0.
mock_entity.write_external_wrench_to_sim.assert_called_once()
call_args = mock_entity.write_external_wrench_to_sim.call_args
env_ids_arg = call_args[1]["env_ids"]
assert len(env_ids_arg) == 1
assert env_ids_arg[0].item() == 0
def test_apply_body_impulse_initial_cooldown(device):
"""The first call after init/reset enters cooldown, not an immediate impulse.
Regression test for #973.
"""
env, mock_entity, asset_cfg, impulse = _make_impulse_env(
device, num_envs=1, num_bodies=1, body_ids=[0], cooldown_s=(0.05, 0.05)
)
def step():
impulse(
env,
None,
force_range=(10.0, 10.0),
torque_range=(0.0, 0.0),
duration_s=(1.0, 1.0),
cooldown_s=(0.05, 0.05), # ~2.5 steps at dt=0.02
asset_cfg=asset_cfg,
)
# First two steps consume the sampled cooldown; impulse must not fire yet.
step()
assert not impulse._active.any()
step()
assert not impulse._active.any()
# Third step crosses the cooldown boundary and triggers.
step()
assert impulse._active.all()
# Reset re-enters cooldown: next step should not immediately re-trigger.
impulse.reset(env_ids=torch.tensor([0], device=device))
assert not impulse._active.any()
step()
assert not impulse._active.any()
# ===========================================================================
# Section 5: Recomputation integration
# ===========================================================================
def test_body_mass_recompute_subtreemass(device):
"""body_subtreemass is correctly recomputed after body_mass change."""
env = create_env(device, ("body_mass", "body_subtreemass"))
robot = env.scene["robot"]
body_ids = robot.indexing.body_ids
original_subtreemass = env.sim.model.body_subtreemass[:, body_ids].clone()
env.sim.model.body_mass[0, body_ids] *= 2.0
env.sim.recompute_constants(RecomputeLevel.set_const)
new_subtreemass = env.sim.model.body_subtreemass[:, body_ids]
assert not torch.allclose(new_subtreemass[0], original_subtreemass[0])
torch.testing.assert_close(new_subtreemass[1], original_subtreemass[1])
def test_body_mass_recompute_invweight(device):
"""dof_invweight0 changes after body_mass modification."""
env = create_env(
device,
("body_mass", "body_subtreemass", "dof_invweight0", "body_invweight0"),
)
robot = env.scene["robot"]
body_ids = robot.indexing.body_ids
dof_adr = robot.indexing.joint_v_adr
original_invweight = env.sim.model.dof_invweight0[:, dof_adr].clone()
env.sim.model.body_mass[0, body_ids] *= 2.0
env.sim.recompute_constants(RecomputeLevel.set_const)
new_invweight = env.sim.model.dof_invweight0[:, dof_adr]
assert not torch.allclose(new_invweight[0], original_invweight[0])
torch.testing.assert_close(new_invweight[1], original_invweight[1])
def test_body_com_offset_recompute(device):
"""body_ipos modification triggers correct recomputation."""
env = create_env(
device,
("body_ipos", "body_subtreemass", "dof_invweight0", "body_invweight0"),
)
robot = env.scene["robot"]
body_ids = robot.indexing.body_ids
dof_adr = robot.indexing.joint_v_adr
original_invweight = env.sim.model.dof_invweight0[:, dof_adr].clone()
link1_idx = body_ids[1]
env.sim.model.body_ipos[0, link1_idx, 0] += 0.05
env.sim.recompute_constants(RecomputeLevel.set_const)
new_invweight = env.sim.model.dof_invweight0[:, dof_adr]
assert not torch.allclose(new_invweight[0], original_invweight[0])
torch.testing.assert_close(new_invweight[1], original_invweight[1])
def test_dof_armature_recompute(device):
"""dof_armature modification updates dof_invweight0."""
env = create_env(
device,
(
"dof_armature",
"dof_invweight0",
"body_invweight0",
"tendon_length0",
"tendon_invweight0",
),
)
robot = env.scene["robot"]
dof_adr = robot.indexing.joint_v_adr
original_invweight = env.sim.model.dof_invweight0[:, dof_adr].clone()
env.sim.model.dof_armature[0, dof_adr] *= 10.0
env.sim.recompute_constants(RecomputeLevel.set_const_0)
new_invweight = env.sim.model.dof_invweight0[:, dof_adr]
assert not torch.allclose(new_invweight[0], original_invweight[0])
torch.testing.assert_close(new_invweight[1], original_invweight[1])
# ===========================================================================
# Section 6: Recompute vs mj_setConst / recompiled MjSpec
# ===========================================================================
def test_body_mass_recompute_matches_set_const(device):
"""Modify body_mass: GPU recompute matches CPU mj_setConst."""
gpu_model = mujoco.MjModel.from_xml_string(ROBOT_XML)
cpu_model = mujoco.MjModel.from_xml_string(ROBOT_XML)
link1_id = mujoco.mj_name2id(cpu_model, mujoco.mjtObj.mjOBJ_BODY, "link1")
# GPU path.
expand_fields = (
"body_mass",
"body_subtreemass",
"dof_invweight0",
"body_invweight0",
)
sim = Simulation(num_envs=1, cfg=SimulationCfg(), model=gpu_model, device=device)
sim.expand_model_fields(expand_fields)
sim.model.body_mass[0, link1_id] = 5.0
sim.recompute_constants(RecomputeLevel.set_const)
# CPU ground truth: apply same cfg mutations as Simulation.__init__.
SimulationCfg().mujoco.apply(cpu_model)
cpu_data = mujoco.MjData(cpu_model)
mujoco.mj_forward(cpu_model, cpu_data)
cpu_model.body_mass[link1_id] = 5.0
mujoco.mj_setConst(cpu_model, cpu_data)
for field in ("body_subtreemass", "dof_invweight0", "body_invweight0"):
gpu_val = getattr(sim.model, field)[0].cpu()
ref_val = torch.tensor(getattr(cpu_model, field), dtype=torch.float32)
torch.testing.assert_close(gpu_val, ref_val, atol=1e-4, rtol=1e-4)
def test_body_ipos_recompute_matches_set_const(device):
"""Modify body_ipos: GPU recompute matches CPU mj_setConst."""
gpu_model = mujoco.MjModel.from_xml_string(ROBOT_XML)
cpu_model = mujoco.MjModel.from_xml_string(ROBOT_XML)
link1_id = mujoco.mj_name2id(cpu_model, mujoco.mjtObj.mjOBJ_BODY, "link1")
# GPU path.
expand_fields = (
"body_ipos",
"body_subtreemass",
"dof_invweight0",
"body_invweight0",
)
sim = Simulation(num_envs=1, cfg=SimulationCfg(), model=gpu_model, device=device)
sim.expand_model_fields(expand_fields)
sim.model.body_ipos[0, link1_id, 0] += 0.05
sim.recompute_constants(RecomputeLevel.set_const)
# CPU ground truth: apply same cfg mutations as Simulation.__init__.
SimulationCfg().mujoco.apply(cpu_model)
cpu_data = mujoco.MjData(cpu_model)
mujoco.mj_forward(cpu_model, cpu_data)
cpu_model.body_ipos[link1_id, 0] += 0.05
# Clear body_sameframe so mj_kinematics recomputes xipos from body_ipos.
# (When sameframe != 0, mj_local2Global skips the ipos→xipos transform.)
cpu_model.body_sameframe[link1_id] = 0
mujoco.mj_setConst(cpu_model, cpu_data)
for field in ("dof_invweight0", "body_invweight0"):
gpu_val = getattr(sim.model, field)[0].cpu()
ref_val = torch.tensor(getattr(cpu_model, field), dtype=torch.float32)
torch.testing.assert_close(gpu_val, ref_val, atol=1e-4, rtol=1e-4)
def test_dof_armature_recompute_matches_recompile(device):
"""Modify dof_armature: GPU recompute matches fresh MjSpec.compile()."""
spec = mujoco.MjSpec.from_string(ROBOT_XML)
original_model = spec.compile()
joint1_id = mujoco.mj_name2id(original_model, mujoco.mjtObj.mjOBJ_JOINT, "joint1")
# Ground truth: modify spec armature, recompile.
spec.joint("joint1").armature = 1.0
recompiled_model = spec.compile()
# GPU path.
expand_fields = (
"dof_armature",
"dof_invweight0",
"body_invweight0",
"tendon_length0",
"tendon_invweight0",
)
sim = Simulation(num_envs=1, cfg=SimulationCfg(), model=original_model, device=device)
sim.expand_model_fields(expand_fields)
dof_adr = original_model.jnt_dofadr[joint1_id]
sim.model.dof_armature[0, dof_adr] = 1.0
sim.recompute_constants(RecomputeLevel.set_const_0)
gpu_dof_invweight = sim.model.dof_invweight0[0].cpu()
ref_dof_invweight = torch.tensor(recompiled_model.dof_invweight0, dtype=torch.float32)
torch.testing.assert_close(gpu_dof_invweight, ref_dof_invweight, atol=1e-4, rtol=1e-4)
# ===========================================================================
# Section: interval timer reset (issue: reset iterated the class-only list)
# ===========================================================================
def _noop_interval_event(env, env_ids):
del env, env_ids
class _ClassIntervalEvent:
"""Class-based interval event with a reset hook."""
def __init__(self, cfg, env):
del cfg, env
def reset(self, env_ids=None):
del env_ids
def __call__(self, env, env_ids):
del env, env_ids
def _make_mock_env(device, num_envs=4):
env = Mock()
env.num_envs = num_envs
env.device = device
env.scene = {}
env.sim = Mock()
return env
def test_interval_timer_resets_for_function_terms(device):
"""Function-based interval terms get their per-env timer resampled on reset."""
env = _make_mock_env(device)
cfg = {
"push": EventTermCfg(
mode="interval", func=_noop_interval_event, interval_range_s=(5.0, 10.0)
),
}
manager = EventManager(cfg, env)
timer = manager._interval_term_time_left[0]
# Zero the reset env's timer so the in-range assertion below can only pass
# if reset actually resampled it (deterministic, not clone-and-compare).
timer[1] = 0.0
before = timer.clone()
reset_ids = torch.tensor([1], dtype=torch.int64, device=device)
manager.reset(env_ids=reset_ids)
assert 5.0 <= timer[1].item() <= 10.0
untouched = torch.tensor([0, 2, 3], device=device)
assert torch.equal(timer[untouched], before[untouched])
def test_interval_timer_reset_with_mixed_terms(device):
"""Mixed function and class interval terms reset the right timer slots."""
env = _make_mock_env(device)
cfg = {
"push": EventTermCfg(
mode="interval", func=_noop_interval_event, interval_range_s=(5.0, 10.0)
),
"impulse": EventTermCfg(
mode="interval", func=_ClassIntervalEvent, interval_range_s=(100.0, 200.0)
),
}
manager = EventManager(cfg, env)
push_timer = manager._interval_term_time_left[0]
impulse_timer = manager._interval_term_time_left[1]
# Zero both timers for the reset env so the in-range assertions can only
# pass if reset resampled each slot (the init draw is from the same range,
# so comparing against it would be vacuous).
push_timer[1] = 0.0
impulse_timer[1] = 0.0
reset_ids = torch.tensor([1], dtype=torch.int64, device=device)
manager.reset(env_ids=reset_ids)
# Each timer slot is resampled from its own term's range.
assert 5.0 <= push_timer[1].item() <= 10.0
assert 100.0 <= impulse_timer[1].item() <= 200.0
def test_interval_timer_reset_all_envs(device):
"""reset(env_ids=None) resamples every env's timer."""
env = _make_mock_env(device)
cfg = {
"push": EventTermCfg(
mode="interval", func=_noop_interval_event, interval_range_s=(5.0, 10.0)
),
}
manager = EventManager(cfg, env)
timer = manager._interval_term_time_left[0]
timer[:] = 0.0
manager.reset(env_ids=None)
assert ((timer >= 5.0) & (timer <= 10.0)).all()