exoskeleton/code/test/test_closed_loop_simulation.py

166 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""Regression tests for the rigid-body closed-loop simulation."""
from dataclasses import replace
from pathlib import Path
import sys
import unittest
import numpy as np
CODE_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CODE_ROOT))
from simulate_closed_loop import ( # noqa: E402
DEFAULT_CONFIG_PATH,
SCENARIOS,
SimulationConfig,
Wall,
build_mapper,
load_models,
load_simulation_config,
make_wall,
master_endpoint_configurations,
simulate_scenario,
)
from core.energy_audit import audit_haptic_energy # noqa: E402
class ClosedLoopSimulationTest(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.models = load_models(add_simulated_tcp=True)
def test_yaml_configuration_is_complete(self) -> None:
config = load_simulation_config(DEFAULT_CONFIG_PATH)
self.assertEqual(config.slave_contact_frame, "R_EE_SIM")
self.assertEqual(config.dt, 0.002)
self.assertEqual(config.mapping_hz, 50.0)
self.assertLess(config.energy_min, config.energy_max)
def test_wall_is_unilateral_dissipative_and_force_limited(self) -> None:
wall = Wall(
point=np.zeros(3),
normal=np.array([1.0, 0.0, 0.0]),
stiffness=100.0,
damping=10.0,
force_limit=5.0,
)
free_wrench, free_penetration = wall.wrench(
np.array([-0.1, 0.0, 0.0]),
np.array([1.0, 0.0, 0.0]),
)
np.testing.assert_allclose(free_wrench, 0.0)
self.assertEqual(free_penetration, 0.0)
contact_wrench, penetration = wall.wrench(
np.array([0.1, 0.0, 0.0]),
np.array([2.0, 0.0, 0.0]),
)
self.assertAlmostEqual(penetration, 0.1)
np.testing.assert_allclose(contact_wrench[:3], [-5.0, 0.0, 0.0])
self.assertLessEqual(float(contact_wrench[:3] @ np.array([2.0, 0.0, 0.0])), 0.0)
def test_generalized_mapping_preserves_virtual_work(self) -> None:
mapper = build_mapper(self.models)
q_master, _ = master_endpoint_configurations()
_, differential, debug = mapper.retarget_with_differential(q_master)
self.assertTrue(debug["differential_valid"])
qd_master = np.array([0.2, -0.1, 0.15, -0.3, 0.05, 0.08, -0.04])
tau_slave = np.array([-1.0, 0.4, 0.8, -0.5, 0.2, -0.3, 0.1])
tau_master = differential.T @ tau_slave
self.assertAlmostEqual(
float(tau_master @ qd_master),
float(tau_slave @ (differential @ qd_master)),
places=12,
)
def test_short_rigid_body_run_is_finite_and_respects_energy_floor(self) -> None:
config = replace(
SimulationConfig(),
duration=0.8,
feedback_delay_s=0.04,
contact_probe_fraction=0.0,
)
mapper = build_mapper(self.models)
wall, _, q_slave_start = make_wall(config, self.models, mapper)
result = simulate_scenario(
SCENARIOS[0],
config,
self.models,
wall,
q_slave_start,
)
self.assertTrue(result.metrics["completed"])
self.assertTrue(result.metrics["finite_state"])
self.assertEqual(result.metrics["master_joint_limit_events"], 0)
self.assertEqual(result.metrics["slave_joint_limit_events"], 0)
self.assertEqual(result.metrics["differential_fallback_count"], 0)
self.assertGreater(result.metrics["wall_contact_duration_s"], 0.0)
self.assertGreaterEqual(
result.metrics["tank_energy_min_J"],
config.energy_min - 1e-12,
)
def test_matched_source_map_and_independent_energy_audit(self) -> None:
config = replace(
SimulationConfig(),
duration=0.6,
feedback_delay_s=0.04,
contact_probe_fraction=0.0,
)
mapper = build_mapper(self.models)
wall, _, q_slave_start = make_wall(config, self.models, mapper)
matched = next(
scenario
for scenario in SCENARIOS
if scenario.key == "matched_wrench_energy"
)
result = simulate_scenario(
matched, config, self.models, wall, q_slave_start
)
active = result.logs["return_packet_active"] > 0.5
self.assertTrue(np.any(active))
self.assertTrue(np.all(result.logs["source_map_id"][active] >= 0))
self.assertTrue(np.all(result.logs["map_id"][active] >= 0))
audit = audit_haptic_energy(
tau_candidate=result.logs["tau_master_candidate"],
tau_projected=result.logs["tau_master_applied"],
tau_accepted=result.logs["tau_master_accepted"],
qd_master=result.logs["qd_master"],
dt=config.dt,
energy_initial=config.energy_initial,
energy_min=config.energy_min,
energy_max=config.energy_max,
logged_preclip=result.logs["energy_preclip"],
)
self.assertLessEqual(audit.max_floor_deficit, 1e-12)
self.assertLessEqual(audit.preclip_log_max_error, 1e-12)
def test_time_domain_popc_is_a_distinct_executable_baseline(self) -> None:
config = replace(
SimulationConfig(),
duration=0.6,
feedback_delay_s=0.02,
contact_probe_fraction=0.0,
)
mapper = build_mapper(self.models)
wall, _, q_slave_start = make_wall(config, self.models, mapper)
popc = next(
scenario for scenario in SCENARIOS if scenario.key == "proposed_popc"
)
result = simulate_scenario(
popc, config, self.models, wall, q_slave_start
)
self.assertTrue(result.metrics["completed"])
self.assertIsNone(result.metrics["rho_min"])
self.assertTrue(np.all(np.isfinite(result.logs["popc_damping_gain"])))
if __name__ == "__main__":
unittest.main()