Files
AR-Autopilot/arautopilot/studio/simulator/rudder_dynamics.py
T
alro65 295efa2d83 sprint-2: PID inner loop + Python rudder simulator
End-to-end implementation per docs/sprint-2-plan.md.

Builds: pio run -e esp32-dev SUCCESS, RAM 6.8%, Flash 26.8% (351 KB).
Tests: pytest 129/129 green (110 Sprint 1 + 19 Sprint 2).

Python (arautopilot/studio/simulator/):

- rudder_dynamics.py: marine-realistic physical model of a hydraulic
  rudder actuator. Defaults tuned so 100 % PWM produces steady-state
  v_max ~5 deg/s, matching the brief's "typical 3-6 dps" for a 30 m
  yacht. Includes deadband, min-useful PWM snap, port/stbd asymmetry,
  end-stops, optional external torque, RunRecorder helper.
- pid_inner.py: pure-Python reference PID. Anti-windup via back-
  calculation, setpoint rate limit, setpoint deadband, derivative LPF,
  actuator non-linearity compensation. This module is the algorithmic
  source of truth; C++ firmware is a line-by-line port.

Firmware (firmware/ar_autopilot_v1/src/pid/):

- pid_inner.h: header-only C++17 controller, byte-equivalent port of
  pid_inner.py. Compiles on ESP32 toolchain AND on host g++/clang/MSVC
  (no Arduino dependencies) -- ready for native Unity cross-validation
  once a host compiler is installed.
- pid_inner_task.{h,cpp}: FreeRTOS task wrapper. 50 Hz on Core 1
  (real-time core). Subscribes to TWDT, bleeds integrator during
  STANDBY, surfaces telemetry + tunables via the Modbus slave.

Modbus map (regenerated from YAML):

- 6 new INPUT registers (40-45): setpoint, output, error, kp/ki/kd live
- 4 new HOLDING registers (16-19): writable setpoint + kp/ki/kd req
  (writes propagate atomically; zero kp rejected as ILLEGAL_DATA_VALUE)

Tests:

- test_rudder_simulator.py: 9 tests (zero-input rest, full deflection,
  end-stop saturation, deadband, min-useful snap, asymmetry, recorder
  API, invalid dt, end-stop velocity zeroing).
- test_pid_inner_python.py: 10 tests (positive/negative step response,
  setpoint deadband holds, anti-windup bounds under saturation,
  allowed=false bleeds integrator, actuator deadband + asymmetry
  compensation, output saturation, rate limit, disturbance rejection).

NOT in Sprint 2 (intentional per brief sec. 12):
  - Outer heading PID, gain scheduling by SOG, ROT feed-forward
    (those land in Sprint 3)
  - Cross-validation tests via ctypes (need host C++ compiler that
    this Windows machine lacks; algorithmic parity enforced by review)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:27:45 -04:00

211 lines
7.7 KiB
Python

