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
+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,
)