"""Tests for causal acceleration, friction, and replayable H2 ablations.""" from __future__ import annotations import sys import unittest from pathlib import Path import numpy as np CODE_ROOT = Path(__file__).resolve().parents[1] if str(CODE_ROOT) not in sys.path: sys.path.insert(0, str(CODE_ROOT)) from core.estimation_signals import ( # noqa: E402 AccelerationEstimatorConfig, AccelerationEstimateStatus, CalibratedResidualWrenchEstimator, CausalAccelerationEstimator, JointFrictionCalibration, ResidualAblation, ResidualTorqueModel, WrenchEstimatorCalibration, ) from core.wrench_solver import UndampedSVDSolver # noqa: E402 class EstimationSignalsTest(unittest.TestCase): def test_acceleration_estimator_is_causal_and_rejects_bad_dt(self) -> None: config = AccelerationEstimatorConfig( cutoff_hz=10.0, minimum_dt_s=0.001, maximum_dt_s=0.1, ) estimator = CausalAccelerationEstimator(2, config) first = estimator.update(np.array([0.0, 0.0]), 1.0) self.assertIs(first.status, AccelerationEstimateStatus.INITIALIZING) second = estimator.update(np.array([0.1, -0.2]), 1.01) self.assertIs(second.status, AccelerationEstimateStatus.VALID) np.testing.assert_allclose(second.raw_acceleration, [10.0, -20.0]) expected_alpha = 1.0 - np.exp(-2.0 * np.pi * 10.0 * 0.01) np.testing.assert_allclose( second.acceleration, expected_alpha * np.array([10.0, -20.0]), ) invalid = estimator.update(np.array([99.0, 99.0]), 1.0101) self.assertIs(invalid.status, AccelerationEstimateStatus.INVALID_DT) # Invalid data do not advance the differentiator state. recovered = estimator.update(np.array([0.2, -0.4]), 1.02) self.assertIs(recovered.status, AccelerationEstimateStatus.VALID) np.testing.assert_allclose(recovered.raw_acceleration, [10.0, -20.0]) def test_friction_fit_recovers_synthetic_coefficients(self) -> None: velocity = np.linspace(-1.2, 1.2, 101) velocity_samples = np.column_stack((velocity, 0.7 * velocity)) true = JointFrictionCalibration( coulomb_nm=np.array([0.4, 0.2]), viscous_nm_per_rad_s=np.array([0.08, 0.12]), smoothing_velocity_rad_s=0.03, ) torque_samples = np.vstack( [true.torque(sample) for sample in velocity_samples] ) fitted = JointFrictionCalibration.fit( velocity_samples, torque_samples, smoothing_velocity_rad_s=0.03, ) np.testing.assert_allclose(fitted.coulomb_nm, true.coulomb_nm, atol=1e-13) np.testing.assert_allclose( fitted.viscous_nm_per_rad_s, true.viscous_nm_per_rad_s, atol=1e-13, ) def test_nominal_and_ablation_residuals_are_explicit(self) -> None: friction = JointFrictionCalibration( coulomb_nm=np.array([0.3, 0.2]), viscous_nm_per_rad_s=np.array([0.1, 0.05]), ) calibration = WrenchEstimatorCalibration( joint_bias_nm=np.array([0.12, -0.08]), friction=friction, characteristic_length_m=0.4, damping=0.02, calibration_id="fixture-v1", ) model = ResidualTorqueModel(calibration) velocity = np.array([0.5, -0.4]) contact = np.array([1.2, -0.7]) rigid = np.array([3.0, 4.0]) measured = ( rigid + contact + calibration.joint_bias_nm + friction.torque(velocity) ) nominal = model.compute(measured, rigid, velocity) no_bias = model.compute( measured, rigid, velocity, ablation=ResidualAblation.no_bias() ) no_friction = model.compute( measured, rigid, velocity, ablation=ResidualAblation.no_friction(), ) np.testing.assert_allclose(nominal.residual_nm, contact, atol=1e-15) np.testing.assert_allclose( no_bias.residual_nm, contact + calibration.joint_bias_nm, atol=1e-15, ) np.testing.assert_allclose( no_friction.residual_nm, contact + friction.torque(velocity), atol=1e-15, ) self.assertFalse(calibration.joint_bias_nm.flags.writeable) def test_calibrated_estimator_accepts_matched_undamped_solver(self) -> None: joint_count = 7 calibration = WrenchEstimatorCalibration( joint_bias_nm=np.zeros(joint_count), friction=JointFrictionCalibration.zeros(joint_count), characteristic_length_m=0.5, damping=0.03, relative_rank_tolerance=1e-8, calibration_id="synthetic-v1", ) solver = UndampedSVDSolver( 0.5, relative_rank_tolerance=1e-8 ) estimator = CalibratedResidualWrenchEstimator(calibration, solver) rng = np.random.default_rng(12) jacobian = rng.normal(size=(6, joint_count)) wrench = np.array([2.0, -1.0, 3.0, 0.2, -0.3, 0.4]) residual = jacobian.T @ wrench estimate = estimator.estimate( jacobian, measured_torque_nm=residual, rigid_body_torque_nm=np.zeros(joint_count), joint_velocity_rad_s=np.zeros(joint_count), ) np.testing.assert_allclose( estimate.solve.wrench, wrench, rtol=1e-13, atol=1e-13 ) mismatched = UndampedSVDSolver( 0.6, relative_rank_tolerance=1e-8 ) with self.assertRaisesRegex(ValueError, "characteristic length"): CalibratedResidualWrenchEstimator(calibration, mismatched) if __name__ == "__main__": unittest.main()