"""Tests for 2-state Heading EKF -- Sprint 8.""" from __future__ import annotations import math import pytest from arautopilot.core.heading_ekf import HeadingEKF, _shortest_arc class TestShortestArc: def test_zero_difference(self): assert _shortest_arc(90.0, 90.0) == pytest.approx(0.0) def test_positive_difference(self): assert _shortest_arc(100.0, 90.0) == pytest.approx(10.0) def test_negative_difference(self): assert _shortest_arc(80.0, 90.0) == pytest.approx(-10.0) def test_wrap_around_positive(self): # 350 → 10 is +20 degrees (going through north) assert _shortest_arc(10.0, 350.0) == pytest.approx(20.0) def test_wrap_around_negative(self): # 10 → 350 is -20 degrees assert _shortest_arc(350.0, 10.0) == pytest.approx(-20.0) def test_exactly_180_degrees(self): diff = _shortest_arc(270.0, 90.0) assert abs(diff) == pytest.approx(180.0) class TestPredict: def test_heading_advances_by_rot_times_dt(self): ekf = HeadingEKF(heading_deg=0.0, rot_dps=10.0) ekf.predict(dt_s=1.0) assert ekf.heading_deg == pytest.approx(10.0) def test_heading_wraps_at_360(self): ekf = HeadingEKF(heading_deg=355.0, rot_dps=10.0) ekf.predict(dt_s=1.0) assert ekf.heading_deg == pytest.approx(5.0) def test_zero_rot_heading_unchanged(self): ekf = HeadingEKF(heading_deg=45.0, rot_dps=0.0) ekf.predict(dt_s=1.0) assert ekf.heading_deg == pytest.approx(45.0) def test_covariance_grows_with_predict(self): ekf = HeadingEKF() p00_before = ekf._P[0] ekf.predict(dt_s=0.1) assert ekf._P[0] > p00_before def test_predict_symmetry_p01_p10(self): ekf = HeadingEKF() for _ in range(5): ekf.predict(dt_s=0.1) assert ekf._P[1] == pytest.approx(ekf._P[2]) class TestUpdateHeading: def test_state_moves_toward_measurement(self): ekf = HeadingEKF(heading_deg=0.0) ekf.update_heading(10.0) assert 0.0 < ekf.heading_deg < 10.0 def test_exact_measurement_moves_fully_when_p_large(self): # With very large P and very small noise, Kalman gain → 1 ekf = HeadingEKF(heading_deg=0.0, _P=[1e6, 0.0, 0.0, 1e6]) ekf.update_heading(90.0, noise_deg=0.001) assert ekf.heading_deg == pytest.approx(90.0, abs=0.1) def test_covariance_shrinks_after_update(self): ekf = HeadingEKF() ekf._P = [100.0, 0.0, 0.0, 100.0] p00_before = ekf._P[0] ekf.update_heading(10.0, noise_deg=2.0) assert ekf._P[0] < p00_before def test_wrap_around_innov(self): ekf = HeadingEKF(heading_deg=355.0) ekf._P = [1e6, 0.0, 0.0, 1e6] ekf.update_heading(5.0, noise_deg=0.001) # Should go toward 5.0 (via shortest arc +10), not regress assert ekf.heading_deg == pytest.approx(5.0, abs=0.5) class TestUpdateRot: def test_state_moves_toward_rot_measurement(self): ekf = HeadingEKF(rot_dps=0.0) ekf.update_rot(5.0) assert 0.0 < ekf.rot_dps < 5.0 def test_covariance_p11_shrinks_after_rot_update(self): ekf = HeadingEKF() ekf._P = [100.0, 0.0, 0.0, 100.0] p11_before = ekf._P[3] ekf.update_rot(2.0, noise_dps=1.0) assert ekf._P[3] < p11_before class TestCovarianceConvergence: def test_covariance_converges_with_repeated_updates(self): ekf = HeadingEKF( process_noise_heading=0.01, process_noise_rot=0.1, _P=[100.0, 0.0, 0.0, 100.0], ) # 50 predict+update cycles for _ in range(50): ekf.predict(dt_s=0.1) ekf.update_heading(ekf.heading_deg, noise_deg=2.0) ekf.update_rot(ekf.rot_dps, noise_dps=1.0) # Covariance should have converged to steady-state (not 100 anymore) assert ekf._P[0] < 50.0 assert ekf._P[3] < 50.0 def test_filter_tracks_constant_heading(self): true_heading = 135.0 ekf = HeadingEKF(heading_deg=0.0) for _ in range(100): ekf.predict(dt_s=0.1) ekf.update_heading(true_heading, noise_deg=2.0) assert ekf.heading_deg == pytest.approx(true_heading, abs=2.0) def test_filter_tracks_constant_rot(self): ekf = HeadingEKF(heading_deg=0.0, rot_dps=0.0) true_rot = 3.0 for _ in range(100): ekf.predict(dt_s=0.1) ekf.update_rot(true_rot, noise_dps=0.5) assert ekf.rot_dps == pytest.approx(true_rot, abs=0.5) def test_covariance_property_returns_tuple(self): ekf = HeadingEKF() cov = ekf.covariance assert len(cov) == 4 assert all(isinstance(v, float) for v in cov)