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>
158 lines
5.3 KiB
Python
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)
|