"""Outer (heading-control) PID -- reference Python implementation. Inputs: heading_setpoint_deg : desired compass heading (0..360) heading_measured_deg : current compass heading (0..360) rate_of_turn_dps : current ROT in deg/s (positive = turning stbd) speed_kn : current SOG in knots, used for gain scheduling allowed : if False, output is zero and integrator bleeds Output: rudder_setpoint_deg : signed rudder angle in degrees, clamped to the mechanical limit. Passed to the inner PID. Algorithm: err = shortest_arc(setpoint, measured) deadband applied: |err| < deadband_deg -> 0 p_term = kp(speed) * err integral += ki(speed) * err * dt (with anti-windup) derivative = kd(speed) * (err - prev_err) / dt (LPF) rot_ff_term = rot_ff_gain * (-rot) # subtract ROT to anticipate raw_output = p_term + integral + derivative + rot_ff_term rudder_setpoint = saturate(raw_output, max_rudder_deg) if saturated, back-calculate the integrator The ROT feed-forward (brief sec. 6) is the term that distinguishes a premium autopilot from a basic one: it subtracts the current rate of turn from the rudder command, so as the vessel approaches the new heading the rudder eases off before the heading is reached, preventing overshoot due to yaw inertia. """ from __future__ import annotations from dataclasses import dataclass, field from arautopilot.studio.simulator.vessel_heading import heading_error_deg @dataclass class GainPoint: """One interpolation point for the SOG-indexed gain schedule.""" speed_kn: float kp: float ki: float kd: float @dataclass class PidOuterConfig: # ----- Sampling ----------------------------------------------------- freq_hz: float = 10.0 # ----- Base gains (used if gain_schedule is empty) ----------------- base_kp: float = 0.9 base_ki: float = 0.02 base_kd: float = 1.2 # ----- SOG-indexed gain schedule (outer loop only) ----------------- gain_schedule: list[GainPoint] = field(default_factory=list) # ----- Setpoint handling ------------------------------------------- deadband_deg: float = 0.5 integral_clamp: float = 35.0 # output-equivalent units (deg of rudder) # ----- Output saturation ------------------------------------------- max_rudder_deg: float = 35.0 rate_limit_dps: float = 30.0 # rate-limit on the produced rudder setpoint # ----- Anti-windup -------------------------------------------------- aw_gain: float | None = None # default: 1 / current kp # ----- Derivative low-pass ----------------------------------------- d_lpf_tau_s: float = 0.10 # ----- Rate-of-Turn feed-forward (brief sec. 6) -------------------- rot_ff_gain: float = 1.5 def dt(self) -> float: return 1.0 / self.freq_hz @dataclass class PidOuterState: integral: float = 0.0 prev_error: float = 0.0 prev_d_term: float = 0.0 prev_rudder_setpoint: float = 0.0 last_output_deg: float = 0.0 def interpolate_gains( schedule: list[GainPoint], speed_kn: float, base_kp: float, base_ki: float, base_kd: float, ) -> tuple[float, float, float]: """Return (kp, ki, kd) interpolated linearly at ``speed_kn``. Outside the range of the schedule the closest endpoint is held (no extrapolation). If the schedule is empty, the base gains are returned unchanged. """ if not schedule: return base_kp, base_ki, base_kd points = sorted(schedule, key=lambda p: p.speed_kn) if speed_kn <= points[0].speed_kn: return points[0].kp, points[0].ki, points[0].kd if speed_kn >= points[-1].speed_kn: return points[-1].kp, points[-1].ki, points[-1].kd for i in range(len(points) - 1): a, b = points[i], points[i + 1] if a.speed_kn <= speed_kn <= b.speed_kn: span = b.speed_kn - a.speed_kn t = 0.0 if span == 0.0 else (speed_kn - a.speed_kn) / span return ( a.kp + t * (b.kp - a.kp), a.ki + t * (b.ki - a.ki), a.kd + t * (b.kd - a.kd), ) raise RuntimeError("Gain schedule interpolation failed unexpectedly") class PidOuter: def __init__(self, config: PidOuterConfig | None = None) -> None: self.config: PidOuterConfig = config or PidOuterConfig() self.state: PidOuterState = PidOuterState() self._dt: float = self.config.dt() def reset(self, *, heading_deg: float = 0.0) -> None: self.state = PidOuterState() def update_config(self, config: PidOuterConfig) -> None: self.config = config self._dt = config.dt() def step( self, *, heading_setpoint_deg: float, heading_measured_deg: float, rate_of_turn_dps: float, speed_kn: float, allowed: bool = True, ) -> float: cfg = self.config st = self.state dt = self._dt # Error with shortest-arc semantics. raw_err = heading_error_deg(heading_setpoint_deg, heading_measured_deg) if abs(raw_err) <= cfg.deadband_deg: error = 0.0 else: sign = 1.0 if raw_err > 0.0 else -1.0 error = raw_err - sign * cfg.deadband_deg if not allowed: st.integral *= 0.95 st.prev_error = error st.last_output_deg = 0.0 return 0.0 # Gain scheduling: pull gains for the current speed. kp, ki, kd = interpolate_gains( cfg.gain_schedule, speed_kn, cfg.base_kp, cfg.base_ki, cfg.base_kd ) # PID terms. p_term = kp * error st.integral += ki * error * dt st.integral = _clamp(st.integral, -cfg.integral_clamp, cfg.integral_clamp) d_raw = kd * (error - st.prev_error) / dt if dt > 0.0 else 0.0 alpha = _lpf_alpha(cfg.d_lpf_tau_s, dt) d_term = (1.0 - alpha) * st.prev_d_term + alpha * d_raw st.prev_d_term = d_term # Rate-of-Turn feed-forward: subtract the current ROT to anticipate # the vessel coasting through the setpoint. Premium autopilot # behaviour per brief sec. 6. rot_ff_term = -cfg.rot_ff_gain * rate_of_turn_dps raw_rudder = p_term + st.integral + d_term + rot_ff_term # Saturate to mechanical rudder limit + back-calculation anti-windup. rudder = _clamp(raw_rudder, -cfg.max_rudder_deg, cfg.max_rudder_deg) if raw_rudder != rudder: aw = cfg.aw_gain if cfg.aw_gain is not None else ( 1.0 / kp if kp != 0.0 else 0.0 ) st.integral -= aw * (raw_rudder - rudder) * dt st.integral = _clamp(st.integral, -cfg.integral_clamp, cfg.integral_clamp) # Rate-limit the rudder setpoint so the inner loop has a smooth # reference. (The inner loop also has a rate limit; this one keeps # the setpoint that the outer loop hands off bounded in delta.) max_delta = cfg.rate_limit_dps * dt delta = rudder - st.prev_rudder_setpoint if delta > max_delta: rudder = st.prev_rudder_setpoint + max_delta elif delta < -max_delta: rudder = st.prev_rudder_setpoint - max_delta st.prev_rudder_setpoint = rudder st.prev_error = error st.last_output_deg = rudder return rudder def _clamp(x: float, lo: float, hi: float) -> float: if x < lo: return lo if x > hi: return hi return x def _lpf_alpha(tau_s: float, dt: float) -> float: if tau_s <= 0.0: return 1.0 return dt / (tau_s + dt)