sprint-0: foundations -- data model, seed library, tests, demo
Initial commit. Delivers what the brief calls 'Sprint 0 - Foundations' (see docs/AR_Autopilot_brief.md section 12): - Complete repository structure (arautopilot package + firmware, display, installer, tools placeholders + docs). - Core data model (Pydantic v2): modes, alarms, actuator config, PID config + gain scheduling, vessel config, knob state machine, project config with YAML/JSON serialisation. - Seed library: 2 actuator profiles (hydraulic & electric DC reversible) and 2 default tunings (yacht motor planeo 30 m and 40 m). Conservative literature values, NOT the integrator's production tuning IP. - Firmware skeleton: only src/hal/pinout.h with the 21 I/O contract for the AR-NMEA-IO v1.0 board. No drivers, no main loop. - Studio stubs (real PySide6 app starts in Sprint 4). - pytest suite (80 tests, all green): modes, alarms, actuator, PID (incl. gain interpolation and the +/-50% adaptive bound from brief section 6), vessel, knob state, project config, library loader, end-to-end roundtrip. - examples/sprint0_demo.py - the acceptance demo from the brief. Acceptance criteria met: - pytest green (80/80) - demo creates, saves (YAML + JSON), reloads, and verifies a full ProjectConfig using the seed library - repository ready for tag `sprint-0-approved` See CHANGELOG.md for the detailed scope. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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)})
|
||||
Reference in New Issue
Block a user