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
Upstream: https://github.com/michaelgillett/mjlab Upstream-Commit: c19f713c415a699a79d71cd96aa13c3104a05047 Upstream-Branch: main
215 lines
6.7 KiB
Python
215 lines
6.7 KiB
Python
"""Tests for spec.py utilities."""
|
|
|
|
import mujoco
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from mjlab.utils.spec import create_position_actuator, create_velocity_actuator
|
|
|
|
|
|
@pytest.fixture
|
|
def spec_with_limited_joint():
|
|
"""Create a spec with a limited joint for testing."""
|
|
spec = mujoco.MjSpec()
|
|
body = spec.worldbody.add_body(name="test_body")
|
|
joint = body.add_joint(
|
|
name="test_joint",
|
|
type=mujoco.mjtJoint.mjJNT_SLIDE,
|
|
axis=[0, 0, 1],
|
|
range=[-1.0, 1.0],
|
|
)
|
|
joint.limited = mujoco.mjtLimited.mjLIMITED_TRUE
|
|
body.add_geom(type=mujoco.mjtGeom.mjGEOM_BOX, size=[0.1, 0.1, 0.1], mass=1.0)
|
|
return spec
|
|
|
|
|
|
def test_position_actuator_allows_setpoints_beyond_joint_limits(
|
|
spec_with_limited_joint,
|
|
):
|
|
"""Verify that position actuators allow commanding setpoints beyond joint limits.
|
|
|
|
This is the core fix: position actuators have ctrllimited=False, allowing
|
|
policies to command positions outside kinematic limits to maximize torque.
|
|
We verify this by comparing torque when commanding beyond vs at the limit.
|
|
"""
|
|
stiffness = 100.0
|
|
create_position_actuator(
|
|
spec_with_limited_joint,
|
|
"test_joint",
|
|
stiffness=stiffness,
|
|
damping=10.0,
|
|
effort_limit=500.0,
|
|
)
|
|
model = spec_with_limited_joint.compile()
|
|
data = mujoco.MjData(model)
|
|
|
|
# Verify ctrllimited=False.
|
|
actuator_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_ACTUATOR, "test_joint")
|
|
assert model.actuator_ctrllimited[actuator_id] == 0
|
|
|
|
# Set joint position.
|
|
current_pos = 0.5
|
|
data.qpos[0] = current_pos
|
|
data.qvel[0] = 0.0
|
|
|
|
# Command beyond joint upper limit (1.0).
|
|
target_beyond = 2.0
|
|
data.ctrl[0] = target_beyond
|
|
mujoco.mj_forward(model, data)
|
|
torque_beyond = data.actuator_force[0]
|
|
|
|
# Expected: -kp * (q - target) = -100 * (0.5 - 2.0) = 150.0.
|
|
expected_torque_beyond = -stiffness * (current_pos - target_beyond)
|
|
np.testing.assert_allclose(torque_beyond, expected_torque_beyond, rtol=1e-5)
|
|
|
|
# If control were clipped to limit (1.0), torque would be 50.0.
|
|
expected_if_clipped = -stiffness * (current_pos - 1.0)
|
|
assert abs(torque_beyond) == 3.0 * abs(expected_if_clipped)
|
|
|
|
|
|
def test_position_actuator_forces_clipped_to_effort_limit(spec_with_limited_joint):
|
|
"""Verify that actuator forces are clipped to effort_limit, not control limits.
|
|
|
|
While control setpoints aren't clipped, forces must still be limited to
|
|
prevent unrealistic torques.
|
|
"""
|
|
effort_limit = 10.0
|
|
stiffness = 1000.0
|
|
create_position_actuator(
|
|
spec_with_limited_joint,
|
|
"test_joint",
|
|
stiffness=stiffness,
|
|
damping=1.0,
|
|
effort_limit=effort_limit,
|
|
)
|
|
model = spec_with_limited_joint.compile()
|
|
data = mujoco.MjData(model)
|
|
|
|
# Verify forcelimited=True.
|
|
actuator_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_ACTUATOR, "test_joint")
|
|
assert model.actuator_forcelimited[actuator_id] == 1
|
|
np.testing.assert_array_almost_equal(
|
|
model.actuator_forcerange[actuator_id], [-effort_limit, effort_limit]
|
|
)
|
|
|
|
# Large position error would generate force > effort_limit without clipping.
|
|
data.qpos[0] = 0.0
|
|
data.ctrl[0] = 3.0
|
|
mujoco.mj_step(model, data)
|
|
|
|
# Force should saturate at effort_limit.
|
|
assert abs(data.actuator_force[0]) <= effort_limit + 1e-6
|
|
assert abs(data.actuator_force[0]) >= effort_limit - 1e-3
|
|
|
|
|
|
def test_ctrllimited_true_would_clip_internally():
|
|
"""Demonstrate that ctrllimited=True clips control internally (the bug).
|
|
|
|
This test shows what would happen with the bug: commanding at vs beyond
|
|
the limit generates identical forces, proving internal clipping.
|
|
"""
|
|
spec = mujoco.MjSpec()
|
|
body = spec.worldbody.add_body(name="test_body")
|
|
joint = body.add_joint(
|
|
name="test_joint",
|
|
type=mujoco.mjtJoint.mjJNT_SLIDE,
|
|
axis=[0, 0, 1],
|
|
range=[-1.0, 1.0],
|
|
)
|
|
joint.limited = mujoco.mjtLimited.mjLIMITED_TRUE
|
|
body.add_geom(type=mujoco.mjtGeom.mjGEOM_BOX, size=[0.1, 0.1, 0.1], mass=1.0)
|
|
|
|
# Manually create actuator WITH THE BUG: ctrllimited=True.
|
|
stiffness = 100.0
|
|
actuator = spec.add_actuator(name="test_joint", target="test_joint")
|
|
actuator.trntype = mujoco.mjtTrn.mjTRN_JOINT
|
|
actuator.dyntype = mujoco.mjtDyn.mjDYN_NONE
|
|
actuator.gaintype = mujoco.mjtGain.mjGAIN_FIXED
|
|
actuator.biastype = mujoco.mjtBias.mjBIAS_AFFINE
|
|
actuator.ctrllimited = True # THE BUG.
|
|
actuator.ctrlrange[:] = np.array([-1.0, 1.0])
|
|
actuator.gainprm[0] = stiffness
|
|
actuator.biasprm[1] = -stiffness
|
|
actuator.biasprm[2] = -10.0
|
|
|
|
model = spec.compile()
|
|
data = mujoco.MjData(model)
|
|
|
|
data.qpos[0] = 0.5
|
|
data.qvel[0] = 0.0
|
|
|
|
# Command at limit.
|
|
data.ctrl[0] = 1.0
|
|
mujoco.mj_forward(model, data)
|
|
force_at_limit = data.actuator_force[0]
|
|
|
|
# Command beyond limit.
|
|
data.ctrl[0] = 2.0
|
|
mujoco.mj_forward(model, data)
|
|
force_beyond = data.actuator_force[0]
|
|
|
|
# With the bug, forces are IDENTICAL (both clipped to 1.0 internally).
|
|
np.testing.assert_allclose(force_beyond, force_at_limit, rtol=1e-10)
|
|
expected_force = -stiffness * (0.5 - 1.0)
|
|
np.testing.assert_allclose(force_at_limit, expected_force, rtol=1e-5)
|
|
|
|
|
|
@pytest.fixture
|
|
def spec_with_continuous_joint():
|
|
"""Create a spec with a continuous (unlimited) hinge joint."""
|
|
spec = mujoco.MjSpec()
|
|
body = spec.worldbody.add_body(name="wheel")
|
|
body.add_joint(
|
|
name="wheel_joint",
|
|
type=mujoco.mjtJoint.mjJNT_HINGE,
|
|
axis=[0, 0, 1],
|
|
)
|
|
body.add_geom(type=mujoco.mjtGeom.mjGEOM_CYLINDER, size=[0.1, 0.05], mass=1.0)
|
|
return spec
|
|
|
|
|
|
def test_velocity_actuator_continuous_joint(spec_with_continuous_joint):
|
|
"""Velocity actuator compiles for continuous joints with no range."""
|
|
damping = 10.0
|
|
create_velocity_actuator(
|
|
spec_with_continuous_joint,
|
|
"wheel_joint",
|
|
damping=damping,
|
|
)
|
|
model = spec_with_continuous_joint.compile()
|
|
data = mujoco.MjData(model)
|
|
|
|
actuator_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_ACTUATOR, "wheel_joint")
|
|
assert model.actuator_ctrllimited[actuator_id] == 0
|
|
|
|
# Commanding a velocity target should produce force = damping * (ctrl - qvel).
|
|
data.qvel[0] = 0.0
|
|
data.ctrl[0] = 5.0
|
|
mujoco.mj_forward(model, data)
|
|
expected_force = damping * 5.0
|
|
np.testing.assert_allclose(data.actuator_force[0], expected_force, rtol=1e-5)
|
|
|
|
|
|
def test_velocity_actuator_forces_clipped_to_effort_limit(
|
|
spec_with_continuous_joint,
|
|
):
|
|
"""Velocity actuator force is bounded by effort_limit."""
|
|
effort_limit = 20.0
|
|
create_velocity_actuator(
|
|
spec_with_continuous_joint,
|
|
"wheel_joint",
|
|
damping=1000.0,
|
|
effort_limit=effort_limit,
|
|
)
|
|
model = spec_with_continuous_joint.compile()
|
|
data = mujoco.MjData(model)
|
|
|
|
actuator_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_ACTUATOR, "wheel_joint")
|
|
assert model.actuator_forcelimited[actuator_id] == 1
|
|
|
|
# Large velocity error would exceed effort_limit without clamping.
|
|
data.qvel[0] = 0.0
|
|
data.ctrl[0] = 100.0
|
|
mujoco.mj_forward(model, data)
|
|
assert abs(data.actuator_force[0]) <= effort_limit + 1e-6
|