sprint-0: foundations -- data model, seed library, tests, demo

Initial commit. Delivers what the brief calls 'Sprint 0 - Foundations'
(see docs/AR_Autopilot_brief.md section 12):

- Complete repository structure (arautopilot package + firmware, display,
  installer, tools placeholders + docs).
- Core data model (Pydantic v2): modes, alarms, actuator config, PID
  config + gain scheduling, vessel config, knob state machine, project
  config with YAML/JSON serialisation.
- Seed library: 2 actuator profiles (hydraulic & electric DC reversible)
  and 2 default tunings (yacht motor planeo 30 m and 40 m). Conservative
  literature values, NOT the integrator's production tuning IP.
- Firmware skeleton: only src/hal/pinout.h with the 21 I/O contract for
  the AR-NMEA-IO v1.0 board. No drivers, no main loop.
- Studio stubs (real PySide6 app starts in Sprint 4).
- pytest suite (80 tests, all green): modes, alarms, actuator, PID
  (incl. gain interpolation and the +/-50% adaptive bound from brief
  section 6), vessel, knob state, project config, library loader,
  end-to-end roundtrip.
- examples/sprint0_demo.py - the acceptance demo from the brief.

Acceptance criteria met:
- pytest green (80/80)
- demo creates, saves (YAML + JSON), reloads, and verifies a full
  ProjectConfig using the seed library
- repository ready for tag `sprint-0-approved`

See CHANGELOG.md for the detailed scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 23:57:18 -04:00
commit 700756c16f
54 changed files with 3855 additions and 0 deletions
+103
View File
@@ -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)})