Files

319 lines
8.6 KiB
Python
Raw Permalink Normal View History

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