"""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