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:
@@ -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__"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)})
|
||||
@@ -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
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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")
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Cross-cutting utilities shared by ``arautopilot.studio`` and tools."""
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
"""Project packaging: .appack + signed MSI installer (Sprint 4+)."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Per-entity editors: actuator, PID, alarms, vessel (Sprint 4+)."""
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Studio main window — Sprint 4 stub.
|
||||
|
||||
Reserved namespace for the PySide6 ``QMainWindow`` arriving in Sprint 4.
|
||||
"""
|
||||
@@ -0,0 +1 @@
|
||||
"""Test bench: vessel + actuator + sensor simulators (Sprint 2-3)."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Project setup wizards (Sprint 4+)."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Sprint 0 test suite for ``arautopilot.core`` and the seed library."""
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Single source of truth for the package version.
|
||||
|
||||
Keep in sync with ``pyproject.toml`` ``[project].version``.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
Reference in New Issue
Block a user