295efa2d83
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>
211 lines
7.7 KiB
Python
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)
|