sprint-0: fundaciones VMS-Sailor
Sprint 0 completo del producto VMS-Sailor (Vessel Management System integrado para buques 30-40m). Brief de referencia en VMS_Sailor_v2_Parte_*.md (intacto). Core (vmssailor.core, 95.17% coverage, 99 tests verde): - ShipCoord: sistema naval x_pp/y_cl/z_bl frozen - Vessel, Deck, Bulkhead - Equipment, EquipmentModel, Sensor, EquipmentSpec - Tag, AlarmConfig, TagBinding, Scaling - CardInstance, Bus, Topology con validacion 21 puntos I/O AR-NMEA-IO-v1.0 - Alarm, PermissiveRule, Condition - Project agregado raiz con validacion cross-entity - Persistencia portable .vmsproj (SQLite) con roundtrip verificable Biblioteca curada seed (vmssailor.library): - systems_catalog.json completo (catalogo maestro Parte 1 sec 7) - 2 vessels: Sunseeker 76, Ferretti 850 - 2 motores: MTU 12V 2000 M96, Volvo D13-900 - 1 genset: Northern Lights M65C13 - yacht_motor_planeo.yaml (reglas heuristicas) - TODO marcado data_source=seed_estimate - requiere validacion datasheets Tools: - vms-validate-library: CLI valida biblioteca completa - vms-generate-test-project: CLI demo + verificacion roundtrip persistencia Design System + 8 mockups HTML estaticos: - docs/design_system.md (paleta Deep Ocean, gradientes, typography, motion) - docs/brand/ (logo + variantes SVG) - docs/mockups/splash, studio_main, runtime_overview, runtime_mimic_fuel (P&ID animado), runtime_alarms, runtime_trim (panel estrella con horizonte artificial), mobile_overview, mobile_trim - docs/mockups/index.html (galeria) Firmware (Sprint 12+ implementacion): - firmware/ar_nmea_io_v1/src/config/pinout.h con macros GPIO Decisiones autonomas documentadas en docs/decisions_sprint0.md. Stack: Python 3.11 + uv + Pydantic v2 + SQLite stdlib + hatchling + pytest 9 + ruff + mypy. Sin PySide6, FastAPI, Flutter ni firmware funcional (entran en sprints siguientes). Criterio de aceptacion Sprint 0: cumplido. - uv sync: OK - pytest: 99/99 verde - cov vmssailor.core: 95.17% (objetivo >=80%) - ruff: clean - vms-validate-library: OK - vms-generate-test-project: INTEGRIDAD OK Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
"""Serializa un Project completo a un archivo .vmsproj (SQLite)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from vmssailor.core.persistence.migrations import (
|
||||
migrate_to_latest,
|
||||
stamp_schema_version,
|
||||
)
|
||||
from vmssailor.core.project import Project
|
||||
from vmssailor.version import VMSPROJ_SCHEMA_VERSION
|
||||
|
||||
_SCHEMA_FILE: Final[Path] = Path(__file__).with_name("schema.sql")
|
||||
|
||||
|
||||
def save_project(project: Project, path: str | Path) -> Path:
|
||||
"""Escribe `project` a un archivo .vmsproj.
|
||||
|
||||
Si el archivo existe, se sobreescribe. Devuelve la `Path` final.
|
||||
|
||||
Garantiza:
|
||||
- Schema aplicado y stampado con VMSPROJ_SCHEMA_VERSION.
|
||||
- Transacción atómica: si algo falla, el archivo destino no se altera.
|
||||
"""
|
||||
project.touch()
|
||||
|
||||
final = Path(path)
|
||||
final.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = final.with_suffix(final.suffix + ".tmp")
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
|
||||
schema_sql = _SCHEMA_FILE.read_text(encoding="utf-8")
|
||||
|
||||
conn = sqlite3.connect(str(tmp))
|
||||
try:
|
||||
conn.executescript(schema_sql)
|
||||
migrate_to_latest(conn)
|
||||
stamp_schema_version(conn, VMSPROJ_SCHEMA_VERSION)
|
||||
with conn:
|
||||
_write_meta(conn)
|
||||
_write_project(conn, project)
|
||||
_write_vessel(conn, project)
|
||||
_write_equipment(conn, project)
|
||||
_write_topology(conn, project)
|
||||
_write_tags(conn, project)
|
||||
_write_permissives(conn, project)
|
||||
conn.close()
|
||||
except Exception:
|
||||
conn.close()
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
raise
|
||||
|
||||
# Rename atómico (en Windows reemplaza si existe)
|
||||
if final.exists():
|
||||
final.unlink()
|
||||
tmp.rename(final)
|
||||
return final
|
||||
|
||||
|
||||
# --- Writers internos por tabla --------------------------------------------
|
||||
|
||||
|
||||
def _write_meta(conn: sqlite3.Connection) -> None:
|
||||
from vmssailor.version import __version__
|
||||
|
||||
rows = [
|
||||
("vmssailor_version", __version__),
|
||||
("vmsproj_schema_version", str(VMSPROJ_SCHEMA_VERSION)),
|
||||
("file_format", "vmsproj"),
|
||||
]
|
||||
conn.executemany(
|
||||
"INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)",
|
||||
rows,
|
||||
)
|
||||
|
||||
|
||||
def _write_project(conn: sqlite3.Connection, project: Project) -> None:
|
||||
systems_json = json.dumps([s.value for s in project.systems_enabled])
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO project
|
||||
(id, name, customer, notes, systems_enabled_json,
|
||||
created_at, updated_at, vmssailor_version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
project.id,
|
||||
project.name,
|
||||
project.customer,
|
||||
project.notes,
|
||||
systems_json,
|
||||
project.created_at.isoformat(),
|
||||
project.updated_at.isoformat(),
|
||||
project.vmssailor_version,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _write_vessel(conn: sqlite3.Connection, project: Project) -> None:
|
||||
v = project.vessel
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO vessel
|
||||
(id, name, type, subtype, length_overall_m, beam_max_m, draft_m,
|
||||
displacement_kg, silhouette_svg, description, data_source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
v.id,
|
||||
v.name,
|
||||
v.type.value,
|
||||
v.subtype.value,
|
||||
v.length_overall_m,
|
||||
v.beam_max_m,
|
||||
v.draft_m,
|
||||
v.displacement_kg,
|
||||
v.silhouette_svg,
|
||||
v.description,
|
||||
v.data_source,
|
||||
),
|
||||
)
|
||||
# Borrar y reinsertar decks + bulkheads de este vessel
|
||||
conn.execute("DELETE FROM deck WHERE vessel_id = ?", (v.id,))
|
||||
for d in v.decks:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO deck (vessel_id, id, name, z_bl_bottom, z_bl_top, polygon_xy_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
v.id,
|
||||
d.id,
|
||||
d.name,
|
||||
d.z_bl_bottom,
|
||||
d.z_bl_top,
|
||||
json.dumps(d.polygon_xy),
|
||||
),
|
||||
)
|
||||
conn.execute("DELETE FROM bulkhead WHERE vessel_id = ?", (v.id,))
|
||||
for b in v.bulkheads:
|
||||
conn.execute(
|
||||
"INSERT INTO bulkhead (vessel_id, id, name, x_pp, description) VALUES (?, ?, ?, ?, ?)",
|
||||
(v.id, b.id, b.name, b.x_pp, b.description),
|
||||
)
|
||||
|
||||
|
||||
def _write_equipment(conn: sqlite3.Connection, project: Project) -> None:
|
||||
conn.execute("DELETE FROM equipment")
|
||||
for e in project.equipment:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO equipment
|
||||
(id, model_ref, tag_prefix, display_name,
|
||||
x_pp, y_cl, z_bl, deck_id, system_id, description, installed)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
e.id,
|
||||
e.model_ref,
|
||||
e.tag_prefix,
|
||||
e.display_name,
|
||||
e.location.x_pp,
|
||||
e.location.y_cl,
|
||||
e.location.z_bl,
|
||||
e.deck_id,
|
||||
e.system_id.value,
|
||||
e.description,
|
||||
1 if e.installed else 0,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _write_topology(conn: sqlite3.Connection, project: Project) -> None:
|
||||
conn.execute("DELETE FROM card_instance")
|
||||
conn.execute("DELETE FROM bus")
|
||||
for b in project.topology.buses:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO bus
|
||||
(id, name, protocol, physical_port, baud_rate, parity, stop_bits,
|
||||
termination, description)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
b.id,
|
||||
b.name,
|
||||
b.protocol.value,
|
||||
b.physical_port,
|
||||
b.baud_rate,
|
||||
b.parity,
|
||||
b.stop_bits,
|
||||
1 if b.termination else 0,
|
||||
b.description,
|
||||
),
|
||||
)
|
||||
for c in project.topology.cards:
|
||||
x = c.location.x_pp if c.location else None
|
||||
y = c.location.y_cl if c.location else None
|
||||
z = c.location.z_bl if c.location else None
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO card_instance
|
||||
(id, serial_number, slot_number, bus_id, bus_role, modbus_address,
|
||||
physical_location, x_pp, y_cl, z_bl, firmware_version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
c.id,
|
||||
c.serial_number,
|
||||
c.slot_number,
|
||||
c.bus_id,
|
||||
c.bus_role.value,
|
||||
c.modbus_address,
|
||||
c.physical_location,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
c.firmware_version,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _write_tags(conn: sqlite3.Connection, project: Project) -> None:
|
||||
conn.execute("DELETE FROM alarm_config")
|
||||
conn.execute("DELETE FROM tag")
|
||||
for t in project.tags:
|
||||
b = t.physical_binding
|
||||
s = b.scaling if b is not None else None
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO tag (
|
||||
id, equipment_id, description, unit_si,
|
||||
range_normal_min, range_normal_max, quality_default,
|
||||
controllable, control_mode, authority_required,
|
||||
protocol, address, historize, historize_period_s,
|
||||
binding_card_id, binding_channel_type, binding_channel_number,
|
||||
binding_signal_type, binding_filter, binding_filter_param,
|
||||
binding_update_rate_ms,
|
||||
binding_scaling_raw_min, binding_scaling_raw_max,
|
||||
binding_scaling_eng_min, binding_scaling_eng_max
|
||||
)
|
||||
VALUES (?, ?, ?, ?,
|
||||
?, ?, ?,
|
||||
?, ?, ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
t.id,
|
||||
t.equipment_id,
|
||||
t.description,
|
||||
t.unit_si.value,
|
||||
t.range_normal_min,
|
||||
t.range_normal_max,
|
||||
t.quality_default.value,
|
||||
1 if t.controllable else 0,
|
||||
t.control_mode.value,
|
||||
t.authority_required.value,
|
||||
t.protocol.value,
|
||||
t.address,
|
||||
1 if t.historize else 0,
|
||||
t.historize_period_s,
|
||||
b.card_id if b else None,
|
||||
b.channel_type.value if b else None,
|
||||
b.channel_number if b else None,
|
||||
b.signal_type.value if b else None,
|
||||
b.filter.value if b else "none",
|
||||
b.filter_param if b else None,
|
||||
b.update_rate_ms if b else 100,
|
||||
s.raw_min if s else None,
|
||||
s.raw_max if s else None,
|
||||
s.eng_min if s else None,
|
||||
s.eng_max if s else None,
|
||||
),
|
||||
)
|
||||
for a in t.alarms:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO alarm_config
|
||||
(tag_id, id, threshold, operator, priority, hysteresis,
|
||||
delay_seconds, message, escalation_minutes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
t.id,
|
||||
a.id,
|
||||
a.threshold,
|
||||
a.operator,
|
||||
a.priority.value,
|
||||
a.hysteresis,
|
||||
a.delay_seconds,
|
||||
a.message,
|
||||
a.escalation_minutes,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _write_permissives(conn: sqlite3.Connection, project: Project) -> None:
|
||||
conn.execute("DELETE FROM permissive_condition")
|
||||
conn.execute("DELETE FROM permissive_rule")
|
||||
for r in project.permissive_rules:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO permissive_rule (id, action_id, description, on_fail_message)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(r.id, r.action_id, r.description, r.on_fail_message),
|
||||
)
|
||||
for seq, c in enumerate(r.conditions):
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO permissive_condition
|
||||
(rule_id, seq, tag_ref, operator, threshold,
|
||||
threshold_low, threshold_high, severity, message_on_fail)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
r.id,
|
||||
seq,
|
||||
c.tag_ref,
|
||||
c.operator,
|
||||
c.threshold,
|
||||
c.threshold_low,
|
||||
c.threshold_high,
|
||||
c.severity,
|
||||
c.message_on_fail,
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user