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 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user