Files
AR-VMS-Seaman/vmssailor/core/persistence/vmsproj_writer.py
T
alro65 deb04c9315 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>
2026-05-17 07:26:06 -04:00

336 lines
11 KiB
Python

"""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,
),
)