sprint-0: foundations -- data model, seed library, tests, demo

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>
This commit is contained in:
2026-05-17 23:57:18 -04:00
commit 700756c16f
54 changed files with 3855 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
"""AR-Autopilot — Professional marine autopilot for 30-40 m vessels.
This is the integrator-side Python package. It contains:
- ``arautopilot.core`` — shared data model (vessel/PID/actuator/alarms/…)
- ``arautopilot.library`` — curated seed (actuator profiles, default tunings)
- ``arautopilot.studio`` — PySide6 configurator GUI (Sprint 4+)
- ``arautopilot.shared`` — utilities shared across studio and tools
- ``arautopilot.tests`` — pytest suite
The real-time control loop lives in the C++ firmware
(``firmware/ar_autopilot_v1/``) running on the ESP32-based AR-NMEA-IO board,
NOT in this Python package. See ``docs/architecture.md``.
"""
from arautopilot.version import __version__
__all__ = ["__version__"]
+88
View File
@@ -0,0 +1,88 @@
"""Core data model for AR-Autopilot.
This module exports the typed, validated Pydantic v2 models used across
the Studio, the firmware build pipeline, and the test bench.
Public surface (Sprint 0):
- :mod:`~arautopilot.core.ids` — typed identifier wrappers
- :mod:`~arautopilot.core.modes` — autopilot operating modes
- :mod:`~arautopilot.core.alarms` — alarm types and severities
- :mod:`~arautopilot.core.actuator_config` — rudder actuator configuration
- :mod:`~arautopilot.core.pid_config` — cascaded PID + gain schedule
- :mod:`~arautopilot.core.vessel_config` — vessel + composed configs
- :mod:`~arautopilot.core.knob_state` — bridge knob arming state machine
- :mod:`~arautopilot.core.project_config` — root project config (root entity)
"""
from arautopilot.core.actuator_config import (
ActuatorConfig,
ActuatorType,
)
from arautopilot.core.alarms import (
Alarm,
AlarmSeverity,
AlarmType,
)
from arautopilot.core.ids import (
ProjectId,
VesselId,
new_project_id,
new_vessel_id,
)
from arautopilot.core.knob_state import (
KnobFunction,
KnobMode,
KnobState,
)
from arautopilot.core.modes import (
AutopilotMode,
is_available_in_phase,
)
from arautopilot.core.pid_config import (
AccessLevel,
GainSchedulePoint,
PidConfig,
PidGains,
interpolate_gains,
)
from arautopilot.core.project_config import (
ProjectConfig,
)
from arautopilot.core.vessel_config import (
VesselConfig,
VesselType,
)
__all__ = [
# ids
"ProjectId",
"VesselId",
"new_project_id",
"new_vessel_id",
# modes
"AutopilotMode",
"is_available_in_phase",
# alarms
"AlarmType",
"AlarmSeverity",
"Alarm",
# actuator
"ActuatorType",
"ActuatorConfig",
# pid
"AccessLevel",
"PidGains",
"GainSchedulePoint",
"PidConfig",
"interpolate_gains",
# vessel
"VesselType",
"VesselConfig",
# knob
"KnobMode",
"KnobFunction",
"KnobState",
# project
"ProjectConfig",
]
+140
View File
@@ -0,0 +1,140 @@
"""Rudder actuator configuration.
Supports the actuator families enumerated in section 3 of the brief.
Sterndrive, IPS and Zeus interfaces are reserved for Phase 3 and are
intentionally **not** enabled in Phase 1 configurations.
"""
from __future__ import annotations
from enum import Enum
from pydantic import BaseModel, ConfigDict, Field, field_validator
class ActuatorType(str, Enum):
"""Actuator family driving the rudder.
Phase-1 supported families have a regular value. Reserved (Phase 3)
families also live here so that configurations and the UI can model
them, but the firmware refuses to operate them in Phase 1.
"""
HYDRAULIC_REVERSIBLE = "hydraulic_reversible"
"""Reversible hydraulic pump (Hynautic, Hypro, Octopus, Vetus, L&S). Phase 1."""
ELECTRIC_DC_REVERSIBLE = "electric_dc_reversible"
"""Reversible DC motor with mechanical end-stops (Lewmar, Simpson Lawrence). Phase 1."""
SERVOMOTOR_FEEDBACK = "servomotor_feedback"
"""Servomotor with built-in position feedback. Phase 1."""
STERNDRIVE_ANALOG = "sterndrive_analog"
"""Analog directional sterndrive. Phase 1."""
VOLVO_IPS = "volvo_ips"
"""Volvo IPS via EVC. Phase 3 — reserved."""
MERCURY_ZEUS = "mercury_zeus"
"""Mercury Zeus via SmartCraft. Phase 3 — reserved."""
_PHASE_1_ACTUATORS: frozenset[ActuatorType] = frozenset(
{
ActuatorType.HYDRAULIC_REVERSIBLE,
ActuatorType.ELECTRIC_DC_REVERSIBLE,
ActuatorType.SERVOMOTOR_FEEDBACK,
ActuatorType.STERNDRIVE_ANALOG,
}
)
def is_phase_1(actuator_type: ActuatorType) -> bool:
"""Return ``True`` iff the firmware is allowed to drive this actuator in Phase 1."""
return actuator_type in _PHASE_1_ACTUATORS
class ActuatorConfig(BaseModel):
"""Configuration of the rudder actuator and its calibration.
All angles are in degrees. ``deadband_pct`` and ``min_useful_pwm_pct``
are percentages of full command. Asymmetry is the ratio of starboard
speed to port speed: ``1.0`` means symmetric, ``1.10`` means the pump
pushes 10 % faster to starboard than to port.
"""
model_config = ConfigDict(extra="forbid", validate_assignment=True)
type: ActuatorType
"""Actuator family."""
name: str = Field(
default="",
max_length=120,
description="Free-form label, e.g. 'Hynautic K-21 + Capilano cylinder'.",
)
# -- Calibration / non-linearity compensation ------------------------------
deadband_pct: float = Field(
default=5.0,
ge=0.0,
le=30.0,
description="Initial percentage of command that produces no motion (static friction).",
)
min_useful_pwm_pct: float = Field(
default=8.0,
ge=0.0,
le=50.0,
description="Minimum PWM where the actuator actually moves; commands below this snap up to it.",
)
asymmetry_stbd_over_port: float = Field(
default=1.0,
ge=0.50,
le=2.00,
description="Ratio of starboard speed to port speed; 1.0 = symmetric.",
)
# -- Mechanical / electrical limits ---------------------------------------
max_rudder_angle_deg: float = Field(
default=35.0,
gt=0.0,
le=45.0,
description="Mechanical limit on either side from amidships; typically 35 deg.",
)
max_rate_dps: float = Field(
default=4.5,
gt=0.0,
le=15.0,
description="Maximum slew rate in degrees per second (typical 3-6 dps).",
)
max_current_a: float = Field(
default=15.0,
gt=0.0,
le=200.0,
description="Overcurrent trip threshold (A) for the actuator power line.",
)
# -- Safety policy --------------------------------------------------------
feedback_required: bool = Field(
default=True,
description=(
"Closed loop only. Open-loop operation requires explicit integrator "
"override (brief section 3, Phase 1). Default True."
),
)
@field_validator("min_useful_pwm_pct")
@classmethod
def _min_useful_must_cover_deadband(cls, v: float, info: object) -> float:
# info.data is provided by pydantic-core for cross-field validation
data = getattr(info, "data", {}) or {}
deadband = data.get("deadband_pct")
if deadband is not None and v < deadband:
raise ValueError(
f"min_useful_pwm_pct ({v}) must be >= deadband_pct ({deadband})"
)
return v
def is_phase_1_supported(self) -> bool:
"""Return ``True`` iff this actuator can be driven in Phase 1."""
return is_phase_1(self.type)
+116
View File
@@ -0,0 +1,116 @@
"""Alarm types, severities, and the ``Alarm`` runtime record.
The catalogue mirrors section 7 of the brief. Each ``AlarmType`` has a
canonical default ``AlarmSeverity`` and a hard-coded flag stating whether
that alarm, when fired, should also trigger automatic disengagement of
the autopilot (return to STANDBY).
"""
from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from pydantic import BaseModel, ConfigDict, Field
class AlarmSeverity(str, Enum):
"""IEC-style four-level severity scheme."""
EMERGENCY = "emergency"
"""Immediate danger; loudest annunciation, blocking modal on display."""
HIGH = "high"
"""Operator must act within a few seconds."""
LOW = "low"
"""Warning; degraded operation but no immediate danger."""
INFO = "info"
"""Informational only; ack with one tap."""
class AlarmType(str, Enum):
"""The fixed catalogue of alarms emitted by the firmware.
Adding an entry here is a deliberate, reviewed change — the firmware
state machine and the display strings must be updated in lockstep.
"""
OFF_COURSE = "off_course"
OFF_COURSE_SEVERE = "off_course_severe"
RUDDER_NOT_RESPONDING = "rudder_not_responding"
HEADING_SENSOR_LOST = "heading_sensor_lost"
ACTUATOR_OVERCURRENT = "actuator_overcurrent"
VOLTAGE_LOW = "voltage_low"
LIMIT_SWITCH_REACHED = "limit_switch_reached"
WATCHDOG_TRIPPED = "watchdog_tripped"
VMS_CRITICAL = "vms_critical"
# Canonical metadata per alarm type. Source of truth referenced by both
# the firmware (via code-generated header) and the Studio (via the GUI).
_CATALOGUE: dict[AlarmType, tuple[AlarmSeverity, bool, str]] = {
AlarmType.OFF_COURSE: (AlarmSeverity.LOW, False, "Heading deviates from setpoint beyond threshold"),
AlarmType.OFF_COURSE_SEVERE: (AlarmSeverity.EMERGENCY, True, "Heading deviation >30 deg for >5 s — auto-disengage"),
AlarmType.RUDDER_NOT_RESPONDING:(AlarmSeverity.EMERGENCY, True, "Rudder command sent but no feedback motion — auto-disengage"),
AlarmType.HEADING_SENSOR_LOST: (AlarmSeverity.EMERGENCY, True, "NMEA 2000 PGN 127250 not received for >5 s — auto-disengage"),
AlarmType.ACTUATOR_OVERCURRENT: (AlarmSeverity.HIGH, True, "Actuator current exceeded configured limit"),
AlarmType.VOLTAGE_LOW: (AlarmSeverity.HIGH, True, "Supply voltage below safe operating threshold"),
AlarmType.LIMIT_SWITCH_REACHED: (AlarmSeverity.LOW, False, "Rudder reached mechanical end-stop"),
AlarmType.WATCHDOG_TRIPPED: (AlarmSeverity.EMERGENCY, True, "Firmware watchdog fired — controller reset to STANDBY"),
AlarmType.VMS_CRITICAL: (AlarmSeverity.EMERGENCY, True, "VMS reported blackout or critical electrical overload"),
}
def default_severity(alarm_type: AlarmType) -> AlarmSeverity:
"""Return the canonical severity for ``alarm_type``."""
return _CATALOGUE[alarm_type][0]
def triggers_auto_disengage(alarm_type: AlarmType) -> bool:
"""Return ``True`` iff this alarm forces the pilot into STANDBY."""
return _CATALOGUE[alarm_type][1]
def default_message(alarm_type: AlarmType) -> str:
"""Return the canonical default human-readable description."""
return _CATALOGUE[alarm_type][2]
class Alarm(BaseModel):
"""A single alarm event raised by the firmware at runtime.
Persisted to the immutable audit log (brief section 14, rule #14).
"""
model_config = ConfigDict(frozen=True, extra="forbid")
type: AlarmType
severity: AlarmSeverity
message: str = Field(min_length=1, max_length=240)
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
source: str = Field(
default="firmware",
description="Subsystem that raised the alarm (e.g. 'firmware', 'display', 'vms').",
max_length=64,
)
acknowledged: bool = False
auto_disengage_triggered: bool = False
@classmethod
def from_type(
cls,
alarm_type: AlarmType,
*,
message: str | None = None,
source: str = "firmware",
) -> "Alarm":
"""Convenience constructor using catalogue defaults."""
return cls(
type=alarm_type,
severity=default_severity(alarm_type),
message=message or default_message(alarm_type),
source=source,
auto_disengage_triggered=triggers_auto_disengage(alarm_type),
)
+27
View File
@@ -0,0 +1,27 @@
"""Typed identifier wrappers.
Pydantic v2 ``Annotated[str, ...]`` aliases that prevent accidentally
passing a ``VesselId`` where a ``ProjectId`` is expected (mypy-enforced).
At runtime they are plain strings — UUID v4 hex by default.
"""
from __future__ import annotations
import uuid
from typing import NewType
ProjectId = NewType("ProjectId", str)
"""Unique identifier of a customer project (one vessel = one project)."""
VesselId = NewType("VesselId", str)
"""Unique identifier of a vessel within a project."""
def new_project_id() -> ProjectId:
"""Generate a fresh, random ``ProjectId`` (UUID v4 hex)."""
return ProjectId(uuid.uuid4().hex)
def new_vessel_id() -> VesselId:
"""Generate a fresh, random ``VesselId`` (UUID v4 hex)."""
return VesselId(uuid.uuid4().hex)
+162
View File
@@ -0,0 +1,162 @@
"""Bridge rotary knob — armed-by-software state machine.
Section 5 of the brief: the knob never controls anything by default.
The operator must press it, pick a function, and then turn. Idle activity
auto-disarms after a configurable timeout (default 30 s).
The state machine modelled here is the **logical** one consumed by the
Display and by the controller; the actual encoder quadrature decoding
happens in the ESP32 firmware (Sprint 7).
"""
from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from pydantic import BaseModel, ConfigDict, Field, model_validator
class KnobMode(str, Enum):
"""High-level state of the bridge knob."""
LIBRE = "libre"
"""Idle: rotation does nothing. Default."""
ARMADO = "armado"
"""Function selected; rotation now produces a pending value."""
CONFIRMANDO = "confirmando"
"""Pending value being shown for operator confirmation."""
class KnobFunction(str, Enum):
"""The set of values the knob may target once armed."""
NONE = "none"
"""No function selected (LIBRE only)."""
RUMBO = "rumbo"
"""Heading setpoint."""
GANANCIA_P = "ganancia_p"
"""Proportional gain (Technician+ only)."""
VELOCIDAD_GIRO = "velocidad_giro"
"""Desired turn rate."""
BRILLO = "brillo"
"""Display brightness."""
VOLUMEN = "volumen"
"""Alarm volume."""
DEFAULT_ARMED_TIMEOUT_S: float = 30.0
"""Default auto-disarm timeout in seconds (brief section 5)."""
class KnobState(BaseModel):
"""Immutable snapshot of the knob state at a point in time."""
model_config = ConfigDict(frozen=True, extra="forbid")
mode: KnobMode = KnobMode.LIBRE
function: KnobFunction = KnobFunction.NONE
current_value: float | None = Field(
default=None,
description="Last confirmed value of the selected function.",
)
pending_value: float | None = Field(
default=None,
description="Value being adjusted but not yet confirmed.",
)
armed_at: datetime | None = Field(
default=None,
description="UTC timestamp when the knob entered ARMADO mode.",
)
timeout_remaining_s: float = Field(
default=0.0,
ge=0.0,
description="Seconds remaining before auto-disarm.",
)
@model_validator(mode="after")
def _consistency(self) -> "KnobState":
if self.mode == KnobMode.LIBRE:
if self.function != KnobFunction.NONE:
raise ValueError("LIBRE mode requires function == NONE")
if self.pending_value is not None:
raise ValueError("LIBRE mode forbids pending_value")
if self.armed_at is not None:
raise ValueError("LIBRE mode forbids armed_at")
if self.timeout_remaining_s != 0.0:
raise ValueError("LIBRE mode requires timeout_remaining_s == 0")
else:
if self.function == KnobFunction.NONE:
raise ValueError(f"{self.mode.value} mode requires a non-NONE function")
if self.armed_at is None:
raise ValueError(f"{self.mode.value} mode requires armed_at to be set")
if self.mode == KnobMode.CONFIRMANDO and self.pending_value is None:
raise ValueError("CONFIRMANDO mode requires pending_value to be set")
return self
# --- Pure transition helpers (return new immutable states) --------------
@classmethod
def idle(cls) -> "KnobState":
"""Construct the canonical idle (LIBRE) state."""
return cls()
def arm(
self,
function: KnobFunction,
*,
current_value: float,
timeout_s: float = DEFAULT_ARMED_TIMEOUT_S,
now: datetime | None = None,
) -> "KnobState":
"""Transition LIBRE → ARMADO for the given function."""
if self.mode != KnobMode.LIBRE:
raise ValueError(f"Cannot arm from mode {self.mode.value}")
if function == KnobFunction.NONE:
raise ValueError("Cannot arm with KnobFunction.NONE")
return KnobState(
mode=KnobMode.ARMADO,
function=function,
current_value=current_value,
pending_value=None,
armed_at=now or datetime.now(timezone.utc),
timeout_remaining_s=timeout_s,
)
def propose(self, value: float, *, timeout_s: float = DEFAULT_ARMED_TIMEOUT_S) -> "KnobState":
"""Operator turned the knob: stage a pending value (ARMADO → CONFIRMANDO)."""
if self.mode not in (KnobMode.ARMADO, KnobMode.CONFIRMANDO):
raise ValueError(f"Cannot propose from mode {self.mode.value}")
return KnobState(
mode=KnobMode.CONFIRMANDO,
function=self.function,
current_value=self.current_value,
pending_value=value,
armed_at=self.armed_at,
timeout_remaining_s=timeout_s,
)
def confirm(self, *, timeout_s: float = DEFAULT_ARMED_TIMEOUT_S) -> "KnobState":
"""Operator pressed to confirm; commits ``pending_value`` and stays armed."""
if self.mode != KnobMode.CONFIRMANDO:
raise ValueError(f"Cannot confirm from mode {self.mode.value}")
return KnobState(
mode=KnobMode.ARMADO,
function=self.function,
current_value=self.pending_value,
pending_value=None,
armed_at=self.armed_at,
timeout_remaining_s=timeout_s,
)
def disarm(self) -> "KnobState":
"""Force the knob back to LIBRE (timeout, alarm, mode change, long-press)."""
return KnobState.idle()
+76
View File
@@ -0,0 +1,76 @@
"""Autopilot operating modes.
The brief (section 3) defines five Phase 1 modes plus three Phase 2 sail
modes that appear greyed-out in the UI but are reserved here for forward
compatibility.
"""
from __future__ import annotations
from enum import Enum
class AutopilotMode(str, Enum):
"""The complete set of autopilot operating modes, across both phases.
Use :func:`is_available_in_phase` to query phase gating instead of
hard-coding lists in the UI.
"""
# --- Phase 1 (Sprint 1+) ---
STANDBY = "standby"
"""Pilot disengaged, helm is manual."""
HEADING_HOLD = "heading_hold"
"""Holds a fixed magnetic/true compass heading."""
TRUE_COURSE = "true_course"
"""Holds COG, compensating drift due to current and wind."""
TRACK_KEEPING = "track_keeping"
"""Follows an ECDIS waypoint route with smooth XTE correction."""
DODGE = "dodge"
"""Temporary deviation without losing the route; auto-returns when released."""
# --- Phase 2 (Sprint 10+, greyed in Phase 1 UI) ---
APPARENT_WIND = "apparent_wind"
"""Holds constant apparent wind angle (vane mode). Sailboats only."""
TRUE_WIND = "true_wind"
"""Holds constant true wind angle. Sailboats only."""
AUTO_TACK = "auto_tack"
"""Automatically tacks at a target relative wind angle. Sailboats only."""
_PHASE_1: frozenset[AutopilotMode] = frozenset(
{
AutopilotMode.STANDBY,
AutopilotMode.HEADING_HOLD,
AutopilotMode.TRUE_COURSE,
AutopilotMode.TRACK_KEEPING,
AutopilotMode.DODGE,
}
)
_PHASE_2: frozenset[AutopilotMode] = frozenset(
{
AutopilotMode.APPARENT_WIND,
AutopilotMode.TRUE_WIND,
AutopilotMode.AUTO_TACK,
}
)
def is_available_in_phase(mode: AutopilotMode, phase: int) -> bool:
"""Return ``True`` if ``mode`` is available in the given product phase.
Phase 1 is the launch product. Phase 2 adds the sailboat wind modes.
Asking for any other phase number returns ``False``.
"""
if phase == 1:
return mode in _PHASE_1
if phase == 2:
return mode in _PHASE_1 or mode in _PHASE_2
return False
+197
View File
@@ -0,0 +1,197 @@
"""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")
+103
View File
@@ -0,0 +1,103 @@
"""Root project entity — what a Studio ``.appack`` package contains.
A ``ProjectConfig`` is the unit of work in the Studio: one customer
project = one configured vessel. It serialises to YAML or JSON for
transport into the firmware build pipeline.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import yaml
from pydantic import BaseModel, ConfigDict, Field
from arautopilot.core.ids import ProjectId, new_project_id
from arautopilot.core.vessel_config import VesselConfig
from arautopilot.version import __version__
class ProjectConfig(BaseModel):
"""Root configuration object persisted to disk and shipped as ``.appack``."""
model_config = ConfigDict(extra="forbid", validate_assignment=True)
schema_version: str = Field(default="0.1.0", pattern=r"^\d+\.\d+\.\d+$")
"""Project file schema version (independent of package ``__version__``)."""
package_version: str = Field(default=__version__)
"""Version of ``arautopilot`` that produced this file (informational)."""
project_id: ProjectId = Field(default_factory=new_project_id)
client_name: str = Field(min_length=1, max_length=120)
project_name: str = Field(min_length=1, max_length=120)
notes: str = Field(default="", max_length=2000)
vessel: VesselConfig
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
modified_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
# --- Serialisation -------------------------------------------------------
def to_dict(self) -> dict[str, Any]:
"""Return a JSON-ready dict (datetimes as ISO 8601 strings)."""
return self.model_dump(mode="json")
def to_json(self, *, indent: int | None = 2) -> str:
"""Serialise to JSON text."""
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
def to_yaml(self) -> str:
"""Serialise to YAML text (block style, sorted keys disabled to preserve schema order)."""
return yaml.safe_dump(
self.to_dict(),
sort_keys=False,
default_flow_style=False,
allow_unicode=True,
)
def save_yaml(self, path: Path | str) -> Path:
"""Write the project to ``path`` as YAML and return the resolved path."""
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(self.to_yaml(), encoding="utf-8")
return p
def save_json(self, path: Path | str) -> Path:
"""Write the project to ``path`` as JSON and return the resolved path."""
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(self.to_json(), encoding="utf-8")
return p
# --- Deserialisation -----------------------------------------------------
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ProjectConfig":
return cls.model_validate(data)
@classmethod
def from_json(cls, text: str) -> "ProjectConfig":
return cls.model_validate(json.loads(text))
@classmethod
def from_yaml(cls, text: str) -> "ProjectConfig":
return cls.model_validate(yaml.safe_load(text))
@classmethod
def load(cls, path: Path | str) -> "ProjectConfig":
"""Load from disk; format inferred from the file extension."""
p = Path(path)
text = p.read_text(encoding="utf-8")
suffix = p.suffix.lower()
if suffix in (".yaml", ".yml"):
return cls.from_yaml(text)
if suffix == ".json":
return cls.from_json(text)
raise ValueError(f"Unsupported project file extension: {suffix!r}")
def touch(self) -> "ProjectConfig":
"""Return a copy with ``modified_at`` refreshed to now (UTC)."""
return self.model_copy(update={"modified_at": datetime.now(timezone.utc)})
+62
View File
@@ -0,0 +1,62 @@
"""Per-vessel configuration: identification + actuator + PID.
Composes the lower-level configs into one object that lives at the heart
of every ``ProjectConfig``.
"""
from __future__ import annotations
from enum import Enum
from pydantic import BaseModel, ConfigDict, Field
from arautopilot.core.actuator_config import ActuatorConfig
from arautopilot.core.ids import VesselId, new_vessel_id
from arautopilot.core.pid_config import PidConfig
class VesselType(str, Enum):
"""Vessel classes targeted by Phase 1 of the product (brief section 3)."""
YACHT_MOTOR_PLANEO = "yacht_motor_planeo"
"""Planing motor yacht, 30-40 m."""
YACHT_MOTOR_DESPLAZAMIENTO = "yacht_motor_desplazamiento"
"""Displacement motor yacht, 30-40 m."""
SAILBOAT_MOTOR = "sailboat_motor"
"""Sailing yacht under motor (no sail trim). Phase 1 only."""
FISHING_BOAT = "fishing_boat"
"""Fishing vessel, 30 m class."""
SMALL_FERRY = "small_ferry"
"""Small ferry, 30 m class."""
PATROL_BOAT = "patrol_boat"
"""Coastal patrol boat, 30 m class."""
class VesselConfig(BaseModel):
"""Identification, geometry, and control configuration of one vessel."""
model_config = ConfigDict(extra="forbid", validate_assignment=True)
vessel_id: VesselId = Field(default_factory=new_vessel_id)
name: str = Field(min_length=1, max_length=120)
type: VesselType
length_m: float = Field(gt=0.0, le=200.0, description="Length overall, metres.")
displacement_t: float = Field(
default=0.0,
ge=0.0,
le=10_000.0,
description="Loaded displacement, tonnes. 0 means unknown.",
)
max_speed_kn: float = Field(
gt=0.0,
le=80.0,
description="Maximum design speed over ground, knots.",
)
actuator: ActuatorConfig
pid: PidConfig
+7
View File
@@ -0,0 +1,7 @@
"""Curated seed library: actuator profiles, default vessel tunings, vessel profiles.
The contents of this package are **proprietary**. Default PID gains shipped
here are conservative literature-based starting values intended only for
bench testing and initial commissioning they are NOT the integrator's
production-tuned values.
"""
+19
View File
@@ -0,0 +1,19 @@
# JSON Schemas
This directory is reserved for JSON Schemas auto-generated from the Pydantic
models in `arautopilot.core`. They are useful for:
- IDE autocompletion / validation when editing actuator profiles or PID
tunings outside the Studio
- External tooling that consumes `.appack` configurations
- Documentation generation
Schemas are not generated in Sprint 0 but can be produced on demand:
```python
from arautopilot.core.actuator_config import ActuatorConfig
import json
print(json.dumps(ActuatorConfig.model_json_schema(), indent=2))
```
A `tools/regenerate_schemas.py` script will be added later (Sprint 4+).
@@ -0,0 +1,11 @@
{
"type": "electric_dc_reversible",
"name": "Generic reversible DC motor with mechanical end-stops (Lewmar / Simpson Lawrence class)",
"deadband_pct": 4.0,
"min_useful_pwm_pct": 8.0,
"asymmetry_stbd_over_port": 1.00,
"max_rudder_angle_deg": 35.0,
"max_rate_dps": 5.5,
"max_current_a": 30.0,
"feedback_required": true
}
@@ -0,0 +1,11 @@
{
"type": "hydraulic_reversible",
"name": "Generic reversible hydraulic pump (Hynautic / Hypro / Octopus / Vetus / L&S class)",
"deadband_pct": 7.0,
"min_useful_pwm_pct": 12.0,
"asymmetry_stbd_over_port": 1.00,
"max_rudder_angle_deg": 35.0,
"max_rate_dps": 4.0,
"max_current_a": 20.0,
"feedback_required": true
}
@@ -0,0 +1,58 @@
# =============================================================================
# Default PID tuning — 30 m planing motor yacht
# =============================================================================
#
# WARNING — Conservative bench/start values only.
# These gains come from classical marine control literature (Fossen 2011,
# Perez 2005) scaled to a 30 m planing motor yacht class. They are intended
# only for bench testing and the very first sea trial — they MUST be replaced
# with the integrator's affinated values during commissioning.
#
# The "real" tuning libraries are integrator IP and are NOT shipped here.
#
# Units:
# - kp/ki/kd: dimensionless (controller internal units)
# - frequencies: Hz
# - rate / deadband: degrees, degrees/second
#
# Cascaded loops (brief section 6):
# - inner: rudder-position controller, 50 Hz
# - outer: heading controller, 10 Hz
#
# Gain scheduling: outer-loop gains interpolated over SOG (knots).
# Outside the scheduled range the closest endpoint is held.
# =============================================================================
schema_version: "0.1.0"
inner_loop_base:
kp: 2.5
ki: 0.15
kd: 0.30
inner_loop_freq_hz: 50.0
outer_loop_base:
kp: 0.90
ki: 0.02
kd: 1.20
outer_loop_freq_hz: 10.0
# Three-point schedule covering manoeuvring (5 kn), cruise (15 kn), top (28 kn).
# At low speed: more kp, less kd (rudder bites more, less inertia anticipation).
# At high speed: less kp, more kd (gentler corrections, anticipate overshoot).
gain_schedule:
- speed_knots: 5.0
gains: { kp: 1.20, ki: 0.03, kd: 0.80 }
- speed_knots: 15.0
gains: { kp: 0.90, ki: 0.02, kd: 1.20 }
- speed_knots: 28.0
gains: { kp: 0.55, ki: 0.01, kd: 1.80 }
rot_feedforward_gain: 1.50
setpoint_deadband_deg: 0.5
setpoint_rate_limit_dps: 4.0
anti_windup_limit: 8.0
adaptive_enabled: false
adaptive_max_deviation_pct: 50.0
@@ -0,0 +1,45 @@
# =============================================================================
# Default PID tuning — 40 m planing motor yacht
# =============================================================================
#
# WARNING — Conservative bench/start values only.
# Scaled from the 30 m profile to account for the larger vessel: higher mass
# moment of inertia and longer time constants. Compared to the 30 m profile:
# - kp slightly lower (slower response per unit rudder)
# - kd slightly higher (more anticipation needed for the inertia)
# - setpoint_rate_limit_dps reduced (gentler heading slew)
#
# As with the 30 m profile, these are commissioning starting points and
# must be replaced with the integrator's production tuning.
# =============================================================================
schema_version: "0.1.0"
inner_loop_base:
kp: 2.20
ki: 0.12
kd: 0.35
inner_loop_freq_hz: 50.0
outer_loop_base:
kp: 0.75
ki: 0.015
kd: 1.55
outer_loop_freq_hz: 10.0
gain_schedule:
- speed_knots: 5.0
gains: { kp: 1.00, ki: 0.025, kd: 1.10 }
- speed_knots: 15.0
gains: { kp: 0.75, ki: 0.015, kd: 1.55 }
- speed_knots: 28.0
gains: { kp: 0.45, ki: 0.008, kd: 2.20 }
rot_feedforward_gain: 1.80
setpoint_deadband_deg: 0.5
setpoint_rate_limit_dps: 3.0
anti_windup_limit: 8.0
adaptive_enabled: false
adaptive_max_deviation_pct: 50.0
+74
View File
@@ -0,0 +1,74 @@
"""Filesystem loader for the seed library.
Resolves paths inside the installed ``arautopilot.library`` package and
deserialises actuator profiles (JSON) and default PID tunings (YAML).
"""
from __future__ import annotations
import json
from importlib import resources
from pathlib import Path
from typing import Any
import yaml
from arautopilot.core.actuator_config import ActuatorConfig
from arautopilot.core.pid_config import PidConfig
_ACTUATOR_PACKAGE = "arautopilot.library.actuators"
_TUNINGS_PACKAGE = "arautopilot.library.default_tunings"
def list_actuator_profiles() -> list[str]:
"""Return the IDs (filename stems) of all bundled actuator profiles."""
return sorted(
r.name.removesuffix(".json")
for r in resources.files(_ACTUATOR_PACKAGE).iterdir()
if r.is_file() and r.name.endswith(".json")
)
def list_default_tunings() -> list[str]:
"""Return the IDs (filename stems) of all bundled default tunings."""
return sorted(
r.name.removesuffix(".yaml")
for r in resources.files(_TUNINGS_PACKAGE).iterdir()
if r.is_file() and r.name.endswith(".yaml")
)
def load_actuator_profile(profile_id: str) -> ActuatorConfig:
"""Load and validate one bundled actuator profile by ID."""
data = _read_json_resource(_ACTUATOR_PACKAGE, f"{profile_id}.json")
return ActuatorConfig.model_validate(data)
def load_default_tuning(tuning_id: str) -> PidConfig:
"""Load and validate one bundled default PID tuning by ID."""
data = _read_yaml_resource(_TUNINGS_PACKAGE, f"{tuning_id}.yaml")
return PidConfig.model_validate(data)
def load_actuator_profile_from_path(path: Path | str) -> ActuatorConfig:
"""Load an actuator profile from an arbitrary filesystem path (for tests / Studio)."""
p = Path(path)
with p.open("r", encoding="utf-8") as f:
return ActuatorConfig.model_validate(json.load(f))
def load_default_tuning_from_path(path: Path | str) -> PidConfig:
"""Load a default PID tuning from an arbitrary filesystem path (for tests / Studio)."""
p = Path(path)
with p.open("r", encoding="utf-8") as f:
return PidConfig.model_validate(yaml.safe_load(f))
def _read_json_resource(package: str, filename: str) -> dict[str, Any]:
return json.loads(resources.files(package).joinpath(filename).read_text(encoding="utf-8"))
def _read_yaml_resource(package: str, filename: str) -> dict[str, Any]:
return yaml.safe_load(
resources.files(package).joinpath(filename).read_text(encoding="utf-8")
)
+1
View File
@@ -0,0 +1 @@
"""Cross-cutting utilities shared by ``arautopilot.studio`` and tools."""
+5
View File
@@ -0,0 +1,5 @@
"""AR-Autopilot Studio — integrator-side configurator GUI (Sprint 4+).
This subpackage is intentionally empty in Sprint 0. The actual PySide6
application starts in Sprint 4.
"""
+25
View File
@@ -0,0 +1,25 @@
"""Studio application entry point — Sprint 4 stub.
This module is intentionally a stub. The real PySide6 ``QApplication`` and
``MainWindow`` arrive in Sprint 4. Trying to launch the Studio now will
print a friendly notice and exit cleanly.
"""
from __future__ import annotations
import sys
def run() -> int:
"""Stub entry point. Will be replaced by a real ``QApplication`` in Sprint 4."""
print(
"AR-Autopilot Studio — Sprint 0 stub.\n"
"The Studio GUI is implemented starting in Sprint 4.\n"
"For now, use the core API (`arautopilot.core`) and the demo:\n"
" python examples/sprint0_demo.py"
)
return 0
if __name__ == "__main__":
sys.exit(run())
+1
View File
@@ -0,0 +1 @@
"""Project packaging: .appack + signed MSI installer (Sprint 4+)."""
+1
View File
@@ -0,0 +1 @@
"""Per-entity editors: actuator, PID, alarms, vessel (Sprint 4+)."""
+4
View File
@@ -0,0 +1,4 @@
"""Studio main window — Sprint 4 stub.
Reserved namespace for the PySide6 ``QMainWindow`` arriving in Sprint 4.
"""
+1
View File
@@ -0,0 +1 @@
"""Test bench: vessel + actuator + sensor simulators (Sprint 2-3)."""
+1
View File
@@ -0,0 +1 @@
"""Project setup wizards (Sprint 4+)."""
+1
View File
@@ -0,0 +1 @@
"""Sprint 0 test suite for ``arautopilot.core`` and the seed library."""
+70
View File
@@ -0,0 +1,70 @@
"""Shared pytest fixtures for the AR-Autopilot Sprint 0 test suite."""
from __future__ import annotations
import pytest
from arautopilot.core import (
ActuatorConfig,
ActuatorType,
GainSchedulePoint,
PidConfig,
PidGains,
ProjectConfig,
VesselConfig,
VesselType,
)
@pytest.fixture
def hydraulic_actuator() -> ActuatorConfig:
"""A realistic hydraulic reversible actuator config for fixture composition."""
return ActuatorConfig(
type=ActuatorType.HYDRAULIC_REVERSIBLE,
name="Test Hynautic-class pump",
deadband_pct=7.0,
min_useful_pwm_pct=12.0,
asymmetry_stbd_over_port=1.0,
max_rudder_angle_deg=35.0,
max_rate_dps=4.0,
max_current_a=20.0,
feedback_required=True,
)
@pytest.fixture
def basic_pid() -> PidConfig:
"""A minimal valid cascaded PID with a 3-point gain schedule."""
return PidConfig(
inner_loop_base=PidGains(kp=2.5, ki=0.15, kd=0.30),
outer_loop_base=PidGains(kp=0.90, ki=0.02, kd=1.20),
gain_schedule=[
GainSchedulePoint(speed_knots=5.0, gains=PidGains(kp=1.20, ki=0.03, kd=0.80)),
GainSchedulePoint(speed_knots=15.0, gains=PidGains(kp=0.90, ki=0.02, kd=1.20)),
GainSchedulePoint(speed_knots=28.0, gains=PidGains(kp=0.55, ki=0.01, kd=1.80)),
],
rot_feedforward_gain=1.5,
)
@pytest.fixture
def basic_vessel(hydraulic_actuator: ActuatorConfig, basic_pid: PidConfig) -> VesselConfig:
return VesselConfig(
name="M/Y Test 30",
type=VesselType.YACHT_MOTOR_PLANEO,
length_m=30.0,
displacement_t=120.0,
max_speed_kn=28.0,
actuator=hydraulic_actuator,
pid=basic_pid,
)
@pytest.fixture
def basic_project(basic_vessel: VesselConfig) -> ProjectConfig:
return ProjectConfig(
client_name="Test Client S.L.",
project_name="Sprint 0 demo project",
notes="Generated by conftest fixture.",
vessel=basic_vessel,
)
+80
View File
@@ -0,0 +1,80 @@
"""Tests for ``arautopilot.core.actuator_config``."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from arautopilot.core.actuator_config import (
ActuatorConfig,
ActuatorType,
is_phase_1,
)
def test_defaults_load_without_error() -> None:
cfg = ActuatorConfig(type=ActuatorType.HYDRAULIC_REVERSIBLE)
assert cfg.type is ActuatorType.HYDRAULIC_REVERSIBLE
assert 0 < cfg.deadband_pct < 30
assert cfg.max_rudder_angle_deg <= 45
assert cfg.feedback_required is True
def test_phase_1_actuators_are_drivable() -> None:
for t in (
ActuatorType.HYDRAULIC_REVERSIBLE,
ActuatorType.ELECTRIC_DC_REVERSIBLE,
ActuatorType.SERVOMOTOR_FEEDBACK,
ActuatorType.STERNDRIVE_ANALOG,
):
assert is_phase_1(t) is True
cfg = ActuatorConfig(type=t)
assert cfg.is_phase_1_supported() is True
@pytest.mark.parametrize("t", [ActuatorType.VOLVO_IPS, ActuatorType.MERCURY_ZEUS])
def test_phase_3_actuators_are_modelled_but_not_phase_1(t: ActuatorType) -> None:
assert is_phase_1(t) is False
cfg = ActuatorConfig(type=t)
assert cfg.is_phase_1_supported() is False
def test_rejects_max_angle_above_45() -> None:
with pytest.raises(ValidationError):
ActuatorConfig(type=ActuatorType.HYDRAULIC_REVERSIBLE, max_rudder_angle_deg=50.0)
def test_rejects_negative_max_rate() -> None:
with pytest.raises(ValidationError):
ActuatorConfig(type=ActuatorType.HYDRAULIC_REVERSIBLE, max_rate_dps=-1.0)
def test_min_useful_pwm_must_cover_deadband() -> None:
"""If the actuator needs >5 % to overcome static friction, the min useful PWM
cannot be below the deadband that combination would tell the controller
'commands above 5 % move the rudder' while also 'commands below 8 % do
nothing', which is internally inconsistent."""
with pytest.raises(ValidationError):
ActuatorConfig(
type=ActuatorType.HYDRAULIC_REVERSIBLE,
deadband_pct=8.0,
min_useful_pwm_pct=5.0,
)
def test_rejects_unknown_field() -> None:
with pytest.raises(ValidationError):
ActuatorConfig(type=ActuatorType.HYDRAULIC_REVERSIBLE, unknown=True) # type: ignore[call-arg]
def test_asymmetry_bounds() -> None:
with pytest.raises(ValidationError):
ActuatorConfig(
type=ActuatorType.HYDRAULIC_REVERSIBLE,
asymmetry_stbd_over_port=0.1,
)
with pytest.raises(ValidationError):
ActuatorConfig(
type=ActuatorType.HYDRAULIC_REVERSIBLE,
asymmetry_stbd_over_port=5.0,
)
+88
View File
@@ -0,0 +1,88 @@
"""Tests for ``arautopilot.core.alarms``."""
from __future__ import annotations
import pytest
from arautopilot.core.alarms import (
Alarm,
AlarmSeverity,
AlarmType,
default_message,
default_severity,
triggers_auto_disengage,
)
def test_catalogue_covers_every_alarm_type() -> None:
"""The internal catalogue must define metadata for every enum member."""
for at in AlarmType:
# Calling any of the helpers must succeed for every type.
sev = default_severity(at)
msg = default_message(at)
flag = triggers_auto_disengage(at)
assert isinstance(sev, AlarmSeverity)
assert isinstance(msg, str) and msg
assert isinstance(flag, bool)
@pytest.mark.parametrize(
"alarm_type",
[
AlarmType.OFF_COURSE_SEVERE,
AlarmType.RUDDER_NOT_RESPONDING,
AlarmType.HEADING_SENSOR_LOST,
AlarmType.WATCHDOG_TRIPPED,
AlarmType.VMS_CRITICAL,
AlarmType.ACTUATOR_OVERCURRENT,
AlarmType.VOLTAGE_LOW,
],
)
def test_safety_critical_alarms_trigger_auto_disengage(alarm_type: AlarmType) -> None:
"""All EMERGENCY alarms (and HIGH overcurrent/voltage) must auto-disengage."""
assert triggers_auto_disengage(alarm_type) is True
@pytest.mark.parametrize(
"alarm_type",
[AlarmType.OFF_COURSE, AlarmType.LIMIT_SWITCH_REACHED],
)
def test_warning_alarms_do_not_disengage(alarm_type: AlarmType) -> None:
assert triggers_auto_disengage(alarm_type) is False
def test_alarm_from_type_uses_catalogue_defaults() -> None:
a = Alarm.from_type(AlarmType.OFF_COURSE_SEVERE)
assert a.type is AlarmType.OFF_COURSE_SEVERE
assert a.severity is AlarmSeverity.EMERGENCY
assert a.auto_disengage_triggered is True
assert a.message # non-empty
assert a.source == "firmware"
assert a.acknowledged is False
def test_alarm_is_frozen() -> None:
a = Alarm.from_type(AlarmType.OFF_COURSE)
with pytest.raises((TypeError, ValueError)):
a.acknowledged = True # type: ignore[misc]
def test_alarm_rejects_unknown_field() -> None:
with pytest.raises(ValueError):
Alarm(
type=AlarmType.OFF_COURSE,
severity=AlarmSeverity.LOW,
message="hi",
foo="bar", # type: ignore[call-arg]
)
def test_alarm_message_length_limits() -> None:
with pytest.raises(ValueError):
Alarm(type=AlarmType.OFF_COURSE, severity=AlarmSeverity.LOW, message="")
with pytest.raises(ValueError):
Alarm(
type=AlarmType.OFF_COURSE,
severity=AlarmSeverity.LOW,
message="x" * 241,
)
+101
View File
@@ -0,0 +1,101 @@
"""Tests for ``arautopilot.core.knob_state``."""
from __future__ import annotations
import pytest
from arautopilot.core.knob_state import (
KnobFunction,
KnobMode,
KnobState,
)
def test_idle_state_defaults() -> None:
s = KnobState.idle()
assert s.mode is KnobMode.LIBRE
assert s.function is KnobFunction.NONE
assert s.pending_value is None
assert s.armed_at is None
assert s.timeout_remaining_s == 0.0
def test_libre_state_is_immutable() -> None:
s = KnobState.idle()
with pytest.raises((TypeError, ValueError)):
s.mode = KnobMode.ARMADO # type: ignore[misc]
def test_arm_transitions_to_armado() -> None:
s = KnobState.idle().arm(KnobFunction.RUMBO, current_value=180.0)
assert s.mode is KnobMode.ARMADO
assert s.function is KnobFunction.RUMBO
assert s.current_value == pytest.approx(180.0)
assert s.armed_at is not None
assert s.timeout_remaining_s > 0
def test_arming_with_none_function_rejected() -> None:
with pytest.raises(ValueError):
KnobState.idle().arm(KnobFunction.NONE, current_value=0.0)
def test_arming_from_non_libre_rejected() -> None:
armed = KnobState.idle().arm(KnobFunction.RUMBO, current_value=90.0)
with pytest.raises(ValueError):
armed.arm(KnobFunction.BRILLO, current_value=50.0)
def test_propose_then_confirm_round_trip() -> None:
armed = KnobState.idle().arm(KnobFunction.RUMBO, current_value=180.0)
pending = armed.propose(185.0)
assert pending.mode is KnobMode.CONFIRMANDO
assert pending.pending_value == pytest.approx(185.0)
assert pending.current_value == pytest.approx(180.0)
confirmed = pending.confirm()
assert confirmed.mode is KnobMode.ARMADO
assert confirmed.pending_value is None
assert confirmed.current_value == pytest.approx(185.0)
def test_propose_from_libre_rejected() -> None:
with pytest.raises(ValueError):
KnobState.idle().propose(10.0)
def test_confirm_without_pending_rejected() -> None:
armed = KnobState.idle().arm(KnobFunction.RUMBO, current_value=180.0)
with pytest.raises(ValueError):
armed.confirm()
def test_disarm_returns_to_libre() -> None:
s = (
KnobState.idle()
.arm(KnobFunction.RUMBO, current_value=180.0)
.propose(190.0)
.disarm()
)
assert s == KnobState.idle()
def test_libre_with_armed_at_set_invalid() -> None:
from datetime import datetime, timezone
with pytest.raises(ValueError):
KnobState(
mode=KnobMode.LIBRE,
function=KnobFunction.NONE,
armed_at=datetime.now(timezone.utc),
)
def test_armado_without_armed_at_invalid() -> None:
with pytest.raises(ValueError):
KnobState(
mode=KnobMode.ARMADO,
function=KnobFunction.RUMBO,
current_value=180.0,
armed_at=None,
)
+62
View File
@@ -0,0 +1,62 @@
"""Tests for ``arautopilot.library.loader`` and the seed assets shipped in Sprint 0."""
from __future__ import annotations
from arautopilot.core.actuator_config import ActuatorType
from arautopilot.library.loader import (
list_actuator_profiles,
list_default_tunings,
load_actuator_profile,
load_default_tuning,
)
def test_seed_actuators_present() -> None:
profiles = list_actuator_profiles()
assert "hydraulic_reversible" in profiles
assert "electric_dc_reversible" in profiles
assert len(profiles) >= 2
def test_seed_tunings_present() -> None:
tunings = list_default_tunings()
assert "yacht_motor_planeo_30m" in tunings
assert "yacht_motor_planeo_40m" in tunings
assert len(tunings) >= 2
def test_load_hydraulic_actuator_profile() -> None:
cfg = load_actuator_profile("hydraulic_reversible")
assert cfg.type is ActuatorType.HYDRAULIC_REVERSIBLE
assert cfg.feedback_required is True
assert 0 < cfg.deadband_pct < 30
def test_load_electric_dc_actuator_profile() -> None:
cfg = load_actuator_profile("electric_dc_reversible")
assert cfg.type is ActuatorType.ELECTRIC_DC_REVERSIBLE
assert cfg.feedback_required is True
def test_load_30m_tuning_has_three_point_schedule() -> None:
pid = load_default_tuning("yacht_motor_planeo_30m")
assert len(pid.gain_schedule) == 3
# Brief: outer kp should drop as speed goes up.
speeds = [p.speed_knots for p in pid.gain_schedule]
kps = [p.gains.kp for p in pid.gain_schedule]
assert speeds == sorted(speeds)
assert kps[0] > kps[-1]
# Inner loop faster than outer.
assert pid.inner_loop_freq_hz > pid.outer_loop_freq_hz
# Adaptive disabled at ship time.
assert pid.adaptive_enabled is False
def test_load_40m_tuning_is_more_damped_than_30m() -> None:
"""Larger vessel should have higher kd at cruise speed (more anticipation)."""
pid30 = load_default_tuning("yacht_motor_planeo_30m")
pid40 = load_default_tuning("yacht_motor_planeo_40m")
# Cruise-speed point (~15 kn) is the second entry in both schedules.
assert pid40.gain_schedule[1].gains.kd > pid30.gain_schedule[1].gains.kd
# And kp at cruise should not be higher than 30 m.
assert pid40.gain_schedule[1].gains.kp <= pid30.gain_schedule[1].gains.kp
+56
View File
@@ -0,0 +1,56 @@
"""Tests for ``arautopilot.core.modes``."""
from __future__ import annotations
import pytest
from arautopilot.core.modes import AutopilotMode, is_available_in_phase
def test_all_modes_have_string_values() -> None:
"""Every enum member must use a kebab/snake-style string value (for serialisation)."""
for m in AutopilotMode:
assert isinstance(m.value, str)
assert m.value == m.value.lower()
assert " " not in m.value
def test_phase_1_modes_are_exactly_the_five_brief_modes() -> None:
expected = {
AutopilotMode.STANDBY,
AutopilotMode.HEADING_HOLD,
AutopilotMode.TRUE_COURSE,
AutopilotMode.TRACK_KEEPING,
AutopilotMode.DODGE,
}
got = {m for m in AutopilotMode if is_available_in_phase(m, 1)}
assert got == expected
def test_phase_2_modes_include_phase_1_plus_wind_modes() -> None:
phase_2 = {m for m in AutopilotMode if is_available_in_phase(m, 2)}
expected = {
AutopilotMode.STANDBY,
AutopilotMode.HEADING_HOLD,
AutopilotMode.TRUE_COURSE,
AutopilotMode.TRACK_KEEPING,
AutopilotMode.DODGE,
AutopilotMode.APPARENT_WIND,
AutopilotMode.TRUE_WIND,
AutopilotMode.AUTO_TACK,
}
assert phase_2 == expected
@pytest.mark.parametrize(
"mode",
[AutopilotMode.APPARENT_WIND, AutopilotMode.TRUE_WIND, AutopilotMode.AUTO_TACK],
)
def test_wind_modes_disabled_in_phase_1(mode: AutopilotMode) -> None:
assert not is_available_in_phase(mode, 1)
@pytest.mark.parametrize("phase", [0, 3, 99, -1])
def test_unknown_phases_disable_everything(phase: int) -> None:
for m in AutopilotMode:
assert not is_available_in_phase(m, phase)
+130
View File
@@ -0,0 +1,130 @@
"""Tests for ``arautopilot.core.pid_config``."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from arautopilot.core.pid_config import (
AccessLevel,
GainSchedulePoint,
PidConfig,
PidGains,
interpolate_gains,
)
def _base_kwargs(**overrides: object) -> dict[str, object]:
base = {
"inner_loop_base": PidGains(kp=2.5, ki=0.15, kd=0.30),
"outer_loop_base": PidGains(kp=0.90, ki=0.02, kd=1.20),
}
base.update(overrides)
return base
def test_basic_config_validates() -> None:
cfg = PidConfig(**_base_kwargs()) # type: ignore[arg-type]
assert cfg.inner_loop_freq_hz > cfg.outer_loop_freq_hz
assert cfg.adaptive_max_deviation_pct == 50.0
def test_inner_must_be_faster_than_outer() -> None:
with pytest.raises(ValidationError):
PidConfig(**_base_kwargs(inner_loop_freq_hz=10.0, outer_loop_freq_hz=10.0)) # type: ignore[arg-type]
with pytest.raises(ValidationError):
PidConfig(**_base_kwargs(inner_loop_freq_hz=5.0, outer_loop_freq_hz=10.0)) # type: ignore[arg-type]
def test_negative_gains_rejected() -> None:
with pytest.raises(ValidationError):
PidGains(kp=-1.0)
def test_gain_schedule_must_be_sorted_by_speed() -> None:
unsorted = [
GainSchedulePoint(speed_knots=15.0, gains=PidGains(kp=0.9)),
GainSchedulePoint(speed_knots=5.0, gains=PidGains(kp=1.2)),
]
with pytest.raises(ValidationError):
PidConfig(**_base_kwargs(gain_schedule=unsorted)) # type: ignore[arg-type]
def test_gain_schedule_rejects_duplicate_speeds() -> None:
dup = [
GainSchedulePoint(speed_knots=10.0, gains=PidGains(kp=0.9)),
GainSchedulePoint(speed_knots=10.0, gains=PidGains(kp=1.0)),
]
with pytest.raises(ValidationError):
PidConfig(**_base_kwargs(gain_schedule=dup)) # type: ignore[arg-type]
def test_interpolate_at_endpoints() -> None:
schedule = [
GainSchedulePoint(speed_knots=5.0, gains=PidGains(kp=1.20, ki=0.03, kd=0.80)),
GainSchedulePoint(speed_knots=28.0, gains=PidGains(kp=0.55, ki=0.01, kd=1.80)),
]
lo = interpolate_gains(schedule, 5.0)
hi = interpolate_gains(schedule, 28.0)
assert lo == schedule[0].gains
assert hi == schedule[1].gains
def test_interpolate_holds_below_and_above_range() -> None:
schedule = [
GainSchedulePoint(speed_knots=5.0, gains=PidGains(kp=1.20)),
GainSchedulePoint(speed_knots=28.0, gains=PidGains(kp=0.55)),
]
assert interpolate_gains(schedule, 0.0).kp == pytest.approx(1.20)
assert interpolate_gains(schedule, 100.0).kp == pytest.approx(0.55)
def test_interpolate_linear_midpoint() -> None:
schedule = [
GainSchedulePoint(speed_knots=5.0, gains=PidGains(kp=1.00, ki=0.04, kd=0.80)),
GainSchedulePoint(speed_knots=15.0, gains=PidGains(kp=0.50, ki=0.02, kd=1.20)),
]
# Midpoint at 10 kn should be the midpoint of every gain.
mid = interpolate_gains(schedule, 10.0)
assert mid.kp == pytest.approx(0.75)
assert mid.ki == pytest.approx(0.03)
assert mid.kd == pytest.approx(1.00)
def test_interpolate_empty_raises() -> None:
with pytest.raises(ValueError):
interpolate_gains([], 10.0)
def test_adaptive_bound_enforces_50_percent_envelope() -> None:
"""Brief section 6: 'ganancias adaptativas nunca salen de ±50% respecto a las base'."""
cfg = PidConfig(**_base_kwargs(adaptive_max_deviation_pct=50.0)) # type: ignore[arg-type]
base = cfg.outer_loop_base # kp=0.90, ki=0.02, kd=1.20
# Within bound
assert cfg.is_within_adaptive_bound(PidGains(kp=base.kp * 1.49, ki=base.ki, kd=base.kd))
assert cfg.is_within_adaptive_bound(PidGains(kp=base.kp * 0.51, ki=base.ki, kd=base.kd))
# Outside bound
assert not cfg.is_within_adaptive_bound(PidGains(kp=base.kp * 1.51, ki=base.ki, kd=base.kd))
assert not cfg.is_within_adaptive_bound(PidGains(kp=base.kp * 0.49, ki=base.ki, kd=base.kd))
def test_adaptive_bound_with_zero_base_requires_zero_candidate() -> None:
cfg = PidConfig(
**_base_kwargs( # type: ignore[arg-type]
outer_loop_base=PidGains(kp=1.0, ki=0.0, kd=0.0),
)
)
assert cfg.is_within_adaptive_bound(PidGains(kp=1.0, ki=0.0, kd=0.0))
assert not cfg.is_within_adaptive_bound(PidGains(kp=1.0, ki=0.01, kd=0.0))
def test_adaptive_max_deviation_capped_at_50() -> None:
# Brief says ±50% is the hard ceiling. The model should refuse higher.
with pytest.raises(ValidationError):
PidConfig(**_base_kwargs(adaptive_max_deviation_pct=60.0)) # type: ignore[arg-type]
def test_access_level_enum_has_three_levels() -> None:
assert {a.value for a in AccessLevel} == {"operator", "technician", "integrator"}
+73
View File
@@ -0,0 +1,73 @@
"""Tests for ``arautopilot.core.project_config``."""
from __future__ import annotations
from pathlib import Path
import pytest
from pydantic import ValidationError
from arautopilot.core import ProjectConfig
def test_project_has_required_fields(basic_project: ProjectConfig) -> None:
assert basic_project.project_id
assert basic_project.client_name
assert basic_project.project_name
assert basic_project.vessel is not None
assert basic_project.schema_version == "0.1.0"
def test_to_dict_is_json_safe(basic_project: ProjectConfig) -> None:
import json
d = basic_project.to_dict()
# json.dumps would raise on raw datetimes; model_dump(mode="json") avoids that.
encoded = json.dumps(d)
assert "project_id" in encoded
def test_round_trip_json(basic_project: ProjectConfig) -> None:
text = basic_project.to_json()
rebuilt = ProjectConfig.from_json(text)
assert rebuilt == basic_project
def test_round_trip_yaml(basic_project: ProjectConfig) -> None:
text = basic_project.to_yaml()
rebuilt = ProjectConfig.from_yaml(text)
assert rebuilt == basic_project
def test_save_and_load_yaml(tmp_path: Path, basic_project: ProjectConfig) -> None:
out = tmp_path / "project.yaml"
basic_project.save_yaml(out)
rebuilt = ProjectConfig.load(out)
assert rebuilt == basic_project
def test_save_and_load_json(tmp_path: Path, basic_project: ProjectConfig) -> None:
out = tmp_path / "project.json"
basic_project.save_json(out)
rebuilt = ProjectConfig.load(out)
assert rebuilt == basic_project
def test_load_rejects_unknown_extension(tmp_path: Path, basic_project: ProjectConfig) -> None:
bogus = tmp_path / "project.toml"
bogus.write_text("# nothing", encoding="utf-8")
with pytest.raises(ValueError):
ProjectConfig.load(bogus)
def test_rejects_unknown_top_level_field(basic_project: ProjectConfig) -> None:
d = basic_project.to_dict()
d["surprise"] = "value"
with pytest.raises(ValidationError):
ProjectConfig.from_dict(d)
def test_touch_updates_modified_at(basic_project: ProjectConfig) -> None:
original = basic_project.modified_at
bumped = basic_project.touch()
assert bumped.modified_at >= original
assert bumped.project_id == basic_project.project_id
+80
View File
@@ -0,0 +1,80 @@
"""End-to-end roundtrip test — Sprint 0 acceptance criterion.
This is the test the brief calls out by name (section 12, "Criterio de
aceptación"): build a project programmatically, persist it, reload it,
and verify the reloaded object matches the original exactly.
"""
from __future__ import annotations
from pathlib import Path
from arautopilot.core import (
ActuatorConfig,
ActuatorType,
ProjectConfig,
VesselConfig,
VesselType,
)
from arautopilot.library.loader import (
load_actuator_profile,
load_default_tuning,
)
def test_full_roundtrip_using_seed_library(tmp_path: Path) -> None:
# 1. Assemble a project using the seed library
actuator: ActuatorConfig = load_actuator_profile("hydraulic_reversible")
pid = load_default_tuning("yacht_motor_planeo_30m")
vessel = VesselConfig(
name="M/Y Sprint-0",
type=VesselType.YACHT_MOTOR_PLANEO,
length_m=30.0,
displacement_t=125.0,
max_speed_kn=28.0,
actuator=actuator,
pid=pid,
)
project = ProjectConfig(
client_name="Acceptance Test Client",
project_name="Sprint 0 Roundtrip",
notes="Demonstrates the brief's acceptance criterion.",
vessel=vessel,
)
# 2. Save to YAML and JSON
yaml_path = tmp_path / "project.yaml"
json_path = tmp_path / "project.json"
project.save_yaml(yaml_path)
project.save_json(json_path)
assert yaml_path.exists() and yaml_path.stat().st_size > 0
assert json_path.exists() and json_path.stat().st_size > 0
# 3. Reload both and verify exact equality
from_yaml = ProjectConfig.load(yaml_path)
from_json = ProjectConfig.load(json_path)
assert from_yaml == project
assert from_json == project
# Critical structural invariants survive serialisation
assert from_yaml.vessel.actuator.type is ActuatorType.HYDRAULIC_REVERSIBLE
assert from_yaml.vessel.pid.inner_loop_freq_hz > from_yaml.vessel.pid.outer_loop_freq_hz
assert len(from_yaml.vessel.pid.gain_schedule) == 3
def test_roundtrip_preserves_ids(tmp_path: Path, basic_project: ProjectConfig) -> None:
p = tmp_path / "p.yaml"
basic_project.save_yaml(p)
rebuilt = ProjectConfig.load(p)
assert rebuilt.project_id == basic_project.project_id
assert rebuilt.vessel.vessel_id == basic_project.vessel.vessel_id
def test_roundtrip_preserves_timestamps(tmp_path: Path, basic_project: ProjectConfig) -> None:
p = tmp_path / "p.json"
basic_project.save_json(p)
rebuilt = ProjectConfig.load(p)
assert rebuilt.created_at == basic_project.created_at
assert rebuilt.modified_at == basic_project.modified_at
+87
View File
@@ -0,0 +1,87 @@
"""Tests for ``arautopilot.core.vessel_config``."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from arautopilot.core.actuator_config import ActuatorConfig, ActuatorType
from arautopilot.core.pid_config import PidConfig, PidGains
from arautopilot.core.vessel_config import VesselConfig, VesselType
def _pid() -> PidConfig:
return PidConfig(
inner_loop_base=PidGains(kp=2.0),
outer_loop_base=PidGains(kp=1.0),
)
def test_vessel_composes_actuator_and_pid() -> None:
v = VesselConfig(
name="Test 30",
type=VesselType.YACHT_MOTOR_PLANEO,
length_m=30.0,
max_speed_kn=28.0,
actuator=ActuatorConfig(type=ActuatorType.HYDRAULIC_REVERSIBLE),
pid=_pid(),
)
assert v.actuator.type is ActuatorType.HYDRAULIC_REVERSIBLE
assert v.pid.inner_loop_base.kp == pytest.approx(2.0)
assert isinstance(v.vessel_id, str) and len(v.vessel_id) > 0
def test_vessel_id_is_unique_across_instances() -> None:
v1 = VesselConfig(
name="A",
type=VesselType.YACHT_MOTOR_PLANEO,
length_m=30.0,
max_speed_kn=20.0,
actuator=ActuatorConfig(type=ActuatorType.HYDRAULIC_REVERSIBLE),
pid=_pid(),
)
v2 = VesselConfig(
name="B",
type=VesselType.YACHT_MOTOR_PLANEO,
length_m=30.0,
max_speed_kn=20.0,
actuator=ActuatorConfig(type=ActuatorType.HYDRAULIC_REVERSIBLE),
pid=_pid(),
)
assert v1.vessel_id != v2.vessel_id
def test_rejects_zero_length() -> None:
with pytest.raises(ValidationError):
VesselConfig(
name="X",
type=VesselType.YACHT_MOTOR_PLANEO,
length_m=0.0,
max_speed_kn=20.0,
actuator=ActuatorConfig(type=ActuatorType.HYDRAULIC_REVERSIBLE),
pid=_pid(),
)
def test_rejects_blank_name() -> None:
with pytest.raises(ValidationError):
VesselConfig(
name="",
type=VesselType.YACHT_MOTOR_PLANEO,
length_m=30.0,
max_speed_kn=20.0,
actuator=ActuatorConfig(type=ActuatorType.HYDRAULIC_REVERSIBLE),
pid=_pid(),
)
def test_vessel_type_covers_brief_categories() -> None:
expected = {
"yacht_motor_planeo",
"yacht_motor_desplazamiento",
"sailboat_motor",
"fishing_boat",
"small_ferry",
"patrol_boat",
}
assert {t.value for t in VesselType} == expected
+6
View File
@@ -0,0 +1,6 @@
"""Single source of truth for the package version.
Keep in sync with ``pyproject.toml`` ``[project].version``.
"""
__version__ = "0.1.0"