Files
AR-Autopilot/arautopilot/tests/test_integration_cascade.py
T
alro65 a2f3e82f17 sprint-9: integration tests + hardening + operator manual
Integration tests (64 new tests, 462 total):
- test_integration_cascade: full cascade closed-loop simulation --
  outer PID → inner PID → rudder dynamics → vessel heading; verifies
  convergence across small/90°/180° turns, wrap-around, and low speed
- test_integration_ekf_pid: EKF-smoothed heading feeding outer PID;
  confirms EKF reduces rudder total-variation vs raw noisy heading
- test_integration_alarm_audit: alarm engine → audit log hash-chain;
  verify, tamper detection, cross-session reload, multi-alarm logging
- test_modbus_utils: 38 tests for scale/raw conversion, bounds checking,
  heading/rudder helpers, signed int16 two's-complement round-trip

Hardening:
- heading_ekf: guard NaN/inf in update_heading() and update_rot() -- skip
  bad measurements silently rather than corrupting filter state
- adaptive_tuner: guard NaN/inf in step() -- ignore corrupt error samples
- modbus_utils.py: new shared module with scale_to_raw, scale_to_raw_signed,
  raw_signed_to_scaled, clamp_uint16, validate_holding_write,
  heading_deg_to_raw, rudder_deg_to_raw_signed

Documentation:
- docs/operator_manual.md: 15-section operator manual covering safety,
  installation, all operating modes, alarm reference, commissioning,
  fault-finding, Modbus register summary, and activation/audit log procedure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 03:35:23 -04:00

154 lines
5.6 KiB
Python

"""Integration: full cascade PID closed-loop simulation -- Sprint 9.
Tests that the three-layer cascade (outer heading PID → inner rudder PID →
rudder dynamics → vessel heading) converges to a heading setpoint within the
acceptance limits of the brief.
No mocks — all four simulator modules run together.
"""
from __future__ import annotations
import math
import pytest
from arautopilot.studio.simulator.pid_inner import PidInner, PidInnerConfig
from arautopilot.studio.simulator.pid_outer import PidOuter, PidOuterConfig
from arautopilot.studio.simulator.rudder_dynamics import (
RudderDynamicsConfig,
RudderSimulator,
)
from arautopilot.studio.simulator.vessel_heading import (
VesselHeadingConfig,
VesselHeadingSimulator,
heading_error_deg,
)
def _run_cascade(
*,
initial_heading: float,
setpoint_deg: float,
duration_s: float = 120.0,
outer_dt: float = 0.1, # 10 Hz
inner_dt: float = 0.02, # 50 Hz
speed_kn: float = 10.0,
) -> tuple[float, float]:
"""Return (final_heading_error_deg, max_rudder_deg_reached)."""
outer = PidOuter(PidOuterConfig(
base_kp=0.9, base_ki=0.02, base_kd=1.2,
deadband_deg=0.5, rot_ff_gain=1.5, max_rudder_deg=35.0,
))
inner = PidInner(PidInnerConfig(
kp=2.5, ki=0.15, kd=0.30,
deadband_deg=0.5, min_useful_pwm_pct=12.0,
))
rudder = RudderSimulator(RudderDynamicsConfig(
actuator_gain=0.2, friction=4.0, max_angle_deg=35.0,
))
vessel = VesselHeadingSimulator(VesselHeadingConfig(
rudder_response_gain=0.18, yaw_damping=0.8, speed_kn=speed_kn,
))
vessel.reset(heading_deg=initial_heading)
t = 0.0
max_rudder = 0.0
rudder_setpoint = 0.0
outer_acc = 0.0
while t < duration_s:
# Outer loop tick (10 Hz)
if outer_acc >= outer_dt - 1e-9:
outer_acc = 0.0
rudder_setpoint = outer.step(
heading_setpoint_deg=setpoint_deg,
heading_measured_deg=vessel.state.heading_deg,
rate_of_turn_dps=vessel.state.rate_of_turn_dps,
speed_kn=speed_kn,
allowed=True,
)
# Inner loop tick (50 Hz)
pwm = inner.step(
setpoint_deg=rudder_setpoint,
measured_deg=rudder.state.angle_deg,
allowed=True,
)
rudder.step(dt=inner_dt, pwm_pct=pwm)
vessel.step(dt=inner_dt, rudder_deg=rudder.state.angle_deg, speed_kn=speed_kn)
max_rudder = max(max_rudder, abs(rudder.state.angle_deg))
outer_acc += inner_dt
t += inner_dt
final_error = abs(heading_error_deg(setpoint_deg, vessel.state.heading_deg))
return final_error, max_rudder
class TestCascadeConvergence:
def test_small_heading_change_converges(self):
err, _ = _run_cascade(initial_heading=0.0, setpoint_deg=10.0)
assert err < 6.0, f"Did not converge: final error = {err:.2f} deg"
def test_90_degree_turn_converges(self):
err, _ = _run_cascade(initial_heading=0.0, setpoint_deg=90.0, duration_s=180.0)
assert err < 8.0, f"Did not converge: final error = {err:.2f} deg"
def test_wrap_around_north_converges(self):
# 350° → 10° = +20 degrees through north
err, _ = _run_cascade(initial_heading=350.0, setpoint_deg=10.0)
assert err < 6.0, f"Wrap-around did not converge: final error = {err:.2f} deg"
def test_180_degree_turn_converges(self):
err, _ = _run_cascade(initial_heading=0.0, setpoint_deg=180.0, duration_s=240.0)
assert err < 10.0, f"180° turn did not converge: final error = {err:.2f} deg"
def test_rudder_stays_within_limits(self):
_, max_rudder = _run_cascade(initial_heading=0.0, setpoint_deg=90.0)
assert max_rudder <= 35.0 + 1e-6
def test_slow_speed_converges(self):
# At low speed the vessel turns slower but must still converge
err, _ = _run_cascade(
initial_heading=0.0, setpoint_deg=30.0, speed_kn=3.0, duration_s=240.0
)
assert err < 10.0, f"Low-speed case did not converge: final error = {err:.2f} deg"
def test_same_heading_no_overshoot(self):
# Already on setpoint: rudder should barely move
err, max_rudder = _run_cascade(
initial_heading=90.0, setpoint_deg=90.0, duration_s=30.0
)
assert err < 2.0
assert max_rudder < 5.0
class TestCascadeEnergyBudget:
def test_integrator_does_not_wind_up(self):
# After convergence, integrator should be small (no sustained error)
outer = PidOuter(PidOuterConfig(base_kp=0.9, base_ki=0.02, base_kd=1.2))
inner = PidInner()
rudder = RudderSimulator()
vessel = VesselHeadingSimulator()
vessel.reset(heading_deg=0.0)
setpoint = 45.0
outer_acc = 0.0
rudder_sp = 0.0
for _ in range(int(60.0 / 0.02)):
if outer_acc >= 0.1 - 1e-9:
outer_acc = 0.0
rudder_sp = outer.step(
heading_setpoint_deg=setpoint,
heading_measured_deg=vessel.state.heading_deg,
rate_of_turn_dps=vessel.state.rate_of_turn_dps,
speed_kn=10.0,
allowed=True,
)
pwm = inner.step(setpoint_deg=rudder_sp, measured_deg=rudder.state.angle_deg, allowed=True)
rudder.step(dt=0.02, pwm_pct=pwm)
vessel.step(dt=0.02, rudder_deg=rudder.state.angle_deg)
outer_acc += 0.02
# Integral should have settled to a small value after convergence
assert abs(outer.state.integral) < 20.0