"""Tests for TrueCoursePilot -- Sprint 5.""" from __future__ import annotations import math import pytest from arautopilot.studio.simulator.true_course import TrueCoursePilot, TrueCourseConfig from arautopilot.studio.simulator.pid_outer import PidOuterConfig def _pilot(xte_gain: float = 0.5, max_corr: float = 20.0) -> TrueCoursePilot: cfg = TrueCourseConfig( pid=PidOuterConfig(base_kp=1.0, base_ki=0.0, base_kd=0.0, rot_ff_gain=0.0, rate_limit_dps=360.0), xte_gain=xte_gain, xte_max_correction_deg=max_corr, ) return TrueCoursePilot(cfg) # --------------------------------------------------------------------------- # TRUE_COURSE mode # --------------------------------------------------------------------------- class TestTrueCoursePilot: def test_zero_error_zero_output(self): p = _pilot() out = p.step_true_course( cog_setpoint_deg=90.0, measured_cog_deg=90.0, sog_kn=8.0, ) assert out == pytest.approx(0.0, abs=0.6) def test_positive_error_steer_starboard(self): """COG is west of setpoint → steer to starboard (positive rudder).""" p = _pilot() out = p.step_true_course( cog_setpoint_deg=90.0, measured_cog_deg=80.0, # 10° behind sog_kn=8.0, ) assert out > 0.0 def test_negative_error_steer_port(self): p = _pilot() out = p.step_true_course( cog_setpoint_deg=90.0, measured_cog_deg=100.0, sog_kn=8.0, ) assert out < 0.0 def test_360_boundary_crossing(self): """Setpoint=5°, measured=355° → shortest arc = +10° → steer stbd.""" p = _pilot() out = p.step_true_course( cog_setpoint_deg=5.0, measured_cog_deg=355.0, sog_kn=8.0, ) assert out > 0.0 def test_not_allowed_returns_zero(self): p = _pilot() out = p.step_true_course( cog_setpoint_deg=90.0, measured_cog_deg=80.0, sog_kn=8.0, allowed=False, ) assert out == pytest.approx(0.0) def test_xte_correction_zero_in_true_course_mode(self): p = _pilot() p.step_true_course(cog_setpoint_deg=90.0, measured_cog_deg=90.0, sog_kn=8.0) assert p.tc_state.xte_correction_deg == pytest.approx(0.0) # --------------------------------------------------------------------------- # TRACK_KEEPING mode # --------------------------------------------------------------------------- class TestTrackKeeping: def test_no_xte_correction_when_nan(self): p = _pilot() p.step_track_keeping( cog_setpoint_deg=90.0, measured_cog_deg=90.0, sog_kn=8.0, xte_m=math.nan, ) assert p.tc_state.xte_correction_deg == pytest.approx(0.0) assert p.tc_state.effective_setpoint_deg == pytest.approx(90.0) def test_positive_xte_steers_port(self): """XTE>0 = vessel to starboard → correction negative → setpoint shifted port.""" p = _pilot(xte_gain=1.0) p.step_track_keeping( cog_setpoint_deg=90.0, measured_cog_deg=90.0, sog_kn=8.0, xte_m=5.0, # 5 m to starboard ) assert p.tc_state.xte_correction_deg == pytest.approx(-5.0) assert p.tc_state.effective_setpoint_deg == pytest.approx(85.0) def test_negative_xte_steers_starboard(self): p = _pilot(xte_gain=1.0) p.step_track_keeping( cog_setpoint_deg=90.0, measured_cog_deg=90.0, sog_kn=8.0, xte_m=-5.0, ) assert p.tc_state.xte_correction_deg == pytest.approx(5.0) assert p.tc_state.effective_setpoint_deg == pytest.approx(95.0) def test_xte_correction_clamped(self): p = _pilot(xte_gain=1.0, max_corr=10.0) p.step_track_keeping( cog_setpoint_deg=90.0, measured_cog_deg=90.0, sog_kn=8.0, xte_m=50.0, # huge XTE → clamp to 10° ) assert p.tc_state.xte_correction_deg == pytest.approx(-10.0) def test_stale_xte_ignored(self): """If xte_age_s > max_age_s, correction must be zero.""" p = _pilot(xte_gain=1.0) p.step_track_keeping( cog_setpoint_deg=90.0, measured_cog_deg=90.0, sog_kn=8.0, xte_m=10.0, xte_age_s=10.0, # stale (default max_age_s=5) ) assert p.tc_state.xte_correction_deg == pytest.approx(0.0) def test_convergence_to_track(self): """Simulate 30 steps: XTE should shrink (very basic closed-loop sanity).""" from arautopilot.studio.simulator.vessel_heading import heading_error_deg p = TrueCoursePilot(TrueCourseConfig( pid=PidOuterConfig(base_kp=2.0, base_ki=0.0, base_kd=0.0, rot_ff_gain=0.0, rate_limit_dps=360.0, max_rudder_deg=35.0), xte_gain=0.5, xte_max_correction_deg=20.0, )) xte = 20.0 # 20 m to starboard cog = 90.0 # vessel COG dt = 0.1 # 10 Hz sog = 5.0 # kn ≈ 2.57 m/s for _ in range(200): rudder = p.step_track_keeping( cog_setpoint_deg=90.0, measured_cog_deg=cog, sog_kn=sog, xte_m=xte, ) cog_change = rudder * 0.5 * dt cog = (cog + cog_change) % 360.0 # Cross-track rate: -SOG * cos(COG) for east-going track (COG=90 nominal) xte -= sog * 0.5144 * math.cos(math.radians(cog)) * dt assert abs(xte) < 18.0 # must have improved from initial 20 m def test_reset_clears_state(self): p = _pilot() p.step_track_keeping( cog_setpoint_deg=90.0, measured_cog_deg=80.0, sog_kn=8.0, xte_m=5.0, ) p.reset() assert p.pid_state.integral == pytest.approx(0.0)