mjlab/tests/test_delayed_actuator.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

364 lines
12 KiB
Python

"""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