Files
AR-Autopilot/arautopilot/tests/test_adaptive_tuner.py
T
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

158 lines
5.3 KiB
Python

"""Tests for AdaptiveTuner -- Sprint 8."""
from __future__ import annotations
import pytest
from arautopilot.core.adaptive_tuner import AdaptiveTuner
from arautopilot.core.pid_config import PidConfig, PidGains
def _make_config(
adaptive_enabled: bool = True,
adaptive_max_deviation_pct: float = 50.0,
) -> PidConfig:
return PidConfig(
inner_loop_base=PidGains(kp=1.0, ki=0.1, kd=0.05),
outer_loop_base=PidGains(kp=2.0, ki=0.2, kd=0.1),
adaptive_enabled=adaptive_enabled,
adaptive_max_deviation_pct=adaptive_max_deviation_pct,
)
def _make_tuner(
kp: float = 2.0,
ki: float = 0.4,
adaptive_max_deviation_pct: float = 50.0,
window_steps: int = 10,
step_pct: float = 0.1,
) -> AdaptiveTuner:
config = PidConfig(
inner_loop_base=PidGains(kp=1.0, ki=0.1, kd=0.05),
outer_loop_base=PidGains(kp=kp, ki=ki, kd=0.1),
adaptive_enabled=True,
adaptive_max_deviation_pct=adaptive_max_deviation_pct,
)
base = PidGains(kp=kp, ki=ki, kd=0.1)
return AdaptiveTuner(config=config, base_gains=base, window_steps=window_steps, step_pct=step_pct)
class TestAdaptiveDisabled:
def test_returns_none_when_disabled(self):
config = _make_config(adaptive_enabled=False)
tuner = AdaptiveTuner(
config=config,
base_gains=PidGains(kp=2.0, ki=0.2, kd=0.1),
)
for _ in range(200):
result = tuner.step(5.0)
assert result is None
class TestBufferFill:
def test_returns_none_before_buffer_full(self):
tuner = _make_tuner(window_steps=20)
for i in range(19):
assert tuner.step(5.0) is None
def test_may_adapt_once_buffer_is_full(self):
tuner = _make_tuner(window_steps=10, step_pct=0.1)
result = None
for _ in range(10):
result = tuner.step(5.0) # large persistent error → increase Kp
# Should have triggered an adaptation on the 10th step
assert result is not None
class TestOscillationDetection:
def test_oscillating_signal_reduces_kp(self):
tuner = _make_tuner(window_steps=10, kp=2.0, step_pct=0.1)
original_kp = tuner._current_kp
# Fill buffer with alternating signs (100% sign flips)
result = None
for i in range(10):
err = 3.0 if i % 2 == 0 else -3.0
result = tuner.step(err)
# After oscillation detection, Kp should be reduced
if result is not None:
assert result.kp < original_kp
def test_steady_error_increases_kp(self):
tuner = _make_tuner(window_steps=10, kp=2.0, step_pct=0.1, ki=0.2)
original_kp = tuner._current_kp
result = None
for _ in range(10):
result = tuner.step(5.0) # sustained positive error
assert result is not None
assert result.kp > original_kp
def test_small_error_within_deadband_no_adapt(self):
tuner = _make_tuner(window_steps=10)
tuner.dead_band_deg = 2.0
result = None
for _ in range(10):
result = tuner.step(0.5) # within dead band
assert result is None
class TestBoundsClamping:
def test_kp_never_exceeds_upper_bound(self):
tuner = _make_tuner(kp=2.0, adaptive_max_deviation_pct=20.0, window_steps=5, step_pct=0.5)
for _ in range(200):
tuner.step(10.0) # large error, keep trying to increase
assert tuner._current_kp <= 2.0 * 1.20 + 1e-9
def test_kp_never_below_lower_bound(self):
tuner = _make_tuner(kp=2.0, adaptive_max_deviation_pct=20.0, window_steps=5, step_pct=0.5)
for i in range(200):
err = 5.0 if i % 2 == 0 else -5.0
assert tuner._current_kp >= 2.0 * 0.80 - 1e-9
def test_ki_stays_within_bound(self):
tuner = _make_tuner(kp=2.0, ki=0.4, adaptive_max_deviation_pct=30.0, window_steps=5, step_pct=0.5)
for _ in range(200):
tuner.step(10.0)
assert tuner._current_ki <= 0.4 * 1.30 + 1e-9
def test_kd_unchanged_after_adaptation(self):
tuner = _make_tuner(window_steps=10, step_pct=0.1)
original_kd = tuner._current_kd
for _ in range(50):
tuner.step(5.0)
assert tuner._current_kd == pytest.approx(original_kd)
class TestKiRatioPreservation:
def test_ki_kp_ratio_preserved_after_adapt(self):
tuner = _make_tuner(kp=2.0, ki=0.4, window_steps=10, step_pct=0.1)
base_ratio = 0.4 / 2.0
result = None
for _ in range(10):
result = tuner.step(5.0)
if result is not None:
new_ratio = result.ki / result.kp
assert new_ratio == pytest.approx(base_ratio, rel=0.01)
class TestReset:
def test_reset_restores_base_gains(self):
tuner = _make_tuner(kp=2.0, ki=0.4, window_steps=10, step_pct=0.1)
for _ in range(10):
tuner.step(5.0)
tuner.reset()
assert tuner._current_kp == pytest.approx(2.0)
assert tuner._current_ki == pytest.approx(0.4)
def test_reset_clears_buffer(self):
tuner = _make_tuner(window_steps=10)
for _ in range(8):
tuner.step(5.0)
tuner.reset()
assert len(tuner._error_buffer) == 0
def test_current_gains_property(self):
tuner = _make_tuner(kp=2.0, ki=0.4)
g = tuner.current_gains
assert g.kp == pytest.approx(2.0)
assert g.ki == pytest.approx(0.4)