exoskeleton/code/test/test_retargeting_differential.py

180 lines
5.8 KiB
Python
Raw Permalink Normal View History

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