700756c16f
Initial commit. Delivers what the brief calls 'Sprint 0 - Foundations' (see docs/AR_Autopilot_brief.md section 12): - Complete repository structure (arautopilot package + firmware, display, installer, tools placeholders + docs). - Core data model (Pydantic v2): modes, alarms, actuator config, PID config + gain scheduling, vessel config, knob state machine, project config with YAML/JSON serialisation. - Seed library: 2 actuator profiles (hydraulic & electric DC reversible) and 2 default tunings (yacht motor planeo 30 m and 40 m). Conservative literature values, NOT the integrator's production tuning IP. - Firmware skeleton: only src/hal/pinout.h with the 21 I/O contract for the AR-NMEA-IO v1.0 board. No drivers, no main loop. - Studio stubs (real PySide6 app starts in Sprint 4). - pytest suite (80 tests, all green): modes, alarms, actuator, PID (incl. gain interpolation and the +/-50% adaptive bound from brief section 6), vessel, knob state, project config, library loader, end-to-end roundtrip. - examples/sprint0_demo.py - the acceptance demo from the brief. Acceptance criteria met: - pytest green (80/80) - demo creates, saves (YAML + JSON), reloads, and verifies a full ProjectConfig using the seed library - repository ready for tag `sprint-0-approved` See CHANGELOG.md for the detailed scope. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
198 lines
7.3 KiB
Python
198 lines
7.3 KiB
Python
"""Cascaded PID configuration + gain scheduling by vessel speed.
|
|
|
|
Mirrors section 6 of the brief:
|
|
|
|
- Inner loop (50 Hz) drives rudder position to setpoint.
|
|
- Outer loop (10 Hz) drives heading error to a rudder setpoint, with
|
|
Rate-of-Turn feed-forward and gain scheduling by SOG.
|
|
- Adaptive tuning (Sprint 9+) may shift the active gains by no more than
|
|
±50 % of the base gains — this hard limit is enforced here.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from enum import Enum
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
|
|
|
|
class AccessLevel(str, Enum):
|
|
"""Three-level RBAC for PID tuning (brief section 6)."""
|
|
|
|
OPERATOR = "operator"
|
|
"""Captain: chooses a pre-configured profile only (Soft/Normal/Sport)."""
|
|
|
|
TECHNICIAN = "technician"
|
|
"""Authorised installer (PIN): may shift gains within ±30 % of base."""
|
|
|
|
INTEGRATOR = "integrator"
|
|
"""Alvaro (PIN): full access, may edit base gains and adaptation limits."""
|
|
|
|
|
|
class PidGains(BaseModel):
|
|
"""A single set of PID gains."""
|
|
|
|
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
|
|
|
kp: float = Field(ge=0.0, description="Proportional gain.")
|
|
ki: float = Field(default=0.0, ge=0.0, description="Integral gain.")
|
|
kd: float = Field(default=0.0, ge=0.0, description="Derivative gain.")
|
|
|
|
|
|
class GainSchedulePoint(BaseModel):
|
|
"""One point of the SOG-indexed gain schedule for the outer loop."""
|
|
|
|
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
|
|
|
speed_knots: float = Field(ge=0.0, le=80.0, description="SOG at which these gains apply.")
|
|
gains: PidGains
|
|
|
|
|
|
class PidConfig(BaseModel):
|
|
"""Complete PID configuration for one vessel.
|
|
|
|
The base gains (``inner_loop_base`` and ``outer_loop_base``) are the
|
|
integrator's IP and are NEVER exposed to the operator. They are the
|
|
starting point for adaptive tuning and the anchor against which the
|
|
adaptive bound (``adaptive_max_deviation_pct``) is enforced.
|
|
"""
|
|
|
|
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
|
|
|
schema_version: str = Field(default="0.1.0", pattern=r"^\d+\.\d+\.\d+$")
|
|
|
|
# --- Inner loop (rudder position controller, 50 Hz) ---------------------
|
|
inner_loop_base: PidGains
|
|
inner_loop_freq_hz: float = Field(default=50.0, gt=0.0, le=200.0)
|
|
|
|
# --- Outer loop (heading controller, 10 Hz) -----------------------------
|
|
outer_loop_base: PidGains
|
|
outer_loop_freq_hz: float = Field(default=10.0, gt=0.0, le=50.0)
|
|
|
|
# --- Gain scheduling by vessel SOG (outer loop) -------------------------
|
|
gain_schedule: list[GainSchedulePoint] = Field(
|
|
default_factory=list,
|
|
description=(
|
|
"Outer-loop gains by SOG. Linear interpolation between points; "
|
|
"outside the range, the closest endpoint is held. Empty list "
|
|
"means scheduling is disabled and the base gains are used."
|
|
),
|
|
)
|
|
|
|
# --- Feed-forward & saturation ------------------------------------------
|
|
rot_feedforward_gain: float = Field(
|
|
default=0.0,
|
|
ge=0.0,
|
|
le=10.0,
|
|
description="Rate-of-Turn feed-forward gain (brief section 6 — anticipates inertia).",
|
|
)
|
|
setpoint_deadband_deg: float = Field(
|
|
default=0.5,
|
|
ge=0.0,
|
|
le=5.0,
|
|
description="Heading deadband around the setpoint to suppress noise oscillation.",
|
|
)
|
|
setpoint_rate_limit_dps: float = Field(
|
|
default=4.0,
|
|
gt=0.0,
|
|
le=20.0,
|
|
description="Maximum rate of change of the heading setpoint (typical 3-6 dps).",
|
|
)
|
|
anti_windup_limit: float = Field(
|
|
default=10.0,
|
|
gt=0.0,
|
|
description="Integrator absolute clamp; saturates when the actuator saturates.",
|
|
)
|
|
|
|
# --- Adaptive tuning safety bound (Sprint 9+) ---------------------------
|
|
adaptive_enabled: bool = Field(
|
|
default=False,
|
|
description="Enabled at commissioning by the integrator only.",
|
|
)
|
|
adaptive_max_deviation_pct: float = Field(
|
|
default=50.0,
|
|
gt=0.0,
|
|
le=50.0,
|
|
description=(
|
|
"Hard cap on how far adaptive tuning may shift the active gains "
|
|
"away from the base gains. Brief section 6: 'never out of ±50 %'."
|
|
),
|
|
)
|
|
|
|
# --- Validators ---------------------------------------------------------
|
|
@field_validator("gain_schedule")
|
|
@classmethod
|
|
def _schedule_must_be_ascending(
|
|
cls, v: list[GainSchedulePoint]
|
|
) -> list[GainSchedulePoint]:
|
|
speeds = [p.speed_knots for p in v]
|
|
if speeds != sorted(speeds):
|
|
raise ValueError("gain_schedule points must be sorted by ascending speed_knots")
|
|
if len(set(speeds)) != len(speeds):
|
|
raise ValueError("gain_schedule points must have unique speed_knots values")
|
|
return v
|
|
|
|
@model_validator(mode="after")
|
|
def _check_loop_frequencies(self) -> "PidConfig":
|
|
if self.inner_loop_freq_hz <= self.outer_loop_freq_hz:
|
|
raise ValueError(
|
|
"inner_loop_freq_hz must be strictly greater than outer_loop_freq_hz "
|
|
"(cascaded control requires the inner loop to be faster)"
|
|
)
|
|
return self
|
|
|
|
def is_within_adaptive_bound(self, candidate: PidGains, *, of: str = "outer") -> bool:
|
|
"""Return ``True`` if ``candidate`` is within the adaptive bound of the base.
|
|
|
|
``of`` is either ``"inner"`` or ``"outer"``.
|
|
"""
|
|
base = self.inner_loop_base if of == "inner" else self.outer_loop_base
|
|
limit = self.adaptive_max_deviation_pct / 100.0
|
|
return all(
|
|
_within_pct(getattr(candidate, axis), getattr(base, axis), limit)
|
|
for axis in ("kp", "ki", "kd")
|
|
)
|
|
|
|
|
|
def _within_pct(candidate: float, base: float, frac: float) -> bool:
|
|
"""``True`` iff ``candidate`` lies within ``±frac`` of ``base``.
|
|
|
|
If ``base`` is exactly zero, we require the candidate to be zero too
|
|
(any non-zero gain breaks the ratio test).
|
|
"""
|
|
if base == 0.0:
|
|
return candidate == 0.0
|
|
lo = base * (1.0 - frac)
|
|
hi = base * (1.0 + frac)
|
|
return lo <= candidate <= hi
|
|
|
|
|
|
def interpolate_gains(schedule: list[GainSchedulePoint], speed_knots: float) -> PidGains:
|
|
"""Linear interpolation of outer-loop gains over the speed schedule.
|
|
|
|
Outside the range of the schedule, the nearest endpoint is held
|
|
(no extrapolation). Raises ``ValueError`` if the schedule is empty.
|
|
"""
|
|
if not schedule:
|
|
raise ValueError("Cannot interpolate over an empty gain_schedule")
|
|
|
|
# Endpoint hold
|
|
if speed_knots <= schedule[0].speed_knots:
|
|
return schedule[0].gains
|
|
if speed_knots >= schedule[-1].speed_knots:
|
|
return schedule[-1].gains
|
|
|
|
# Find bracketing points
|
|
for i in range(len(schedule) - 1):
|
|
a, b = schedule[i], schedule[i + 1]
|
|
if a.speed_knots <= speed_knots <= b.speed_knots:
|
|
span = b.speed_knots - a.speed_knots
|
|
t = 0.0 if span == 0.0 else (speed_knots - a.speed_knots) / span
|
|
return PidGains(
|
|
kp=a.gains.kp + t * (b.gains.kp - a.gains.kp),
|
|
ki=a.gains.ki + t * (b.gains.ki - a.gains.ki),
|
|
kd=a.gains.kd + t * (b.gains.kd - a.gains.kd),
|
|
)
|
|
# Unreachable given the endpoint guards above
|
|
raise RuntimeError("Gain schedule interpolation failed unexpectedly")
|