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

180 lines
6.0 KiB
Python

"""Tests for command manager."""
from dataclasses import dataclass
from unittest.mock import Mock
import pytest
import torch
from conftest import get_test_device
from mjlab.envs import ManagerBasedRlEnv
from mjlab.managers.command_manager import CommandTerm, CommandTermCfg
from mjlab.tasks.cartpole.cartpole_env_cfg import cartpole_balance_env_cfg
from mjlab.tasks.tracking.mdp.commands import MotionCommand
@pytest.fixture(scope="module")
def device():
return get_test_device()
class CounterCommand(CommandTerm):
"""Stateful command term: a per-env counter ticked by _update_command."""
def __init__(self, cfg, env):
super().__init__(cfg, env)
self.ticks = torch.zeros(self.num_envs, dtype=torch.long, device=self.device)
@property
def command(self) -> torch.Tensor:
return self.ticks.unsqueeze(-1).float()
def _update_metrics(self) -> None:
pass
def _resample_command(self, env_ids: torch.Tensor) -> None:
self.ticks[env_ids] = 0
def _update_command(self, env_ids: torch.Tensor | None = None) -> None:
if env_ids is None:
self.ticks += 1
else:
self.ticks[env_ids] += 1
@dataclass(kw_only=True)
class CounterCommandCfg(CommandTermCfg):
resampling_time_range: tuple[float, float] = (1e9, 1e9)
def build(self, env) -> CounterCommand:
return CounterCommand(self, env)
@pytest.fixture
def counter_env(device):
cfg = cartpole_balance_env_cfg()
cfg.scene.num_envs = 4
cfg.commands = {"counter": CounterCommandCfg()}
env = ManagerBasedRlEnv(cfg=cfg, device=device)
yield env
env.close()
def test_partial_reset_does_not_advance_other_envs(counter_env):
env = counter_env
term = env.command_manager.get_term("counter")
assert isinstance(term, CounterCommand)
# Full reset: every env is resampled (counter zeroed) then ticked once.
env.reset()
assert term.ticks.tolist() == [1, 1, 1, 1]
action = torch.zeros((env.num_envs, 1), device=env.device)
env.step(action)
assert term.ticks.tolist() == [2, 2, 2, 2]
# Partial reset: only env 1 is resampled and ticked. Before the fix for
# issue #1138 the other envs advanced too (to 3).
env.reset(env_ids=torch.tensor([1], dtype=torch.int64, device=env.device))
assert term.ticks.tolist() == [2, 1, 2, 2]
# The next step advances everyone by exactly one.
env.step(action)
assert term.ticks.tolist() == [3, 2, 3, 3]
def test_old_style_update_command_raises(counter_env):
"""Terms with the old zero-arg _update_command fail fast at construction."""
class OldStyleCommand(CounterCommand):
def _update_command(self) -> None: # type: ignore[override]
self.ticks += 1
@dataclass(kw_only=True)
class OldStyleCommandCfg(CounterCommandCfg):
def build(self, env) -> "OldStyleCommand":
return OldStyleCommand(self, env)
with pytest.raises(TypeError, match="env_ids"):
OldStyleCommandCfg().build(counter_env)
def _make_motion_command_stub(time_steps, total, sampling_mode="uniform"):
"""A stub with just enough state to drive MotionCommand._update_command."""
cmd = Mock()
cmd.time_steps = torch.tensor(time_steps, dtype=torch.long)
cmd.motion = Mock()
cmd.motion.time_step_total = total
cmd.cfg = Mock()
cmd.cfg.sampling_mode = sampling_mode
cmd.cfg.adaptive_alpha = 0.5
cmd.bin_failed_count = torch.tensor([1.0, 1.0])
cmd._current_bin_failed = torch.tensor([4.0, 4.0])
cmd._pending_forward = False
cmd._resample_command = Mock(
side_effect=lambda ids: setattr(cmd, "_pending_forward", True)
)
return cmd
def test_motion_command_update_scopes_time_advance():
cmd = _make_motion_command_stub([2, 5, 7], total=100)
MotionCommand._update_command(cmd, env_ids=torch.tensor([1]))
assert cmd.time_steps.tolist() == [2, 6, 7]
cmd._resample_command.assert_not_called()
cmd.update_relative_body_poses.assert_called_once()
MotionCommand._update_command(cmd, env_ids=None)
assert cmd.time_steps.tolist() == [3, 7, 8]
def test_motion_command_update_resamples_on_wraparound():
cmd = _make_motion_command_stub([2, 9], total=10)
MotionCommand._update_command(cmd, env_ids=torch.tensor([1]))
# Env 1 wrapped past the end of the motion and must be resampled; env 0
# is untouched.
(wrap_ids,), _ = cmd._resample_command.call_args
assert wrap_ids.tolist() == [1]
assert cmd.time_steps[0].item() == 2
cmd._env.sim.forward.assert_called_once()
def test_motion_command_ema_folds_only_on_step_update():
cmd = _make_motion_command_stub([2, 5], total=100, sampling_mode="adaptive")
MotionCommand._update_command(cmd, env_ids=torch.tensor([0]))
# Reset-scoped update: EMA untouched, pending failure counts preserved.
assert cmd.bin_failed_count.tolist() == [1.0, 1.0]
assert cmd._current_bin_failed.tolist() == [4.0, 4.0]
MotionCommand._update_command(cmd, env_ids=None)
# Per-step update: EMA folds the counts and clears them.
assert cmd.bin_failed_count.tolist() == [2.5, 2.5]
assert cmd._current_bin_failed.tolist() == [0.0, 0.0]
def test_motion_command_gui_reset_forwards_before_pose_update():
"""apply_gui_reset must refresh kinematics between the state write and
update_relative_body_poses (viewer forwards only after it returns)."""
calls = []
cmd = Mock()
cmd._scrubber_handles = (Mock(value=5),)
cmd.reset_to_frame = lambda ids, frame: calls.append("reset_to_frame")
cmd._env.sim.forward = lambda: calls.append("forward")
cmd.update_relative_body_poses = lambda: calls.append("update_poses")
assert MotionCommand.apply_gui_reset(cmd, torch.tensor([0])) is True
assert calls == ["reset_to_frame", "forward", "update_poses"]
def test_motion_command_timer_resample_triggers_forward():
"""A timer-expiry resample (flag set before _update_command) forwards."""
cmd = _make_motion_command_stub([2, 5], total=100)
cmd._pending_forward = True # As set by a compute-path _resample_command.
MotionCommand._update_command(cmd, env_ids=None)
cmd._env.sim.forward.assert_called_once()
assert cmd._pending_forward is False
cmd._env.sim.forward.reset_mock()
MotionCommand._update_command(cmd, env_ids=None)
cmd._env.sim.forward.assert_not_called()