"""Vessel heading dynamics: simplified yaw-rate model. Used by Sprint 3 to validate the outer (heading-control) PID without a real boat. Combine with ``RudderSimulator`` (Sprint 2) to get a full two-stage cascade: outer PID -> rudder setpoint -> inner PID -> PWM -> rudder dynamics -> rudder angle -> vessel yaw -> heading -> loop. Model ----- The simplest physically-honest yaw model for a displacement / planing vessel under way is a first-order response of rate-of-turn (ROT) to rudder angle, with the gain proportional to forward speed: yaw_response_dps = rudder_response_gain * speed_kn * rudder_deg accel_yaw = (yaw_response_dps - yaw_damping * rot) / yaw_inertia rot += accel_yaw * dt heading += rot * dt (mod 360) This captures the qualitative behaviour the outer PID needs to handle: - More rudder -> faster turn (linear at low angles, saturates at large angles -- this simple model is linear so the outer PID's rate limit on the rudder setpoint must keep it inside the linear region). - More speed -> more turn per degree of rudder (basis for SOG-based gain scheduling). - The turn rate decays toward zero when the rudder is centred (damped first-order response). Defaults are tuned for a 30 m motor yacht at cruise speed: rudder_response_gain * speed_kn = 1.0 * 10 = 10 dps per +-1 rad of rudder per second^2 of acceleration which yields ~3 dps steady-state turn for a 5 deg rudder at 10 kn -- comparable to real yachts. """ from __future__ import annotations from dataclasses import dataclass, field @dataclass class VesselHeadingConfig: yaw_inertia: float = 1.0 """Effective rotational inertia of the vessel (dimensionless scaling).""" yaw_damping: float = 0.8 """Viscous damping on yaw (higher = less coasting after the rudder centres).""" rudder_response_gain: float = 0.18 """Yaw torque per (rudder_deg * speed_kn). Tuned so that 5 deg of rudder at 10 kn produces ~3 dps steady-state ROT.""" speed_kn: float = 10.0 """Default forward speed over ground in knots. Can be set per-step.""" external_yaw_torque: float = 0.0 """Constant external yaw torque (wind / current). 0 by default.""" @dataclass class VesselHeadingState: heading_deg: float = 0.0 # 0..360 rate_of_turn_dps: float = 0.0 class VesselHeadingSimulator: """Discrete-time integrator of the vessel yaw model.""" def __init__(self, config: VesselHeadingConfig | None = None) -> None: self.config: VesselHeadingConfig = config or VesselHeadingConfig() self.state: VesselHeadingState = VesselHeadingState() def reset(self, *, heading_deg: float = 0.0, rate_of_turn_dps: float = 0.0) -> None: self.state = VesselHeadingState( heading_deg=_wrap_deg(heading_deg), rate_of_turn_dps=rate_of_turn_dps, ) def step(self, *, dt: float, rudder_deg: float, speed_kn: float | None = None) -> VesselHeadingState: if dt <= 0.0: raise ValueError(f"dt must be > 0, got {dt}") cfg = self.config st = self.state v_kn = cfg.speed_kn if speed_kn is None else speed_kn yaw_response = cfg.rudder_response_gain * v_kn * rudder_deg accel = (yaw_response + cfg.external_yaw_torque - cfg.yaw_damping * st.rate_of_turn_dps) / cfg.yaw_inertia st.rate_of_turn_dps += accel * dt st.heading_deg = _wrap_deg(st.heading_deg + st.rate_of_turn_dps * dt) return st def _wrap_deg(deg: float) -> float: """Wrap a heading into [0, 360).""" return deg % 360.0 def heading_error_deg(setpoint_deg: float, measured_deg: float) -> float: """Signed shortest-arc error between two compass headings. Result is in (-180, +180]. Positive means "we should turn starboard (clockwise)" to reduce the error -- this matches the marine convention of positive ROT = turning starboard. """ delta = (setpoint_deg - measured_deg) % 360.0 if delta > 180.0: delta -= 360.0 return delta @dataclass class HeadingTrajectorySample: t: float setpoint_deg: float heading_deg: float rot_dps: float rudder_setpoint_deg: float rudder_actual_deg: float @dataclass class HeadingRunRecorder: samples: list[HeadingTrajectorySample] = field(default_factory=list) def record( self, *, t: float, setpoint_deg: float, heading_sim: VesselHeadingSimulator, rudder_setpoint_deg: float, rudder_actual_deg: float, ) -> None: self.samples.append( HeadingTrajectorySample( t=t, setpoint_deg=setpoint_deg, heading_deg=heading_sim.state.heading_deg, rot_dps=heading_sim.state.rate_of_turn_dps, rudder_setpoint_deg=rudder_setpoint_deg, rudder_actual_deg=rudder_actual_deg, ) ) def final_heading_error_deg(self, setpoint: float) -> float: if not self.samples: return 0.0 return abs(heading_error_deg(setpoint, self.samples[-1].heading_deg))