"""Minimal physical model of a hydraulic rudder actuator.
The state is the rudder angle in degrees and its angular velocity in deg/s.
The driver is a signed PWM command in percent (-100..+100):
pwm > 0 -> drive starboard (positive angular acceleration)
pwm < 0 -> drive port (negative angular acceleration)
pwm == 0 -> no torque from the actuator (still subject to friction)
The model is intentionally small: it must capture the qualitative behaviour
that makes a PID interesting (inertia, friction, deadband, asymmetry,
end-stop saturation) but it does NOT pretend to be a faithful CFD of any
real pump. The integrator's affinated tunings live elsewhere.
Equations (per integration step ``dt``):
effective_cmd = compensate_deadband_and_asymmetry(pwm)
torque = actuator_gain * effective_cmd
accel = (torque - friction * angular_vel) / inertia
angular_vel += accel * dt
angle += angular_vel * dt
# then clamp to [-max_angle_deg, +max_angle_deg], zeroing velocity at the stop
Units throughout:
angle [deg]
velocity [deg / s]
accel [deg / s^2]
torque [arbitrary scaled units; absorbed into actuator_gain]
friction [(deg/s) -> (deg/s^2)], i.e. viscous coefficient
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class RudderDynamicsConfig:
"""Tunable parameters of the rudder physical model.
Defaults are reasonable for a 30 m motor yacht with a reversible
hydraulic pump (calibration profile shipped in the seed library).
"""
# --- Mechanical ---------------------------------------------------------
max_angle_deg: float = 35.0
"""Mechanical end-stop on either side, degrees."""
inertia: float = 1.0
"""Rotational inertia (arbitrary units; absorbed with actuator_gain)."""
friction: float = 4.0
"""Viscous friction coefficient (higher = more damping)."""
# --- Actuator -----------------------------------------------------------
# Tuned so that 100 % PWM produces a steady-state angular velocity of
# ~5 deg/s, which matches the brief's "typical 3-6 dps" max rate for a
# 30 m yacht hydraulic pump:
# v_steady = (actuator_gain * 100) / friction = (0.2 * 100) / 4 = 5
actuator_gain: float = 0.2
"""Torque-equivalent per percent of PWM. Marine-realistic for a 30 m yacht."""
deadband_pct: float = 7.0
"""Below this absolute command, the pump produces no torque (static friction)."""
min_useful_pwm_pct: float = 12.0
"""Once above the deadband, the effective PWM is snapped up to this minimum."""
asymmetry_stbd_over_port: float = 1.0
"""Ratio of starboard speed to port speed; 1.0 = symmetric."""
# --- Optional disturbance -----------------------------------------------
external_torque: float = 0.0
"""Constant external torque (e.g. simulating wave action). 0 by default."""
@dataclass
class RudderState:
angle_deg: float = 0.0
angular_vel_dps: float = 0.0
class RudderSimulator:
"""Discrete-time integrator of the rudder model.
Typical usage::
sim = RudderSimulator()
sim.reset(angle_deg=0.0)
for k in range(1000):
sim.step(dt=0.001, pwm_pct=cmd)
print(sim.state.angle_deg)
"""
def __init__(self, config: RudderDynamicsConfig | None = None) -> None:
self.config: RudderDynamicsConfig = config or RudderDynamicsConfig()
self.state: RudderState = RudderState()
# -- Lifecycle -----------------------------------------------------------
def reset(self, *, angle_deg: float = 0.0, angular_vel_dps: float = 0.0) -> None:
self.state = RudderState(angle_deg=angle_deg, angular_vel_dps=angular_vel_dps)
# -- Integration ---------------------------------------------------------
def step(self, *, dt: float, pwm_pct: float) -> RudderState:
"""Integrate the model forward by ``dt`` seconds with constant ``pwm_pct``."""
if dt <= 0.0:
raise ValueError(f"dt must be > 0, got {dt}")
cfg = self.config
st = self.state
effective = self._compensate(pwm_pct)
torque = cfg.actuator_gain * effective + cfg.external_torque
accel = (torque - cfg.friction * st.angular_vel_dps) / cfg.inertia
st.angular_vel_dps += accel * dt
st.angle_deg += st.angular_vel_dps * dt
# End-stop saturation.
if st.angle_deg > cfg.max_angle_deg:
st.angle_deg = cfg.max_angle_deg
if st.angular_vel_dps > 0.0:
st.angular_vel_dps = 0.0
elif st.angle_deg < -cfg.max_angle_deg:
st.angle_deg = -cfg.max_angle_deg
if st.angular_vel_dps < 0.0:
st.angular_vel_dps = 0.0
return st
# -- Actuator non-linearity ---------------------------------------------
def _compensate(self, pwm_pct: float) -> float:
"""Apply deadband + min-useful-PWM + asymmetry.
Returned units: signed effective PWM in percent. Sign convention
matches the input.
"""
cfg = self.config
if pwm_pct == 0.0:
return 0.0
magnitude = abs(pwm_pct)
if magnitude <= cfg.deadband_pct:
return 0.0
if magnitude < cfg.min_useful_pwm_pct:
magnitude = cfg.min_useful_pwm_pct
sign = 1.0 if pwm_pct > 0.0 else -1.0
effective = sign * magnitude
# Asymmetry: starboard (positive) is multiplied; port is divided so
# the symmetric case asymmetry=1.0 leaves both sides untouched.
if effective > 0.0:
effective *= cfg.asymmetry_stbd_over_port
elif cfg.asymmetry_stbd_over_port != 0.0:
effective /= cfg.asymmetry_stbd_over_port
return effective
@dataclass
class TrajectorySample:
"""One row of a recorded run."""
t: float
setpoint_deg: float
angle_deg: float
angular_vel_dps: float
pwm_pct: float
@dataclass
class RunRecorder:
"""Capture a (time, setpoint, angle, vel, cmd) trace for plotting/tests."""
samples: list[TrajectorySample] = field(default_factory=list)
def record(self, *, t: float, setpoint_deg: float, sim: RudderSimulator,
pwm_pct: float) -> None:
self.samples.append(
TrajectorySample(
t=t,
setpoint_deg=setpoint_deg,
angle_deg=sim.state.angle_deg,
angular_vel_dps=sim.state.angular_vel_dps,
pwm_pct=pwm_pct,
)
)
def final_angle(self) -> float:
return self.samples[-1].angle_deg if self.samples else 0.0
def settling_time_s(self, target: float, tolerance_deg: float = 0.5) -> float | None:
"""Return time at which ``|angle - target| <= tolerance`` and stays there
until the end of the run. ``None`` if it never settles."""
if not self.samples:
return None
# Walk backwards: find the last time the error exceeded tolerance.
for i in range(len(self.samples) - 1, -1, -1):
if abs(self.samples[i].angle_deg - target) > tolerance_deg:
if i + 1 < len(self.samples):
return self.samples[i + 1].t
return None
return self.samples[0].t
def max_overshoot_deg(self, target: float) -> float:
"""Maximum overshoot past ``target`` in the direction of the step."""
if not self.samples:
return 0.0
start = self.samples[0].angle_deg
going_up = target > start
peak = max((s.angle_deg for s in self.samples), default=start) if going_up \
else min((s.angle_deg for s in self.samples), default=start)
overshoot = (peak - target) if going_up else (target - peak)
return max(0.0, overshoot)