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