"""True Course and Track Keeping pilot -- Sprint 5. TRUE_COURSE mode ---------------- Holds a fixed Course Over Ground (COG) instead of compass heading. This compensates for current/wind drift: if the vessel is pushed sideways, COG drifts and the outer PID corrects heading to restore the intended track. Input: cog_setpoint_deg, measured_cog_deg, sog_kn, rot_dps Output: rudder_setpoint_deg (passed to inner PID) TRACK_KEEPING mode ------------------ Follows an ECDIS/chart-plotter route using XTE feedback. An XTE proportional term adds a heading correction to the COG setpoint: xte_correction_deg = -xte_gain * xte_m (clamped ± max_correction) effective_setpoint = cog_setpoint_deg + xte_correction_deg Sign convention (NMEA 0183 / IEC 61162-1): XTE > 0 → vessel is to starboard of track → steer port → negative correction XTE < 0 → vessel is to port of track → steer stbd → positive correction The correction is clamped so the autopilot cannot be tricked into dangerous large heading swings by a stale or erroneous XTE feed. """ from __future__ import annotations import math from dataclasses import dataclass, field from arautopilot.studio.simulator.pid_outer import ( PidOuter, PidOuterConfig, PidOuterState, ) from arautopilot.studio.simulator.vessel_heading import heading_error_deg @dataclass class TrueCourseConfig: """Configuration for both TRUE_COURSE and TRACK_KEEPING modes.""" # Inner PID outer loop config (reused — same dynamics, different input) pid: PidOuterConfig = field(default_factory=PidOuterConfig) # XTE correction parameters (TRACK_KEEPING only) xte_gain: float = 0.5 # deg of heading correction per metre of XTE xte_max_correction_deg: float = 20.0 # hard clamp on XTE term xte_max_age_s: float = 5.0 # treat XTE as stale beyond this @dataclass class TrueCourseState: xte_correction_deg: float = 0.0 effective_setpoint_deg: float = 0.0 class TrueCoursePilot: """Outer-loop pilot for TRUE_COURSE and TRACK_KEEPING modes. Wraps PidOuter, substituting COG for heading and optionally adding an XTE proportional correction to the setpoint. """ def __init__(self, config: TrueCourseConfig | None = None) -> None: self.config = config or TrueCourseConfig() self._pid = PidOuter(self.config.pid) self.tc_state = TrueCourseState() def reset(self) -> None: self._pid.reset() self.tc_state = TrueCourseState() def update_config(self, config: TrueCourseConfig) -> None: self.config = config self._pid.update_config(config.pid) # ------------------------------------------------------------------ # TRUE_COURSE step # ------------------------------------------------------------------ def step_true_course( self, *, cog_setpoint_deg: float, measured_cog_deg: float, sog_kn: float, rot_dps: float = 0.0, allowed: bool = True, ) -> float: """Pure COG-hold step (no XTE). Returns rudder setpoint (deg).""" self.tc_state.effective_setpoint_deg = cog_setpoint_deg self.tc_state.xte_correction_deg = 0.0 return self._pid.step( heading_setpoint_deg=cog_setpoint_deg, heading_measured_deg=measured_cog_deg, rate_of_turn_dps=rot_dps, speed_kn=sog_kn, allowed=allowed, ) # ------------------------------------------------------------------ # TRACK_KEEPING step # ------------------------------------------------------------------ def step_track_keeping( self, *, cog_setpoint_deg: float, measured_cog_deg: float, sog_kn: float, rot_dps: float = 0.0, xte_m: float = math.nan, xte_age_s: float = 0.0, allowed: bool = True, ) -> float: """COG-hold with XTE correction. Returns rudder setpoint (deg). If XTE is NaN or stale the correction is zeroed and the pilot falls back to pure COG hold — safer than going to STANDBY. """ cfg = self.config xte_valid = ( not math.isnan(xte_m) and xte_age_s < cfg.xte_max_age_s ) if xte_valid: raw_correction = -cfg.xte_gain * xte_m correction = _clamp( raw_correction, -cfg.xte_max_correction_deg, cfg.xte_max_correction_deg, ) else: correction = 0.0 self.tc_state.xte_correction_deg = correction effective_sp = (cog_setpoint_deg + correction) % 360.0 self.tc_state.effective_setpoint_deg = effective_sp return self._pid.step( heading_setpoint_deg=effective_sp, heading_measured_deg=measured_cog_deg, rate_of_turn_dps=rot_dps, speed_kn=sog_kn, allowed=allowed, ) # ------------------------------------------------------------------ # Accessors # ------------------------------------------------------------------ @property def pid_state(self) -> PidOuterState: return self._pid.state @property def last_rudder_setpoint_deg(self) -> float: return self._pid.state.last_output_deg def _clamp(x: float, lo: float, hi: float) -> float: if x < lo: return lo if x > hi: return hi return x