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
180 lines
6.3 KiB
Python
180 lines
6.3 KiB
Python
"""Tests for motion tracking evaluation metrics."""
|
|
|
|
import math
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
from mjlab.tasks.tracking.mdp.metrics import (
|
|
compute_ee_orientation_error,
|
|
compute_ee_position_error,
|
|
compute_joint_velocity_error,
|
|
compute_mpkpe,
|
|
compute_root_relative_mpkpe,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_command():
|
|
"""Create a mock MotionCommand for testing."""
|
|
command = Mock()
|
|
command.num_envs = 4
|
|
command.device = "cpu"
|
|
command.cfg = Mock()
|
|
command.cfg.body_names = (
|
|
"pelvis",
|
|
"left_knee",
|
|
"right_knee",
|
|
"left_ankle",
|
|
"right_ankle",
|
|
"left_wrist",
|
|
"right_wrist",
|
|
)
|
|
return command
|
|
|
|
|
|
def test_mpkpe_zero_when_positions_match(mock_command):
|
|
"""Test MPKPE is zero when global positions are identical."""
|
|
num_bodies = len(mock_command.cfg.body_names)
|
|
positions = torch.rand(mock_command.num_envs, num_bodies, 3)
|
|
|
|
mock_command.body_pos_w = positions.clone()
|
|
mock_command.robot_body_pos_w = positions.clone()
|
|
|
|
mpkpe = compute_mpkpe(mock_command)
|
|
|
|
assert mpkpe.shape == (mock_command.num_envs,)
|
|
assert torch.allclose(mpkpe, torch.zeros(mock_command.num_envs), atol=1e-6)
|
|
|
|
|
|
def test_mpkpe_correct_error(mock_command):
|
|
"""Test MPKPE computes the correct mean global error."""
|
|
num_bodies = len(mock_command.cfg.body_names)
|
|
|
|
mock_command.body_pos_w = torch.zeros(mock_command.num_envs, num_bodies, 3)
|
|
mock_command.robot_body_pos_w = torch.zeros(mock_command.num_envs, num_bodies, 3)
|
|
mock_command.robot_body_pos_w[:, :, 0] = 1.0 # 1 unit offset in x
|
|
|
|
mpkpe = compute_mpkpe(mock_command)
|
|
|
|
assert torch.allclose(mpkpe, torch.ones(mock_command.num_envs), atol=1e-6)
|
|
|
|
|
|
def test_mpkpe_uses_global_reference(mock_command):
|
|
"""MPKPE must read the global reference, not the drift-cancelled one.
|
|
|
|
Pins issue #1006: setting body_pos_relative_w to match the robot exactly
|
|
would yield zero error if it were (incorrectly) used; the metric must
|
|
instead follow body_pos_w.
|
|
"""
|
|
num_bodies = len(mock_command.cfg.body_names)
|
|
robot_pos = torch.rand(mock_command.num_envs, num_bodies, 3)
|
|
mock_command.robot_body_pos_w = robot_pos.clone()
|
|
mock_command.body_pos_relative_w = robot_pos.clone() # zero error if misused
|
|
mock_command.body_pos_w = robot_pos.clone()
|
|
mock_command.body_pos_w[:, :, 0] += 1.0 # 1 unit of global drift
|
|
|
|
mpkpe = compute_mpkpe(mock_command)
|
|
|
|
assert torch.allclose(mpkpe, torch.ones(mock_command.num_envs), atol=1e-6)
|
|
|
|
|
|
def test_r_mpkpe_zero_when_relative_positions_match(mock_command):
|
|
"""R-MPKPE is zero when re-anchored positions are identical."""
|
|
num_bodies = len(mock_command.cfg.body_names)
|
|
positions = torch.rand(mock_command.num_envs, num_bodies, 3)
|
|
|
|
mock_command.body_pos_relative_w = positions.clone()
|
|
mock_command.robot_body_pos_w = positions.clone()
|
|
|
|
r_mpkpe = compute_root_relative_mpkpe(mock_command)
|
|
|
|
assert r_mpkpe.shape == (mock_command.num_envs,)
|
|
assert torch.allclose(r_mpkpe, torch.zeros(mock_command.num_envs), atol=1e-6)
|
|
|
|
|
|
def test_r_mpkpe_uses_relative_reference(mock_command):
|
|
"""R-MPKPE reads the re-anchored reference, not the global one.
|
|
|
|
Setting body_pos_w to match the robot exactly would yield zero error if
|
|
it were (incorrectly) used; the metric must instead follow
|
|
body_pos_relative_w.
|
|
"""
|
|
num_bodies = len(mock_command.cfg.body_names)
|
|
robot_pos = torch.rand(mock_command.num_envs, num_bodies, 3)
|
|
mock_command.robot_body_pos_w = robot_pos.clone()
|
|
mock_command.body_pos_w = robot_pos.clone() # zero error if misused
|
|
mock_command.body_pos_relative_w = robot_pos.clone()
|
|
mock_command.body_pos_relative_w[:, :, 0] += 1.0 # 1 unit of local pose error
|
|
|
|
r_mpkpe = compute_root_relative_mpkpe(mock_command)
|
|
|
|
assert torch.allclose(r_mpkpe, torch.ones(mock_command.num_envs), atol=1e-6)
|
|
|
|
|
|
def test_joint_velocity_error_rms(mock_command):
|
|
"""Joint velocity error is the per-joint RMS of the velocity error."""
|
|
num_joints = 3
|
|
|
|
mock_command.joint_vel = torch.zeros(mock_command.num_envs, num_joints)
|
|
mock_command.robot_joint_vel = torch.zeros(mock_command.num_envs, num_joints)
|
|
mock_command.robot_joint_vel[:, 0] = 3.0
|
|
mock_command.robot_joint_vel[:, 1] = 4.0 # Error [3, 4, 0]
|
|
|
|
error = compute_joint_velocity_error(mock_command)
|
|
|
|
expected = math.sqrt((3.0**2 + 4.0**2 + 0.0**2) / num_joints)
|
|
assert torch.allclose(error, torch.ones(mock_command.num_envs) * expected, atol=1e-6)
|
|
|
|
|
|
def test_ee_position_error_only_uses_specified_bodies(mock_command):
|
|
"""Test EE position error only uses specified bodies."""
|
|
num_bodies = len(mock_command.cfg.body_names)
|
|
|
|
mock_command.body_pos_relative_w = torch.zeros(mock_command.num_envs, num_bodies, 3)
|
|
mock_command.robot_body_pos_w = torch.zeros(mock_command.num_envs, num_bodies, 3)
|
|
|
|
# Large error for pelvis (not an EE).
|
|
mock_command.robot_body_pos_w[:, 0, :] = 100.0
|
|
# Small error for ankles.
|
|
mock_command.robot_body_pos_w[:, 3, 0] = 1.0 # left_ankle
|
|
mock_command.robot_body_pos_w[:, 4, 0] = 1.0 # right_ankle
|
|
|
|
error = compute_ee_position_error(mock_command, ("left_ankle", "right_ankle"))
|
|
|
|
# Should only reflect ankle error, not pelvis.
|
|
assert torch.allclose(error, torch.ones(mock_command.num_envs), atol=1e-6)
|
|
|
|
|
|
def test_ee_orientation_error_detects_rotation(mock_command):
|
|
"""Test EE orientation error detects rotations."""
|
|
num_bodies = len(mock_command.cfg.body_names)
|
|
|
|
identity_quat = torch.tensor([1.0, 0.0, 0.0, 0.0])
|
|
mock_command.body_quat_relative_w = (
|
|
identity_quat.view(1, 1, 4).expand(mock_command.num_envs, num_bodies, 4).clone()
|
|
)
|
|
|
|
# 90 degree rotation around z-axis.
|
|
rotated_quat = torch.tensor([0.7071, 0.0, 0.0, 0.7071])
|
|
mock_command.robot_body_quat_w = (
|
|
rotated_quat.view(1, 1, 4).expand(mock_command.num_envs, num_bodies, 4).clone()
|
|
)
|
|
|
|
error = compute_ee_orientation_error(mock_command, ("left_wrist",))
|
|
|
|
# Error should be approximately pi/2 radians.
|
|
expected = torch.ones(mock_command.num_envs) * (3.14159 / 2)
|
|
assert torch.allclose(error, expected, atol=0.01)
|
|
|
|
|
|
def test_ee_metrics_raise_on_unknown_body(mock_command):
|
|
"""Unknown end-effector names raise instead of silently scoring zero."""
|
|
num_bodies = len(mock_command.cfg.body_names)
|
|
mock_command.body_pos_relative_w = torch.zeros(mock_command.num_envs, num_bodies, 3)
|
|
mock_command.robot_body_pos_w = torch.zeros(mock_command.num_envs, num_bodies, 3)
|
|
|
|
with pytest.raises(ValueError, match="not tracked"):
|
|
compute_ee_position_error(mock_command, ("nonexistent_body",))
|