"""Tests for delayed actuators.""" import mujoco import pytest import torch from conftest import get_test_device, load_fixture_xml from mjlab.actuator import ( BuiltinPositionActuatorCfg, IdealPdActuatorCfg, ) from mjlab.entity import Entity, EntityArticulationInfoCfg, EntityCfg from mjlab.sim.sim import Simulation, SimulationCfg ROBOT_XML = load_fixture_xml("floating_base_articulated") @pytest.fixture(scope="module") def device(): return get_test_device() def create_entity_with_delayed_builtin(delay_min_lag=0, delay_max_lag=3): cfg = EntityCfg( spec_fn=lambda: mujoco.MjSpec.from_string(ROBOT_XML), articulation=EntityArticulationInfoCfg( actuators=( BuiltinPositionActuatorCfg( target_names_expr=("joint.*",), effort_limit=100.0, stiffness=80.0, damping=10.0, delay_min_lag=delay_min_lag, delay_max_lag=delay_max_lag, ), ) ), ) return Entity(cfg) def create_entity_with_delayed_ideal(delay_min_lag=0, delay_max_lag=3): cfg = EntityCfg( spec_fn=lambda: mujoco.MjSpec.from_string(ROBOT_XML), articulation=EntityArticulationInfoCfg( actuators=( IdealPdActuatorCfg( target_names_expr=("joint.*",), effort_limit=100.0, stiffness=80.0, damping=10.0, delay_min_lag=delay_min_lag, delay_max_lag=delay_max_lag, ), ) ), ) return Entity(cfg) def initialize_entity(entity, device, num_envs=1): model = entity.compile() sim_cfg = SimulationCfg() sim = Simulation(num_envs=num_envs, cfg=sim_cfg, model=model, device=device) entity.initialize(model, sim.model, sim.data, device) return entity, sim def test_delayed_builtin_applies_constant_delay(device): """Test that delayed builtin actuator delays position targets.""" entity = create_entity_with_delayed_builtin(delay_min_lag=2, delay_max_lag=2) entity, sim = initialize_entity(entity, device) # Set position targets for 3 steps. targets = [ torch.tensor([[0.1, 0.2]], device=device), torch.tensor([[0.3, 0.4]], device=device), torch.tensor([[0.5, 0.6]], device=device), ] joint_vel = torch.zeros(1, 2, device=device) for target in targets: entity.set_joint_position_target(target) entity.set_joint_velocity_target(joint_vel) entity.set_joint_effort_target(torch.zeros(1, 2, device=device)) entity.write_data_to_sim() # After 3 steps with lag=2, the output should be the target from step 0. ctrl = sim.data.ctrl[0] # With constant lag=2, after 3 appends, we expect target from step 0. assert torch.allclose(ctrl, targets[0][0], atol=1e-5) def test_delayed_ideal_applies_delay(device): """Test that delayed ideal actuator delays position targets.""" entity = create_entity_with_delayed_ideal(delay_min_lag=2, delay_max_lag=2) entity, sim = initialize_entity(entity, device) joint_pos = torch.zeros(1, 2, device=device) joint_vel = torch.zeros(1, 2, device=device) entity.write_joint_state_to_sim(joint_pos, joint_vel) # Set position targets for 3 steps. targets = [ torch.tensor([[0.1, 0.2]], device=device), torch.tensor([[0.3, 0.4]], device=device), torch.tensor([[0.5, 0.6]], device=device), ] for target in targets: entity.set_joint_position_target(target) entity.set_joint_velocity_target(joint_vel) entity.set_joint_effort_target(torch.zeros(1, 2, device=device)) entity.write_data_to_sim() sim.forward() # Compute actuator forces # The computed torque should use the delayed target from step 0. joint_v_adr = entity.indexing.joint_v_adr qfrc = sim.data.qfrc_actuator[0, joint_v_adr] # Expected torque: kp * (delayed_target - joint_pos) + kd * (0 - joint_vel) + 0 # = 80.0 * targets[0] + 0 = 80.0 * [0.1, 0.2] expected_torque = 80.0 * targets[0][0] assert torch.allclose(qfrc, expected_torque, atol=1e-4) def test_delayed_ideal_delays_velocity(device): """Velocity targets share the same delay as position targets. Regression test: the velocity reference used to bypass the delay buffer, so the damping term consumed the latest target instead of the delayed one. """ entity = create_entity_with_delayed_ideal(delay_min_lag=2, delay_max_lag=2) entity, sim = initialize_entity(entity, device) joint_pos = torch.zeros(1, 2, device=device) joint_vel = torch.zeros(1, 2, device=device) entity.write_joint_state_to_sim(joint_pos, joint_vel) # Only the velocity target varies; position and effort stay zero. vel_targets = [ torch.tensor([[0.1, 0.2]], device=device), torch.tensor([[0.3, 0.4]], device=device), torch.tensor([[0.5, 0.6]], device=device), ] for vel_target in vel_targets: entity.set_joint_position_target(joint_pos) entity.set_joint_velocity_target(vel_target) entity.set_joint_effort_target(torch.zeros(1, 2, device=device)) entity.write_data_to_sim() sim.forward() joint_v_adr = entity.indexing.joint_v_adr qfrc = sim.data.qfrc_actuator[0, joint_v_adr] # With lag=2, the damping term uses the velocity target from step 0: # kd * (delayed_vel_target - 0) = 10.0 * [0.1, 0.2]. expected_torque = 10.0 * vel_targets[0][0] assert torch.allclose(qfrc, expected_torque, atol=1e-4) def test_delayed_ideal_delays_effort(device): """Feedforward effort targets share the same delay as position targets.""" entity = create_entity_with_delayed_ideal(delay_min_lag=2, delay_max_lag=2) entity, sim = initialize_entity(entity, device) joint_pos = torch.zeros(1, 2, device=device) joint_vel = torch.zeros(1, 2, device=device) entity.write_joint_state_to_sim(joint_pos, joint_vel) effort_targets = [ torch.tensor([[1.0, 2.0]], device=device), torch.tensor([[3.0, 4.0]], device=device), torch.tensor([[5.0, 6.0]], device=device), ] for effort_target in effort_targets: entity.set_joint_position_target(joint_pos) entity.set_joint_velocity_target(joint_vel) entity.set_joint_effort_target(effort_target) entity.write_data_to_sim() sim.forward() joint_v_adr = entity.indexing.joint_v_adr qfrc = sim.data.qfrc_actuator[0, joint_v_adr] # With lag=2, the feedforward term uses the effort target from step 0. expected_torque = effort_targets[0][0] assert torch.allclose(qfrc, expected_torque, atol=1e-4) def test_delayed_actuator_reset(device): """Test that reset clears the delay buffer.""" entity = create_entity_with_delayed_builtin(delay_min_lag=1, delay_max_lag=3) entity, _ = initialize_entity(entity, device, num_envs=2) # Set some targets to fill the buffer. entity.set_joint_position_target(torch.ones(2, 2, device=device) * 0.5) entity.set_joint_velocity_target(torch.zeros(2, 2, device=device)) entity.set_joint_effort_target(torch.zeros(2, 2, device=device)) entity.write_data_to_sim() # Reset env 0. entity.reset(torch.tensor([0], device=device)) # Check that delay buffer was reset for env 0. actuator = entity.actuators[0] assert actuator.has_delay assert actuator._delay_buffer is not None assert actuator._delay_buffer.current_lags[0] == 0 def test_delayed_actuator_set_lags(device): """Test that set_lags sets lag values on all delay buffers.""" entity = create_entity_with_delayed_builtin(delay_min_lag=0, delay_max_lag=5) entity, _ = initialize_entity(entity, device, num_envs=4) actuator = entity.actuators[0] assert actuator.has_delay # Set lags for all environments. lags = torch.tensor([1, 2, 3, 4], device=device) actuator.set_lags(lags) # Check that lags were set. buffer = actuator._delay_buffer assert buffer is not None assert torch.equal(buffer.current_lags, lags) def test_delayed_actuator_set_lags_subset(device): """Test that set_lags can set lag values for a subset of environments.""" entity = create_entity_with_delayed_builtin(delay_min_lag=0, delay_max_lag=5) entity, _ = initialize_entity(entity, device, num_envs=4) actuator = entity.actuators[0] assert actuator.has_delay # Set lags for envs 1 and 3 only. env_ids = torch.tensor([1, 3], device=device) lags = torch.tensor([4, 5], device=device) actuator.set_lags(lags, env_ids) # Check that only specified envs were updated. buffer = actuator._delay_buffer assert buffer is not None assert buffer.current_lags[0] == 0 # Unchanged (initial value) assert buffer.current_lags[1] == 4 assert buffer.current_lags[2] == 0 # Unchanged assert buffer.current_lags[3] == 5 def test_delayed_actuator_set_lags_clamps_to_range(device): """Test that set_lags clamps values to the configured lag range.""" entity = create_entity_with_delayed_builtin(delay_min_lag=1, delay_max_lag=3) entity, _ = initialize_entity(entity, device, num_envs=2) actuator = entity.actuators[0] assert actuator.has_delay # Try to set lags outside the valid range. lags = torch.tensor([0, 10], device=device) # 0 < min_lag, 10 > max_lag actuator.set_lags(lags) # Lags should be clamped to [1, 3]. buffer = actuator._delay_buffer assert buffer is not None assert buffer.current_lags[0] == 1 # Clamped from 0 assert buffer.current_lags[1] == 3 # Clamped from 10 def test_delayed_actuator_set_lags_affects_delay(device): """Test that setting lags actually changes the delay behavior.""" # Use hold_prob=1.0 to prevent automatic lag resampling. cfg = EntityCfg( spec_fn=lambda: mujoco.MjSpec.from_string(ROBOT_XML), articulation=EntityArticulationInfoCfg( actuators=( BuiltinPositionActuatorCfg( target_names_expr=("joint.*",), effort_limit=100.0, stiffness=80.0, damping=10.0, delay_min_lag=0, delay_max_lag=5, delay_hold_prob=1.0, # Prevent automatic resampling ), ) ), ) entity = Entity(cfg) entity, sim = initialize_entity(entity, device, num_envs=1) actuator = entity.actuators[0] assert actuator.has_delay # Set lag to 1. actuator.set_lags(torch.tensor([1], device=device)) # Fill the buffer with known targets. targets = [ torch.tensor([[0.1, 0.2]], device=device), torch.tensor([[0.3, 0.4]], device=device), torch.tensor([[0.5, 0.6]], device=device), ] for target in targets: entity.set_joint_position_target(target) entity.set_joint_velocity_target(torch.zeros(1, 2, device=device)) entity.set_joint_effort_target(torch.zeros(1, 2, device=device)) entity.write_data_to_sim() # With lag=1, after 3 steps, ctrl should use target from step 1 (index 1). ctrl = sim.data.ctrl[0] assert torch.allclose(ctrl, targets[1][0], atol=1e-5) def test_delayed_actuator_set_lags_overwritten_without_hold_prob(device): """Test that set_lags gets overwritten when delay_hold_prob < 1.0.""" # Use min_lag=max_lag=2 so resampling always produces 2. cfg = EntityCfg( spec_fn=lambda: mujoco.MjSpec.from_string(ROBOT_XML), articulation=EntityArticulationInfoCfg( actuators=( BuiltinPositionActuatorCfg( target_names_expr=("joint.*",), effort_limit=100.0, stiffness=80.0, damping=10.0, delay_min_lag=2, delay_max_lag=2, delay_hold_prob=0.0, # Always resample ), ) ), ) entity = Entity(cfg) entity, sim = initialize_entity(entity, device, num_envs=1) actuator = entity.actuators[0] assert actuator.has_delay buffer = actuator._delay_buffer assert buffer is not None # Set lag to 2 (the only valid value, so set_lags won't clamp it). actuator.set_lags(torch.tensor([2], device=device)) assert buffer.current_lags[0] == 2 # Now manually set _current_lags to 0 to simulate "we want 0". # This bypasses clamping to test the resampling behavior. buffer._current_lags[0] = 0 assert buffer.current_lags[0] == 0 # After compute, with hold_prob=0.0, it resamples to [min_lag, max_lag] = 2. entity.set_joint_position_target(torch.zeros(1, 2, device=device)) entity.set_joint_velocity_target(torch.zeros(1, 2, device=device)) entity.set_joint_effort_target(torch.zeros(1, 2, device=device)) entity.write_data_to_sim() # Lag should have been resampled back to 2. assert buffer.current_lags[0] == 2