Files
alro65 45642fda0e sprint-8: EKF + adaptive tuner + HWID + SHA-256 audit hash-chain
- 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>
2026-05-20 03:07:27 -04:00

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)