deb04c9315
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>
336 lines
11 KiB
Python
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,
|
|
),
|
|
)
|