45642fda0e
- heading_ekf.py: 2-state Kalman filter fusing PGN 127250 heading and 127251 ROT with shortest-arc innovation and symmetric covariance update - adaptive_tuner.py: gradient-descent outer-loop Kp/Ki adjuster bounded to ±adaptive_max_deviation_pct; oscillation vs steady-state detection - hwid.py: HMAC-SHA256 activation token (verify side); hwid_from_mac_words converts three Modbus uint16 MAC words to 12-char hex HWID - audit.py: SHA-256 hash-chain -- each JSONL line carries prev_hash and line_hash; verify_chain() detects tampering, deletion, insertion - firmware/system/hwid.h+cpp: esp_efuse_mac_get_default wrapper + FNV-32 hash + "AA:BB:CC:DD:EE:FF" formatter - modbus_registers.yaml + generated .h/.py: HWID_MAC_01/23/45 at input addrs 9/10/11 (three 16-bit words = 6-byte MAC) - modbus_slave.cpp: INPUT_HWID_MAC_01/23/45 cases read eFuse MAC - main.cpp: logs HWID string + FNV-32 hash at boot (activation traceability) - tests: 72 new tests (audit signing, EKF, adaptive tuner, HWID) -- 398 total Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
143 lines
4.7 KiB
Python
143 lines
4.7 KiB
Python
"""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)
|