From 700756c16fc01b65c80b598bc8f6995e3dd6737b Mon Sep 17 00:00:00 2001 From: alro1965 Date: Sun, 17 May 2026 23:57:18 -0400 Subject: [PATCH] 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) --- .gitignore | 139 +++ CHANGELOG.md | 44 + LICENSE.txt | 58 ++ README.md | 100 ++ arautopilot/__init__.py | 18 + arautopilot/core/__init__.py | 88 ++ arautopilot/core/actuator_config.py | 140 +++ arautopilot/core/alarms.py | 116 +++ arautopilot/core/ids.py | 27 + arautopilot/core/knob_state.py | 162 ++++ arautopilot/core/modes.py | 76 ++ arautopilot/core/pid_config.py | 197 ++++ arautopilot/core/project_config.py | 103 +++ arautopilot/core/vessel_config.py | 62 ++ arautopilot/library/__init__.py | 7 + arautopilot/library/_schemas/README.md | 19 + .../actuators/electric_dc_reversible.json | 11 + .../actuators/hydraulic_reversible.json | 11 + .../yacht_motor_planeo_30m.yaml | 58 ++ .../yacht_motor_planeo_40m.yaml | 45 + arautopilot/library/loader.py | 74 ++ arautopilot/library/vessel_profiles/.gitkeep | 0 arautopilot/shared/__init__.py | 1 + arautopilot/studio/__init__.py | 5 + arautopilot/studio/app.py | 25 + arautopilot/studio/compiler/__init__.py | 1 + arautopilot/studio/editors/__init__.py | 1 + arautopilot/studio/main_window.py | 4 + arautopilot/studio/simulator/__init__.py | 1 + arautopilot/studio/wizards/__init__.py | 1 + arautopilot/tests/__init__.py | 1 + arautopilot/tests/conftest.py | 70 ++ arautopilot/tests/test_actuator_config.py | 80 ++ arautopilot/tests/test_alarms.py | 88 ++ arautopilot/tests/test_knob_state.py | 101 ++ arautopilot/tests/test_library_loader.py | 62 ++ arautopilot/tests/test_modes.py | 56 ++ arautopilot/tests/test_pid_config.py | 130 +++ arautopilot/tests/test_project_config.py | 73 ++ arautopilot/tests/test_roundtrip.py | 80 ++ arautopilot/tests/test_vessel_config.py | 87 ++ arautopilot/version.py | 6 + display/.gitkeep | 0 docs/AR_Autopilot_brief.md | 861 ++++++++++++++++++ docs/architecture.md | 137 +++ examples/sprint0_demo.py | 128 +++ firmware/ar_autopilot_v1/src/hal/pinout.h | 112 +++ firmware/ar_autopilot_v1/test/.gitkeep | 0 installer/.gitkeep | 0 pyproject.toml | 160 ++++ requirements-dev.txt | 9 + requirements.txt | 6 + studio_main.py | 14 + tools/.gitkeep | 0 54 files changed, 3855 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 arautopilot/__init__.py create mode 100644 arautopilot/core/__init__.py create mode 100644 arautopilot/core/actuator_config.py create mode 100644 arautopilot/core/alarms.py create mode 100644 arautopilot/core/ids.py create mode 100644 arautopilot/core/knob_state.py create mode 100644 arautopilot/core/modes.py create mode 100644 arautopilot/core/pid_config.py create mode 100644 arautopilot/core/project_config.py create mode 100644 arautopilot/core/vessel_config.py create mode 100644 arautopilot/library/__init__.py create mode 100644 arautopilot/library/_schemas/README.md create mode 100644 arautopilot/library/actuators/electric_dc_reversible.json create mode 100644 arautopilot/library/actuators/hydraulic_reversible.json create mode 100644 arautopilot/library/default_tunings/yacht_motor_planeo_30m.yaml create mode 100644 arautopilot/library/default_tunings/yacht_motor_planeo_40m.yaml create mode 100644 arautopilot/library/loader.py create mode 100644 arautopilot/library/vessel_profiles/.gitkeep create mode 100644 arautopilot/shared/__init__.py create mode 100644 arautopilot/studio/__init__.py create mode 100644 arautopilot/studio/app.py create mode 100644 arautopilot/studio/compiler/__init__.py create mode 100644 arautopilot/studio/editors/__init__.py create mode 100644 arautopilot/studio/main_window.py create mode 100644 arautopilot/studio/simulator/__init__.py create mode 100644 arautopilot/studio/wizards/__init__.py create mode 100644 arautopilot/tests/__init__.py create mode 100644 arautopilot/tests/conftest.py create mode 100644 arautopilot/tests/test_actuator_config.py create mode 100644 arautopilot/tests/test_alarms.py create mode 100644 arautopilot/tests/test_knob_state.py create mode 100644 arautopilot/tests/test_library_loader.py create mode 100644 arautopilot/tests/test_modes.py create mode 100644 arautopilot/tests/test_pid_config.py create mode 100644 arautopilot/tests/test_project_config.py create mode 100644 arautopilot/tests/test_roundtrip.py create mode 100644 arautopilot/tests/test_vessel_config.py create mode 100644 arautopilot/version.py create mode 100644 display/.gitkeep create mode 100644 docs/AR_Autopilot_brief.md create mode 100644 docs/architecture.md create mode 100644 examples/sprint0_demo.py create mode 100644 firmware/ar_autopilot_v1/src/hal/pinout.h create mode 100644 firmware/ar_autopilot_v1/test/.gitkeep create mode 100644 installer/.gitkeep create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 studio_main.py create mode 100644 tools/.gitkeep diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21499b8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,139 @@ +# ============================================================================ +# AR-Autopilot — .gitignore +# ============================================================================ + +# ---------------------------------------------------------------------------- +# Python +# ---------------------------------------------------------------------------- +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ +.env + +# pytest / coverage / mypy / ruff +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ +.tox/ +.nox/ +coverage.xml +*.cover +.hypothesis/ +.mypy_cache/ +.dmypy.json +dmypy.json +.ruff_cache/ + +# Jupyter +.ipynb_checkpoints/ + +# ---------------------------------------------------------------------------- +# Flutter / Dart (display app) +# ---------------------------------------------------------------------------- +display/.dart_tool/ +display/.flutter-plugins +display/.flutter-plugins-dependencies +display/.packages +display/.pub-cache/ +display/.pub/ +display/build/ +display/**/build/ +display/**/.dart_tool/ +display/**/.idea/ +display/**/*.iml +display/ios/Pods/ +display/ios/.symlinks/ +display/android/.gradle/ +display/android/local.properties +display/android/captures/ +display/android/gradlew +display/android/gradlew.bat +display/android/gradle-wrapper.jar +display/windows/flutter/ephemeral/ +display/linux/flutter/ephemeral/ +display/macos/Flutter/ephemeral/ + +# ---------------------------------------------------------------------------- +# PlatformIO / ESP32 firmware +# ---------------------------------------------------------------------------- +firmware/**/.pio/ +firmware/**/.pioenvs/ +firmware/**/.piolibdeps/ +firmware/**/.vscode/ +firmware/**/.clang_complete +firmware/**/.gcc-flags.json + +# ---------------------------------------------------------------------------- +# IDEs / Editors +# ---------------------------------------------------------------------------- +.idea/ +.vscode/ +*.swp +*.swo +*~ +.project +.pydevproject +.settings/ + +# ---------------------------------------------------------------------------- +# OS files +# ---------------------------------------------------------------------------- +.DS_Store +Thumbs.db +Desktop.ini +*.lnk + +# ---------------------------------------------------------------------------- +# Build artifacts / installers +# ---------------------------------------------------------------------------- +installer/output/ +installer/build/ +*.msi +*.exe +*.appack + +# ---------------------------------------------------------------------------- +# Local config / secrets +# ---------------------------------------------------------------------------- +*.local.yaml +*.local.json +secrets/ +.env.local +.secrets/ + +# ---------------------------------------------------------------------------- +# Examples output +# ---------------------------------------------------------------------------- +examples/output/ + +# ---------------------------------------------------------------------------- +# Logs +# ---------------------------------------------------------------------------- +*.log +logs/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c217abf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,44 @@ +# Changelog + +All notable changes to AR-Autopilot will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +--- + +## [Unreleased] + +## [0.1.0] — Sprint 0 — Foundations — 2026-05-17 + +### Added + +- Repository structure following the layout defined in the project brief (section 11) +- Python package skeleton `arautopilot` with submodules: + - `core/` — data model (Pydantic v2): modes, alarms, actuator config, PID config, + vessel config, knob state, project config, IDs + - `library/` — curated seed: 2 actuator profiles (hydraulic reversible, + electric DC reversible) and 2 default tunings (yacht motor planeo 30 m, 40 m) + - `studio/` — empty stubs for Sprint 4 + - `tests/` — pytest suite covering the core data model +- Firmware skeleton: `firmware/ar_autopilot_v1/src/hal/pinout.h` only — + 21 I/O assignment for the AR-NMEA-IO v1.0 board, no functional code yet +- Build configuration: + - `pyproject.toml` with Pydantic v2, PyYAML, python-dateutil + - Dev dependencies: pytest, pytest-cov, ruff, mypy + - Ruff + mypy strict configuration +- `examples/sprint0_demo.py` — end-to-end project creation, save, and reload +- Documentation moved/created: + - `docs/AR_Autopilot_brief.md` — full project brief + - `docs/architecture.md` — one-page architecture overview +- `LICENSE.txt` — Proprietary, all rights reserved +- `.gitignore` covering Python, Flutter, PlatformIO, IDEs, Windows artifacts + +### Notes + +- **No functional firmware**, Studio GUI, or display in this sprint — those + start in Sprint 1, 4, and 4 respectively. +- The seed PID tunings are **conservative starting values** drawn from + classical marine control literature (Fossen, Perez). They are explicitly + **not** the integrator's affinated production values, which remain IP. +- Python ≥3.11 required. diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..2a3a6ce --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,58 @@ +AR-Autopilot +Copyright (c) 2026 Alvaro Romero. All Rights Reserved. + +PROPRIETARY AND CONFIDENTIAL — NOT FOR REDISTRIBUTION +================================================================================ + +This software, including but not limited to its source code, firmware, +configuration files, default tuning parameters, schematics, documentation, +and any accompanying assets (collectively, the "Software"), is the exclusive +property of Alvaro Romero ("the Author") and is protected by international +copyright law and treaty provisions. + +PERMITTED USE +-------------------------------------------------------------------------------- +No license, express or implied, is granted to any person or entity to: + + (a) use, copy, modify, merge, publish, distribute, sublicense, or sell + copies of the Software, in whole or in part; + + (b) reverse-engineer, decompile, disassemble, or otherwise attempt to + derive the source code from compiled binaries or firmware images; + + (c) extract, reuse, or redistribute the default PID tuning parameters, + gain schedules, actuator profiles, vessel profiles, or any other + proprietary parameter set bundled with the Software, which constitute + trade secrets of the Author; + + (d) use the Software, or any derivative thereof, for the development of + competing autopilot, dynamic positioning, or vessel control products; + +except under the terms of a separate written commercial license agreement +signed by the Author. + +COMMERCIAL LICENSING +-------------------------------------------------------------------------------- +Commercial deployment of the Software on board a vessel requires a per-vessel +license bound to the unique hardware identifier (HWID) of the installation, +issued by the Author. Contact the Author for licensing inquiries. + +SAFETY-CRITICAL DISCLAIMER +-------------------------------------------------------------------------------- +THE SOFTWARE CONTROLS VESSEL STEERING SYSTEMS AND IS SAFETY-CRITICAL. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES, INJURY, LOSS OF LIFE, LOSS OF +VESSEL, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. + +Installation, commissioning, and operation of the Software must comply with +the relevant maritime regulations and standards applicable to the vessel and +its area of operation, including but not limited to ISO 11674, ISO 16329, and +IMO MSC.64(67). + +================================================================================ +For licensing inquiries: alro65@gmail.com +================================================================================ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a115df --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# AR-Autopilot + +Professional marine autopilot for vessels in the 30-40 m range (motor yachts, motor sailboats, fishing vessels, small ferries, coastal patrol boats). + +Part of the **AR Suite** alongside AR-ECDIS, VMS-Sailor, AR-ShipDesign, AR-ElecArrangement, and AR-StabCol. Sold standalone or bundled with AR-ECDIS. + +> **NOT** Dynamic Positioning. **NOT** joystick docking. This is a classic heading-and-track autopilot with intelligent drift compensation, controlling rudder actuators (hydraulic or electric). + +--- + +## Status + +**Sprint 0 — Foundations (in progress).** + +This sprint delivers the repository structure, core data model, seed library, and a passing test suite. No functional firmware, Studio GUI, or display yet — those start in Sprint 1. + +See [`docs/AR_Autopilot_brief.md`](docs/AR_Autopilot_brief.md) for the complete project brief, scope, and roadmap. + +--- + +## Components + +| Component | Tech | Purpose | +|---|---|---| +| **Studio** (`arautopilot/studio/`) | Python 3.11 + PySide6 | Project configurator (integrator-side, not shipped to customers). Generates per-vessel `.appack` packages | +| **Firmware** (`firmware/ar_autopilot_v1/`) | C++ on ESP32 via PlatformIO | Real-time PID control, NMEA 2000 + Modbus, safety logic. Runs on the AR-NMEA-IO v1.0 board (shared with VMS-Sailor) | +| **Display** (`display/`) | Flutter Desktop (Win + Linux) | Dedicated bridge cockpit-feel touch display with rotary knob input | +| **Core models** (`arautopilot/core/`) | Pydantic v2 | Shared data model (vessel config, PID config, actuator config, alarms, modes, knob state) | +| **Library** (`arautopilot/library/`) | YAML + JSON | Curated seed: actuator profiles, default tunings per vessel type | + +--- + +## Requirements + +- Python **3.11** or newer +- Git +- (Later sprints) PlatformIO, Flutter SDK, WiX Toolset + +--- + +## Quick start (Sprint 0) + +```powershell +# Create venv and install +python -m venv .venv +.\.venv\Scripts\Activate.ps1 +python -m pip install -U pip +pip install -e ".[dev]" + +# Run tests +pytest + +# Run the Sprint 0 demo (creates, saves, reloads a project config) +python examples/sprint0_demo.py +``` + +--- + +## Repository layout + +``` +AR-Autopilot/ +├── arautopilot/ # Python package (core models, library, studio stubs, tests) +├── firmware/ # ESP32 firmware (Sprint 1+; only pinout.h in Sprint 0) +├── display/ # Flutter dedicated display (Sprint 4+) +├── examples/ # Runnable demos +├── docs/ # Brief + per-sprint design docs +├── installer/ # WiX MSI scripts (later) +└── tools/ # Helper scripts (later) +``` + +See [`docs/architecture.md`](docs/architecture.md) for a one-page architecture overview. + +--- + +## Sprint roadmap + +| Sprint | Focus | +|---|---| +| **0** | Foundations: repo structure, core data model, seed library, tests | +| 1 | Firmware base (I/O, Modbus, NMEA 2000 read, STANDBY mode) | +| 2 | PID inner loop (rudder position control) | +| 3 | PID outer loop + Heading Hold (with ROT feed-forward & gain scheduling) | +| 4 | Studio + basic dedicated display | +| 5 | True Course + Track Keeping (smooth XTE correction) | +| 6 | Safety, alarms, NMEA 2000 publish, VMS alarm consumption | +| 7 | Knob + commissioning + offline auto-tuning | +| 8 | EKF + adaptive tuning + telemetry + VPN | +| 9 | Hardening + integrated testing | +| 10+ | Phase 2 (wind modes for sailboats) and beyond | + +Full detail in the brief. + +--- + +## License + +Proprietary. All rights reserved. See [`LICENSE.txt`](LICENSE.txt). + +Commercial deployment requires a per-vessel license bound to the installation HWID. Contact for licensing. diff --git a/arautopilot/__init__.py b/arautopilot/__init__.py new file mode 100644 index 0000000..97a808f --- /dev/null +++ b/arautopilot/__init__.py @@ -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__"] diff --git a/arautopilot/core/__init__.py b/arautopilot/core/__init__.py new file mode 100644 index 0000000..135f09f --- /dev/null +++ b/arautopilot/core/__init__.py @@ -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", +] diff --git a/arautopilot/core/actuator_config.py b/arautopilot/core/actuator_config.py new file mode 100644 index 0000000..d2cda42 --- /dev/null +++ b/arautopilot/core/actuator_config.py @@ -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) diff --git a/arautopilot/core/alarms.py b/arautopilot/core/alarms.py new file mode 100644 index 0000000..dc45380 --- /dev/null +++ b/arautopilot/core/alarms.py @@ -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), + ) diff --git a/arautopilot/core/ids.py b/arautopilot/core/ids.py new file mode 100644 index 0000000..3772ec9 --- /dev/null +++ b/arautopilot/core/ids.py @@ -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) diff --git a/arautopilot/core/knob_state.py b/arautopilot/core/knob_state.py new file mode 100644 index 0000000..5d51701 --- /dev/null +++ b/arautopilot/core/knob_state.py @@ -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() diff --git a/arautopilot/core/modes.py b/arautopilot/core/modes.py new file mode 100644 index 0000000..c8158b0 --- /dev/null +++ b/arautopilot/core/modes.py @@ -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 diff --git a/arautopilot/core/pid_config.py b/arautopilot/core/pid_config.py new file mode 100644 index 0000000..ee8c9a3 --- /dev/null +++ b/arautopilot/core/pid_config.py @@ -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") diff --git a/arautopilot/core/project_config.py b/arautopilot/core/project_config.py new file mode 100644 index 0000000..f96663d --- /dev/null +++ b/arautopilot/core/project_config.py @@ -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)}) diff --git a/arautopilot/core/vessel_config.py b/arautopilot/core/vessel_config.py new file mode 100644 index 0000000..e19ac00 --- /dev/null +++ b/arautopilot/core/vessel_config.py @@ -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 diff --git a/arautopilot/library/__init__.py b/arautopilot/library/__init__.py new file mode 100644 index 0000000..4ea4d99 --- /dev/null +++ b/arautopilot/library/__init__.py @@ -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. +""" diff --git a/arautopilot/library/_schemas/README.md b/arautopilot/library/_schemas/README.md new file mode 100644 index 0000000..fc38120 --- /dev/null +++ b/arautopilot/library/_schemas/README.md @@ -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+). diff --git a/arautopilot/library/actuators/electric_dc_reversible.json b/arautopilot/library/actuators/electric_dc_reversible.json new file mode 100644 index 0000000..b5c7368 --- /dev/null +++ b/arautopilot/library/actuators/electric_dc_reversible.json @@ -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 +} diff --git a/arautopilot/library/actuators/hydraulic_reversible.json b/arautopilot/library/actuators/hydraulic_reversible.json new file mode 100644 index 0000000..d2316c8 --- /dev/null +++ b/arautopilot/library/actuators/hydraulic_reversible.json @@ -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 +} diff --git a/arautopilot/library/default_tunings/yacht_motor_planeo_30m.yaml b/arautopilot/library/default_tunings/yacht_motor_planeo_30m.yaml new file mode 100644 index 0000000..690f9ab --- /dev/null +++ b/arautopilot/library/default_tunings/yacht_motor_planeo_30m.yaml @@ -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 diff --git a/arautopilot/library/default_tunings/yacht_motor_planeo_40m.yaml b/arautopilot/library/default_tunings/yacht_motor_planeo_40m.yaml new file mode 100644 index 0000000..43c7c49 --- /dev/null +++ b/arautopilot/library/default_tunings/yacht_motor_planeo_40m.yaml @@ -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 diff --git a/arautopilot/library/loader.py b/arautopilot/library/loader.py new file mode 100644 index 0000000..9cf0f8d --- /dev/null +++ b/arautopilot/library/loader.py @@ -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") + ) diff --git a/arautopilot/library/vessel_profiles/.gitkeep b/arautopilot/library/vessel_profiles/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/arautopilot/shared/__init__.py b/arautopilot/shared/__init__.py new file mode 100644 index 0000000..689b5bb --- /dev/null +++ b/arautopilot/shared/__init__.py @@ -0,0 +1 @@ +"""Cross-cutting utilities shared by ``arautopilot.studio`` and tools.""" diff --git a/arautopilot/studio/__init__.py b/arautopilot/studio/__init__.py new file mode 100644 index 0000000..c33d9d6 --- /dev/null +++ b/arautopilot/studio/__init__.py @@ -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. +""" diff --git a/arautopilot/studio/app.py b/arautopilot/studio/app.py new file mode 100644 index 0000000..76d45cf --- /dev/null +++ b/arautopilot/studio/app.py @@ -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()) diff --git a/arautopilot/studio/compiler/__init__.py b/arautopilot/studio/compiler/__init__.py new file mode 100644 index 0000000..0f4ca7f --- /dev/null +++ b/arautopilot/studio/compiler/__init__.py @@ -0,0 +1 @@ +"""Project packaging: .appack + signed MSI installer (Sprint 4+).""" diff --git a/arautopilot/studio/editors/__init__.py b/arautopilot/studio/editors/__init__.py new file mode 100644 index 0000000..669c836 --- /dev/null +++ b/arautopilot/studio/editors/__init__.py @@ -0,0 +1 @@ +"""Per-entity editors: actuator, PID, alarms, vessel (Sprint 4+).""" diff --git a/arautopilot/studio/main_window.py b/arautopilot/studio/main_window.py new file mode 100644 index 0000000..f413faf --- /dev/null +++ b/arautopilot/studio/main_window.py @@ -0,0 +1,4 @@ +"""Studio main window — Sprint 4 stub. + +Reserved namespace for the PySide6 ``QMainWindow`` arriving in Sprint 4. +""" diff --git a/arautopilot/studio/simulator/__init__.py b/arautopilot/studio/simulator/__init__.py new file mode 100644 index 0000000..941f620 --- /dev/null +++ b/arautopilot/studio/simulator/__init__.py @@ -0,0 +1 @@ +"""Test bench: vessel + actuator + sensor simulators (Sprint 2-3).""" diff --git a/arautopilot/studio/wizards/__init__.py b/arautopilot/studio/wizards/__init__.py new file mode 100644 index 0000000..69a8c46 --- /dev/null +++ b/arautopilot/studio/wizards/__init__.py @@ -0,0 +1 @@ +"""Project setup wizards (Sprint 4+).""" diff --git a/arautopilot/tests/__init__.py b/arautopilot/tests/__init__.py new file mode 100644 index 0000000..87c96e3 --- /dev/null +++ b/arautopilot/tests/__init__.py @@ -0,0 +1 @@ +"""Sprint 0 test suite for ``arautopilot.core`` and the seed library.""" diff --git a/arautopilot/tests/conftest.py b/arautopilot/tests/conftest.py new file mode 100644 index 0000000..8cdf741 --- /dev/null +++ b/arautopilot/tests/conftest.py @@ -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, + ) diff --git a/arautopilot/tests/test_actuator_config.py b/arautopilot/tests/test_actuator_config.py new file mode 100644 index 0000000..10b10f5 --- /dev/null +++ b/arautopilot/tests/test_actuator_config.py @@ -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, + ) diff --git a/arautopilot/tests/test_alarms.py b/arautopilot/tests/test_alarms.py new file mode 100644 index 0000000..662f1fa --- /dev/null +++ b/arautopilot/tests/test_alarms.py @@ -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, + ) diff --git a/arautopilot/tests/test_knob_state.py b/arautopilot/tests/test_knob_state.py new file mode 100644 index 0000000..eebda47 --- /dev/null +++ b/arautopilot/tests/test_knob_state.py @@ -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, + ) diff --git a/arautopilot/tests/test_library_loader.py b/arautopilot/tests/test_library_loader.py new file mode 100644 index 0000000..a1ebf4b --- /dev/null +++ b/arautopilot/tests/test_library_loader.py @@ -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 diff --git a/arautopilot/tests/test_modes.py b/arautopilot/tests/test_modes.py new file mode 100644 index 0000000..05d6dd0 --- /dev/null +++ b/arautopilot/tests/test_modes.py @@ -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) diff --git a/arautopilot/tests/test_pid_config.py b/arautopilot/tests/test_pid_config.py new file mode 100644 index 0000000..4226b0f --- /dev/null +++ b/arautopilot/tests/test_pid_config.py @@ -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"} diff --git a/arautopilot/tests/test_project_config.py b/arautopilot/tests/test_project_config.py new file mode 100644 index 0000000..3c18688 --- /dev/null +++ b/arautopilot/tests/test_project_config.py @@ -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 diff --git a/arautopilot/tests/test_roundtrip.py b/arautopilot/tests/test_roundtrip.py new file mode 100644 index 0000000..409bdad --- /dev/null +++ b/arautopilot/tests/test_roundtrip.py @@ -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 diff --git a/arautopilot/tests/test_vessel_config.py b/arautopilot/tests/test_vessel_config.py new file mode 100644 index 0000000..ec4eceb --- /dev/null +++ b/arautopilot/tests/test_vessel_config.py @@ -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 diff --git a/arautopilot/version.py b/arautopilot/version.py new file mode 100644 index 0000000..b160c50 --- /dev/null +++ b/arautopilot/version.py @@ -0,0 +1,6 @@ +"""Single source of truth for the package version. + +Keep in sync with ``pyproject.toml`` ``[project].version``. +""" + +__version__ = "0.1.0" diff --git a/display/.gitkeep b/display/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/AR_Autopilot_brief.md b/docs/AR_Autopilot_brief.md new file mode 100644 index 0000000..0410d20 --- /dev/null +++ b/docs/AR_Autopilot_brief.md @@ -0,0 +1,861 @@ +# AR-Autopilot · Brief para Claude Code + +## Piloto automático marino — sistema de gobierno con PID adaptativo + +> **Instrucciones para Álvaro (no para Code):** Este es el brief completo del proyecto. A diferencia de VMS-Sailor (6 partes), AR-Autopilot cabe en un solo documento porque es un sistema más acotado (gobierno). Las guías técnicas profundas (tuning PID en campo, derivación matemática del EKF, protocolo NMEA 2000 detallado, design system Flutter completo) se generarán como documentos adicionales cuando lleguemos a los sprints correspondientes. +> +> Abre Claude Code en `D:/Proyectos Software/` y deja que cree la subcarpeta `AR-Autopilot`. No abras Code dentro de `D:/Proyectos Software/AR-Autopilot/` antes de tiempo. + +--- + +## 1. Qué es AR-Autopilot + +Eres mi co-desarrollador senior en Python, Dart y firmware embebido. Vamos a construir **AR-Autopilot**, un piloto automático marino profesional para sistemas de gobierno, dirigido inicialmente a embarcaciones de 30 a 40 metros — yates motor, veleros a motor, pesqueros, ferries pequeños, patrulleros. + +Es **producto independiente** dentro de mi suite (junto a AR-ECDIS, VMS-Sailor, AR-ShipDesign, AR-ElecArrangement, AR-StabCol). Se vende solo o como parte de una oferta integrada con AR-ECDIS. + +**No es DP (Dynamic Positioning).** No es joystick docking. Es un piloto automático clásico de gobierno con compensación inteligente de deriva y seguimiento de ruta — controlando el timón del buque mediante actuadores hidráulicos o eléctricos. + +**Compite con:** Raymarine Evolution, Garmin Reactor, Simrad AP-series, B&G H5000 Pilot, Furuno NavPilot. Apunta a ser **igual o mejor** porque tienes control total del firmware y puedes afinar cosas que productos cerrados no permiten. + +--- + +## 2. Contexto en la suite AR + +| Producto | Rol | +|---|---| +| **AR-ECDIS** | Sistema de navegación con cartografía, GPS, BNO085. Publica datos de navegación al backbone NMEA 2000 | +| **AR-Autopilot** | **Este proyecto.** Controla el sistema de gobierno del buque | +| VMS-Sailor | Monitoreo y control de máquinas. NO interfiere con gobierno | +| AR-ShipDesign, AR-ElecArrangement, AR-StabCol | Diseño naval, sin interacción con gobierno | + +### Relación con AR-ECDIS + +- AR-Autopilot **consume** del backbone NMEA 2000: rumbo del BNO085 (PGN 127250), rate of turn (PGN 127251), posición GPS (PGN 129025/129029), COG/SOG (PGN 129026), waypoints activos (PGN 129284) +- AR-Autopilot **publica** al backbone NMEA 2000: estado del piloto (PGN 127237), ángulo de timón comandado y actual (PGN 127245), heading deseado +- Si el ECDIS no está instalado, el piloto puede conectarse a otras fuentes NMEA 2000 estándar (Furuno, Garmin, Raymarine) + +### Relación con VMS-Sailor + +- No comparten control. Son independientes +- El VMS puede *leer* el estado del piloto desde el backbone NMEA 2000 (solo visualización, no control) +- El piloto puede *leer* alarmas críticas del VMS (blackout, sobrecarga eléctrica) y **desengancharse automáticamente** como medida de seguridad + +--- + +## 3. Alcance por fases + +### Fase 1 — Lanzamiento (este desarrollo) + +**Embarcaciones objetivo:** 30-40 m yates motor, veleros a motor (con motor activo, sin vela), pesqueros, ferries pequeños, patrulleros costeros. + +**Modos de operación:** + +1. **Standby** — piloto desenganchado, timón manual +2. **Heading Hold** — mantiene rumbo magnético/true compass fijo. El operador ajusta el rumbo deseado con knob físico o controles en pantalla +3. **True Course Hold (TC)** — mantiene **COG** (course over ground) respecto al norte geográfico, compensando deriva por corriente o viento. Más profesional que Heading Hold para navegación real +4. **Track Keeping** — sigue una ruta de waypoints del ECDIS. Calcula y corrige XTE (cross-track error) usando TC internamente. Incluye **compensación suave automática de XTE** (S-curve approach) +5. **Dodge** — desvío temporal sin perder la ruta (esquivar otro barco, objeto en el agua). Vuelve a la ruta automáticamente cuando se libera + +**Tipos de actuador soportados (configurables):** + +- Bomba hidráulica reversible (Hynautic, Hypro, Octopus, Vetus, Lecomble & Schmitt) — más común en 30-40 m +- Motor eléctrico DC reversible con final de carrera (Lewmar, Simpson Lawrence) +- Servomotor con feedback de posición +- Sterndrive direccional analógico +- **NO incluidos en Fase 1**: Volvo IPS, Mercury Zeus, sistemas propietarios con CAN bus específico (requieren licencia y módulo de interfaz adicional) + +**Lazo cerrado obligatorio:** sensor de posición de timón (rudder angle indicator) realimenta al PID interno. Sin feedback no se opera (modo "open loop" solo disponible si el integrador lo habilita explícitamente con override consciente). + +### Fase 2 — Futuro (no en alcance inicial) + +Para veleros propiamente dichos con vela activa: + +- **Vane Mode (Apparent Wind Angle)** — mantiene ángulo aparente del viento constante +- **True Wind Mode** — mantiene ángulo real del viento (compensa COG y SOG) +- **Auto-tack** — vira automáticamente al alcanzar cierto ángulo + +Estos modos aparecerán **grisados/deshabilitados** en la UI desde Fase 1 (con etiqueta "Fase 2") para que el cliente vea que están planeados sin rediseñar después. + +### Fase 3 — Más futuro + +- Integración con sterndrive electrónicos propietarios (Volvo IPS via EVC, Mercury Zeus via SmartCraft) +- Auto-tuning avanzado adaptativo +- Modo crucero con optimización de consumo + +--- + +## 4. Componentes del producto + +### 4.1 AR-Autopilot Controller (hardware en cabina técnica) + +Usa tu tarjeta **AR-NMEA-IO-v1.0** (la misma que VMS-Sailor: ESP32-DOWD, 21 I/O, RS485 + NMEA 2000 + WiFi). + +**Asignación típica de puertos para piloto motor 30-40m:** + +| Puerto | Función | +|---|---| +| AI1 | Sensor posición timón (potenciómetro o Hall, 0-10V o 4-20mA) | +| AI2 | Voltaje batería del sistema | +| AI3 | Corriente bomba/motor timón (shunt o sensor Hall) — protección sobrecarga | +| AI4 | Reserva (futuro: presión hidráulica) | +| DI1 | Pulsador físico "Engage / Disengage" en consola | +| DI2 | Final de carrera babor (límite mecánico timón) | +| DI3 | Final de carrera estribor | +| DI4 | Alarma externa (blackout VMS, alarma genset crítica) | +| DI5 | Confirmación manual (override emergencia) | +| RPM1 | Reserva (futuro: sensor rotación si aplica) | +| DO1 | Comando bomba/motor a babor (sentido) | +| DO2 | Comando bomba/motor a estribor (sentido) | +| DO3 | Habilita potencia al actuador (relé maestro) | +| DO4 | Alarma sonora | +| DO5 | Indicador "piloto enganchado" (lámpara consola) | +| DO6-DO10 | Reserva | + +**Comunicación:** +- **NMEA 2000 activo**: publica estado del piloto al backbone, consume heading/posición del backbone +- **RS485 Modbus activo**: comunica con el monitor dedicado del puente +- **WiFi**: solo para OTA del firmware, no para operación + +### 4.2 AR-Autopilot Display (monitor dedicado en consola del puente) + +Pantalla táctil dedicada exclusivamente al piloto — no comparte con ECDIS ni VMS. + +**Hardware:** +- PC industrial pequeño tipo Mini-PC fanless o Raspberry Pi 5 con encapsulado marino +- Pantalla táctil 10-12" capacitiva, brillo alto para uso bajo sol (≥1000 nits) +- Resistente a salpicaduras (IP54 mínimo) + +**Periféricos en consola:** +- **Trackball industrial USB** (puntero, navegar menús): NSI Cortron CTM, Logitech Trackman, o similar marítimo +- **Knob rotativo con push button** para ajustar rumbo y otros valores: + - Especificación funcional: encoder rotativo de **montaje en panel** con tuerca pasante, 24 detents, push button axial integrado, eje 6 mm, IP65 deseable + - Modelo concreto a definir al hacer compras (Grayhill, Bourns serie de panel, o encoder + knob aluminio aparte). NO encoder de PCB tipo PEC11R que es muy pequeño + - Conexión: 3 DI de la tarjeta AR-NMEA-IO (A, B, SW) o USB al PC del display +- Botón físico de **emergency disengage** (DI1 de la tarjeta) — siempre desconecta el piloto al instante + +### 4.3 AR-Autopilot Studio (configurador del integrador) + +Aplicación de escritorio en mi PC (similar al VMS-Sailor Studio) para: +- Configurar cada proyecto de piloto que instalo en un cliente +- Definir tipo de actuador, sensores, ganancias PID iniciales, alarmas +- Generar firmware personalizado para la tarjeta AR-NMEA-IO del proyecto +- Generar paquete distribuible `.appack` + instalador MSI + +**Stack:** Python 3.11 + PySide6 (coherente con resto de mi suite). + +Este Studio es **mío**, no se entrega al cliente. + +### 4.4 Firmware AR-NMEA-IO (ESP32) + +Donde corre el **PID en tiempo real**. Esto es lo crítico técnicamente. + +--- + +## 5. UI/UX del Display dedicado + +### Filosofía de diseño + +**Premium cockpit feel.** No genérico. Debe sentirse como una consola profesional, no una app de teléfono ampliada. + +**Stack:** **Flutter Desktop** (coherente con VMS-Sailor Mobile). Renderiza con motor Skia/Impeller propio — performance gráfico superior para indicadores animados en tiempo real. + +### Estética + +- **Tema oscuro premium**: fondo azul-negro profundo tipo cockpit, no negro plano +- **Material 3 + Cupertino** combinados según contexto +- **Glassmorphism**: paneles superpuestos con efecto cristal (blur de fondo + transparencia) +- **Sombras suaves con elevación** en cards y botones grandes +- **Acentos de color por estado:** + - Verde fosforescente: ENGAGED, OK + - Cyan: información, datos en vivo + - Ámbar: warnings, valores pendientes de confirmación + - Rojo: alarmas críticas, acciones peligrosas (disengage) +- **Indicadores circulares animados**: compass rose grande para rumbo, indicador semicircular para timón +- **Tipografía monoespaciada gruesa** para números críticos (rumbo, velocidad, ángulo) +- **Transiciones fluidas** entre cambios de modo y de estado (sin saltos bruscos) +- **Anti-glare mode**: alto contraste para uso bajo sol directo + +### Layout principal + +Tres zonas: + +1. **Zona superior (estado global)**: marca + buque + estado de conexiones NMEA 2000 / GPS / VPN + hora UTC +2. **Zona central (lectura dominante)**: + - **Izquierda (60%)**: compass rose grande con rumbo actual, rumbo deseado (flecha punteada), ángulo de timón visible, métricas (error, ROT, SOG, COG) + - **Derecha (40%)**: selector de modos (5 botones grandes touch-friendly), información de XTE/waypoint/ETA, alarmas activas +3. **Zona inferior (controles inmediatos)**: ajuste fino ±1°/±10°, botón Dodge, botón DISENGAGE grande siempre visible + +### Selección de modos (5 botones) + +| Modo | Estado | Disponibilidad | +|---|---|---| +| Standby | Inicial / desenganchado | Siempre | +| Heading Hold | Mantiene rumbo compass | Fase 1 | +| **True Course** | Compensa deriva, mantiene COG | Fase 1 | +| Track Keeping | Sigue ruta con XTE | Fase 1 | +| Apparent Wind | Modo vela | **Fase 2 - grisado** | + +### Operación del knob físico (filosofía de seguridad) + +El knob **nunca controla nada por defecto**. Requiere armado explícito para prevenir cambios accidentales por bandazo, roce o golpe. + +**Flujo "hundir → seleccionar → girar → hundir confirma":** + +1. **Estado normal**: knob inactivo. Indicador en pantalla: 🔒 "KNOB: LIBRE" en gris +2. **Operador hunde el knob** (push del encoder): + - En pantalla aparece diálogo de selección grande: "¿Qué quieres ajustar?" + - Rumbo + - Ganancia P (solo nivel Técnico) + - Velocidad de giro deseada + - Brillo de pantalla + - Volumen de alarmas + - Operador selecciona con toque en pantalla, O gira el knob hasta la opción correcta y la hunde para confirmar selección +3. **Knob armado**: indicador cambia a 🔓 "KNOB ACTIVO · RUMBO · 28s" en amarillo +4. **Operador gira**: cada click = ±1° (o lo que aplique). Giro rápido se acelera a ±5° por click +5. **Pantalla muestra valor pendiente** en amarillo parpadeante junto al valor actual +6. **Operador hunde knob de nuevo** → confirma cambio pendiente. Knob queda armado 30s más por si quiere más ajustes +7. **Hundir prolongado (>2s)** → desarma completamente + +**Auto-desarme:** +- 30 segundos sin actividad +- Cambio de modo del piloto +- Alarma crítica +- Cambio de autoridad + +Esto se llama "armado por software" en aviónica/marina y es el patrón estándar para prevenir activaciones accidentales en consolas críticas. + +--- + +## 6. El PID — corazón técnico del producto + +### Arquitectura en cascada (lazos anidados) + +Un PID único intentando controlar "rumbo deseado → comando a bomba" oscila o es lento. Solución estándar industrial: **cascada de dos PIDs**. + +``` +┌────────────────────────────────────────────────────────────┐ +│ Lazo externo (slow) - 10 Hz │ +│ Entrada: error de rumbo deseado vs actual │ +│ Salida: setpoint de ángulo de timón │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ Lazo interno (fast) - 50 Hz │ │ +│ │ Entrada: error de posición de timón │ │ +│ │ Salida: PWM/dirección a bomba/motor │ │ +│ └──────────────────────────────────────────────────────┘ │ +└────────────────────────────────────────────────────────────┘ +``` + +### Frecuencias de los lazos + +| Capa | Frecuencia | Justificación | +|---|---|---| +| Lectura sensor posición timón (AI1) | 100 Hz | Datos frescos para lazo interno | +| Lectura rumbo (BNO085 vía NMEA 2000) | 50-100 Hz | Limitado por el sensor | +| Lectura rate of turn (BNO085) | 50-100 Hz | Crítico para feed-forward | +| **Lazo PID interno** (timón a posición) | 50 Hz (20 ms) | Determinístico, sin jitter | +| **Lazo PID externo** (rumbo → setpoint timón) | 10 Hz (100 ms) | Suficiente para dinámica del buque | +| Reporte de estado al display (Modbus) | 5-10 Hz | UX fluida sin saturar bus | +| Publicación NMEA 2000 al backbone | 5 Hz | PGN 127245 Rudder | + +### Por qué el PID corre en el firmware ESP32 y no en el display + +- **Latencia determinística**: ESP32 corre el lazo a frecuencia fija sin jitter de sistema operativo +- **Seguridad**: si el display se cuelga o reinicia, el piloto sigue funcionando (modo degradado) +- **Eficiencia**: ESP32 a 240 MHz dual-core ejecuta un PID en microsegundos, sobra capacidad + +### Técnicas obligatorias del PID + +**1. Feed-forward de Rate of Turn (CRÍTICO)** + +El BNO085 publica rate of turn nativo (velocidad angular del giro). Se mete directo al lazo externo como feed-forward — el PID anticipa cuándo dejar de aplicar timón para que el barco no sobrepase por inercia. Esto es lo que separa un piloto "decente" de uno "premium". + +**2. Gain scheduling por velocidad** + +Las ganancias K_p, K_i, K_d **varían con la velocidad** (SOG del GPS): +- A baja velocidad (<5 nudos): K_p mayor, K_d menor, más amortiguamiento +- A alta velocidad (>15 nudos): K_p menor, K_d mayor, anticipación más rápida +- Interpolación suave entre puntos de ajuste + +Esto es 30 líneas de código. Hace diferencia enorme en sensación. + +**3. Compensación de no-linealidades del actuador** + +- **Deadband del actuador**: la bomba no responde al primer 5-10% de comando (fricción estática, resistencia hidráulica). Se compensa con offset configurable +- **Velocidad mínima útil**: debajo de cierto PWM consume corriente pero no mueve. Saltar al mínimo útil +- **Asimetría babor/estribor**: la bomba puede empujar más rápido para un lado que para el otro. Ganancias asimétricas +- **Histéresis del sensor de posición**: filtrar con mediana + deadband del setpoint + +**4. Anti-windup, deadband del setpoint, saturación** + +- **Anti-windup del integrador**: cuando el actuador satura, no acumular error indefinidamente +- **Deadband alrededor del setpoint**: ±0.5° por defecto, evita oscilaciones por ruido +- **Saturación de salida**: no exceder límites mecánicos del timón (típicamente ±35°) +- **Rate limit del setpoint**: cambios suaves de rumbo, no bruscos (típicamente 3-6°/s máximo) + +### Filtros y fusión sensorial + +**Sprint 2-3 (filtros clásicos):** +- **Mediana** (5 muestras) para sensor de posición de timón — elimina spikes +- **Low-pass** para rate of turn — suaviza ruido +- **Low-pass** para COG del GPS (constante de tiempo 3-5s) — el COG es ruidoso a baja velocidad + +**Sprint 8+ (EKF de fusión sensorial):** + +Implementación de **Extended Kalman Filter** para integración GNSS-INS. Combina: +- Heading del BNO085 (alta frecuencia, sin deriva) +- COG del GPS (baja frecuencia, con deriva real) +- Posición GPS (con corrección lever-arm por pitch/roll del BNO085) +- Velocidad angular del giroscopio + +Produce: +- Heading filtrado robusto sin spikes +- Rate of turn estimado óptimo +- Set/leeway estimado (clave para modo True Course) +- Posición corregida por antena alta + +**Importante**: el EKF llega cuando el piloto ya funciona bien con filtros clásicos. Implementarlo mal es PEOR que no implementarlo. Matrices de covarianza mal tuneadas hacen que el filtro "crea" más en datos malos que en buenos. + +### True Course mode (compensación de deriva) + +El modo más profesional del piloto. Diferencias clave: + +| Concepto | Qué es | Fuente | +|---|---|---| +| Magnetic Heading | Hacia dónde apunta la proa (norte magnético) | BNO085 | +| True Heading | Hacia dónde apunta la proa (norte geográfico) | BNO085 + declinación | +| **COG** | Hacia dónde se mueve realmente el barco | GPS | +| **True Course** | Rumbo planeado en la carta | ECDIS / setpoint operador | + +Con corriente o viento cruzado, el barco apunta hacia un lado (heading) pero se mueve hacia otro (COG). La diferencia se llama **set/leeway** (abatimiento). + +**En modo True Course Hold:** +- El error que entra al PID externo es `(true_course_deseado - COG)`, no `(heading_deseado - heading)` +- El sistema observa el set/leeway calculado por el EKF (o filtros simples en Fase 1) +- Aplica offset al heading para compensar la deriva +- Resultado: el barco se mueve realmente hacia el rumbo deseado, no solo apunta + +**Salvaguarda**: bajo 3 nudos el COG del GPS no tiene sentido. El modo se degrada automáticamente a Heading Hold con aviso al operador. + +### Compensación suave automática de XTE (Track Keeping) + +En modo Track Keeping, el piloto sigue una ruta de waypoints. Si el barco se desvía lateralmente (XTE distinto de cero), un piloto mal diseñado corrige bruscamente: timonazo grande, sobrepasa, oscila. Se ve y se siente horrible. + +**Técnicas combinadas para corrección suave (S-curve approach):** + +1. **Saturación del término XTE**: el aporte del XTE al comando de heading se satura (típicamente ±20°). Por mucho XTE, las correcciones nunca son extremas +2. **Approach angle scheduling**: el ángulo con el que se aproxima a la ruta depende de la distancia. Lejos: ángulo cerrado (30-45°). Cerca: ángulo abierto (5-10°). Trayectoria de reincorporación es curva suave +3. **Velocity-aware tuning**: a alta velocidad las correcciones son más pequeñas (encaja con gain scheduling general) +4. **Rate of approach limiter**: limita la velocidad lateral con la que el barco se acerca a la ruta. Detecta sobrepaso anticipadamente y "frena" el ángulo antes de tiempo + +Esto es lo que la industria llama trayectoria de reincorporación tangencial. Lo que distingue Furuno NavPilot 700/711 de pilotos baratos que zigzaguean. + +### Auto-tuning + +**Sprint 7-8 (tuning offline):** + +Auto-tuning Ziegler-Nichols modificado o relay test durante prueba de mar: +- Sistema aplica perturbaciones controladas en el timón +- Mide la respuesta (overshoot, settling time, oscilación) +- Calcula ganancias óptimas +- Guarda en capa de comisionado + +**Sprint 9+ (adaptive tuning online):** + +Adaptive control en operación normal: +- En cada maniobra (cambio de rumbo solicitado), observa respuesta real +- Compara con respuesta ideal esperada +- Ajusta K_p, K_i, K_d levemente en la dirección que mejora (1-2% por maniobra) +- Después de 20-50 maniobras converge a ganancias adaptadas al barco específico + +**Salvaguardas críticas:** +- Ganancias adaptativas **nunca** salen de ±50% respecto a las base +- Adaptación lenta (1-2% por maniobra máximo) +- Si detecta divergencia (más oscilación, no menos), vuelve automáticamente a base +- Alarma al integrador si las ganancias se acercan a los límites + +### Tres niveles de acceso al PID + +| Nivel | Quién accede | Qué puede tocar | +|---|---|---| +| **Operador** | Capitán | Solo elegir perfil pre-configurado: Suave / Normal / Deportivo | +| **Técnico** | Jefe máquinas, instalador autorizado (PIN) | Ver ganancias activas, ajustar ±30% de base, activar/desactivar adaptación | +| **Integrador** | Yo (Álvaro) con PIN especial | Acceso completo: editar ganancias base, modificar límites de adaptación, calibración inicial | + +**Las ganancias base son IP del integrador**. Las que vienen en biblioteca son resultado de mi experiencia, no se exponen al cliente final. + +--- + +## 7. Seguridad funcional + +Crítica en piloto automático — un fallo puede causar accidente grave. Filosofía: **cualquier duda, más conservador, no menos**. Un piloto que se desengancha por error es molesto pero seguro. Un piloto que no se desengancha en emergencia es peligroso. + +### Watchdog hardware + +El ESP32 reinicia automáticamente si el firmware se cuelga >2 segundos. Al reiniciar: +- Estado del piloto pasa a **STANDBY** (timón manual) +- Alarma sonora y visual +- Registro del evento en log + +### Pulsadores de override + +- **Disengage físico** (DI1) en consola del puente: un toque desengancha el piloto al instante. Independiente del display +- **Volante con sensor de torque** (opcional, configurable): si el operador toca el timón con fuerza, el piloto se desengancha automáticamente +- **Botón virtual DISENGAGE grande siempre visible** en pantalla (rojo, ocupa parte significativa de la zona inferior) + +### Alarmas de seguridad + +| Alarma | Condición | Acción | +|---|---|---| +| Off course | Rumbo se aparta >X° del deseado por >Ns | Warning sonoro + visual | +| Off course severo | Desviación >30° por >5s | Auto-desenganche + alarma máxima | +| Rudder no responde | Comando enviado pero posición no cambia >5s | Auto-desenganche + alarma máxima | +| Sensor rumbo perdido | NMEA 2000 deja de publicar PGN 127250 | Auto-desenganche tras 5s | +| Sobrecarga actuador | Corriente excede límite configurado | Cortar actuador + alarma | +| Voltaje bajo | <11V (12V) o <22V (24V) | Warning + auto-desenganche si crítico | +| Final de carrera | Timón al tope mecánico | Cortar comando en esa dirección | +| Watchdog dispara | Firmware colgado | Auto-reset + standby | +| Alarma crítica VMS | Blackout, sobrecarga eléctrica general | Auto-desenganche | + +Cada alarma con prioridad (Emergency / High / Low / Info) y acción asociada. + +### Auto-desenganche resumen + +El piloto pasa a STANDBY automáticamente si: +- Watchdog dispara +- Pierde sensor de rumbo por >5 segundos +- Off course severo (>30° del deseado) +- Rudder no responde (comando vs feedback divergen) +- Alarma crítica del VMS +- Voltaje del sistema crítico +- Operador hunde botón DISENGAGE físico o virtual + +--- + +## 8. Comunicaciones + +### NMEA 2000 (vía backbone del buque) + +**PGNs que CONSUME:** + +| PGN | Datos | +|---|---| +| 127250 | Heading (BNO085 del ECDIS) | +| 127251 | Rate of Turn (BNO085) | +| 127257 | Attitude (pitch, roll, yaw) — para correcciones lever-arm | +| 129025 / 129029 | Position (GPS lat/lon) | +| 129026 | COG / SOG | +| 129284 | Navigation Data (waypoint activo, XTE) | +| 128259 | Speed Water (futuro, Fase 2) | +| 130306 | Wind Data (futuro, Fase 2) | + +**PGNs que PUBLICA:** + +| PGN | Datos | +|---|---| +| 127245 | Rudder (ángulo actual y comandado) | +| 127237 | Heading/Track Control (estado piloto, modo, heading deseado) | + +### Modbus RTU (display ↔ tarjeta) + +Comunicación local entre la tarjeta AR-NMEA-IO y el monitor de control del puente. + +**Registros expuestos:** +- Estado del piloto (engaged/standby, modo activo) +- Rumbo deseado, rumbo actual, error +- Ángulo de timón actual, ángulo objetivo +- Ganancias PID activas +- Alarmas activas +- Estado del knob (libre / armado / función / tiempo restante) +- Comandos del usuario (engage, disengage, cambiar modo, cambiar setpoint, armar knob) + +--- + +## 9. Arquitectura de configuración por capas + +Reusa el concepto de capas de VMS-Sailor (ver documento VMS-Sailor): + +- **Capa 1 — Paquete base** (`.appack` generado por mi Studio): tipo de actuador, sensores, ganancias por defecto del tipo de buque, configuración inicial +- **Capa 2 — Comisionado en campo**: rangos reales de timón (límites mecánicos detectados durante prueba de mar), ganancias afinadas, offsets de calibración +- **Capa 3 — Preferencias del operador**: rumbos favoritos, perfiles preferidos (Suave/Normal/Deportivo), sensibilidad de alarmas, brillo, volumen + +Cada capa se aplica encima de la anterior. Actualizaciones futuras (firmware nuevo, ganancias mejoradas, modo viento de Fase 2) llegan como **deltas firmados** que Álvaro genera. + +**Aprobación de actualizaciones**: igual que VMS-Sailor — orden de trabajo + verificación de disponibilidad operativa (buque en puerto, sin alarmas, sin maniobras críticas). + +--- + +## 10. Modelo de negocio + +- **Hardware**: tarjeta AR-NMEA-IO + display dedicado + trackball + knob + cableado. Actuadores los compra el cliente o se incluyen según proyecto +- **Software**: licenciado por buque, atado a HWID del display +- **Año 1 garantía**: soporte remoto VPN, ajustes de tuning incluidos +- **Año 2 en adelante**: contrato anual de mantenimiento (10-18% del valor inicial) +- **Expansiones cobradas**: + - Modo viento (Fase 2) — paquete adicional para veleros + - Integración Volvo IPS / Mercury Zeus (Fase 3) + - Auto-tuning adaptativo avanzado + - Tuning custom para condiciones especiales + +--- + +## 11. Estructura del repositorio + +``` +D:/Proyectos Software/AR-Autopilot/ +├── README.md +├── LICENSE.txt +├── CHANGELOG.md +├── pyproject.toml +├── requirements.txt +├── requirements-dev.txt +├── .gitignore +│ +├── studio_main.py # Entry point del Studio +│ +├── arautopilot/ # Código Python compartido +│ ├── __init__.py +│ ├── version.py +│ ├── core/ # Modelo de datos +│ │ ├── vessel_config.py +│ │ ├── actuator_config.py +│ │ ├── pid_config.py +│ │ ├── alarms.py +│ │ ├── modes.py +│ │ └── ids.py +│ ├── library/ # Biblioteca curada (solo Studio) +│ │ ├── actuators/ # Tipos de actuador soportados +│ │ │ ├── hydraulic_reversible.json +│ │ │ ├── electric_dc_reversible.json +│ │ │ └── servomotor_feedback.json +│ │ ├── default_tunings/ # Ganancias iniciales por tipo de buque +│ │ │ ├── yacht_motor_planeo_30m.yaml +│ │ │ ├── yacht_motor_planeo_40m.yaml +│ │ │ ├── pesquero_30m.yaml +│ │ │ ├── patrullero_costero_30m.yaml +│ │ │ └── ferry_pequeño_30m.yaml +│ │ ├── vessel_profiles/ +│ │ └── _schemas/ +│ ├── studio/ # App Studio (PySide6) +│ │ ├── app.py +│ │ ├── main_window.py +│ │ ├── wizards/ +│ │ ├── editors/ +│ │ ├── simulator/ # Test bench sin hardware real +│ │ └── compiler/ # Genera .appack + MSI +│ ├── shared/ +│ └── tests/ +│ +├── firmware/ # Firmware ESP32 +│ └── ar_autopilot_v1/ +│ ├── platformio.ini +│ ├── src/ +│ │ ├── main.cpp +│ │ ├── pid/ +│ │ │ ├── pid_inner.cpp # PID control de timón (50 Hz) +│ │ │ ├── pid_outer.cpp # PID control de rumbo (10 Hz) +│ │ │ ├── feedforward_rot.cpp # Feed-forward rate of turn +│ │ │ ├── gain_scheduling.cpp # Por velocidad +│ │ │ ├── anti_windup.cpp +│ │ │ ├── actuator_compensation.cpp # Deadband, asimetría +│ │ │ └── auto_tuner.cpp # Sprint 7+ +│ │ ├── modes/ +│ │ │ ├── standby.cpp +│ │ │ ├── heading_hold.cpp +│ │ │ ├── true_course.cpp +│ │ │ ├── track_keeping.cpp +│ │ │ ├── xte_smooth_correction.cpp +│ │ │ └── dodge.cpp +│ │ ├── filters/ +│ │ │ ├── median.cpp +│ │ │ ├── low_pass.cpp +│ │ │ └── ekf_fusion.cpp # Sprint 8+ +│ │ ├── safety/ +│ │ │ ├── watchdog.cpp +│ │ │ ├── auto_disengage.cpp +│ │ │ ├── alarms.cpp +│ │ │ ├── override_handler.cpp +│ │ │ └── limits_check.cpp +│ │ ├── protocols/ +│ │ │ ├── modbus_slave.cpp # Con el display +│ │ │ ├── nmea2000_consumer.cpp +│ │ │ └── nmea2000_publisher.cpp +│ │ ├── hal/ # Hardware abstraction layer +│ │ │ ├── rudder_sensor.cpp +│ │ │ ├── rudder_actuator.cpp +│ │ │ ├── knob_encoder.cpp # Push button + cuadratura +│ │ │ └── pinout.h +│ │ └── system/ +│ │ ├── ota_client.cpp +│ │ └── health_reporter.cpp +│ └── test/ +│ +├── display/ # App del display dedicado (Flutter) +│ ├── pubspec.yaml +│ ├── lib/ +│ │ ├── main.dart +│ │ ├── app.dart +│ │ ├── core/ +│ │ │ ├── models/ +│ │ │ ├── modbus_client.dart # Cliente Modbus al ESP32 +│ │ │ └── theme/ +│ │ │ ├── cockpit_dark.dart # Tema oscuro premium +│ │ │ └── glassmorphism.dart +│ │ ├── features/ +│ │ │ ├── overview/ # Pantalla principal +│ │ │ ├── compass/ # Compass rose animada +│ │ │ ├── rudder_indicator/ +│ │ │ ├── modes/ +│ │ │ ├── knob_control/ # Diálogo armado del knob +│ │ │ ├── alarms/ +│ │ │ ├── tuning/ # Solo Técnico+ +│ │ │ ├── trim_display/ # Si aplica +│ │ │ └── settings/ +│ │ └── widgets/ +│ │ ├── compass_rose.dart +│ │ ├── rudder_bar.dart +│ │ ├── mode_button.dart +│ │ ├── disengage_button.dart +│ │ └── pending_value.dart +│ ├── assets/ +│ ├── android/ +│ ├── ios/ +│ ├── linux/ # Para Raspberry Pi +│ ├── windows/ # Para Mini-PC +│ └── macos/ +│ +├── docs/ +│ ├── architecture.md +│ ├── pid_tuning_guide.md # Sprint 7+ (documento separado) +│ ├── ekf_implementation.md # Sprint 8+ (documento separado) +│ ├── nmea2000_protocol.md +│ ├── safety_functional.md +│ └── operator_manual.md +│ +├── installer/ # WiX MSI scripts +└── tools/ +``` + +--- + +## 12. Plan de sprints + +### Sprint 0 — Fundaciones (2 semanas) + +**Objetivo**: estructura completa + modelo de datos + biblioteca seed mínima. + +**Entregables:** +- Repositorio Git inicializado con estructura completa (stubs) +- `pyproject.toml`, `requirements.txt`, `.gitignore` +- Modelo de datos core: VesselConfig, ActuatorConfig, PidConfig, Modes, Alarms, KnobState +- Biblioteca seed mínima: 2 tipos de actuador (hidráulico, eléctrico), 2 perfiles de buque (yate motor 30m, yate motor 40m) +- Tests unitarios del core +- Pinout inicial del firmware en `firmware/ar_autopilot_v1/src/hal/pinout.h` (sin firmware funcional aún) + +**Criterio de aceptación**: pytest pasa todo, se puede crear/guardar/leer configuración de proyecto programáticamente. + +### Sprint 1 — Firmware ESP32 base (3-4 semanas) + +- Pinout funcional, lectura AI/DI, comando DO +- Modbus RTU esclava básica con el display +- Lectura NMEA 2000 (PGN 127250 heading mínimo, 127251 ROT) +- Stub del PID sin lazo cerrado +- Modo STANDBY funcional +- Comunicación bidireccional display ↔ ESP32 + +### Sprint 2 — PID inner loop (control de timón) (3 semanas) + +- PID completo del lazo interno (posición timón) +- Anti-windup, deadband, saturación, rate limit +- Compensación de no-linealidades del actuador (deadband, asimetría, histéresis) +- Configuración de ganancias vía Modbus +- Filtros clásicos (mediana para sensor de timón, low-pass) +- Tests con simulador de timón (modelo simple inercia + fricción) + +### Sprint 3 — PID outer loop + Heading Hold (3 semanas) + +- PID externo de rumbo → setpoint de timón +- **Feed-forward de Rate of Turn** del BNO085 +- **Gain scheduling por velocidad** +- Modo HEADING HOLD funcional +- Filtros para rumbo (anti-spike, suavizado) +- Pruebas en banco con sensor de rumbo simulado + +### Sprint 4 — Studio + Display básico (3-4 semanas) + +- Studio Python+PySide6: crear proyecto, configurar tipo de actuador, ganancias iniciales +- Generador de paquete `.appack` +- Display Flutter Desktop: + - Tema oscuro cockpit con glassmorphism + - Vista principal con compass rose animada + - Indicador de timón + - Cambio de modo + - Botón DISENGAGE prominente +- Conexión Modbus display ↔ tarjeta funcional + +### Sprint 5 — True Course + Track Keeping (3 semanas) + +- Cálculo de set/leeway (filtros clásicos) +- Modo TRUE COURSE HOLD funcional +- Consumo de PGN 129284 (waypoint activo del ECDIS) +- Cálculo de XTE +- Modo TRACK KEEPING con compensación suave automática: + - Saturación del término XTE + - Approach angle scheduling + - Velocity-aware tuning + - Rate of approach limiter +- Modo DODGE temporal + +### Sprint 6 — Seguridad y alarmas (3 semanas) + +- Watchdog hardware funcional +- Pulsadores físicos de override (DI1, DI5) +- Alarmas completas (off course, rudder no responde, sensor perdido, sobrecarga, voltaje bajo, final de carrera, watchdog) +- Auto-desenganche en condiciones críticas +- Publicación NMEA 2000 al backbone (PGN 127245, 127237) +- Lectura de alarmas del VMS por NMEA 2000 + +### Sprint 7 — Knob físico + comisionado + auto-tuning offline (3-4 semanas) + +- Driver del knob encoder con push button (3 DI: A, B, SW) +- Decoder en hardware (PCNT del ESP32) para no perder pulsos +- Filosofía "armado por software" del knob completa +- Diálogo de armado en display Flutter +- Auto-desarme por timeout/alarma/cambio de modo +- Wizard de comisionado en campo desde el display (modo Técnico) +- Rangos reales de timón (límites mecánicos detectados) +- Ajuste fino de ganancias en campo +- Auto-tuning Ziegler-Nichols o relay test +- Tres niveles de acceso (Operador / Técnico / Integrador con PIN) +- Capas de configuración (base + comisionado + owner_prefs) +- Deltas firmados + +### Sprint 8 — EKF + adaptive tuning + telemetría (3-4 semanas) + +- Extended Kalman Filter para fusión sensorial (BNO085 + GPS + sensor timón) +- Estimación robusta de heading, ROT, set/leeway, posición lever-arm-corrected +- Auto-tuning adaptive online con salvaguardas (±50% máx, cambios lentos, auto-disable si diverge) +- Telemetría de salud del sistema (visible al cliente, pausable) +- Acceso VPN para soporte remoto +- Activación HWID + +### Sprint 9 — Hardening + pruebas integradas (3 semanas) + +- Pruebas en banco completas con simulador de buque +- Pruebas de fallo (cable cortado, tarjeta sin energía, bus colgado, sensor mintiendo) +- Stress test (alarmas masivas, cambios rápidos de rumbo, condiciones límite) +- Watchdog y recovery automático +- Documentación de operador y mantenimiento +- Manual técnico de comisionado + +### Sprint 10+ — Fase 2 y refinamientos + +- Modo Vane (apparent wind angle hold) para veleros +- Modo True Wind +- Auto-tack +- Integración Seakeeper como periférico +- Integraciones específicas (Volvo IPS, Mercury Zeus) — Fase 3 +- Mejoras de UI basadas en feedback de clientes reales + +--- + +## 13. Stack tecnológico + +| Componente | Tecnología | +|---|---| +| Studio | Python 3.11 + PySide6 | +| Display dedicado | Flutter Desktop (Linux para RPi5, Windows para Mini-PC) | +| Firmware ESP32 | PlatformIO + Arduino framework o ESP-IDF (decidir en Sprint 1) | +| Hardware tarjeta | AR-NMEA-IO-v1.0 (la misma que VMS-Sailor) | +| Modbus | pymodbus (Studio), Modbus library en firmware | +| NMEA 2000 | NMEA2000_esp32 library | +| Base de datos local Studio | SQLite | +| Empaquetado | PyInstaller + WiX Toolset MSI | +| Tests Python | pytest, pytest-qt | +| Tests firmware | Unity + PlatformIO test framework | +| Tests Flutter | flutter_test | +| Activación HWID | UUID + MAC + Disk Serial firmados | + +--- + +## 14. Reglas de oro + +1. **Antes de cada sprint**, me presentas plan detallado y esperas mi OK. No improvises features. + +2. **Cada cambio importante** se discute primero. Yo decido alcance. + +3. **Tests obligatorios** en firmware (Unity) y en core Python (pytest). Display Flutter con flutter_test en flujos críticos. + +4. **No agregues dependencias** sin preguntarme. + +5. **Documentar normativa** cuando aplique: + - ISO 11674 (heading control systems) + - ISO 16329 (heading control systems for high-speed craft) + - IMO MSC.64(67) (autopilot performance standards) + - NMEA 2000 (PGNs) + - Wheelmark si se busca certificación europea + +6. **Sin red de salida** en producto final, salvo VPN administrativa auditada y activación inicial. + +7. **Seguridad primero**: cualquier duda → más conservador, no menos. Un piloto que se desengancha por error es molesto pero seguro. Un piloto que no se desengancha en emergencia es peligroso. + +8. **Coherencia con la suite AR**: misma filosofía UX, español por defecto, sistema de coordenadas naval donde aplique, mismo hardware AR-NMEA-IO. + +9. **Las ganancias PID son IP del integrador**. Las que vienen en biblioteca son resultado de mi experiencia. No se exponen al cliente final. + +10. **El sistema de gobierno es CRÍTICO de seguridad**: cumplir con principios de seguridad funcional aunque no busquemos certificación formal inicialmente. Auditoría completa de todas las acciones operativas. + +11. **El PID corre en firmware ESP32, no en el display**. Esto es no-negociable: latencia determinística, seguridad ante caída del display, eficiencia. + +12. **El display puede fallar y el piloto debe seguir funcionando**. La tarjeta ESP32 mantiene el estado y reporta al backbone NMEA 2000. Al recuperarse el display, retoma comunicación. + +13. **Conformidad con arquitectura de capas** (base + comisionado + preferencias) idéntica a VMS-Sailor. Para que ambos productos compartan filosofía de actualización y mantenimiento. + +14. **Auditoría siempre activa**: cada engage/disengage, cada cambio de modo, cada armado de knob, cada confirmación, cada alarma con su ack, cada conexión VPN del fabricante. Inmutable y firmado. + +--- + +## 15. Cómo proceder + +**Paso 1.** Confirma que entendiste el alcance. Pregunta dudas ANTES de tocar código. + +**Paso 2.** Verifica si existe `D:/Proyectos Software/AR-Autopilot/`. Si está vacía, OK. Si tiene contenido, pregúntame. + +**Paso 3.** Presenta tu plan detallado del Sprint 0: archivos a crear, librerías a usar, tests a escribir. Espera mi OK. + +**Paso 4.** Ejecuta Sprint 0. Al final me presentas: +- Commit `sprint-0` +- pytest verde +- Script demo que cree configuración de proyecto de ejemplo, la guarde y la lea de vuelta + +**Paso 5.** Yo reviso, doy feedback, apruebo, taggeas `sprint-0-approved`, pasamos al Sprint 1. + +--- + +## 16. Documentos futuros + +Estos se generarán cuando lleguemos a su sprint correspondiente. No se crean ahora: + +- `pid_tuning_guide.md` — Sprint 7 (guía completa de tuning en campo) +- `ekf_implementation.md` — Sprint 8 (derivación matemática del Kalman, matrices de covarianza, código de referencia) +- `nmea2000_protocol.md` — Sprint 6 (detalle de cada PGN consumido y publicado) +- `ui_design_system.md` — Sprint 4 (design system Flutter completo: colores, tipografía, animaciones) +- `safety_functional.md` — Sprint 6 (documento de análisis de seguridad funcional) +- `operator_manual.md` — Sprint 9 (manual de usuario final del display) + +--- + +## 17. Notas finales + +- AR-Autopilot **tiene riesgo de daño físico real** si funciona mal. Toda decisión técnica debe priorizar seguridad sobre conveniencia. +- Reusamos la tarjeta AR-NMEA-IO v1.0 **sin modificar hardware**. Si en el camino descubrimos que algún I/O no alcanza, lo discutimos antes de cambiar hardware. +- AR-ECDIS es producto **separado**, no se desarrolla en este repositorio. Solo asumimos que publica al backbone NMEA 2000 los PGNs estándar de navegación y actitud. +- El **EKF, adaptive tuning y modo viento** son features avanzadas que solo se implementan cuando el sistema base funciona perfecto. No nos saltamos sprints. +- Hardware del knob: **encoder rotativo de panel** (montaje pasante, no PCB) con push button, 24 detents, eje 6 mm. Modelo concreto se elige al hacer compras (Grayhill, Bourns serie panel, o encoder + knob aluminio por separado). + +--- + +**Fin del brief.** + +Comienza confirmando que entendiste el alcance y preséntame tu plan del Sprint 0. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..d9263a1 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,137 @@ +# AR-Autopilot — Architecture overview + +> One-page architectural overview. For full scope, see [`AR_Autopilot_brief.md`](AR_Autopilot_brief.md). + +--- + +## Three deployment units + one configuration tool + +``` +┌──────────────────────────────────────────────────────────────────────────────┐ +│ INTEGRATOR-SIDE (Alvaro's PC) │ +│ │ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ AR-Autopilot Studio — Python 3.11 + PySide6 │ │ +│ │ • Per-project configurator │ │ +│ │ • Vessel profile + actuator selection + initial PID gains │ │ +│ │ • Generates per-vessel signed .appack + MSI installer │ │ +│ │ • NOT shipped to customer │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ │ .appack (signed config + firmware) │ +└──────────────────────────────────┼───────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────────────────┐ +│ CUSTOMER VESSEL (per-vessel HWID license) │ +│ │ +│ ┌─────────────────────────────────┐ ┌────────────────────────────┐ │ +│ │ Bridge Console │ │ Tech Cabinet │ │ +│ │ │ │ │ │ +│ │ ┌──────────────────────────┐ │ Modbus │ ┌──────────────────────┐ │ │ +│ │ │ AR-Autopilot Display │◄──┼─ RTU ──►│ │ AR-NMEA-IO v1.0 │ │ │ +│ │ │ Flutter Desktop │ │ RS-485 │ │ (ESP32-DOWD) │ │ │ +│ │ │ (Mini-PC or RPi5) │ │ │ │ │ │ │ +│ │ │ │ │ │ │ • PID inner 50 Hz │ │ │ +│ │ │ • Compass rose │ │ │ │ • PID outer 10 Hz │ │ │ +│ │ │ • Rudder indicator │ │ │ │ • ROT feed-forward │ │ │ +│ │ │ • Mode selector │ │ │ │ • Gain scheduling │ │ │ +│ │ │ • DISENGAGE button │ │ │ │ • Safety / alarms │ │ │ +│ │ │ │ │ │ │ • Watchdog 2 s │ │ │ +│ │ │ Inputs: │ │ │ │ │ │ │ +│ │ │ Trackball USB │ │ │ │ 21 I/O: │ │ │ +│ │ │ Knob (panel encoder) │───┼─DI A/B/SW─►│ AI1..AI4 │ │ │ +│ │ │ Disengage button │───┼──DI1───►│ │ DI1..DI5 │ │ │ +│ │ └──────────────────────────┘ │ │ │ RPM1 │ │ │ +│ │ │ │ │ DO1..DO10 │ │ │ +│ └─────────────────────────────────┘ │ └──────────────────────┘ │ │ +│ │ │ │ │ +│ │ │ NMEA 2000 │ │ +│ │ ▼ │ │ +│ │ ┌──────────────────────┐ │ │ +│ │ │ Vessel N2K backbone │ │ │ +│ │ │ │ │ │ +│ │ │ Subscribes: │ │ │ +│ │ │ 127250 Heading │ │ │ +│ │ │ 127251 ROT │ │ │ +│ │ │ 129025/9 Position │ │ │ +│ │ │ 129026 COG/SOG │ │ │ +│ │ │ 129284 Nav Data │ │ │ +│ │ │ │ │ │ +│ │ │ Publishes: │ │ │ +│ │ │ 127245 Rudder │ │ │ +│ │ │ 127237 Heading Ctl │ │ │ +│ │ └──────────────────────┘ │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ to actuator: pump / motor│ │ +│ │ + rudder feedback sensor │ │ +│ └────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Why the PID lives on the ESP32, not on the display + +- **Deterministic latency** — fixed loop frequency, no OS jitter +- **Safety** — if the display crashes or reboots, steering keeps working + (degraded mode, last good setpoint, alarm raised) +- **Efficiency** — ESP32-DOWD dual-core at 240 MHz runs the cascaded PID + in microseconds + +The display is a **rich UI client** of the autopilot, not its brain. + +--- + +## Configuration layering (same model as VMS-Sailor) + +``` +┌─────────────────────────────────────────────────────┐ +│ Layer 3 — Owner preferences │ +│ favorite headings, profile (Soft/Normal/Sport), │ +│ alarm sensitivity, brightness, volume │ +├─────────────────────────────────────────────────────┤ +│ Layer 2 — Field commissioning │ +│ actual rudder mechanical limits, affinated gains, │ +│ calibration offsets │ +├─────────────────────────────────────────────────────┤ +│ Layer 1 — Base package (.appack from Studio) │ +│ actuator type, sensors, default gains for vessel │ +│ type, initial configuration │ +└─────────────────────────────────────────────────────┘ +``` + +Each layer overrides the layer below. Firmware/gain updates ship as +**signed deltas** approved by an explicit work order. + +--- + +## Sprint 0 scope (what this commit delivers) + +**Yes:** +- Complete repository layout +- Core data model (Pydantic v2) for everything above +- 2 seed actuator profiles + 2 seed default tunings (conservative literature + values, not the integrator's IP tunings) +- Firmware `pinout.h` only — the 21 I/O contract +- Test suite + end-to-end demo + +**No (later sprints):** +- Functional firmware, PID, filters, EKF, auto-tuning +- Studio GUI, display Flutter app +- Modbus, NMEA 2000, OTA, VPN, HWID activation + +--- + +## Further reading + +| Document | When | What | +|---|---|---| +| [`AR_Autopilot_brief.md`](AR_Autopilot_brief.md) | Now | Full project brief — scope, modes, hardware, sprints | +| `pid_tuning_guide.md` | Sprint 7 | Field tuning methodology | +| `ekf_implementation.md` | Sprint 8 | Kalman filter derivation + covariance tuning | +| `nmea2000_protocol.md` | Sprint 6 | PGN-by-PGN consumption/publication detail | +| `safety_functional.md` | Sprint 6 | Functional safety analysis | +| `ui_design_system.md` | Sprint 4 | Flutter design system (colors, typography, animations) | +| `operator_manual.md` | Sprint 9 | End-user manual | diff --git a/examples/sprint0_demo.py b/examples/sprint0_demo.py new file mode 100644 index 0000000..2d81a2e --- /dev/null +++ b/examples/sprint0_demo.py @@ -0,0 +1,128 @@ +"""Sprint 0 acceptance demo. + +Runs end-to-end the workflow the brief calls out in section 12: + + 1. Build a ``ProjectConfig`` programmatically using the seed library. + 2. Save it to disk (YAML and JSON). + 3. Reload it from disk. + 4. Verify the reloaded object matches the original. + 5. Print a human-readable summary. + +Usage:: + + python examples/sprint0_demo.py +""" + +from __future__ import annotations + +from pathlib import Path + +from arautopilot.core import ( + ProjectConfig, + VesselConfig, + VesselType, + interpolate_gains, +) +from arautopilot.library.loader import ( + list_actuator_profiles, + list_default_tunings, + load_actuator_profile, + load_default_tuning, +) +from arautopilot.version import __version__ + +OUTPUT_DIR = Path(__file__).parent / "output" + + +def main() -> int: + print("=" * 78) + print(f" AR-Autopilot v{__version__} -- Sprint 0 acceptance demo") + print("=" * 78) + + # ---- 1. Inspect what the seed library carries --------------------------- + print("\n[1] Seed library contents:") + for profile in list_actuator_profiles(): + print(f" - actuator profile : {profile}") + for tuning in list_default_tunings(): + print(f" - default tuning : {tuning}") + + # ---- 2. Compose a project from seed assets ------------------------------ + print("\n[2] Building project from seed library...") + actuator = load_actuator_profile("hydraulic_reversible") + pid = load_default_tuning("yacht_motor_planeo_30m") + + vessel = VesselConfig( + name="M/Y Sprint Zero", + 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="Demo Shipyard S.L.", + project_name="Sprint 0 demonstrator", + notes=( + "Created by examples/sprint0_demo.py. Seed-library actuator + tuning " + "for a 30 m planing motor yacht. Conservative literature gains; not " + "the integrator's production tuning." + ), + vessel=vessel, + ) + + # ---- 3. Save it to disk ------------------------------------------------- + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + yaml_out = project.save_yaml(OUTPUT_DIR / "demo_project.yaml") + json_out = project.save_json(OUTPUT_DIR / "demo_project.json") + print(f"\n[3] Saved to:") + print(f" - YAML : {yaml_out}") + print(f" - JSON : {json_out}") + + # ---- 4. Reload and verify integrity ------------------------------------- + print("\n[4] Reloading from disk and verifying integrity...") + rebuilt_yaml = ProjectConfig.load(yaml_out) + rebuilt_json = ProjectConfig.load(json_out) + + assert rebuilt_yaml == project, "YAML round-trip mismatch" + assert rebuilt_json == project, "JSON round-trip mismatch" + print(" OK -- both YAML and JSON reload to identical objects.") + + # ---- 5. Show a human-readable summary ----------------------------------- + print("\n[5] Project summary:") + print(f" project_id : {project.project_id}") + print(f" client : {project.client_name}") + print(f" project name : {project.project_name}") + print(f" vessel : {project.vessel.name}") + print(f" vessel type : {project.vessel.type.value}") + print(f" length / Vmax : {project.vessel.length_m:.1f} m / {project.vessel.max_speed_kn:.1f} kn") + print(f" actuator type : {project.vessel.actuator.type.value}") + print(f" rudder limit : +/-{project.vessel.actuator.max_rudder_angle_deg:.1f} deg" + f" at max {project.vessel.actuator.max_rate_dps:.1f} deg/s") + print(f" inner PID Kp/Ki/Kd: " + f"{project.vessel.pid.inner_loop_base.kp} / " + f"{project.vessel.pid.inner_loop_base.ki} / " + f"{project.vessel.pid.inner_loop_base.kd}") + print(f" outer PID Kp/Ki/Kd: " + f"{project.vessel.pid.outer_loop_base.kp} / " + f"{project.vessel.pid.outer_loop_base.ki} / " + f"{project.vessel.pid.outer_loop_base.kd}") + print(f" ROT feed-forward : {project.vessel.pid.rot_feedforward_gain}") + print(f" gain schedule pts : {len(project.vessel.pid.gain_schedule)}") + + # Show interpolation working + print("\n Interpolated outer-loop gains by SOG:") + for sog in (3.0, 5.0, 10.0, 15.0, 20.0, 28.0, 35.0): + gains = interpolate_gains(project.vessel.pid.gain_schedule, sog) + print( + f" SOG = {sog:5.1f} kn -> " + f"Kp={gains.kp:.3f} Ki={gains.ki:.4f} Kd={gains.kd:.3f}" + ) + + print("\nDemo complete.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/firmware/ar_autopilot_v1/src/hal/pinout.h b/firmware/ar_autopilot_v1/src/hal/pinout.h new file mode 100644 index 0000000..359ce7c --- /dev/null +++ b/firmware/ar_autopilot_v1/src/hal/pinout.h @@ -0,0 +1,112 @@ +// ============================================================================= +// AR-Autopilot v1 — Pinout for AR-NMEA-IO v1.0 board (ESP32-DOWD) +// ============================================================================= +// +// Sprint 0: pin contract only. No drivers, no main loop. This file is the +// source of truth for which physical I/O carries which logical function for +// the autopilot application; the firmware code in Sprint 1+ consumes it. +// +// Hardware reference: AR-NMEA-IO v1.0 (same board used by VMS-Sailor). +// 21 user I/O: 4 analog inputs, 5 digital inputs, 1 RPM/frequency input, +// 10 digital outputs, plus dedicated RS-485 (Modbus), CAN (NMEA 2000) and +// WiFi (OTA only). +// +// Source: brief section 4.1 "Asignación típica de puertos para piloto +// motor 30-40 m". +// +// NOTE: The numeric GPIO assignments below are PLACEHOLDERS aligned with the +// AR-NMEA-IO schematic. They will be confirmed and locked in Sprint 1 +// against the real board revision. Do not depend on them yet. +// ============================================================================= + +#pragma once + +// ----------------------------------------------------------------------------- +// Firmware identity +// ----------------------------------------------------------------------------- +#define AR_AUTOPILOT_FW_NAME "AR-Autopilot" +#define AR_AUTOPILOT_FW_VERSION "0.1.0-sprint0" +#define AR_AUTOPILOT_HW_BOARD "AR-NMEA-IO v1.0" +#define AR_AUTOPILOT_MCU "ESP32-DOWD" + +// ----------------------------------------------------------------------------- +// Analog inputs (4) +// AI1 — Rudder angle position sensor (pot / Hall, 0-10 V or 4-20 mA) +// AI2 — System battery voltage +// AI3 — Actuator current (shunt or Hall) — overcurrent protection +// AI4 — Reserved (future: hydraulic pressure) +// ----------------------------------------------------------------------------- +#define PIN_AI1_RUDDER_ANGLE 36 // GPIO36 / ADC1_CH0 / SVP +#define PIN_AI2_BATTERY_VOLTAGE 39 // GPIO39 / ADC1_CH3 / SVN +#define PIN_AI3_ACTUATOR_CURRENT 34 // GPIO34 / ADC1_CH6 +#define PIN_AI4_RESERVED 35 // GPIO35 / ADC1_CH7 + +// ----------------------------------------------------------------------------- +// Digital inputs (5) +// DI1 — "Engage / Disengage" push-button on bridge console (ALWAYS DISENGAGES) +// DI2 — Port-side rudder mechanical limit switch +// DI3 — Starboard-side rudder mechanical limit switch +// DI4 — External critical alarm (VMS blackout, genset critical) +// DI5 — Manual confirmation (emergency override) +// ----------------------------------------------------------------------------- +#define PIN_DI1_DISENGAGE_BUTTON 32 // GPIO32 — wakes from light sleep +#define PIN_DI2_LIMIT_SWITCH_PORT 33 // GPIO33 +#define PIN_DI3_LIMIT_SWITCH_STBD 25 // GPIO25 +#define PIN_DI4_EXTERNAL_ALARM 26 // GPIO26 +#define PIN_DI5_MANUAL_CONFIRM 27 // GPIO27 + +// ----------------------------------------------------------------------------- +// RPM / frequency input (1) +// RPM1 — Reserved (future: rotation sensor if applicable) +// ----------------------------------------------------------------------------- +#define PIN_RPM1_RESERVED 14 // GPIO14 — PCNT-capable + +// ----------------------------------------------------------------------------- +// Digital outputs (10) +// DO1 — Pump/motor command PORT (direction) +// DO2 — Pump/motor command STARBOARD (direction) +// DO3 — Master power enable to actuator (relay) +// DO4 — Audible alarm +// DO5 — "Pilot engaged" console lamp +// DO6..DO10 — Reserved +// ----------------------------------------------------------------------------- +#define PIN_DO1_PUMP_PORT 13 // GPIO13 +#define PIN_DO2_PUMP_STBD 12 // GPIO12 +#define PIN_DO3_ACTUATOR_POWER 15 // GPIO15 +#define PIN_DO4_BUZZER 2 // GPIO2 — on-board LED on many boards; check schematic +#define PIN_DO5_ENGAGED_LAMP 4 // GPIO4 +#define PIN_DO6_RESERVED 16 // GPIO16 +#define PIN_DO7_RESERVED 17 // GPIO17 +#define PIN_DO8_RESERVED 5 // GPIO5 +#define PIN_DO9_RESERVED 18 // GPIO18 +#define PIN_DO10_RESERVED 19 // GPIO19 + +// ----------------------------------------------------------------------------- +// Communication buses (board-level; pins shown for reference, drivers in Sprint 1) +// ----------------------------------------------------------------------------- +// RS-485 (Modbus RTU to dedicated display) +#define PIN_RS485_TX 21 // U2_TXD +#define PIN_RS485_RX 22 // U2_RXD +#define PIN_RS485_DE 23 // driver enable + +// CAN bus (NMEA 2000 backbone) +#define PIN_CAN_TX 3 // U0_RXD on some boards — verify +#define PIN_CAN_RX 1 // U0_TXD on some boards — verify + +// ----------------------------------------------------------------------------- +// Control-loop frequencies (logical; consumed by Sprint 2+ PID code) +// ----------------------------------------------------------------------------- +#define AR_INNER_LOOP_HZ 50 // rudder position controller +#define AR_OUTER_LOOP_HZ 10 // heading controller +#define AR_RUDDER_SENSOR_SAMPLE_HZ 100 // ADC sample rate for AI1 +#define AR_WATCHDOG_TIMEOUT_MS 2000 // brief section 7 + +// ----------------------------------------------------------------------------- +// Safety constants +// ----------------------------------------------------------------------------- +#define AR_DEFAULT_MAX_RUDDER_DEG 35 // ± from amidships +#define AR_DEFAULT_MAX_RUDDER_RATE_DPS 6 // degrees/second +#define AR_DEFAULT_OFF_COURSE_DEG 10 // warning threshold +#define AR_SEVERE_OFF_COURSE_DEG 30 // auto-disengage threshold +#define AR_SEVERE_OFF_COURSE_HOLD_MS 5000 // duration before auto-disengage +#define AR_HEADING_SENSOR_TIMEOUT_MS 5000 // auto-disengage if no PGN 127250 diff --git a/firmware/ar_autopilot_v1/test/.gitkeep b/firmware/ar_autopilot_v1/test/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/installer/.gitkeep b/installer/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f1c49d9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,160 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "arautopilot" +version = "0.1.0" +description = "AR-Autopilot — Professional marine autopilot for 30-40 m vessels (Studio + firmware + display)" +readme = "README.md" +license = { file = "LICENSE.txt" } +requires-python = ">=3.11" +authors = [{ name = "Alvaro Romero", email = "alro65@gmail.com" }] +keywords = ["marine", "autopilot", "pid", "nmea2000", "esp32", "vessel-control"] +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "Intended Audience :: Manufacturing", + "License :: Other/Proprietary License", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering", + "Topic :: System :: Hardware", +] + +# Runtime dependencies — kept intentionally minimal for Sprint 0. +# GUI (PySide6), Modbus (pymodbus), serial, etc. are added in later sprints. +dependencies = [ + "pydantic>=2.6,<3.0", + "pyyaml>=6.0", + "python-dateutil>=2.8", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-cov>=4.1", + "ruff>=0.4", + "mypy>=1.10", + "types-PyYAML", + "types-python-dateutil", +] + +[project.urls] +Homepage = "https://github.com/alro65/AR-Autopilot" + +[tool.setuptools.packages.find] +where = ["."] +include = ["arautopilot*"] +exclude = ["arautopilot.tests*"] + +[tool.setuptools.package-data] +"arautopilot.library" = [ + "actuators/*.json", + "default_tunings/*.yaml", + "vessel_profiles/*.yaml", + "_schemas/*.json", +] + +# ---------------------------------------------------------------------------- +# pytest +# ---------------------------------------------------------------------------- +[tool.pytest.ini_options] +minversion = "8.0" +testpaths = ["arautopilot/tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "-ra", + "--strict-markers", + "--strict-config", + "--showlocals", +] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "integration: marks integration tests", +] + +# ---------------------------------------------------------------------------- +# Coverage +# ---------------------------------------------------------------------------- +[tool.coverage.run] +source = ["arautopilot"] +omit = [ + "arautopilot/tests/*", + "arautopilot/studio/*", # GUI not in scope for Sprint 0 +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "raise NotImplementedError", + "if TYPE_CHECKING:", + "if __name__ == .__main__.:", +] + +# ---------------------------------------------------------------------------- +# Ruff (linting + formatting) +# ---------------------------------------------------------------------------- +[tool.ruff] +line-length = 100 +target-version = "py311" +extend-exclude = [ + "firmware", + "display", + "installer", + ".venv", + "build", + "dist", +] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "N", # pep8-naming + "SIM", # flake8-simplify +] +ignore = [ + "E501", # line too long (handled by formatter) + "B008", # function calls in default args (common with pydantic Field) +] + +[tool.ruff.lint.per-file-ignores] +"arautopilot/tests/*" = ["N802", "N803"] # test names + +# ---------------------------------------------------------------------------- +# mypy +# ---------------------------------------------------------------------------- +[tool.mypy] +python_version = "3.11" +strict = true +warn_unused_ignores = true +warn_redundant_casts = true +plugins = ["pydantic.mypy"] +exclude = [ + "build/", + "dist/", + "firmware/", + "display/", + "installer/", + "arautopilot/studio/", # GUI stubs, not in scope for Sprint 0 +] + +[[tool.mypy.overrides]] +module = "arautopilot.tests.*" +disallow_untyped_defs = false + +[tool.pydantic-mypy] +init_forbid_extra = true +init_typed = true +warn_required_dynamic_aliases = true diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..bed5015 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,9 @@ +# AR-Autopilot — development dependencies (Sprint 0) +-r requirements.txt + +pytest>=8.0 +pytest-cov>=4.1 +ruff>=0.4 +mypy>=1.10 +types-PyYAML +types-python-dateutil diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4d08fa8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +# AR-Autopilot — runtime dependencies (Sprint 0) +# Generated from pyproject.toml. Kept in sync manually for environments +# that do not yet read PEP 621 metadata. +pydantic>=2.6,<3.0 +pyyaml>=6.0 +python-dateutil>=2.8 diff --git a/studio_main.py b/studio_main.py new file mode 100644 index 0000000..f879b35 --- /dev/null +++ b/studio_main.py @@ -0,0 +1,14 @@ +"""AR-Autopilot Studio — top-level launcher. + +Currently delegates to ``arautopilot.studio.app.run`` which is a Sprint 0 +stub. The real Qt application is implemented from Sprint 4 onwards. +""" + +from __future__ import annotations + +import sys + +from arautopilot.studio.app import run + +if __name__ == "__main__": + sys.exit(run()) diff --git a/tools/.gitkeep b/tools/.gitkeep new file mode 100644 index 0000000..e69de29