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

319 lines
8.6 KiB
Python

"""Tests for actions."""
from pathlib import Path
from unittest.mock import Mock
import mujoco
import pytest
import torch
from conftest import get_test_device, load_fixture_xml
from mjlab.actuator.actuator import TransmissionType
from mjlab.actuator.builtin_actuator import BuiltinMotorActuatorCfg
from mjlab.entity import Entity, EntityArticulationInfoCfg, EntityCfg
from mjlab.envs import ManagerBasedRlEnv
from mjlab.envs.mdp.actions import (
JointPositionActionCfg,
RelativeJointPositionActionCfg,
SiteEffortActionCfg,
TendonEffortActionCfg,
TendonLengthActionCfg,
TendonVelocityActionCfg,
)
from mjlab.sim.sim import Simulation, SimulationCfg
@pytest.fixture(scope="module")
def device():
return get_test_device()
@pytest.fixture(scope="module")
def fixtures_dir():
return Path(__file__).parent / "fixtures"
@pytest.fixture(scope="module")
def tendon_finger_entity(fixtures_dir, device):
return make_entity(
fixtures_dir / "tendon_finger.xml",
("finger_tendon",),
TransmissionType.TENDON,
device,
from_file=True,
)
@pytest.fixture(scope="module")
def fixed_base_entity(fixtures_dir, device):
return make_entity(
fixtures_dir / "fixed_base_articulated.xml",
("joint.*",),
TransmissionType.JOINT,
device,
from_file=True,
)
@pytest.fixture(scope="module")
def floating_base_entity(device):
return make_entity(
load_fixture_xml("floating_base_articulated"),
("joint.*",),
TransmissionType.JOINT,
device,
from_file=False,
)
def make_entity(xml_or_path, target_expr, transmission_type, device, from_file=False):
"""Create and initialize entity."""
def spec_fn():
if from_file:
return mujoco.MjSpec.from_file(str(xml_or_path))
return mujoco.MjSpec.from_string(xml_or_path)
cfg = EntityCfg(
spec_fn=spec_fn,
articulation=EntityArticulationInfoCfg(
actuators=(
BuiltinMotorActuatorCfg(
target_names_expr=target_expr,
transmission_type=transmission_type,
effort_limit=10.0,
),
)
),
)
entity = Entity(cfg)
model = entity.compile()
sim = Simulation(num_envs=4, cfg=SimulationCfg(), model=model, device=device)
entity.initialize(model, sim.model, sim.data, device)
return entity
def make_env(entity, name, device):
"""Create mock environment."""
env = Mock(spec=ManagerBasedRlEnv)
env.num_envs = 4
env.device = device
env.scene = {name: entity}
return env
def test_base_action_applies_scale_and_offset(tendon_finger_entity, device):
"""BaseAction: processed = raw * scale + offset."""
entity = tendon_finger_entity
env = make_env(entity, "finger", device)
cfg = TendonLengthActionCfg(
entity_name="finger",
actuator_names=("finger_tendon",),
scale=2.0,
offset=0.5,
)
action = cfg.build(env)
raw = torch.tensor([[1.0], [2.0], [3.0], [4.0]], device=device)
action.process_actions(raw)
assert torch.allclose(action._processed_actions, raw * 2.0 + 0.5)
def test_base_action_reset_zeros_specific_envs(tendon_finger_entity, device):
"""BaseAction.reset() zeros raw_action for specified env_ids only."""
entity = tendon_finger_entity
env = make_env(entity, "finger", device)
cfg = TendonLengthActionCfg(entity_name="finger", actuator_names=("finger_tendon",))
action = cfg.build(env)
action.process_actions(torch.ones(4, 1, device=device))
action.reset(env_ids=torch.tensor([0, 2], device=device))
assert torch.all(action.raw_action[[0, 2]] == 0.0)
assert torch.all(action.raw_action[[1, 3]] == 1.0)
@pytest.mark.parametrize(
"cfg_cls,target_attr,fixture,target_expr,transmission,entity_name",
[
# Joints.
(
JointPositionActionCfg,
"joint_pos_target",
"floating_base_articulated",
("joint.*",),
TransmissionType.JOINT,
"robot",
),
# Tendons.
(
TendonLengthActionCfg,
"tendon_len_target",
"tendon_finger.xml",
("finger_tendon",),
TransmissionType.TENDON,
"finger",
),
(
TendonVelocityActionCfg,
"tendon_vel_target",
"tendon_finger.xml",
("finger_tendon",),
TransmissionType.TENDON,
"finger",
),
(
TendonEffortActionCfg,
"tendon_effort_target",
"tendon_finger.xml",
("finger_tendon",),
TransmissionType.TENDON,
"finger",
),
# Sites.
(
SiteEffortActionCfg,
"site_effort_target",
"quadcopter.xml",
("rotor_.*",),
TransmissionType.SITE,
"drone",
),
],
)
def test_action_sets_entity_target(
fixtures_dir,
device,
cfg_cls,
target_attr,
fixture,
target_expr,
transmission,
entity_name,
):
"""Each action type writes to correct entity.data field."""
if fixture.endswith(".xml"):
entity = make_entity(
fixtures_dir / fixture, target_expr, transmission, device, from_file=True
)
else:
entity = make_entity(
load_fixture_xml(fixture), target_expr, transmission, device, from_file=False
)
env = make_env(entity, entity_name, device)
cfg = cfg_cls(entity_name=entity_name, actuator_names=target_expr)
action = cfg.build(env)
target = torch.arange(4 * action.action_dim, device=device, dtype=torch.float32)
target = target.reshape(4, action.action_dim) * 0.1
action.process_actions(target)
action.apply_actions()
entity_target = getattr(entity.data, target_attr)
assert torch.allclose(entity_target, target)
def test_base_action_clip(fixed_base_entity, device):
"""BaseAction: clip clamps only matched actuators; others stay unclipped."""
entity = fixed_base_entity
env = make_env(entity, "robot", device)
# Clip only joint1; joint2 should remain unclipped.
cfg = JointPositionActionCfg(
entity_name="robot",
actuator_names=("joint.*",),
scale=1.0,
use_default_offset=False,
clip={"joint1": (-0.5, 0.5)},
)
action = cfg.build(env)
# joint1=2.0 should be clipped to 0.5, joint2=2.0 should pass through.
raw = torch.tensor([[2.0, 2.0]], device=device).expand(4, -1)
action.process_actions(raw)
assert torch.allclose(
action._processed_actions[:, 0], torch.tensor(0.5, device=device)
)
assert torch.allclose(
action._processed_actions[:, 1], torch.tensor(2.0, device=device)
)
def test_relative_joint_position_zero_action(floating_base_entity, device):
"""With zero action, targets equal current_pos."""
entity = floating_base_entity
env = make_env(entity, "robot", device)
cfg = RelativeJointPositionActionCfg(
entity_name="robot", actuator_names=("joint.*",), scale=1.0
)
action = cfg.build(env)
current_pos = entity.data.joint_pos[:, action.target_ids].clone()
action.process_actions(torch.zeros(4, action.action_dim, device=device))
action.apply_actions()
assert torch.allclose(entity.data.joint_pos_target[:, action.target_ids], current_pos)
def test_relative_joint_position_nonzero_action(floating_base_entity, device):
"""With nonzero action, targets shift by action * scale from current."""
entity = floating_base_entity
env = make_env(entity, "robot", device)
scale = 0.1
cfg = RelativeJointPositionActionCfg(
entity_name="robot", actuator_names=("joint.*",), scale=scale
)
action = cfg.build(env)
current_pos = entity.data.joint_pos[:, action.target_ids].clone()
raw = torch.ones(4, action.action_dim, device=device)
action.process_actions(raw)
action.apply_actions()
expected = current_pos + raw * scale
assert torch.allclose(entity.data.joint_pos_target[:, action.target_ids], expected)
def test_relative_joint_position_ignores_encoder_bias(floating_base_entity, device):
"""Encoder bias must not affect the target: target = current_pos + delta."""
entity = floating_base_entity
env = make_env(entity, "robot", device)
cfg = RelativeJointPositionActionCfg(
entity_name="robot", actuator_names=("joint.*",), scale=1.0
)
action = cfg.build(env)
entity.data.encoder_bias[:, action.target_ids] = 0.05
current_pos = entity.data.joint_pos[:, action.target_ids].clone()
delta = 0.1
raw = torch.full((4, action.action_dim), delta, device=device)
action.process_actions(raw)
action.apply_actions()
assert torch.allclose(
entity.data.joint_pos_target[:, action.target_ids], current_pos + delta
)
# Reset bias so this shared fixture doesn't affect other tests.
entity.data.encoder_bias[:, action.target_ids] = 0.0
def test_relative_joint_position_offset_raises(floating_base_entity, device):
"""Setting offset on RelativeJointPositionActionCfg raises ValueError."""
with pytest.raises(ValueError, match="offset"):
RelativeJointPositionActionCfg(
entity_name="robot", actuator_names=("joint.*",), offset=0.5
)