exoskeleton/code/test/test_retargeting_differential.py

180 lines
5.8 KiB
Python

"""Regression tests for the bounded SEW retargeting differential."""
from pathlib import Path
import sys
import unittest
import numpy as np
import pinocchio as pin
CODE_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CODE_ROOT))
from core.sew_mapper2 import ( # noqa: E402
BallJointConfig,
SEWMapper,
_wrap_angle_delta,
)
def build_real_mapper() -> SEWMapper:
config_dir = CODE_ROOT / "config"
master_model = pin.buildModelFromUrdf(str(config_dir / "master_7dof.urdf"))
slave_model = pin.buildModelFromUrdf(str(config_dir / "real_slave_7dof.urdf"))
master_joints = (
"master_shoulder_pitch_joint",
"master_shoulder_yaw_joint",
"master_shoulder_roll_joint",
"master_elbow_flex_joint",
"master_wrist_roll_joint",
"master_wrist_yaw_joint",
"master_wrist_pitch_joint",
)
slave_joints = (
"R_SHOULDER_P",
"R_SHOULDER_R",
"R_SHOULDER_Y",
"R_ELBOW_R",
"R_WRIST_P",
"R_WRIST_Y",
"R_WRIST_R",
)
shoulder = BallJointConfig(
axis_order="yxy",
joint_names=("R_SHOULDER_P", "R_SHOULDER_R", "R_SHOULDER_Y"),
signs=(-1.0, 1.0, -1.0),
)
wrist = BallJointConfig(
axis_order="yzx",
joint_names=("R_WRIST_P", "R_WRIST_Y", "R_WRIST_R"),
signs=(-1.0, 1.0, 1.0),
)
return SEWMapper(
master_model=master_model,
slave_model=slave_model,
m_shoulder="master_shoulder",
m_elbow="master_forearm",
m_wrist="master_wrist",
m_ee="master_ee",
s_shoulder="R_SHOULDER_R_S",
s_elbow="R_ELBOW_R_S",
s_wrist="R_WRIST_R_S",
s_ee="R_WRIST_R_S",
master_joint_names=master_joints,
slave_joint_names=slave_joints,
slave_shoulder_cfg=shoulder,
slave_wrist_cfg=wrist,
)
class TestRetargetingDifferential(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.mapper = build_real_mapper()
# An interior, exactly recoverable pose on the real slave arm.
cls.q_master = np.array(
[0.534, 0.314, -0.1, 2.14, 0.38, 0.38, -0.72],
dtype=float,
)
def test_virtual_work_identity(self) -> None:
q_slave, A, debug = self.mapper.retarget_with_differential(self.q_master)
self.assertTrue(debug["success"], debug["events"])
self.assertTrue(
debug["differential_valid"],
debug["differential"]["events"],
)
self.assertFalse(debug["clipped"])
self.assertFalse(debug["near_limit"])
q_slave7 = self.mapper._slave_q7(q_slave)
self.assertTrue(np.all(q_slave7 >= self.mapper.s_lower7))
self.assertTrue(np.all(q_slave7 <= self.mapper.s_upper7))
qdot_master = np.array(
[0.21, -0.34, 0.17, 0.09, -0.11, 0.28, -0.07]
)
tau_slave = np.array(
[-1.2, 0.4, 2.3, -0.7, 0.5, -1.1, 0.8]
)
qdot_slave = A @ qdot_master
tau_master = A.T @ tau_slave
self.assertAlmostEqual(
float(tau_master @ qdot_master),
float(tau_slave @ qdot_slave),
places=12,
)
def test_matches_independent_directional_finite_difference(self) -> None:
q_slave, A, debug = self.mapper.retarget_with_differential(
self.q_master, fd_step=1e-4
)
self.assertTrue(debug["differential_valid"])
direction = np.array([0.3, -0.2, 0.5, -0.1, 0.4, -0.25, 0.35])
direction /= np.linalg.norm(direction)
independent_step = 2.5e-5
q_plus, plus_debug = self.mapper.retarget(
self.q_master + independent_step * direction,
q_s_init=q_slave,
)
q_minus, minus_debug = self.mapper.retarget(
self.q_master - independent_step * direction,
q_s_init=q_slave,
)
self.assertTrue(plus_debug["success"], plus_debug["events"])
self.assertTrue(minus_debug["success"], minus_debug["events"])
directional_fd = _wrap_angle_delta(
self.mapper._slave_q7(q_plus) - self.mapper._slave_q7(q_minus)
) / (2.0 * independent_step)
np.testing.assert_allclose(
directional_fd,
A @ direction,
rtol=2e-4,
atol=2e-5,
)
def test_degenerate_geometry_invalidates_differential(self) -> None:
# Zero elbow flex makes the master shoulder-elbow-wrist plane undefined.
q_degenerate = np.array([-np.pi / 2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])
A, debug = self.mapper.compute_differential(q_degenerate)
self.assertFalse(debug["valid"])
self.assertTrue(np.isnan(A).all())
self.assertIn("base_invalid", debug["events"])
self.assertIn(
"master_arm_plane_degenerate",
debug["base"]["events"],
)
def test_active_limit_invalidates_differential(self) -> None:
old_margin = self.mapper.joint_limit_margin
try:
# Enlarge only the diagnostic margin to exercise the active-set
# safety gate without changing the bounded pose solution.
self.mapper.joint_limit_margin = 10.0
A, debug = self.mapper.compute_differential(self.q_master)
finally:
self.mapper.joint_limit_margin = old_margin
self.assertTrue(debug["base"]["success"])
self.assertTrue(debug["base"]["near_limit"])
self.assertFalse(debug["valid"])
self.assertTrue(np.isnan(A).all())
self.assertIn("base_nonsmooth", debug["events"])
def test_angle_difference_wrap(self) -> None:
raw = np.array([2.0 * np.pi - 0.1, -2.0 * np.pi + 0.2, np.pi])
np.testing.assert_allclose(
_wrap_angle_delta(raw),
np.array([-0.1, 0.2, -np.pi]),
atol=1e-14,
)
if __name__ == "__main__":
unittest.main()