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:
2026-05-17 07:26:06 -04:00
commit deb04c9315
96 changed files with 15335 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
"""VMS-Sailor — Vessel Management System.
Producto propietario de Álvaro. Ver LICENSE.txt.
"""
from vmssailor.version import __version__
__all__ = ["__version__"]
+100
View File
@@ -0,0 +1,100 @@
"""vmssailor.core — Modelo de datos compartido entre Studio y Runtime.
Este módulo es el **corazón del sistema**. Define todas las entidades del
producto y se construye en Sprint 0 antes de cualquier UI o driver.
Entidades principales:
- `ShipCoord` — sistema de coordenadas naval (x_pp, y_cl, z_bl)
- `Vessel`, `Deck` — definición física del buque
- `Equipment`,
`EquipmentModel`,
`Sensor` — equipos y sus sensores típicos
- `Tag`, `AlarmConfig`,
`TagBinding` — punto I/O concreto y su mapeo físico
- `CardInstance`,
`Bus`, `Topology` — tarjetas AR-NMEA-IO y la red
- `Alarm` — instancia activa de alarma
- `PermissiveRule`,
`Condition` — pre-condiciones para acciones de control
- `Project` — agregado raíz que une todo lo anterior
Reglas de oro relevantes:
- Coordenadas navales SIEMPRE: ShipCoord.
- Unidades SI internas SIEMPRE: m, kg, Pa, °C, s.
- Idioma: español por defecto (campos `name`, `description` libres).
- Persistencia portable a SQLite (`.vmsproj`).
"""
from vmssailor.core.alarm import Alarm
from vmssailor.core.card import Bus, CardInstance, Topology
from vmssailor.core.coords import ShipCoord
from vmssailor.core.enums import (
AlarmPriority,
AlarmState,
AuthorityRequired,
BusRole,
ChannelType,
ControlMode,
EquipmentCategory,
FilterType,
Protocol,
Quality,
SignalType,
SystemId,
UnitSI,
VesselSubtype,
VesselType,
)
from vmssailor.core.equipment import Equipment, EquipmentModel, EquipmentSpec, Sensor
from vmssailor.core.permissive import Condition, PermissiveRule
from vmssailor.core.project import Project
from vmssailor.core.tag import AlarmConfig, Scaling, Tag, TagBinding
from vmssailor.core.vessel import Bulkhead, Deck, Vessel
__all__ = [ # noqa: RUF022 -- agrupado por entidad, no alfabético, para legibilidad
# coords
"ShipCoord",
# enums
"AlarmPriority",
"AlarmState",
"AuthorityRequired",
"BusRole",
"ChannelType",
"ControlMode",
"EquipmentCategory",
"FilterType",
"Protocol",
"Quality",
"SignalType",
"SystemId",
"UnitSI",
"VesselSubtype",
"VesselType",
# vessel
"Bulkhead",
"Deck",
"Vessel",
# equipment
"Equipment",
"EquipmentModel",
"EquipmentSpec",
"Sensor",
# tag
"AlarmConfig",
"Scaling",
"Tag",
"TagBinding",
# card
"Bus",
"CardInstance",
"Topology",
# alarm
"Alarm",
# permissive
"Condition",
"PermissiveRule",
# project (root aggregate)
"Project",
]
+83
View File
@@ -0,0 +1,83 @@
"""Instancia activa de alarma (estado runtime, no configuración).
La **configuración** de una alarma vive en `AlarmConfig` (ver `tag.py`).
La **instancia** de una alarma — qué se disparó, cuándo, quién acuso recibo —
vive aquí.
Estas instancias son las que persiste el Runtime en su tabla de alarmas y
las que la API WebSocket transmite en mensajes `alarm_event`.
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, model_validator
from vmssailor.core.enums import AlarmPriority, AlarmState
class Alarm(BaseModel):
"""Una alarma activa o histórica en el Runtime."""
model_config = ConfigDict(extra="forbid")
id: str = Field(..., min_length=1, max_length=128, description="UUID o ID determinista.")
tag_id: str = Field(..., min_length=1, max_length=128, description="Tag que disparó la alarma.")
alarm_config_id: str = Field(
...,
min_length=1,
max_length=128,
description="ID del AlarmConfig que se evaluó como verdadero.",
)
priority: AlarmPriority
state: AlarmState
timestamp_active: datetime = Field(
..., description="Cuándo se disparó (entró en ACTIVE)."
)
timestamp_ack: datetime | None = Field(
default=None, description="Cuándo el operador hizo ack. None si nunca."
)
timestamp_cleared: datetime | None = Field(
default=None,
description="Cuándo la condición se resolvió. None si sigue presente.",
)
acknowledged_by: str | None = Field(
default=None,
max_length=128,
description="Usuario que hizo ack. None si nunca.",
)
message: str = Field(default="", max_length=512)
value_at_trigger: float | None = Field(
default=None,
description="Valor del tag al momento del disparo (snapshot para logbook).",
)
@model_validator(mode="after")
def _state_timestamps_consistency(self) -> Alarm:
if self.state == AlarmState.ACK and self.timestamp_ack is None:
raise ValueError(
"Alarma en estado ACK requiere timestamp_ack."
)
if self.state == AlarmState.CLEARED and self.timestamp_cleared is None:
raise ValueError(
"Alarma en estado CLEARED requiere timestamp_cleared."
)
if (
self.timestamp_ack is not None
and self.timestamp_ack < self.timestamp_active
):
raise ValueError("timestamp_ack debe ser ≥ timestamp_active.")
if (
self.timestamp_cleared is not None
and self.timestamp_cleared < self.timestamp_active
):
raise ValueError("timestamp_cleared debe ser ≥ timestamp_active.")
if (
self.acknowledged_by is not None
and self.timestamp_ack is None
):
raise ValueError(
"acknowledged_by sin timestamp_ack es inconsistente."
)
return self
+219
View File
@@ -0,0 +1,219 @@
"""Tarjetas AR-NMEA-IO-v1.0 y la topología de buses.
Capacidades fijas por tarjeta (Parte 4 sec 1):
- 10 DO (Digital Output) — MOSFET IRLML6344TRPBF
- 5 DI (Digital Input) — Opto PC817
- 1 RPM (Frequency input)
- 4 AI (Analog Input)
- Comunicación: RS485 + CAN/NMEA 2000 + WiFi + USB
Slot dipswitch físico 1-16 por bus.
"""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from vmssailor.core.coords import ShipCoord
from vmssailor.core.enums import BusRole, Protocol
# ---------------------------------------------------------------------------
# Bus
# ---------------------------------------------------------------------------
class Bus(BaseModel):
"""Bus físico al que se conectan tarjetas o nodos NMEA 2000."""
model_config = ConfigDict(extra="forbid")
id: str = Field(..., min_length=1, max_length=64)
name: str = Field(..., min_length=1, max_length=128)
protocol: Protocol = Field(
...,
description="MODBUS_RTU o NMEA2000. Otros protocolos no son buses.",
)
physical_port: str = Field(
...,
min_length=1,
max_length=64,
description="Puerto físico del PC del Runtime: 'COM3', 'USB-CAN0', etc.",
)
baud_rate: int = Field(
default=115200,
ge=1200,
le=1_000_000,
description="Solo aplica a Modbus RTU. NMEA 2000 ignora este campo (CAN 250 kbps).",
)
parity: str = Field(default="N", pattern=r"^[NEO]$", description="N/E/O. Solo Modbus.")
stop_bits: int = Field(default=1, ge=1, le=2, description="1 o 2. Solo Modbus.")
termination: bool = Field(default=True, description="Resistencia de terminación 120Ω.")
description: str = Field(default="", max_length=512)
@model_validator(mode="after")
def _check_protocol(self) -> Bus:
if self.protocol not in (Protocol.MODBUS_RTU, Protocol.NMEA2000):
raise ValueError(
f"Un Bus solo puede ser MODBUS_RTU o NMEA2000, no {self.protocol.value}."
)
return self
# ---------------------------------------------------------------------------
# CardInstance — tarjeta concreta del proyecto
# ---------------------------------------------------------------------------
class CardInstance(BaseModel):
"""Una tarjeta AR-NMEA-IO-v1.0 colocada en un proyecto específico.
El hardware es siempre el mismo (una sola SKU). Lo que varía:
- Su rol en el bus (esclava Modbus, NMEA 2000 node, dual, bridge).
- Su slot dipswitch físico (1-16).
- Qué hace cada canal (vía `Tag.physical_binding`).
"""
model_config = ConfigDict(extra="forbid")
id: str = Field(..., min_length=1, max_length=64, description="ID lógico: 'card_001'.")
serial_number: str = Field(
default="",
max_length=64,
description="Serial físico de fábrica: 'ARC-2026-00153'. Vacío hasta comisionado.",
)
slot_number: int = Field(
...,
ge=1,
le=16,
description="Slot dipswitch físico (1-16) por bus.",
)
bus_id: str = Field(..., min_length=1, max_length=64, description="ID del Bus al que conecta.")
bus_role: BusRole
modbus_address: int | None = Field(
default=None,
ge=1,
le=247,
description="Dirección Modbus si bus_role implica esclavitud Modbus.",
)
physical_location: str = Field(
default="",
max_length=256,
description="Descripción humana: 'Sala máquinas, panel principal'.",
)
location: ShipCoord | None = Field(
default=None,
description="Coordenada aproximada de la tarjeta en el buque.",
)
firmware_version: str = Field(
default="0.0.0",
max_length=32,
description="Versión de firmware esperada/desplegada.",
)
# Capacidades fijas del hardware AR-NMEA-IO-v1.0 (Parte 4 sec 1)
CAP_DO: int = Field(default=10, frozen=True)
CAP_DI: int = Field(default=5, frozen=True)
CAP_RPM: int = Field(default=1, frozen=True)
CAP_AI: int = Field(default=4, frozen=True)
@model_validator(mode="after")
def _modbus_address_consistency(self) -> CardInstance:
needs_addr = self.bus_role in (
BusRole.MODBUS_SLAVE,
BusRole.DUAL,
BusRole.BRIDGE,
)
if needs_addr and self.modbus_address is None:
raise ValueError(
f"CardInstance '{self.id}' con bus_role={self.bus_role.value} "
"requiere modbus_address."
)
if not needs_addr and self.modbus_address is not None:
raise ValueError(
f"CardInstance '{self.id}' con bus_role={self.bus_role.value} "
"no debe tener modbus_address."
)
return self
# ---------------------------------------------------------------------------
# Topology — red completa
# ---------------------------------------------------------------------------
class Topology(BaseModel):
"""Red completa del proyecto: buses + cartas."""
model_config = ConfigDict(extra="forbid")
buses: list[Bus] = Field(default_factory=list)
cards: list[CardInstance] = Field(default_factory=list)
@field_validator("buses")
@classmethod
def _buses_unique_ids(cls, v: list[Bus]) -> list[Bus]:
ids = [b.id for b in v]
if len(ids) != len(set(ids)):
raise ValueError("Bus IDs deben ser únicos en una Topology.")
return v
@field_validator("cards")
@classmethod
def _cards_unique_ids(cls, v: list[CardInstance]) -> list[CardInstance]:
ids = [c.id for c in v]
if len(ids) != len(set(ids)):
raise ValueError("CardInstance IDs deben ser únicos en una Topology.")
return v
@model_validator(mode="after")
def _validate_cards_reference_existing_bus(self) -> Topology:
bus_ids = {b.id for b in self.buses}
for c in self.cards:
if c.bus_id not in bus_ids:
raise ValueError(
f"CardInstance '{c.id}' referencia bus '{c.bus_id}' que no "
f"existe en la topología (buses: {sorted(bus_ids)})."
)
return self
@model_validator(mode="after")
def _validate_unique_slots_per_bus(self) -> Topology:
seen: dict[tuple[str, int], str] = {}
for c in self.cards:
key = (c.bus_id, c.slot_number)
if key in seen:
raise ValueError(
f"Slot {c.slot_number} duplicado en bus '{c.bus_id}': "
f"cartas '{seen[key]}' y '{c.id}'."
)
seen[key] = c.id
return self
@model_validator(mode="after")
def _validate_unique_modbus_addresses_per_bus(self) -> Topology:
seen: dict[tuple[str, int], str] = {}
for c in self.cards:
if c.modbus_address is None:
continue
key = (c.bus_id, c.modbus_address)
if key in seen:
raise ValueError(
f"Modbus address {c.modbus_address} duplicada en bus "
f"'{c.bus_id}': cartas '{seen[key]}' y '{c.id}'."
)
seen[key] = c.id
return self
def card_by_id(self, card_id: str) -> CardInstance | None:
for c in self.cards:
if c.id == card_id:
return c
return None
def bus_by_id(self, bus_id: str) -> Bus | None:
for b in self.buses:
if b.id == bus_id:
return b
return None
+83
View File
@@ -0,0 +1,83 @@
"""Sistema de coordenadas naval.
Regla de oro #11: coordenadas navales consistentes en TODO el código.
- X: desde Perpendicular de Popa (Pp), positivo hacia proa.
- Y: desde Línea de Crujía (CL), positivo a estribor, negativo a babor.
- Z: desde Línea Base (BL), positivo hacia arriba.
Todo en metros (SI).
`ShipCoord` es inmutable (`frozen=True`). Cualquier transformación a
pantalla pasa por renderers (UI) explícitos, NO por métodos de esta clase.
"""
from __future__ import annotations
from typing import ClassVar
from pydantic import BaseModel, ConfigDict, Field
class ShipCoord(BaseModel):
"""Punto en el marco del buque (Pp / CL / BL).
Buques típicos del segmento 30-40 m:
- Eslora total: 20 - 60 m
- Manga máxima: 5 - 15 m
- Calado: 0.5 - 5 m
- Altura sobre BL: -3 m (quilla) a +20 m (mástil)
Los validadores permiten un margen extra para cubrir buques en
desarrollo y errores de captura del integrador.
"""
model_config = ConfigDict(frozen=True, extra="forbid")
x_pp: float = Field(
...,
ge=-5.0,
le=200.0,
description="Metros desde Perpendicular de Popa, positivo hacia proa.",
)
y_cl: float = Field(
...,
ge=-30.0,
le=30.0,
description="Metros desde Línea de Crujía, +estribor / -babor.",
)
z_bl: float = Field(
...,
ge=-10.0,
le=50.0,
description="Metros desde Línea Base, positivo hacia arriba.",
)
ORIGIN: ClassVar[str] = "Pp/CL/BL"
UNIT: ClassVar[str] = "m"
def as_tuple(self) -> tuple[float, float, float]:
"""Devuelve (x_pp, y_cl, z_bl) para serialización o cálculos numpy."""
return (self.x_pp, self.y_cl, self.z_bl)
def is_starboard(self) -> bool:
"""True si está a estribor (y_cl > 0)."""
return self.y_cl > 0.0
def is_port(self) -> bool:
"""True si está a babor (y_cl < 0)."""
return self.y_cl < 0.0
def is_centerline(self) -> bool:
"""True si está en línea de crujía (y_cl == 0, con tolerancia mm)."""
return abs(self.y_cl) < 1e-3
def distance_to(self, other: ShipCoord) -> float:
"""Distancia euclídea 3D en metros entre dos puntos del buque."""
dx = self.x_pp - other.x_pp
dy = self.y_cl - other.y_cl
dz = self.z_bl - other.z_bl
return (dx * dx + dy * dy + dz * dz) ** 0.5
def __str__(self) -> str:
return f"ShipCoord(x_pp={self.x_pp:.2f}, y_cl={self.y_cl:+.2f}, z_bl={self.z_bl:+.2f}) [m]"
+359
View File
@@ -0,0 +1,359 @@
"""Enums del modelo de datos core.
Toda enumeración del producto vive aquí para tener una única fuente de
verdad y poder serializarla consistentemente a `.vmsproj` y `.vmspack`.
Convención de valores: snake_case_minúsculas ASCII para que sean estables
en JSON/YAML/SQLite sin problemas de encoding.
"""
from __future__ import annotations
from enum import StrEnum
# ---------------------------------------------------------------------------
# Buque
# ---------------------------------------------------------------------------
class VesselType(StrEnum):
"""Categoría principal del buque (Parte 2 sec 3, Paso 1)."""
YACHT_MOTOR = "yacht_motor"
YACHT_SAIL = "yacht_sail"
FISHING = "fishing"
PATROL = "patrol"
FERRY = "ferry"
OFFSHORE_SUPPORT = "offshore_support"
class VesselSubtype(StrEnum):
"""Subcategoría que afina el tipo principal."""
# Yacht motor
PLANING = "planing"
SEMI_PLANING = "semi_planing"
DISPLACEMENT = "displacement"
# Fishing
PURSE_SEINER = "purse_seiner" # cerquero
TRAWLER = "trawler" # arrastrero
LONGLINER = "longliner" # palangrero
# Patrol
COASTAL = "coastal"
OCEANIC = "oceanic"
# Ferry
PASSENGER = "passenger"
ROLL_ON_ROLL_OFF = "ro_ro"
# Offshore support
AHTS = "ahts" # Anchor Handling Tug Supply
PSV = "psv" # Platform Supply Vessel
# Catch-all
OTHER = "other"
# ---------------------------------------------------------------------------
# Catálogo maestro de sistemas (Parte 1 sec 7)
# ---------------------------------------------------------------------------
class SystemId(StrEnum):
"""ID estable de cada sistema del catálogo maestro.
Coincide con `vmssailor/library/systems_catalog.json`. El menú lateral del
Runtime se genera a partir de los sistemas que el proyecto tenga
habilitados.
"""
# Propulsión y maquinaria
MAIN_ENGINE = "main_engine"
TRANSMISSION = "transmission"
SHAFT_PROPELLER = "shaft_propeller"
THRUSTER = "thruster"
# Maniobra y trimado
TRIM_STERNDRIVE = "trim_sterndrive"
TRIM_TABS = "trim_tabs"
CPP = "cpp"
GYROSTABILIZER = "gyrostabilizer"
FIN_STABILIZER = "fin_stabilizer"
JOYSTICK_DOCKING = "joystick_docking"
# Generación eléctrica
GENSET = "genset"
SHORE_POWER = "shore_power"
INVERTER_CHARGER = "inverter_charger"
BATTERY_BANK = "battery_bank"
MSB = "msb"
ESB = "esb"
UPS = "ups"
SOLAR = "solar"
SMART_DC_BUSBAR = "smart_dc_busbar"
SMART_PANEL = "smart_panel"
# Aislamiento eléctrico
SECTIONALIZING = "sectionalizing"
EMERGENCY_ISOLATION = "emergency_isolation"
BREAKERS = "breakers"
LOCKOUT_TAGOUT = "lockout_tagout"
# Fluidos
FUEL = "fuel"
LUBE_OIL = "lube_oil"
HYDRAULIC_OIL = "hydraulic_oil"
FW_COOLING = "fw_cooling"
SW_COOLING = "sw_cooling"
STARTING_AIR = "starting_air"
BILGE = "bilge"
BALLAST = "ballast"
GREY_WATER = "grey_water"
BLACK_WATER = "black_water"
POTABLE_WATER = "potable_water"
SW_SERVICE = "sw_service"
WATERMAKER = "watermaker"
# Seguridad
FIRE_DETECTION = "fire_detection"
FIRE_EXTINGUISHING = "fire_extinguishing"
FIFI_EXTERNAL = "fifi_external"
EMERGENCY_BILGE = "emergency_bilge"
GAS_DETECTION = "gas_detection"
MOB = "mob"
# Ambiente
HVAC = "hvac"
ENGINE_VENT = "engine_vent"
HEATING = "heating"
REFRIGERATION = "refrigeration"
# Iluminación
NAV_LIGHTS = "nav_lights"
DECK_LIGHTS = "deck_lights"
INTERIOR_LIGHTS = "interior_lights"
EMERGENCY_LIGHTS = "emergency_lights"
SEARCHLIGHTS = "searchlights"
# Tanques estructurales
FUEL_TANKS = "fuel_tanks"
WATER_TANKS = "water_tanks"
GREY_BLACK_TANKS = "grey_black_tanks"
VOIDS = "voids"
COFFERDAMS = "cofferdams"
# Cubierta y maniobra
WINDLASS = "windlass"
ANCHOR_SYSTEM = "anchor_system"
MOORING = "mooring"
DAVITS = "davits"
GANGWAY = "gangway"
CRANE = "crane"
# Específicos por tipo
FISHING_MACHINERY = "fishing_machinery"
LARGE_FRIDGE_HOLDS = "large_fridge_holds"
ROV = "rov"
DIVING_SYSTEM = "diving_system"
# ---------------------------------------------------------------------------
# Equipos
# ---------------------------------------------------------------------------
class EquipmentCategory(StrEnum):
"""Categoría de un EquipmentModel para clasificar la biblioteca."""
ENGINE_MAIN = "engine_main"
GENSET = "genset"
PUMP = "pump"
VALVE = "valve"
TANK = "tank"
HEAT_EXCHANGER = "heat_exchanger"
FILTER_SEPARATOR = "filter_separator"
COMPRESSOR = "compressor"
SENSOR = "sensor"
INDICATOR = "indicator"
BREAKER = "breaker"
INVERTER = "inverter"
BATTERY = "battery"
THRUSTER = "thruster"
STABILIZER = "stabilizer"
WATERMAKER = "watermaker"
LIGHTING = "lighting"
HVAC_UNIT = "hvac_unit"
OTHER = "other"
# ---------------------------------------------------------------------------
# Señales físicas y canales de tarjeta
# ---------------------------------------------------------------------------
class ChannelType(StrEnum):
"""Tipo de canal físico en la tarjeta AR-NMEA-IO-v1.0."""
AI = "ai" # Analog Input (4 por tarjeta)
DI = "di" # Digital Input (5 por tarjeta)
DO = "do" # Digital Output (10 por tarjeta)
RPM = "rpm" # Frequency input (1 por tarjeta)
class SignalType(StrEnum):
"""Tipo eléctrico de la señal conectada a un canal."""
# Analógicas
SIG_4_20_MA = "4-20ma"
SIG_0_10_V = "0-10v"
SIG_0_5_V = "0-5v"
RTD_PT100 = "rtd_pt100"
RTD_PT1000 = "rtd_pt1000"
THERMOCOUPLE_K = "thermocouple_k"
THERMOCOUPLE_J = "thermocouple_j"
RESISTIVE_SENDER = "resistive_sender" # tank sender, etc.
VOLTAGE_DIVIDER = "voltage_divider" # batería con divisor
# Digitales / discretas
DRY_CONTACT = "dry_contact"
CONTACT_24VDC = "contact_24vdc"
RELAY_NO = "relay_no"
RELAY_NC = "relay_nc"
# Frecuencia
PULSE_MAGNETIC_PICKUP = "pulse_magnetic_pickup"
PULSE_INDUCTIVE = "pulse_inductive"
PULSE_TACHO = "pulse_tacho"
# ---------------------------------------------------------------------------
# Protocolos y buses
# ---------------------------------------------------------------------------
class Protocol(StrEnum):
"""Protocolo por el que se accede a un tag."""
MODBUS_RTU = "modbus_rtu"
MODBUS_TCP = "modbus_tcp"
NMEA2000 = "nmea2000"
J1939 = "j1939"
INTERNAL = "internal" # tags virtuales calculados, no van al bus
class BusRole(StrEnum):
"""Rol de una tarjeta en su bus (Parte 2 sec 3, Paso 7)."""
MODBUS_SLAVE = "modbus_slave"
MODBUS_MASTER = "modbus_master"
NMEA2000_NODE = "nmea2000_node"
DUAL = "dual" # Modbus + NMEA 2000 simultáneo
BRIDGE = "bridge" # Lee NMEA 2000, expone como Modbus
# ---------------------------------------------------------------------------
# Filtros locales (firmware tarjeta)
# ---------------------------------------------------------------------------
class FilterType(StrEnum):
"""Filtro local que aplica la tarjeta antes de reportar (Parte 4 sec 7)."""
NONE = "none"
MOVING_AVG = "moving_avg"
MEDIAN = "median"
DEADBAND = "deadband"
RATE_LIMIT = "rate_limit"
# ---------------------------------------------------------------------------
# Tags: control, autoridad, calidad
# ---------------------------------------------------------------------------
class ControlMode(StrEnum):
"""Estado del tag respecto a control.
Filosofía monitor-now / control-later: todos los tags se definen con
capacidad de control desde el día 1 aunque empiecen como MONITOR.
"""
MONITOR = "monitor"
MANUAL = "manual"
AUTO = "auto"
FUTURE = "future" # actuador físico aún no instalado
class AuthorityRequired(StrEnum):
"""Qué estación debe tener autoridad para ejecutar el control."""
BRIDGE = "bridge"
ENGINE = "engine"
EITHER = "either"
class Quality(StrEnum):
"""Calidad del valor leído (OPC UA estilo)."""
GOOD = "good"
BAD = "bad"
UNCERTAIN = "uncertain"
STALE = "stale" # último conocido pero sensor offline
# ---------------------------------------------------------------------------
# Alarmas
# ---------------------------------------------------------------------------
class AlarmPriority(StrEnum):
"""Prioridad de alarma (Parte 3 sec 3, Parte 1 sec 6)."""
EMERGENCY = "emergency"
HIGH = "high"
LOW = "low"
INFO = "info"
class AlarmState(StrEnum):
"""Estado de una alarma activa."""
ACTIVE = "active" # disparada, no ack
ACK = "ack" # ack pero condición persiste
CLEARED = "cleared" # condición resuelta
# ---------------------------------------------------------------------------
# Unidades SI obligatorias internas
# ---------------------------------------------------------------------------
class UnitSI(StrEnum):
"""Lista cerrada de unidades SI permitidas en `Tag.unit_si` / `Sensor.unit_si`.
Regla de oro: todo internamente en SI. Conversión a imperial solo en UI.
"""
# Sin unidad / boolean
NONE = "none"
BOOL = "bool"
PERCENT = "%"
# Eléctricas
VOLT = "V"
AMPERE = "A"
WATT = "W"
KILOWATT = "kW"
KILOWATT_HOUR = "kWh"
HERTZ = "Hz"
OHM = "ohm"
# Mecánicas
RPM = "rpm"
NEWTON_METER = "Nm"
METER = "m"
METER_PER_SECOND = "m/s"
METER_PER_SECOND_SQ = "m/s2"
DEGREE = "deg"
DEGREE_PER_SECOND = "deg/s"
# Fluidos
PASCAL = "Pa"
KILOPASCAL = "kPa"
BAR = "bar"
LITER = "L"
CUBIC_METER = "m3"
LITER_PER_HOUR = "L/h"
LITER_PER_MINUTE = "L/min"
CUBIC_METER_PER_HOUR = "m3/h"
# Térmicas
DEGREE_CELSIUS = "C"
KELVIN = "K"
# Tiempo
SECOND = "s"
HOUR = "h"
# Masa
KILOGRAM = "kg"
TONNE = "t"
+154
View File
@@ -0,0 +1,154 @@
"""Equipos del proyecto y modelos catalogados en la biblioteca."""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field, field_validator
from vmssailor.core.coords import ShipCoord
from vmssailor.core.enums import (
AlarmPriority,
EquipmentCategory,
SignalType,
SystemId,
UnitSI,
)
class Sensor(BaseModel):
"""Sensor típico que pertenece a un EquipmentModel.
Esta es la "plantilla" del sensor declarada en la biblioteca curada.
Cuando un proyecto instancia un Equipment, los sensores se traducen a
`Tag`s concretos con su binding físico.
"""
model_config = ConfigDict(extra="forbid")
id: str = Field(..., min_length=1, max_length=64, description="Snake_case, ej: 'oil_press'.")
name: str = Field(..., min_length=1, max_length=128, description="Nombre humano.")
unit_si: UnitSI = Field(default=UnitSI.NONE)
range_normal_min: float | None = Field(
default=None, description="Mínimo del rango normal en unidad SI."
)
range_normal_max: float | None = Field(
default=None, description="Máximo del rango normal en unidad SI."
)
alarm_low_value: float | None = None
alarm_low_priority: AlarmPriority | None = None
alarm_high_value: float | None = None
alarm_high_priority: AlarmPriority | None = None
default_signal_type: SignalType | None = Field(
default=None,
description="Tipo de señal sugerido para conectar a la tarjeta.",
)
description: str = Field(default="", max_length=512)
@field_validator("range_normal_max")
@classmethod
def _max_above_min(cls, v: float | None, info) -> float | None:
rmin = info.data.get("range_normal_min")
if v is not None and rmin is not None and v <= rmin:
raise ValueError(
"range_normal_max debe ser > range_normal_min "
f"(min={rmin}, max={v})."
)
return v
class EquipmentSpec(BaseModel):
"""Especificaciones técnicas declarativas del modelo.
Mapa flexible para que cada `EquipmentCategory` pueda guardar sus
parámetros característicos (potencia para motores, capacidad para
tanques, etc.) sin proliferar clases hijas.
"""
model_config = ConfigDict(extra="allow")
power_kw: float | None = Field(default=None, ge=0.0, description="Potencia nominal [kW].")
rpm_nominal: float | None = Field(default=None, ge=0.0, description="RPM nominales.")
weight_kg: float | None = Field(default=None, ge=0.0, description="Peso [kg].")
length_m: float | None = Field(default=None, ge=0.0, description="Longitud [m].")
width_m: float | None = Field(default=None, ge=0.0, description="Ancho [m].")
height_m: float | None = Field(default=None, ge=0.0, description="Altura [m].")
voltage_v: float | None = Field(default=None, ge=0.0, description="Tensión nominal [V].")
current_a: float | None = Field(default=None, ge=0.0, description="Corriente nominal [A].")
fuel_consumption_lph: float | None = Field(
default=None, ge=0.0, description="Consumo a régimen [L/h]."
)
capacity_l: float | None = Field(default=None, ge=0.0, description="Capacidad [L].")
class EquipmentModel(BaseModel):
"""Modelo de equipo de la biblioteca curada.
Ejemplos: "MTU 12V 2000 M96", "Volvo D13 900hp", "Northern Lights M65C13".
Cada modelo declara qué sensores típicamente trae, lo cual permite al
wizard del Studio proponer tags automáticamente cuando el integrador
selecciona ese modelo.
"""
model_config = ConfigDict(extra="forbid")
id: str = Field(..., min_length=1, max_length=128)
manufacturer: str = Field(..., min_length=1, max_length=128)
model_name: str = Field(..., min_length=1, max_length=128)
category: EquipmentCategory
typical_systems: list[SystemId] = Field(
default_factory=list,
description="Sistemas en los que típicamente aparece este modelo.",
)
specs: EquipmentSpec = Field(default_factory=EquipmentSpec)
default_sensors: list[Sensor] = Field(default_factory=list)
description: str = Field(default="", max_length=2048)
data_source: str = Field(
default="seed_estimate",
description=(
"'seed_estimate' | 'manufacturer_datasheet' | 'field_measured' | 'user_input'. "
"Ver docs/seed_data_notes.md."
),
)
@field_validator("default_sensors")
@classmethod
def _sensors_unique_ids(cls, v: list[Sensor]) -> list[Sensor]:
ids = [s.id for s in v]
if len(ids) != len(set(ids)):
raise ValueError("Sensor IDs deben ser únicos dentro de un EquipmentModel.")
return v
class Equipment(BaseModel):
"""Instancia concreta de un equipo dentro de un proyecto.
Por ejemplo: el "Motor principal babor" del Sunseeker 76 del Sr. Pérez es
una instancia `Equipment` con `tag_prefix='ME_PORT'` que referencia al
`EquipmentModel` 'mtu_12v_2000_m96'.
"""
model_config = ConfigDict(extra="forbid")
id: str = Field(..., min_length=1, max_length=128, description="UUID o snake_case único.")
model_ref: str = Field(
...,
min_length=1,
max_length=128,
description="`EquipmentModel.id` referenciado.",
)
tag_prefix: str = Field(
...,
min_length=1,
max_length=32,
pattern=r"^[A-Z][A-Z0-9_]*$",
description="Prefijo de tags del equipo, MAYÚSCULAS: ME_PORT, GEN_1, BILGE_PUMP_FWD.",
)
display_name: str = Field(..., min_length=1, max_length=128)
location: ShipCoord
deck_id: str | None = Field(default=None, description="ID de la Deck donde está montado.")
system_id: SystemId = Field(..., description="Sistema al que pertenece este equipo.")
description: str = Field(default="", max_length=1024)
installed: bool = Field(
default=True,
description="Si False, el equipo está planeado pero no instalado todavía.",
)
+105
View File
@@ -0,0 +1,105 @@
"""Permissive Engine — pre-condiciones declarativas para acciones de control.
Cada acción de control crítica (arrancar motor, abrir válvula de mar, etc.)
debe pasar TODAS sus pre-condiciones antes de ejecutarse. Las pre-condiciones
se evalúan en el **servidor** del Runtime, no en la UI — única fuente de
verdad (Parte 1 sec 9).
Estados posibles de cada permissive:
- **OK**: condición cumplida.
- **FAIL**: condición no cumplida, BLOQUEA acción.
- **WARNING**: el sensor que debería verificar la condición no existe o está
en estado inválido. Requiere override consciente del Admin del buque.
- **N/A**: la pre-condición no aplica a este buque.
"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
OperatorStr = Literal["==", "!=", ">", "<", ">=", "<=", "between", "is_true", "is_false"]
class Condition(BaseModel):
"""Una pre-condición evaluable contra el valor de un tag."""
model_config = ConfigDict(extra="forbid")
tag_ref: str = Field(
...,
min_length=1,
max_length=128,
description="Tag.id a evaluar.",
)
operator: OperatorStr = Field(
...,
description="Operador de comparación. 'between' usa threshold_low + threshold_high.",
)
threshold: float | None = Field(
default=None,
description="Para operadores escalares (==, !=, >, <, >=, <=).",
)
threshold_low: float | None = Field(
default=None, description="Para 'between' (límite inferior)."
)
threshold_high: float | None = Field(
default=None, description="Para 'between' (límite superior)."
)
severity: Literal["fail", "warning"] = Field(
default="fail",
description=(
"'fail' bloquea la acción. 'warning' permite pero exige override "
"consciente del Admin del buque."
),
)
message_on_fail: str = Field(
default="", max_length=512, description="Mensaje al operador si falla."
)
@model_validator(mode="after")
def _between_requires_both(self) -> Condition:
if self.operator == "between" and (
self.threshold_low is None or self.threshold_high is None
):
raise ValueError(
"Operator 'between' requiere threshold_low y threshold_high."
)
if self.operator in ("==", "!=", ">", "<", ">=", "<=") and self.threshold is None:
raise ValueError(
f"Operator '{self.operator}' requiere threshold."
)
return self
class PermissiveRule(BaseModel):
"""Conjunto de pre-condiciones que rigen una acción de control."""
model_config = ConfigDict(extra="forbid")
id: str = Field(..., min_length=1, max_length=128)
action_id: str = Field(
...,
min_length=1,
max_length=128,
description="ID de la acción que protege, ej: 'START_ME_PORT'.",
)
description: str = Field(default="", max_length=512)
conditions: list[Condition] = Field(default_factory=list)
on_fail_message: str = Field(
default="",
max_length=512,
description="Mensaje agregado al operador cuando el conjunto falla.",
)
@field_validator("conditions")
@classmethod
def _at_least_one_condition(cls, v: list[Condition]) -> list[Condition]:
if not v:
raise ValueError(
"PermissiveRule requiere al menos 1 condition (use lista vacía solo si "
"la acción no tiene permissives, en cuyo caso no debería existir esta regla)."
)
return v
+18
View File
@@ -0,0 +1,18 @@
"""Persistencia portable de Project a SQLite (.vmsproj).
Un .vmsproj es un archivo SQLite único que contiene toda la configuración
del proyecto. Es **portable**: se puede copiar entre máquinas, abrir con
cualquier visor SQLite, y reconstruir el `Project` en memoria via roundtrip.
API pública:
- `save_project(project, path)` — escribe el Project a disco
- `load_project(path)` — reconstruye el Project desde disco
El roundtrip está garantizado: `load_project(save_project(p)) == p`.
"""
from vmssailor.core.persistence.vmsproj_reader import load_project
from vmssailor.core.persistence.vmsproj_writer import save_project
__all__ = ["load_project", "save_project"]
+46
View File
@@ -0,0 +1,46 @@
"""Migraciones de schema entre versiones de .vmsproj.
Sprint 0 sólo tiene v1. Cuando agreguemos columnas o tablas en sprints
futuros, agregamos funciones `_migrate_v1_to_v2(conn)`, etc.
"""
from __future__ import annotations
import sqlite3
from datetime import UTC, datetime
from vmssailor.version import VMSPROJ_SCHEMA_VERSION
def current_schema_version(conn: sqlite3.Connection) -> int:
"""Devuelve la versión activa en este .vmsproj, o 0 si no hay tabla."""
row = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='schema_version'"
).fetchone()
if not row:
return 0
row = conn.execute(
"SELECT MAX(version) FROM schema_version"
).fetchone()
return int(row[0]) if row and row[0] is not None else 0
def stamp_schema_version(conn: sqlite3.Connection, version: int) -> None:
"""Registra que el schema fue aplicado/migrado a `version`."""
ts = datetime.now(UTC).isoformat()
conn.execute(
"INSERT OR REPLACE INTO schema_version(version, applied_at) VALUES (?, ?)",
(version, ts),
)
def migrate_to_latest(conn: sqlite3.Connection) -> None:
"""Migra el schema desde su versión actual hasta `VMSPROJ_SCHEMA_VERSION`.
En Sprint 0 sólo aplicamos el schema inicial v1.
"""
current = current_schema_version(conn)
if current >= VMSPROJ_SCHEMA_VERSION:
return
# Sprint 0: una sola versión, no hay migraciones intermedias.
stamp_schema_version(conn, VMSPROJ_SCHEMA_VERSION)
+186
View File
@@ -0,0 +1,186 @@
-- VMS-Sailor .vmsproj schema v1
-- SQLite portable. Cada proyecto = 1 archivo. Sprint 0.
PRAGMA foreign_keys = ON;
-- ----- Meta del archivo -----------------------------------------------------
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- ----- Project (singleton) --------------------------------------------------
CREATE TABLE IF NOT EXISTS project (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
customer TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
systems_enabled_json TEXT NOT NULL, -- JSON array of SystemId values
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
vmssailor_version TEXT NOT NULL
);
-- ----- Vessel ---------------------------------------------------------------
CREATE TABLE IF NOT EXISTS vessel (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
subtype TEXT NOT NULL,
length_overall_m REAL NOT NULL,
beam_max_m REAL NOT NULL,
draft_m REAL NOT NULL,
displacement_kg REAL,
silhouette_svg TEXT,
description TEXT NOT NULL DEFAULT '',
data_source TEXT NOT NULL DEFAULT 'user_input'
);
CREATE TABLE IF NOT EXISTS deck (
vessel_id TEXT NOT NULL,
id TEXT NOT NULL,
name TEXT NOT NULL,
z_bl_bottom REAL NOT NULL,
z_bl_top REAL NOT NULL,
polygon_xy_json TEXT NOT NULL DEFAULT '[]',
PRIMARY KEY (vessel_id, id),
FOREIGN KEY (vessel_id) REFERENCES vessel(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS bulkhead (
vessel_id TEXT NOT NULL,
id TEXT NOT NULL,
name TEXT NOT NULL,
x_pp REAL NOT NULL,
description TEXT NOT NULL DEFAULT '',
PRIMARY KEY (vessel_id, id),
FOREIGN KEY (vessel_id) REFERENCES vessel(id) ON DELETE CASCADE
);
-- ----- Equipment ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS equipment (
id TEXT PRIMARY KEY,
model_ref TEXT NOT NULL,
tag_prefix TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
x_pp REAL NOT NULL,
y_cl REAL NOT NULL,
z_bl REAL NOT NULL,
deck_id TEXT,
system_id TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
installed INTEGER NOT NULL DEFAULT 1
);
-- ----- Bus / CardInstance / Topology ---------------------------------------
CREATE TABLE IF NOT EXISTS bus (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
protocol TEXT NOT NULL,
physical_port TEXT NOT NULL,
baud_rate INTEGER NOT NULL DEFAULT 115200,
parity TEXT NOT NULL DEFAULT 'N',
stop_bits INTEGER NOT NULL DEFAULT 1,
termination INTEGER NOT NULL DEFAULT 1,
description TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS card_instance (
id TEXT PRIMARY KEY,
serial_number TEXT NOT NULL DEFAULT '',
slot_number INTEGER NOT NULL,
bus_id TEXT NOT NULL,
bus_role TEXT NOT NULL,
modbus_address INTEGER,
physical_location TEXT NOT NULL DEFAULT '',
x_pp REAL,
y_cl REAL,
z_bl REAL,
firmware_version TEXT NOT NULL DEFAULT '0.0.0',
FOREIGN KEY (bus_id) REFERENCES bus(id) ON DELETE CASCADE
);
-- ----- Tag + binding + alarms ----------------------------------------------
CREATE TABLE IF NOT EXISTS tag (
id TEXT PRIMARY KEY,
equipment_id TEXT,
description TEXT NOT NULL DEFAULT '',
unit_si TEXT NOT NULL DEFAULT 'none',
range_normal_min REAL,
range_normal_max REAL,
quality_default TEXT NOT NULL DEFAULT 'good',
controllable INTEGER NOT NULL DEFAULT 0,
control_mode TEXT NOT NULL DEFAULT 'monitor',
authority_required TEXT NOT NULL DEFAULT 'either',
protocol TEXT NOT NULL DEFAULT 'modbus_rtu',
address INTEGER,
historize INTEGER NOT NULL DEFAULT 1,
historize_period_s REAL NOT NULL DEFAULT 1.0,
-- Physical binding inlined (1:1 with tag, optional)
binding_card_id TEXT,
binding_channel_type TEXT,
binding_channel_number INTEGER,
binding_signal_type TEXT,
binding_filter TEXT NOT NULL DEFAULT 'none',
binding_filter_param REAL,
binding_update_rate_ms INTEGER NOT NULL DEFAULT 100,
binding_scaling_raw_min REAL,
binding_scaling_raw_max REAL,
binding_scaling_eng_min REAL,
binding_scaling_eng_max REAL
);
CREATE TABLE IF NOT EXISTS alarm_config (
tag_id TEXT NOT NULL,
id TEXT NOT NULL,
threshold REAL NOT NULL,
operator TEXT NOT NULL,
priority TEXT NOT NULL,
hysteresis REAL NOT NULL DEFAULT 0.0,
delay_seconds REAL NOT NULL DEFAULT 0.0,
message TEXT NOT NULL DEFAULT '',
escalation_minutes INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (tag_id, id),
FOREIGN KEY (tag_id) REFERENCES tag(id) ON DELETE CASCADE
);
-- ----- Permissive rules + conditions ---------------------------------------
CREATE TABLE IF NOT EXISTS permissive_rule (
id TEXT PRIMARY KEY,
action_id TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
on_fail_message TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS permissive_condition (
rule_id TEXT NOT NULL,
seq INTEGER NOT NULL,
tag_ref TEXT NOT NULL,
operator TEXT NOT NULL,
threshold REAL,
threshold_low REAL,
threshold_high REAL,
severity TEXT NOT NULL DEFAULT 'fail',
message_on_fail TEXT NOT NULL DEFAULT '',
PRIMARY KEY (rule_id, seq),
FOREIGN KEY (rule_id) REFERENCES permissive_rule(id) ON DELETE CASCADE
);
-- ----- Índices auxiliares --------------------------------------------------
CREATE INDEX IF NOT EXISTS idx_equipment_system ON equipment(system_id);
CREATE INDEX IF NOT EXISTS idx_tag_equipment ON tag(equipment_id);
CREATE INDEX IF NOT EXISTS idx_tag_protocol ON tag(protocol);
CREATE INDEX IF NOT EXISTS idx_card_bus ON card_instance(bus_id);
@@ -0,0 +1,365 @@
"""Reconstruye un Project desde un archivo .vmsproj (SQLite)."""
from __future__ import annotations
import json
import sqlite3
from datetime import datetime
from pathlib import Path
from vmssailor.core.alarm import Alarm # noqa: F401 (re-export consistency)
from vmssailor.core.card import Bus, CardInstance, Topology
from vmssailor.core.coords import ShipCoord
from vmssailor.core.enums import (
AlarmPriority,
AuthorityRequired,
BusRole,
ChannelType,
ControlMode,
FilterType,
Protocol,
Quality,
SignalType,
SystemId,
UnitSI,
VesselSubtype,
VesselType,
)
from vmssailor.core.equipment import Equipment
from vmssailor.core.permissive import Condition, PermissiveRule
from vmssailor.core.persistence.migrations import current_schema_version
from vmssailor.core.project import Project
from vmssailor.core.tag import AlarmConfig, Scaling, Tag, TagBinding
from vmssailor.core.vessel import Bulkhead, Deck, Vessel
from vmssailor.version import VMSPROJ_SCHEMA_VERSION
class VmsProjError(Exception):
"""Error al cargar un .vmsproj."""
def load_project(path: str | Path) -> Project:
"""Reconstruye un `Project` desde un archivo `.vmsproj`.
Garantiza: `load_project(save_project(p))` produce un Project
equivalente campo por campo.
"""
p = Path(path)
if not p.exists():
raise VmsProjError(f"Archivo .vmsproj no encontrado: {p}")
conn = sqlite3.connect(str(p))
conn.row_factory = sqlite3.Row
try:
ver = current_schema_version(conn)
if ver == 0:
raise VmsProjError(
f"Archivo {p} no tiene tabla schema_version — probablemente no es .vmsproj válido."
)
if ver > VMSPROJ_SCHEMA_VERSION:
raise VmsProjError(
f"Schema version {ver} es más nueva que la soportada por esta versión "
f"de vmssailor ({VMSPROJ_SCHEMA_VERSION}). Actualizar el código."
)
vessel = _read_vessel(conn)
equipment = _read_equipment(conn)
topology = _read_topology(conn)
tags = _read_tags(conn)
rules = _read_permissive_rules(conn)
project = _read_project(conn, vessel, equipment, topology, tags, rules)
finally:
conn.close()
return project
# --- Readers internos por tabla --------------------------------------------
def _read_project(
conn: sqlite3.Connection,
vessel: Vessel,
equipment: list[Equipment],
topology: Topology,
tags: list[Tag],
rules: list[PermissiveRule],
) -> Project:
row = conn.execute(
"""
SELECT id, name, customer, notes, systems_enabled_json,
created_at, updated_at, vmssailor_version
FROM project
LIMIT 1
"""
).fetchone()
if row is None:
raise VmsProjError("Tabla 'project' vacía.")
systems_raw = json.loads(row["systems_enabled_json"])
systems = [SystemId(s) for s in systems_raw]
return Project(
id=row["id"],
name=row["name"],
customer=row["customer"] or "",
notes=row["notes"] or "",
systems_enabled=systems,
vessel=vessel,
equipment=equipment,
topology=topology,
tags=tags,
permissive_rules=rules,
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
vmssailor_version=row["vmssailor_version"],
)
def _read_vessel(conn: sqlite3.Connection) -> Vessel:
row = conn.execute(
"""
SELECT id, name, type, subtype, length_overall_m, beam_max_m, draft_m,
displacement_kg, silhouette_svg, description, data_source
FROM vessel
LIMIT 1
"""
).fetchone()
if row is None:
raise VmsProjError("Tabla 'vessel' vacía.")
decks_rows = conn.execute(
"SELECT id, name, z_bl_bottom, z_bl_top, polygon_xy_json FROM deck "
"WHERE vessel_id = ? ORDER BY rowid",
(row["id"],),
).fetchall()
decks = [
Deck(
id=d["id"],
name=d["name"],
z_bl_bottom=d["z_bl_bottom"],
z_bl_top=d["z_bl_top"],
polygon_xy=[tuple(p) for p in json.loads(d["polygon_xy_json"])],
)
for d in decks_rows
]
bulks_rows = conn.execute(
"SELECT id, name, x_pp, description FROM bulkhead WHERE vessel_id = ? ORDER BY rowid",
(row["id"],),
).fetchall()
bulkheads = [
Bulkhead(id=b["id"], name=b["name"], x_pp=b["x_pp"], description=b["description"] or "")
for b in bulks_rows
]
return Vessel(
id=row["id"],
name=row["name"],
type=VesselType(row["type"]),
subtype=VesselSubtype(row["subtype"]),
length_overall_m=row["length_overall_m"],
beam_max_m=row["beam_max_m"],
draft_m=row["draft_m"],
displacement_kg=row["displacement_kg"],
silhouette_svg=row["silhouette_svg"],
description=row["description"] or "",
data_source=row["data_source"] or "user_input",
decks=decks,
bulkheads=bulkheads,
)
def _read_equipment(conn: sqlite3.Connection) -> list[Equipment]:
rows = conn.execute(
"""
SELECT id, model_ref, tag_prefix, display_name,
x_pp, y_cl, z_bl, deck_id, system_id, description, installed
FROM equipment
ORDER BY rowid
"""
).fetchall()
return [
Equipment(
id=r["id"],
model_ref=r["model_ref"],
tag_prefix=r["tag_prefix"],
display_name=r["display_name"],
location=ShipCoord(x_pp=r["x_pp"], y_cl=r["y_cl"], z_bl=r["z_bl"]),
deck_id=r["deck_id"],
system_id=SystemId(r["system_id"]),
description=r["description"] or "",
installed=bool(r["installed"]),
)
for r in rows
]
def _read_topology(conn: sqlite3.Connection) -> Topology:
bus_rows = conn.execute(
"""
SELECT id, name, protocol, physical_port, baud_rate, parity, stop_bits,
termination, description
FROM bus ORDER BY rowid
"""
).fetchall()
buses = [
Bus(
id=b["id"],
name=b["name"],
protocol=Protocol(b["protocol"]),
physical_port=b["physical_port"],
baud_rate=b["baud_rate"],
parity=b["parity"],
stop_bits=b["stop_bits"],
termination=bool(b["termination"]),
description=b["description"] or "",
)
for b in bus_rows
]
card_rows = conn.execute(
"""
SELECT id, serial_number, slot_number, bus_id, bus_role, modbus_address,
physical_location, x_pp, y_cl, z_bl, firmware_version
FROM card_instance
ORDER BY rowid
"""
).fetchall()
cards: list[CardInstance] = []
for r in card_rows:
loc = None
if r["x_pp"] is not None and r["y_cl"] is not None and r["z_bl"] is not None:
loc = ShipCoord(x_pp=r["x_pp"], y_cl=r["y_cl"], z_bl=r["z_bl"])
cards.append(
CardInstance(
id=r["id"],
serial_number=r["serial_number"] or "",
slot_number=r["slot_number"],
bus_id=r["bus_id"],
bus_role=BusRole(r["bus_role"]),
modbus_address=r["modbus_address"],
physical_location=r["physical_location"] or "",
location=loc,
firmware_version=r["firmware_version"] or "0.0.0",
)
)
return Topology(buses=buses, cards=cards)
def _read_tags(conn: sqlite3.Connection) -> list[Tag]:
rows = conn.execute(
"""
SELECT
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
FROM tag ORDER BY rowid
"""
).fetchall()
tags: list[Tag] = []
for r in rows:
binding = None
if r["binding_card_id"]:
scaling = None
if r["binding_scaling_raw_min"] is not None:
scaling = Scaling(
raw_min=r["binding_scaling_raw_min"],
raw_max=r["binding_scaling_raw_max"],
eng_min=r["binding_scaling_eng_min"],
eng_max=r["binding_scaling_eng_max"],
)
binding = TagBinding(
card_id=r["binding_card_id"],
channel_type=ChannelType(r["binding_channel_type"]),
channel_number=r["binding_channel_number"],
signal_type=SignalType(r["binding_signal_type"]),
filter=FilterType(r["binding_filter"] or "none"),
filter_param=r["binding_filter_param"],
update_rate_ms=r["binding_update_rate_ms"],
scaling=scaling,
)
alarm_rows = conn.execute(
"""
SELECT id, threshold, operator, priority, hysteresis,
delay_seconds, message, escalation_minutes
FROM alarm_config WHERE tag_id = ? ORDER BY rowid
""",
(r["id"],),
).fetchall()
alarms = [
AlarmConfig(
id=a["id"],
threshold=a["threshold"],
operator=a["operator"],
priority=AlarmPriority(a["priority"]),
hysteresis=a["hysteresis"],
delay_seconds=a["delay_seconds"],
message=a["message"] or "",
escalation_minutes=a["escalation_minutes"],
)
for a in alarm_rows
]
tags.append(
Tag(
id=r["id"],
equipment_id=r["equipment_id"],
description=r["description"] or "",
unit_si=UnitSI(r["unit_si"]),
range_normal_min=r["range_normal_min"],
range_normal_max=r["range_normal_max"],
quality_default=Quality(r["quality_default"]),
alarms=alarms,
controllable=bool(r["controllable"]),
control_mode=ControlMode(r["control_mode"]),
authority_required=AuthorityRequired(r["authority_required"]),
protocol=Protocol(r["protocol"]),
address=r["address"],
physical_binding=binding,
historize=bool(r["historize"]),
historize_period_s=r["historize_period_s"],
)
)
return tags
def _read_permissive_rules(conn: sqlite3.Connection) -> list[PermissiveRule]:
rows = conn.execute(
"SELECT id, action_id, description, on_fail_message FROM permissive_rule ORDER BY rowid"
).fetchall()
out: list[PermissiveRule] = []
for r in rows:
cond_rows = conn.execute(
"""
SELECT seq, tag_ref, operator, threshold, threshold_low,
threshold_high, severity, message_on_fail
FROM permissive_condition WHERE rule_id = ? ORDER BY seq
""",
(r["id"],),
).fetchall()
conds = [
Condition(
tag_ref=c["tag_ref"],
operator=c["operator"],
threshold=c["threshold"],
threshold_low=c["threshold_low"],
threshold_high=c["threshold_high"],
severity=c["severity"],
message_on_fail=c["message_on_fail"] or "",
)
for c in cond_rows
]
out.append(
PermissiveRule(
id=r["id"],
action_id=r["action_id"],
description=r["description"] or "",
on_fail_message=r["on_fail_message"] or "",
conditions=conds,
)
)
return out
@@ -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,
),
)
+188
View File
@@ -0,0 +1,188 @@
"""Project — agregado raíz del modelo de datos.
Un Project es el conjunto completo de configuración para UN buque de UN
cliente. Se persiste como archivo único `.vmsproj` (SQLite portable) y se
compila a `.vmspack` (ZIP firmado) para distribuir al Runtime del buque.
"""
from __future__ import annotations
from datetime import UTC, datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from vmssailor.core.card import Topology
from vmssailor.core.enums import SystemId
from vmssailor.core.equipment import Equipment
from vmssailor.core.permissive import PermissiveRule
from vmssailor.core.tag import Tag
from vmssailor.core.vessel import Vessel
from vmssailor.version import __version__
def _now_utc() -> datetime:
return datetime.now(UTC)
class Project(BaseModel):
"""Configuración completa de un buque de un cliente."""
model_config = ConfigDict(extra="forbid")
id: str = Field(
...,
min_length=1,
max_length=128,
pattern=r"^[a-z0-9][a-z0-9_-]*$",
description="ID estable snake_case-kebab, ej: 'm_y_aurora_sunseeker_76'.",
)
name: str = Field(..., min_length=1, max_length=256, description='Nombre humano: "M/Y Aurora".')
customer: str = Field(default="", max_length=256)
notes: str = Field(default="", max_length=4096)
vessel: Vessel
systems_enabled: list[SystemId] = Field(
default_factory=list,
description="Sistemas habilitados — definen el menú lateral del Runtime.",
)
equipment: list[Equipment] = Field(default_factory=list)
tags: list[Tag] = Field(default_factory=list)
topology: Topology = Field(default_factory=Topology)
permissive_rules: list[PermissiveRule] = Field(default_factory=list)
created_at: datetime = Field(default_factory=_now_utc)
updated_at: datetime = Field(default_factory=_now_utc)
vmssailor_version: str = Field(default=__version__, max_length=32)
# ---- Validadores ----------------------------------------------------
@field_validator("systems_enabled")
@classmethod
def _systems_unique(cls, v: list[SystemId]) -> list[SystemId]:
if len(v) != len(set(v)):
raise ValueError("systems_enabled no debe contener duplicados.")
return v
@field_validator("equipment")
@classmethod
def _equipment_unique_ids(cls, v: list[Equipment]) -> list[Equipment]:
ids = [e.id for e in v]
if len(ids) != len(set(ids)):
raise ValueError("Equipment IDs deben ser únicos en un Project.")
prefixes = [e.tag_prefix for e in v]
if len(prefixes) != len(set(prefixes)):
raise ValueError("Equipment tag_prefix deben ser únicos en un Project.")
return v
@field_validator("tags")
@classmethod
def _tags_unique_ids(cls, v: list[Tag]) -> list[Tag]:
ids = [t.id for t in v]
if len(ids) != len(set(ids)):
raise ValueError("Tag IDs deben ser únicos en un Project.")
return v
@field_validator("permissive_rules")
@classmethod
def _rules_unique_ids(cls, v: list[PermissiveRule]) -> list[PermissiveRule]:
ids = [r.id for r in v]
if len(ids) != len(set(ids)):
raise ValueError("PermissiveRule IDs deben ser únicos en un Project.")
return v
@model_validator(mode="after")
def _equipment_systems_must_be_enabled(self) -> Project:
enabled = set(self.systems_enabled)
for eq in self.equipment:
if eq.system_id not in enabled:
raise ValueError(
f"Equipment '{eq.id}' pertenece a sistema "
f"'{eq.system_id.value}' que no está en systems_enabled."
)
return self
@model_validator(mode="after")
def _tags_reference_existing_equipment(self) -> Project:
eq_ids = {e.id for e in self.equipment}
for t in self.tags:
if t.equipment_id is not None and t.equipment_id not in eq_ids:
raise ValueError(
f"Tag '{t.id}' referencia equipment_id='{t.equipment_id}' "
"que no existe en este Project."
)
return self
@model_validator(mode="after")
def _tag_bindings_reference_existing_cards(self) -> Project:
card_ids = {c.id for c in self.topology.cards}
for t in self.tags:
if t.physical_binding is None:
continue
if t.physical_binding.card_id not in card_ids:
raise ValueError(
f"Tag '{t.id}' tiene physical_binding.card_id="
f"'{t.physical_binding.card_id}' que no existe en topology."
)
return self
@model_validator(mode="after")
def _decks_referenced_by_equipment_exist(self) -> Project:
deck_ids = {d.id for d in self.vessel.decks}
for eq in self.equipment:
if eq.deck_id is not None and eq.deck_id not in deck_ids:
raise ValueError(
f"Equipment '{eq.id}' referencia deck_id='{eq.deck_id}' "
"que no existe en vessel.decks."
)
return self
@model_validator(mode="after")
def _permissive_conditions_reference_existing_tags(self) -> Project:
tag_ids = {t.id for t in self.tags}
for rule in self.permissive_rules:
for cond in rule.conditions:
if cond.tag_ref not in tag_ids:
raise ValueError(
f"PermissiveRule '{rule.id}' tiene Condition.tag_ref="
f"'{cond.tag_ref}' que no existe en tags."
)
return self
# ---- Conveniencias --------------------------------------------------
def equipment_by_id(self, equipment_id: str) -> Equipment | None:
for e in self.equipment:
if e.id == equipment_id:
return e
return None
def tag_by_id(self, tag_id: str) -> Tag | None:
for t in self.tags:
if t.id == tag_id:
return t
return None
def tags_for_equipment(self, equipment_id: str) -> list[Tag]:
return [t for t in self.tags if t.equipment_id == equipment_id]
def tags_for_system(self, system_id: SystemId) -> list[Tag]:
eq_ids = {e.id for e in self.equipment if e.system_id == system_id}
return [t for t in self.tags if t.equipment_id in eq_ids]
def touch(self) -> None:
"""Actualiza `updated_at`. Llamar antes de cada `save`."""
self.updated_at = _now_utc()
def stats(self) -> dict[str, int]:
"""Resumen numérico para debug y para el panel resumen del Studio."""
return {
"systems": len(self.systems_enabled),
"equipment": len(self.equipment),
"tags": len(self.tags),
"tags_with_alarms": sum(1 for t in self.tags if t.alarms),
"tags_controllable": sum(1 for t in self.tags if t.controllable),
"buses": len(self.topology.buses),
"cards": len(self.topology.cards),
"permissive_rules": len(self.permissive_rules),
"permissive_conditions": sum(len(r.conditions) for r in self.permissive_rules),
}
+238
View File
@@ -0,0 +1,238 @@
"""Tags: punto I/O concreto del proyecto.
Un Tag representa un valor que el sistema lee (sensor) o controla (actuador).
Cada tag tiene:
- `id` estable usado en mímicos, alarmas, permissives.
- `unit_si` obligatorio (regla de oro #12).
- `controllable` + `control_mode` (filosofía monitor-now / control-later).
- `protocol` + `address` para saber cómo accederlo.
- `physical_binding` opcional para tags conectados a tarjeta AR-NMEA-IO.
Los tags con `protocol == INTERNAL` son tags calculados o virtuales
(estados de máquina, fórmulas) y no requieren binding físico.
"""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from vmssailor.core.enums import (
AlarmPriority,
AuthorityRequired,
ChannelType,
ControlMode,
FilterType,
Protocol,
Quality,
SignalType,
UnitSI,
)
class Scaling(BaseModel):
"""Mapeo lineal raw → engineering value.
`eng = eng_min + (raw - raw_min) * (eng_max - eng_min) / (raw_max - raw_min)`
Para 4-20 mA con sensor 0-10 bar: raw_min=4, raw_max=20, eng_min=0,
eng_max=10.
"""
model_config = ConfigDict(extra="forbid")
raw_min: float = Field(..., description="Valor crudo mínimo (ADC count, mA, etc).")
raw_max: float = Field(..., description="Valor crudo máximo.")
eng_min: float = Field(..., description="Valor de ingeniería mínimo (en unit_si).")
eng_max: float = Field(..., description="Valor de ingeniería máximo (en unit_si).")
@model_validator(mode="after")
def _validate_ranges(self) -> Scaling:
if self.raw_max == self.raw_min:
raise ValueError("raw_max y raw_min no pueden ser iguales (división por cero).")
if self.eng_max == self.eng_min:
raise ValueError("eng_max y eng_min no pueden ser iguales.")
return self
def apply(self, raw: float) -> float:
"""Convierte un valor crudo a engineering value."""
slope = (self.eng_max - self.eng_min) / (self.raw_max - self.raw_min)
return self.eng_min + (raw - self.raw_min) * slope
def invert(self, eng: float) -> float:
"""Convierte un engineering value a su raw equivalente."""
slope = (self.raw_max - self.raw_min) / (self.eng_max - self.eng_min)
return self.raw_min + (eng - self.eng_min) * slope
class TagBinding(BaseModel):
"""Cómo se mapea un tag a un canal físico de tarjeta AR-NMEA-IO."""
model_config = ConfigDict(extra="forbid")
card_id: str = Field(..., min_length=1, max_length=64, description="ID de la CardInstance.")
channel_type: ChannelType
channel_number: int = Field(..., ge=1, description="1-10 para DO, 1-5 para DI, 1-4 para AI, 1 para RPM.")
signal_type: SignalType
scaling: Scaling | None = Field(
default=None,
description="Necesario para AI (escalado raw→eng). Opcional para DI/DO/RPM.",
)
filter: FilterType = FilterType.NONE
filter_param: float | None = Field(
default=None,
description=(
"Parámetro del filtro: ventana para MOVING_AVG, deadband para "
"DEADBAND, rate max para RATE_LIMIT, etc."
),
)
update_rate_ms: int = Field(
default=100,
ge=10,
le=60_000,
description="Cada cuántos ms la tarjeta reporta este canal al VMS.",
)
@model_validator(mode="after")
def _validate_channel_capacity(self) -> TagBinding:
# Capacidades de la tarjeta AR-NMEA-IO-v1.0 (Parte 4 sec 1)
limits = {
ChannelType.AI: 4,
ChannelType.DI: 5,
ChannelType.DO: 10,
ChannelType.RPM: 1,
}
max_ch = limits[self.channel_type]
if self.channel_number > max_ch:
raise ValueError(
f"channel_number {self.channel_number} excede capacidad "
f"{self.channel_type.value}={max_ch} de la tarjeta."
)
return self
class AlarmConfig(BaseModel):
"""Configuración declarativa de una alarma asociada a un Tag."""
model_config = ConfigDict(extra="forbid")
id: str = Field(..., min_length=1, max_length=128)
threshold: float = Field(..., description="Umbral en unit_si del tag.")
operator: str = Field(
...,
pattern=r"^(>|<|>=|<=|==|!=)$",
description="Operador de comparación contra el valor del tag.",
)
priority: AlarmPriority
hysteresis: float = Field(
default=0.0,
ge=0.0,
description="Histéresis: el tag no sale de alarma hasta cruzar threshold±hysteresis.",
)
delay_seconds: float = Field(
default=0.0,
ge=0.0,
le=300.0,
description="Persistencia mínima antes de disparar (anti-flicker).",
)
message: str = Field(default="", max_length=512)
escalation_minutes: int = Field(
default=0,
ge=0,
le=1440,
description="Si la alarma sigue sin ack, se escala tras N min. 0 = sin escalación.",
)
class Tag(BaseModel):
"""Definición declarativa de un tag del proyecto.
El **valor en tiempo real** del tag NO se persiste aquí — esta clase
es la *configuración* del tag. El valor vive en el tag_store del
Runtime y se historiza en DuckDB (Sprint 4).
"""
model_config = ConfigDict(extra="forbid")
id: str = Field(
...,
min_length=1,
max_length=128,
pattern=r"^[A-Z][A-Z0-9_]*(\.[A-Z][A-Z0-9_]*)*$",
description="Tag ID jerárquico: ME_PORT.OIL_PRESS, TANK_FUEL_1.LEVEL.",
)
equipment_id: str | None = Field(
default=None,
description="ID de la Equipment de la que este tag forma parte, si aplica.",
)
description: str = Field(default="", max_length=512)
unit_si: UnitSI = UnitSI.NONE
range_normal_min: float | None = None
range_normal_max: float | None = None
quality_default: Quality = Quality.GOOD
alarms: list[AlarmConfig] = Field(default_factory=list)
controllable: bool = False
control_mode: ControlMode = ControlMode.MONITOR
authority_required: AuthorityRequired = AuthorityRequired.EITHER
protocol: Protocol = Protocol.MODBUS_RTU
address: int | None = Field(
default=None,
description=(
"Dirección Modbus (holding register / coil) o ID CAN. "
"None si protocol == INTERNAL."
),
)
physical_binding: TagBinding | None = Field(
default=None,
description=(
"Mapeo a canal físico de tarjeta AR-NMEA-IO. None para tags "
"puramente NMEA 2000 o internos."
),
)
historize: bool = Field(
default=True,
description="Si True, el Runtime guarda historial en DuckDB.",
)
historize_period_s: float = Field(
default=1.0,
ge=0.1,
le=3600.0,
description="Período típico de muestreo en historian (segundos).",
)
@field_validator("alarms")
@classmethod
def _alarm_ids_unique(cls, v: list[AlarmConfig]) -> list[AlarmConfig]:
ids = [a.id for a in v]
if len(ids) != len(set(ids)):
raise ValueError("AlarmConfig IDs deben ser únicos dentro de un Tag.")
return v
@model_validator(mode="after")
def _control_consistency(self) -> Tag:
if self.controllable and self.control_mode == ControlMode.MONITOR:
raise ValueError(
"Tag controllable=True no puede tener control_mode=MONITOR. "
"Use FUTURE si el actuador aún no se ha instalado."
)
if not self.controllable and self.control_mode != ControlMode.MONITOR:
raise ValueError(
"Tag controllable=False debe tener control_mode=MONITOR."
)
return self
@model_validator(mode="after")
def _protocol_address_consistency(self) -> Tag:
if self.protocol == Protocol.INTERNAL and self.address is not None:
raise ValueError(
"Tags INTERNAL no tienen address (son virtuales calculados)."
)
if (
self.protocol in (Protocol.MODBUS_RTU, Protocol.MODBUS_TCP)
and self.address is None
and self.physical_binding is None
):
raise ValueError(
f"Tag Modbus '{self.id}' requiere `address` o `physical_binding`."
)
return self
+255
View File
@@ -0,0 +1,255 @@
"""Validación cross-entity y reglas de negocio del proyecto.
Las validaciones intra-entidad viven en cada `BaseModel` (validators de
Pydantic). Aquí se agrupan chequeos que cruzan múltiples entidades o
que no son hard-errors sino *warnings* informativos para el integrador.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum
from vmssailor.core.enums import ChannelType, ControlMode
from vmssailor.core.project import Project
class Severity(StrEnum):
ERROR = "error"
WARNING = "warning"
INFO = "info"
@dataclass(slots=True)
class ValidationIssue:
"""Un hallazgo de la validación cross-entity."""
severity: Severity
code: str
message: str
entity_id: str = ""
def __str__(self) -> str:
eid = f" [{self.entity_id}]" if self.entity_id else ""
return f"{self.severity.value.upper():7s} {self.code:24s} {self.message}{eid}"
@dataclass(slots=True)
class ValidationReport:
"""Reporte agregado de validación de un Project."""
issues: list[ValidationIssue] = field(default_factory=list)
@property
def errors(self) -> list[ValidationIssue]:
return [i for i in self.issues if i.severity == Severity.ERROR]
@property
def warnings(self) -> list[ValidationIssue]:
return [i for i in self.issues if i.severity == Severity.WARNING]
@property
def infos(self) -> list[ValidationIssue]:
return [i for i in self.issues if i.severity == Severity.INFO]
def ok(self) -> bool:
return len(self.errors) == 0
def add(self, severity: Severity, code: str, message: str, entity_id: str = "") -> None:
self.issues.append(
ValidationIssue(
severity=severity, code=code, message=message, entity_id=entity_id
)
)
def format(self) -> str:
if not self.issues:
return "Validation: OK (no issues)."
lines = [str(i) for i in self.issues]
lines.append("")
lines.append(
f"Total: {len(self.errors)} errors, {len(self.warnings)} warnings, "
f"{len(self.infos)} info."
)
return "\n".join(lines)
def validate_project(project: Project) -> ValidationReport:
"""Validación cross-entity de un Project (warnings + errors informativos).
Las validaciones que son hard-errors (referencias rotas, IDs duplicados)
ya están en los model_validators de Pydantic y harán fallar la
construcción del objeto. Aquí se chequean reglas más laxas.
"""
report = ValidationReport()
_check_card_capacity_utilization(project, report)
_check_orphan_systems(project, report)
_check_controllable_tags_authority(project, report)
_check_tag_alarms_within_range(project, report)
_check_equipment_coords_within_vessel(project, report)
_check_unbound_modbus_tags(project, report)
return report
# --- chequeos específicos ---
def _check_card_capacity_utilization(project: Project, report: ValidationReport) -> None:
"""Avisa si una carta tiene canales del mismo tipo asignados que se solapan o exceden."""
# Cuenta de canales usados por (card_id, channel_type, channel_number)
seen: dict[tuple[str, ChannelType, int], str] = {}
for t in project.tags:
b = t.physical_binding
if b is None:
continue
key = (b.card_id, b.channel_type, b.channel_number)
if key in seen:
report.add(
Severity.ERROR,
"DUPLICATE_CHANNEL_BINDING",
f"Canal {b.channel_type.value}{b.channel_number} de la tarjeta "
f"'{b.card_id}' ya está asignado a tag '{seen[key]}', "
f"colisiona con '{t.id}'.",
entity_id=t.id,
)
else:
seen[key] = t.id
# Cuenta agregada por carta y tipo
use: dict[tuple[str, ChannelType], int] = {}
for t in project.tags:
b = t.physical_binding
if b is None:
continue
use[(b.card_id, b.channel_type)] = use.get((b.card_id, b.channel_type), 0) + 1
caps = {
ChannelType.AI: 4,
ChannelType.DI: 5,
ChannelType.DO: 10,
ChannelType.RPM: 1,
}
for (card_id, ch_type), count in use.items():
cap = caps[ch_type]
pct = (count / cap) * 100.0
if count > cap:
report.add(
Severity.ERROR,
"CARD_CAPACITY_EXCEEDED",
f"Tarjeta '{card_id}' usa {count} canales {ch_type.value} "
f"(capacidad {cap}).",
entity_id=card_id,
)
elif pct >= 100.0:
report.add(
Severity.WARNING,
"CARD_CAPACITY_AT_LIMIT",
f"Tarjeta '{card_id}' a 100% de capacidad {ch_type.value} "
f"({count}/{cap}). Considere expandir.",
entity_id=card_id,
)
elif pct >= 80.0:
report.add(
Severity.INFO,
"CARD_CAPACITY_HIGH",
f"Tarjeta '{card_id}' al {pct:.0f}% de {ch_type.value} ({count}/{cap}).",
entity_id=card_id,
)
def _check_orphan_systems(project: Project, report: ValidationReport) -> None:
"""Avisa si hay sistemas habilitados que no tienen ningún equipment."""
used = {e.system_id for e in project.equipment}
for sys_id in project.systems_enabled:
if sys_id not in used:
report.add(
Severity.WARNING,
"SYSTEM_WITHOUT_EQUIPMENT",
f"Sistema '{sys_id.value}' habilitado pero sin equipos asignados.",
entity_id=sys_id.value,
)
def _check_controllable_tags_authority(
project: Project, report: ValidationReport
) -> None:
"""Sanity-check de tags controllable con control_mode AUTO sin permissive."""
permissive_actions = {r.action_id for r in project.permissive_rules}
for t in project.tags:
if not t.controllable:
continue
if t.control_mode == ControlMode.AUTO:
# Heurística: acciones AUTO sin permissive son sospechosas.
implied_action = f"AUTO_{t.id}"
if implied_action not in permissive_actions:
report.add(
Severity.INFO,
"AUTO_TAG_NO_PERMISSIVE",
f"Tag '{t.id}' en control_mode=AUTO sin PermissiveRule "
f"con action_id='{implied_action}'. Considere agregar.",
entity_id=t.id,
)
def _check_tag_alarms_within_range(project: Project, report: ValidationReport) -> None:
"""Avisa si una alarma está claramente fuera del rango normal del tag."""
for t in project.tags:
if t.range_normal_min is None or t.range_normal_max is None:
continue
for a in t.alarms:
inside = t.range_normal_min <= a.threshold <= t.range_normal_max
if inside and a.operator in (">", ">="):
report.add(
Severity.INFO,
"ALARM_THRESHOLD_IN_NORMAL_RANGE",
f"Tag '{t.id}' alarm '{a.id}' tiene threshold={a.threshold} "
f"dentro del rango normal [{t.range_normal_min},{t.range_normal_max}]. "
"Verifique que esto es intencional.",
entity_id=t.id,
)
def _check_equipment_coords_within_vessel(
project: Project, report: ValidationReport
) -> None:
"""Avisa si un equipo está claramente fuera de la envolvente del buque."""
v = project.vessel
for eq in project.equipment:
if eq.location.x_pp > v.length_overall_m + 1.0:
report.add(
Severity.WARNING,
"EQUIPMENT_OUT_OF_HULL",
f"Equipment '{eq.id}' x_pp={eq.location.x_pp:.2f} excede "
f"eslora total {v.length_overall_m:.2f}.",
entity_id=eq.id,
)
if abs(eq.location.y_cl) > v.beam_max_m / 2.0 + 0.5:
report.add(
Severity.WARNING,
"EQUIPMENT_OUT_OF_HULL",
f"Equipment '{eq.id}' y_cl={eq.location.y_cl:+.2f} excede "
f"semi-manga {v.beam_max_m / 2.0:.2f}.",
entity_id=eq.id,
)
def _check_unbound_modbus_tags(project: Project, report: ValidationReport) -> None:
"""Info: tags Modbus sin physical_binding (válido pero raro)."""
from vmssailor.core.enums import Protocol
for t in project.tags:
if (
t.protocol in (Protocol.MODBUS_RTU, Protocol.MODBUS_TCP)
and t.physical_binding is None
and t.address is not None
):
report.add(
Severity.INFO,
"MODBUS_TAG_NO_PHYSICAL_BINDING",
f"Tag '{t.id}' es Modbus con address={t.address} pero sin "
"physical_binding. Esto es OK para equipos externos "
"(no AR-NMEA-IO).",
entity_id=t.id,
)
+121
View File
@@ -0,0 +1,121 @@
"""Definición física del buque: silueta, cubiertas, mamparos."""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field, field_validator
from vmssailor.core.coords import ShipCoord
from vmssailor.core.enums import VesselSubtype, VesselType
class Deck(BaseModel):
"""Una cubierta del buque.
Cada cubierta se describe por su polígono en planta (lista de puntos
`(x_pp, y_cl)`) y la altura `z_bl_top` que define el techo de esa cubierta
sobre la línea base. La altura del piso es `z_bl_bottom`.
El polígono se almacena como pares `(x_pp, y_cl)` en metros. El render a
pantalla aplica una transformada lineal en el cliente UI; en core jamás
se hacen estas transformadas.
"""
model_config = ConfigDict(extra="forbid")
id: str = Field(..., min_length=1, max_length=64)
name: str = Field(..., min_length=1, max_length=128)
z_bl_bottom: float = Field(..., ge=-5.0, le=40.0, description="Cota piso [m sobre BL].")
z_bl_top: float = Field(..., ge=-5.0, le=45.0, description="Cota techo [m sobre BL].")
polygon_xy: list[tuple[float, float]] = Field(
default_factory=list,
description="Polígono en planta como pares (x_pp, y_cl) en metros.",
)
@field_validator("polygon_xy")
@classmethod
def _polygon_must_be_closed_or_empty(
cls, v: list[tuple[float, float]]
) -> list[tuple[float, float]]:
if not v:
return v
if len(v) < 3:
raise ValueError("Polígono de cubierta requiere al menos 3 vértices.")
return v
def height(self) -> float:
"""Altura libre de la cubierta en metros."""
return self.z_bl_top - self.z_bl_bottom
class Bulkhead(BaseModel):
"""Mamparo principal del buque.
Identifica separaciones críticas (sala de máquinas, mamparo de colisión,
etc.) que el integrador puede colocar en la silueta.
"""
model_config = ConfigDict(extra="forbid")
id: str = Field(..., min_length=1, max_length=64)
name: str = Field(..., min_length=1, max_length=128)
x_pp: float = Field(..., ge=0.0, le=200.0, description="Posición longitudinal [m].")
description: str = Field(default="", max_length=512)
class Vessel(BaseModel):
"""Definición del buque (clase de buque o instancia específica).
Puede provenir de la biblioteca curada (`vmssailor/library/vessels/`) o
importarse para un proyecto específico.
"""
model_config = ConfigDict(extra="forbid")
id: str = Field(..., min_length=1, max_length=128, description="ID estable, snake_case.")
name: str = Field(..., min_length=1, max_length=128)
type: VesselType
subtype: VesselSubtype
length_overall_m: float = Field(..., gt=0.0, le=200.0, description="Eslora total [m].")
beam_max_m: float = Field(..., gt=0.0, le=30.0, description="Manga máxima [m].")
draft_m: float = Field(..., gt=0.0, le=10.0, description="Calado [m].")
displacement_kg: float | None = Field(
default=None, gt=0.0, description="Desplazamiento [kg]. Opcional."
)
decks: list[Deck] = Field(default_factory=list)
bulkheads: list[Bulkhead] = Field(default_factory=list)
silhouette_svg: str | None = Field(
default=None, description="SVG inline opcional de la silueta. Sprint posterior."
)
description: str = Field(default="", max_length=2048)
data_source: str = Field(
default="user_input",
description=(
"Origen de los datos: 'seed_estimate' | 'manufacturer_datasheet' | 'measured' | "
"'user_input'. Marcar 'seed_estimate' para entradas de biblioteca semilla "
"que requieren validación contra fuente oficial."
),
)
@field_validator("decks")
@classmethod
def _decks_unique_ids(cls, v: list[Deck]) -> list[Deck]:
ids = [d.id for d in v]
if len(ids) != len(set(ids)):
raise ValueError("Deck IDs deben ser únicos dentro de un Vessel.")
return v
@field_validator("bulkheads")
@classmethod
def _bulkheads_unique_ids(cls, v: list[Bulkhead]) -> list[Bulkhead]:
ids = [b.id for b in v]
if len(ids) != len(set(ids)):
raise ValueError("Bulkhead IDs deben ser únicos dentro de un Vessel.")
return v
def position_at_origin(self) -> ShipCoord:
"""Coordenada del origen del marco del buque (Pp, CL, BL) = (0, 0, 0)."""
return ShipCoord(x_pp=0.0, y_cl=0.0, z_bl=0.0)
def position_at_bow(self) -> ShipCoord:
"""Coordenada aproximada en la proa (línea de crujía, línea base)."""
return ShipCoord(x_pp=self.length_overall_m, y_cl=0.0, z_bl=0.0)
+39
View File
@@ -0,0 +1,39 @@
{
"_meta": {
"locale": "en",
"name": "English",
"description": "Secondary language. Regla de oro #13."
},
"app": {
"name": "VMS-Sailor",
"tagline": "Vessel Management System"
},
"common": {
"ok": "OK",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"yes": "Yes",
"no": "No",
"loading": "Loading…",
"error": "Error"
},
"alarms": {
"priority_emergency": "Emergency",
"priority_high": "High",
"priority_low": "Low",
"priority_info": "Info",
"state_active": "Active",
"state_ack": "Acknowledged",
"state_cleared": "Cleared",
"ack_button": "Acknowledge"
},
"trim": {
"screen_title": "Trim & Maneuver",
"reset_emergency": "Emergency reset",
"owner_manual_mode": "Owner manual mode",
"roll": "Roll",
"pitch": "Pitch",
"envelope": "Safety envelope"
}
}
+39
View File
@@ -0,0 +1,39 @@
{
"_meta": {
"locale": "es",
"name": "Español",
"description": "Idioma por defecto del producto. Regla de oro #13."
},
"app": {
"name": "VMS-Sailor",
"tagline": "Vessel Management System"
},
"common": {
"ok": "Aceptar",
"cancel": "Cancelar",
"save": "Guardar",
"delete": "Eliminar",
"yes": "Sí",
"no": "No",
"loading": "Cargando…",
"error": "Error"
},
"alarms": {
"priority_emergency": "Emergencia",
"priority_high": "Alta",
"priority_low": "Baja",
"priority_info": "Información",
"state_active": "Activa",
"state_ack": "Reconocida",
"state_cleared": "Resuelta",
"ack_button": "Reconocer"
},
"trim": {
"screen_title": "Trim y Maniobra",
"reset_emergency": "Reset emergencia",
"owner_manual_mode": "Modo manual del owner",
"roll": "Escora",
"pitch": "Cabeceo",
"envelope": "Sobre de seguridad"
}
}
+29
View File
@@ -0,0 +1,29 @@
"""vmssailor.library — Biblioteca curada (Sprint 0).
Contiene:
- `systems_catalog.json` — catálogo maestro de sistemas instalables
- `vessels/` — plantillas de buques (Sunseeker 76, Ferretti 850, ...)
- `equipment/` — modelos de equipos (motores, gensets, bombas, ...)
- `rules/` — reglas heurísticas YAML por tipo de buque
- `loader.py` — carga y validación de toda la biblioteca
**REGLA DE ORO #7:** La biblioteca curada es ORO. Cambios de formato
requieren migración para los proyectos existentes.
**REGLA DE ORO #5:** Esta biblioteca pertenece a Álvaro (propiedad
intelectual). El cliente final nunca la ve cruda — sólo el resultado de
aplicarla en el .vmspack.
"""
from pathlib import Path
LIBRARY_ROOT: Path = Path(__file__).parent
"""Raíz del directorio de biblioteca curada (paquete instalado o source)."""
from vmssailor.library.loader import ( # noqa: E402
LibraryLoadResult,
load_library,
load_systems_catalog,
)
__all__ = ["LIBRARY_ROOT", "LibraryLoadResult", "load_library", "load_systems_catalog"]
@@ -0,0 +1,130 @@
{
"id": "mtu_12v_2000_m96",
"manufacturer": "MTU",
"model_name": "12V 2000 M96",
"category": "engine_main",
"typical_systems": ["main_engine"],
"specs": {
"power_kw": 1432,
"rpm_nominal": 2450,
"weight_kg": 2600,
"length_m": 2.13,
"width_m": 1.18,
"height_m": 1.27,
"fuel_consumption_lph": 320
},
"description": "MTU Series 2000, 12 cilindros en V, 24.0 L, 2-stage turbo. Aplicación yates rápidos / patrulleros / fast ferries. Common Rail. Habla J1939 nativo en su MCU.",
"data_source": "seed_estimate",
"default_sensors": [
{
"id": "rpm",
"name": "RPM",
"unit_si": "rpm",
"range_normal_min": 0,
"range_normal_max": 2600,
"alarm_high_value": 2550,
"alarm_high_priority": "high",
"default_signal_type": "pulse_magnetic_pickup",
"description": "Régimen del cigüeñal."
},
{
"id": "oil_press",
"name": "Presión de aceite",
"unit_si": "bar",
"range_normal_min": 3.5,
"range_normal_max": 6.5,
"alarm_low_value": 1.5,
"alarm_low_priority": "emergency",
"default_signal_type": "4-20ma",
"description": "Presión aceite lubricante en galería principal."
},
{
"id": "oil_temp",
"name": "Temperatura de aceite",
"unit_si": "C",
"range_normal_min": 60,
"range_normal_max": 110,
"alarm_high_value": 120,
"alarm_high_priority": "high",
"default_signal_type": "rtd_pt100"
},
{
"id": "coolant_temp",
"name": "Temperatura refrigerante",
"unit_si": "C",
"range_normal_min": 65,
"range_normal_max": 95,
"alarm_high_value": 100,
"alarm_high_priority": "emergency",
"default_signal_type": "rtd_pt100"
},
{
"id": "boost_press",
"name": "Presión de sobrealimentación",
"unit_si": "bar",
"range_normal_min": 0.0,
"range_normal_max": 2.5,
"default_signal_type": "4-20ma"
},
{
"id": "fuel_press",
"name": "Presión de combustible",
"unit_si": "bar",
"range_normal_min": 3.0,
"range_normal_max": 6.0,
"alarm_low_value": 2.0,
"alarm_low_priority": "high",
"default_signal_type": "4-20ma"
},
{
"id": "alternator_v",
"name": "Voltaje alternador",
"unit_si": "V",
"range_normal_min": 27.0,
"range_normal_max": 29.0,
"alarm_low_value": 24.0,
"alarm_low_priority": "high",
"default_signal_type": "voltage_divider"
},
{
"id": "load_pct",
"name": "Carga del motor",
"unit_si": "%",
"range_normal_min": 0,
"range_normal_max": 100,
"default_signal_type": "4-20ma"
},
{
"id": "running_hours",
"name": "Horas totales",
"unit_si": "h",
"range_normal_min": 0,
"range_normal_max": 80000,
"default_signal_type": "4-20ma"
},
{
"id": "start_cmd",
"name": "Comando arranque",
"unit_si": "bool",
"default_signal_type": "relay_no"
},
{
"id": "stop_cmd",
"name": "Comando parada",
"unit_si": "bool",
"default_signal_type": "relay_no"
},
{
"id": "running_state",
"name": "Estado motor en marcha",
"unit_si": "bool",
"default_signal_type": "dry_contact"
},
{
"id": "estop_active",
"name": "E-stop activado",
"unit_si": "bool",
"default_signal_type": "dry_contact"
}
]
}
@@ -0,0 +1,112 @@
{
"id": "volvo_d13_900hp",
"manufacturer": "Volvo Penta",
"model_name": "D13-900",
"category": "engine_main",
"typical_systems": ["main_engine"],
"specs": {
"power_kw": 662,
"rpm_nominal": 2300,
"weight_kg": 1430,
"length_m": 1.69,
"width_m": 0.99,
"height_m": 1.16,
"fuel_consumption_lph": 160
},
"description": "Volvo Penta D13 inline-6, 12.8 L, common rail, twin-entry turbo. Aplicación yates motor 60-90 pies. Habla J1939 nativo.",
"data_source": "seed_estimate",
"default_sensors": [
{
"id": "rpm",
"name": "RPM",
"unit_si": "rpm",
"range_normal_min": 0,
"range_normal_max": 2400,
"alarm_high_value": 2350,
"alarm_high_priority": "high",
"default_signal_type": "pulse_magnetic_pickup"
},
{
"id": "oil_press",
"name": "Presión de aceite",
"unit_si": "bar",
"range_normal_min": 3.0,
"range_normal_max": 5.5,
"alarm_low_value": 1.5,
"alarm_low_priority": "emergency",
"default_signal_type": "4-20ma"
},
{
"id": "oil_temp",
"name": "Temperatura de aceite",
"unit_si": "C",
"range_normal_min": 60,
"range_normal_max": 110,
"alarm_high_value": 120,
"alarm_high_priority": "high",
"default_signal_type": "rtd_pt100"
},
{
"id": "coolant_temp",
"name": "Temperatura refrigerante",
"unit_si": "C",
"range_normal_min": 70,
"range_normal_max": 92,
"alarm_high_value": 98,
"alarm_high_priority": "emergency",
"default_signal_type": "rtd_pt100"
},
{
"id": "boost_press",
"name": "Presión de sobrealimentación",
"unit_si": "bar",
"range_normal_min": 0.0,
"range_normal_max": 2.2,
"default_signal_type": "4-20ma"
},
{
"id": "alternator_v",
"name": "Voltaje alternador",
"unit_si": "V",
"range_normal_min": 13.5,
"range_normal_max": 14.5,
"alarm_low_value": 12.0,
"alarm_low_priority": "high",
"default_signal_type": "voltage_divider"
},
{
"id": "load_pct",
"name": "Carga del motor",
"unit_si": "%",
"range_normal_min": 0,
"range_normal_max": 100,
"default_signal_type": "4-20ma"
},
{
"id": "running_hours",
"name": "Horas totales",
"unit_si": "h",
"range_normal_min": 0,
"range_normal_max": 80000,
"default_signal_type": "4-20ma"
},
{
"id": "start_cmd",
"name": "Comando arranque",
"unit_si": "bool",
"default_signal_type": "relay_no"
},
{
"id": "stop_cmd",
"name": "Comando parada",
"unit_si": "bool",
"default_signal_type": "relay_no"
},
{
"id": "running_state",
"name": "Estado motor en marcha",
"unit_si": "bool",
"default_signal_type": "dry_contact"
}
]
}
@@ -0,0 +1,114 @@
{
"id": "northern_lights_m65c13",
"manufacturer": "Northern Lights",
"model_name": "M65C13",
"category": "genset",
"typical_systems": ["genset"],
"specs": {
"power_kw": 52,
"rpm_nominal": 1800,
"weight_kg": 880,
"length_m": 1.55,
"width_m": 0.74,
"height_m": 0.96,
"voltage_v": 230,
"current_a": 226,
"fuel_consumption_lph": 16.3
},
"description": "Genset marino diésel Northern Lights M65C13 (Lugger), 65 kVA / 52 kW @ 1800 rpm 60 Hz (válido también a 50 Hz con curva diferente). Motor John Deere 4045 base. Aplicación yates motor 70-90 pies y patrulleros medianos.",
"data_source": "seed_estimate",
"default_sensors": [
{
"id": "rpm",
"name": "RPM",
"unit_si": "rpm",
"range_normal_min": 0,
"range_normal_max": 1850,
"default_signal_type": "pulse_magnetic_pickup"
},
{
"id": "oil_press",
"name": "Presión de aceite",
"unit_si": "bar",
"range_normal_min": 2.5,
"range_normal_max": 4.5,
"alarm_low_value": 1.0,
"alarm_low_priority": "emergency",
"default_signal_type": "4-20ma"
},
{
"id": "coolant_temp",
"name": "Temperatura refrigerante",
"unit_si": "C",
"range_normal_min": 70,
"range_normal_max": 95,
"alarm_high_value": 102,
"alarm_high_priority": "emergency",
"default_signal_type": "rtd_pt100"
},
{
"id": "voltage_l1",
"name": "Tensión L1",
"unit_si": "V",
"range_normal_min": 220,
"range_normal_max": 240,
"alarm_low_value": 200,
"alarm_low_priority": "high",
"alarm_high_value": 250,
"alarm_high_priority": "high",
"default_signal_type": "voltage_divider"
},
{
"id": "current_l1",
"name": "Corriente L1",
"unit_si": "A",
"range_normal_min": 0,
"range_normal_max": 230,
"alarm_high_value": 250,
"alarm_high_priority": "high",
"default_signal_type": "4-20ma"
},
{
"id": "freq",
"name": "Frecuencia",
"unit_si": "Hz",
"range_normal_min": 49.5,
"range_normal_max": 60.5,
"default_signal_type": "pulse_inductive"
},
{
"id": "load_pct",
"name": "Carga",
"unit_si": "%",
"range_normal_min": 0,
"range_normal_max": 100,
"default_signal_type": "4-20ma"
},
{
"id": "running_hours",
"name": "Horas totales",
"unit_si": "h",
"range_normal_min": 0,
"range_normal_max": 80000,
"default_signal_type": "4-20ma"
},
{
"id": "start_cmd",
"name": "Comando arranque",
"unit_si": "bool",
"default_signal_type": "relay_no"
},
{
"id": "stop_cmd",
"name": "Comando parada",
"unit_si": "bool",
"default_signal_type": "relay_no"
},
{
"id": "breaker_status",
"name": "Estado breaker principal",
"unit_si": "bool",
"default_signal_type": "dry_contact"
}
]
}
+214
View File
@@ -0,0 +1,214 @@
"""Carga y validación de la biblioteca curada.
Funciones públicas:
- `load_systems_catalog()` — devuelve el dict del catálogo maestro
- `load_library(root=None)` — carga TODA la biblioteca y devuelve un
`LibraryLoadResult` con vessels, equipment,
rules y la lista de issues.
El loader es defensivo: NO levanta excepciones por archivos individuales
malos. Acumula issues y deja al caller decidir si abortar.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
from pydantic import ValidationError
from vmssailor.core.equipment import EquipmentModel
from vmssailor.core.vessel import Vessel
@dataclass(slots=True)
class LibraryIssue:
"""Un problema encontrado al cargar la biblioteca."""
severity: str # "error" | "warning" | "info"
path: str
message: str
def __str__(self) -> str:
return f"{self.severity.upper():7s} {self.path:60s} {self.message}"
@dataclass(slots=True)
class LibraryLoadResult:
"""Resultado de cargar la biblioteca completa."""
vessels: list[Vessel] = field(default_factory=list)
equipment_models: list[EquipmentModel] = field(default_factory=list)
rules: dict[str, dict[str, Any]] = field(default_factory=dict)
systems_catalog: dict[str, Any] = field(default_factory=dict)
issues: list[LibraryIssue] = field(default_factory=list)
@property
def errors(self) -> list[LibraryIssue]:
return [i for i in self.issues if i.severity == "error"]
@property
def warnings(self) -> list[LibraryIssue]:
return [i for i in self.issues if i.severity == "warning"]
def ok(self) -> bool:
return len(self.errors) == 0
def format(self) -> str:
lines: list[str] = []
lines.append("Library load summary:")
lines.append(f" Vessels : {len(self.vessels)}")
lines.append(f" EquipmentModels: {len(self.equipment_models)}")
lines.append(f" Rules : {len(self.rules)}")
lines.append(f" Issues : {len(self.errors)} errors, {len(self.warnings)} warnings")
if self.issues:
lines.append("")
for i in self.issues:
lines.append(f" {i}")
return "\n".join(lines)
def load_systems_catalog(root: Path | None = None) -> dict[str, Any]:
"""Carga el catálogo maestro de sistemas."""
base = root or _default_root()
catalog_path = base / "systems_catalog.json"
if not catalog_path.exists():
raise FileNotFoundError(f"systems_catalog.json no encontrado en {base}")
return json.loads(catalog_path.read_text(encoding="utf-8"))
def load_library(root: Path | None = None) -> LibraryLoadResult:
"""Carga la biblioteca completa y devuelve un `LibraryLoadResult`."""
base = root or _default_root()
result = LibraryLoadResult()
# systems_catalog
try:
result.systems_catalog = load_systems_catalog(base)
except Exception as exc:
result.issues.append(
LibraryIssue("error", "systems_catalog.json", f"{type(exc).__name__}: {exc}")
)
# vessels/
vessels_dir = base / "vessels"
if vessels_dir.exists():
for f in sorted(vessels_dir.glob("*.json")):
try:
data = json.loads(f.read_text(encoding="utf-8"))
v = Vessel(**data)
result.vessels.append(v)
if v.data_source == "seed_estimate":
result.issues.append(
LibraryIssue(
"info",
str(f.relative_to(base)),
"data_source=seed_estimate — requiere validación de Álvaro.",
)
)
except ValidationError as exc:
result.issues.append(
LibraryIssue("error", str(f.relative_to(base)), f"Pydantic: {exc.errors()}")
)
except Exception as exc:
result.issues.append(
LibraryIssue(
"error", str(f.relative_to(base)), f"{type(exc).__name__}: {exc}"
)
)
# equipment/**/*.json
eq_dir = base / "equipment"
if eq_dir.exists():
for f in sorted(eq_dir.glob("**/*.json")):
try:
data = json.loads(f.read_text(encoding="utf-8"))
em = EquipmentModel(**data)
result.equipment_models.append(em)
if em.data_source == "seed_estimate":
result.issues.append(
LibraryIssue(
"info",
str(f.relative_to(base)),
"data_source=seed_estimate — requiere validación.",
)
)
except ValidationError as exc:
result.issues.append(
LibraryIssue("error", str(f.relative_to(base)), f"Pydantic: {exc.errors()}")
)
except Exception as exc:
result.issues.append(
LibraryIssue(
"error", str(f.relative_to(base)), f"{type(exc).__name__}: {exc}"
)
)
# rules/*.yaml
rules_dir = base / "rules"
if rules_dir.exists():
for f in sorted(rules_dir.glob("*.yaml")):
try:
data = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
meta = data.get("meta") or {}
rule_id = meta.get("rule_id") or f.stem
result.rules[rule_id] = data
except Exception as exc:
result.issues.append(
LibraryIssue(
"error", str(f.relative_to(base)), f"{type(exc).__name__}: {exc}"
)
)
# Validación cross-references
_validate_cross_refs(result)
return result
def _validate_cross_refs(result: LibraryLoadResult) -> None:
"""Chequea referencias entre rules → equipment models → systems."""
em_ids = {em.id for em in result.equipment_models}
valid_systems = set()
for cat in result.systems_catalog.get("categories", []):
for s in cat.get("systems", []):
valid_systems.add(s["id"])
# Chequear que equipment.typical_systems referencien sistemas válidos
for em in result.equipment_models:
for sys_id in em.typical_systems:
if sys_id.value not in valid_systems:
result.issues.append(
LibraryIssue(
"error",
f"equipment/.../{em.id}.json",
f"typical_systems contiene '{sys_id.value}' que no existe en "
"systems_catalog.json.",
)
)
# Chequear que rules referencien EquipmentModel.id existentes
for rule_id, rule_data in result.rules.items():
proposals = rule_data.get("equipment_proposals") or {}
for _sys_id, sys_proposals in proposals.items():
if not isinstance(sys_proposals, dict):
continue
for cand in sys_proposals.get("candidates", []) or []:
model_ref = cand.get("model_ref")
if model_ref and model_ref not in em_ids:
result.issues.append(
LibraryIssue(
"warning",
f"rules/{rule_id}.yaml",
f"Rule referencia model_ref='{model_ref}' que no existe en "
"equipment_models cargados.",
)
)
def _default_root() -> Path:
return Path(__file__).parent
@@ -0,0 +1,161 @@
# Reglas heurísticas para yates motor planeo 20-30 m.
#
# Este archivo captura el conocimiento de Álvaro sobre qué sistemas y qué
# equipos lleva típicamente un yate motor planeo del segmento objetivo.
# El motor de reglas del Studio (Sprint 2) consulta este archivo en el
# Paso 5 del wizard para proponer equipos al integrador.
#
# Filosofía: PROPONE, no impone. El integrador siempre puede ajustar.
# data_source de cada propuesta es "seed_estimate" y queda en
# docs/seed_data_notes.md hasta que Álvaro lo valide contra proyectos reales.
meta:
version: 1
rule_id: yacht_motor_planeo
applies_to:
vessel_types: ["yacht_motor"]
vessel_subtypes: ["planing", "semi_planing"]
length_overall_m:
min: 18.0
max: 32.0
data_source: seed_estimate
# ----- Sistemas que típicamente se incluyen -------------------------------
systems_default_enabled:
- main_engine
- transmission
- shaft_propeller
- thruster
- trim_tabs
- genset
- shore_power
- msb
- solar
- fuel
- lube_oil
- fw_cooling
- sw_cooling
- bilge
- potable_water
- watermaker
- fire_detection
- fire_extinguishing
- hvac
- engine_vent
- nav_lights
- deck_lights
- interior_lights
- emergency_lights
- fuel_tanks
- water_tanks
- grey_black_tanks
- windlass
- anchor_system
systems_optional:
- gyrostabilizer # Seakeeper se vuelve muy común en este rango
- joystick_docking
- inverter_charger
- battery_bank
- searchlights
- davits
- gangway
# ----- Equipos propuestos por sistema --------------------------------------
equipment_proposals:
main_engine:
# Para yates de 20-25 m, MTU o Volvo en pares. Para 25-32 m, MTU.
count: 2
candidates:
- model_ref: mtu_12v_2000_m96
when:
length_overall_m: { min: 22.0, max: 32.0 }
rationale: "Estándar de oro en este rango. Buena disponibilidad de partes y servicio."
- model_ref: volvo_d13_900hp
when:
length_overall_m: { min: 18.0, max: 26.0 }
rationale: "Más liviano y económico que MTU 2000. Servicio mundial Volvo Penta."
location_template:
port: { x_pp_pct: 0.25, y_cl: -0.9, z_bl: 1.2 }
starboard: { x_pp_pct: 0.25, y_cl: 0.9, z_bl: 1.2 }
tag_prefix_template: "ME_{side}"
sides: ["PORT", "STBD"]
genset:
count: 1
candidates:
- model_ref: northern_lights_m65c13
when:
length_overall_m: { min: 18.0, max: 30.0 }
rationale: "Confiabilidad probada. Aceptado por clase RINA/Lloyd's con poco trámite."
location_template:
default: { x_pp_pct: 0.20, y_cl: 0.0, z_bl: 1.0 }
tag_prefix_template: "GEN_{idx}"
fuel:
# Sin modelo concreto — el integrador definirá tanques estructurales en Paso 6.
sensors_per_tank:
- level
- temperature
typical_tank_count: 2
tag_prefix_template: "TANK_FUEL_{idx}"
bilge:
typical_pump_count: 3
tag_prefix_template: "BILGE_{location}"
locations_template: ["FWD", "MID", "AFT"]
# ----- Permissives típicos a sugerir ---------------------------------------
permissives_template:
- id: start_main_engine
action_id_template: "START_{tag_prefix}"
apply_to: ["main_engine"]
conditions:
- tag_ref_template: "{tag_prefix}.OIL_PRESS"
operator: ">"
threshold: 0.3
message_on_fail: "Presión aceite previa al arranque demasiado baja (lubricación insuficiente)."
- tag_ref_template: "{tag_prefix}.COOLANT_TEMP"
operator: ">"
threshold: 5.0
message_on_fail: "Refrigerante por debajo de 5°C — pre-calentar antes de arrancar."
- tag_ref_template: "{tag_prefix}.ESTOP_ACTIVE"
operator: "is_false"
message_on_fail: "Pulsador E-stop activado — desbloquear antes de arrancar."
on_fail_message: "Pre-condiciones de arranque del motor principal no cumplidas."
- id: start_genset
action_id_template: "START_{tag_prefix}"
apply_to: ["genset"]
conditions:
- tag_ref_template: "{tag_prefix}.OIL_PRESS"
operator: ">"
threshold: 0.3
message_on_fail: "Presión aceite previa baja."
- tag_ref_template: "{tag_prefix}.COOLANT_TEMP"
operator: ">"
threshold: 0.0
message_on_fail: "Refrigerante demasiado frío para arranque seguro."
on_fail_message: "Pre-condiciones de arranque del genset no cumplidas."
# ----- Topología sugerida de tarjetas AR-NMEA-IO --------------------------
topology_template:
# Patrón típico para yate planeo con 2 motores + 1 genset + tanques + auxiliares:
# 5-7 tarjetas distribuidas. Una maestra Modbus en el PC industrial.
cards_estimate:
min: 5
typical: 6
max: 8
buses:
- id: bus_main
protocol: modbus_rtu
role: "Maestra en PC industrial central. Esclavas distribuidas."
- id: bus_n2k
protocol: nmea2000
role: "Backbone NMEA 2000 del buque. Motores y gensets en modo dual."
+151
View File
@@ -0,0 +1,151 @@
{
"_meta": {
"version": 1,
"source": "VMS_Sailor_v2_Parte_01.md section 7",
"notes": "Catálogo maestro completo. Sirve de checklist para el wizard del Studio (paso 4). Lo que el integrador marca define el menú lateral del Runtime. SystemId values deben coincidir con vmssailor.core.enums.SystemId."
},
"categories": [
{
"id": "propulsion",
"name": "Propulsión y maquinaria",
"systems": [
{ "id": "main_engine", "name": "Máquina principal", "name_en": "Main engine", "default_for": ["yacht_motor", "fishing", "patrol", "ferry", "offshore_support"] },
{ "id": "transmission", "name": "Transmisiones / reductoras", "name_en": "Transmissions" },
{ "id": "shaft_propeller", "name": "Ejes y hélices", "name_en": "Shafts & propellers" },
{ "id": "thruster", "name": "Hélices de proa/popa", "name_en": "Bow/stern thrusters" }
]
},
{
"id": "maneuvering",
"name": "Maniobra y trimado",
"systems": [
{ "id": "trim_sterndrive", "name": "Trim de motores / sterndrives", "name_en": "Engine trim" },
{ "id": "trim_tabs", "name": "Trim tabs", "name_en": "Trim tabs (Bennett, Lenco)" },
{ "id": "cpp", "name": "Hélices de paso variable", "name_en": "Controllable Pitch Propellers" },
{ "id": "gyrostabilizer", "name": "Estabilizadores girostáticos", "name_en": "Gyrostabilizers (Seakeeper, Quick MC²)" },
{ "id": "fin_stabilizer", "name": "Estabilizadores de aletas activas", "name_en": "Active fin stabilizers" },
{ "id": "joystick_docking", "name": "Joystick docking", "name_en": "Joystick docking" }
]
},
{
"id": "electrical_generation",
"name": "Generación eléctrica",
"systems": [
{ "id": "genset", "name": "Gensets diésel", "name_en": "Diesel gensets", "default_for": ["yacht_motor", "fishing", "patrol", "ferry", "offshore_support"] },
{ "id": "shore_power", "name": "Shore power con transferencia", "name_en": "Shore power with ATS" },
{ "id": "inverter_charger", "name": "Inversores/cargadores combinados", "name_en": "Inverter/chargers (Victron, Mastervolt)" },
{ "id": "battery_bank", "name": "Bancos de baterías litio con BMS", "name_en": "Lithium battery banks with BMS" },
{ "id": "msb", "name": "Cuadros principales (MSB)", "name_en": "Main switchboards", "default_for": ["yacht_motor", "fishing", "patrol", "ferry"] },
{ "id": "esb", "name": "Cuadros de emergencia (ESB)", "name_en": "Emergency switchboards" },
{ "id": "ups", "name": "UPS", "name_en": "UPS" },
{ "id": "solar", "name": "Paneles solares + MPPT", "name_en": "Solar + MPPT" },
{ "id": "smart_dc_busbar", "name": "Smart busbars DC", "name_en": "Smart DC busbars (Lynx Smart BMS)" },
{ "id": "smart_panel", "name": "Tableros inteligentes con monitoreo", "name_en": "Smart panels with embedded monitoring" }
]
},
{
"id": "electrical_isolation",
"name": "Aislamiento eléctrico",
"systems": [
{ "id": "sectionalizing", "name": "Aislamiento por sectores", "name_en": "Sectionalizing" },
{ "id": "emergency_isolation", "name": "Aislamiento total de emergencia", "name_en": "Total emergency isolation" },
{ "id": "breakers", "name": "Breakers configurables", "name_en": "Configurable breakers" },
{ "id": "lockout_tagout", "name": "Lockout-tagout digital", "name_en": "Digital lockout-tagout" }
]
},
{
"id": "fluids",
"name": "Fluidos del buque",
"systems": [
{ "id": "fuel", "name": "Combustible (DO/MDO)", "name_en": "Fuel oil", "default_for": ["yacht_motor", "fishing", "patrol", "ferry", "offshore_support"] },
{ "id": "lube_oil", "name": "Aceite lubricante", "name_en": "Lube oil" },
{ "id": "hydraulic_oil", "name": "Aceite hidráulico", "name_en": "Hydraulic oil" },
{ "id": "fw_cooling", "name": "Refrigeración agua dulce", "name_en": "Fresh water cooling" },
{ "id": "sw_cooling", "name": "Refrigeración agua salada", "name_en": "Sea water cooling" },
{ "id": "starting_air", "name": "Aire de arranque / aire comprimido", "name_en": "Starting / compressed air" },
{ "id": "bilge", "name": "Sentinas", "name_en": "Bilge", "default_for": ["yacht_motor", "fishing", "patrol", "ferry", "offshore_support"] },
{ "id": "ballast", "name": "Lastre", "name_en": "Ballast" },
{ "id": "grey_water", "name": "Aguas grises", "name_en": "Grey water" },
{ "id": "black_water", "name": "Aguas negras", "name_en": "Black water" },
{ "id": "potable_water", "name": "Agua potable", "name_en": "Potable water", "default_for": ["yacht_motor", "ferry"] },
{ "id": "sw_service", "name": "Agua salada de servicio", "name_en": "Sea water service" },
{ "id": "watermaker", "name": "Watermaker", "name_en": "Watermaker (desalinator)" }
]
},
{
"id": "safety",
"name": "Seguridad",
"systems": [
{ "id": "fire_detection", "name": "Detección de incendio", "name_en": "Fire detection", "default_for": ["yacht_motor", "fishing", "patrol", "ferry", "offshore_support"] },
{ "id": "fire_extinguishing", "name": "Extinción (CO₂, HiFog, espuma)", "name_en": "Fire extinguishing" },
{ "id": "fifi_external", "name": "FiFi externo", "name_en": "External FiFi monitors" },
{ "id": "emergency_bilge", "name": "Achique de emergencia", "name_en": "Emergency bilge" },
{ "id": "gas_detection", "name": "Detección de gases", "name_en": "Gas detection" },
{ "id": "mob", "name": "Hombre al agua (MOB)", "name_en": "Man overboard" }
]
},
{
"id": "environment",
"name": "Ambiente y confort",
"systems": [
{ "id": "hvac", "name": "HVAC / aire acondicionado", "name_en": "HVAC", "default_for": ["yacht_motor", "ferry"] },
{ "id": "engine_vent", "name": "Ventilación de máquinas", "name_en": "Engine room ventilation", "default_for": ["yacht_motor", "fishing", "patrol", "ferry", "offshore_support"] },
{ "id": "heating", "name": "Calefacción", "name_en": "Heating" },
{ "id": "refrigeration", "name": "Refrigeración (cámaras, neveras)", "name_en": "Refrigeration" }
]
},
{
"id": "lighting",
"name": "Iluminación",
"systems": [
{ "id": "nav_lights", "name": "Luces de navegación", "name_en": "Navigation lights", "default_for": ["yacht_motor", "fishing", "patrol", "ferry", "offshore_support"] },
{ "id": "deck_lights", "name": "Luces de cubierta", "name_en": "Deck lights" },
{ "id": "interior_lights", "name": "Luces interiores por sector", "name_en": "Interior lights" },
{ "id": "emergency_lights", "name": "Luces de emergencia", "name_en": "Emergency lights" },
{ "id": "searchlights", "name": "Reflectores", "name_en": "Searchlights" }
]
},
{
"id": "structural_tanks",
"name": "Tanques estructurales",
"systems": [
{ "id": "fuel_tanks", "name": "Tanques de combustible", "name_en": "Fuel tanks", "default_for": ["yacht_motor", "fishing", "patrol", "ferry", "offshore_support"] },
{ "id": "water_tanks", "name": "Tanques de agua", "name_en": "Water tanks" },
{ "id": "grey_black_tanks", "name": "Tanques de aguas grises/negras", "name_en": "Grey/black water tanks" },
{ "id": "voids", "name": "Voids", "name_en": "Voids" },
{ "id": "cofferdams", "name": "Cofferdams", "name_en": "Cofferdams" }
]
},
{
"id": "deck_maneuvering",
"name": "Cubierta y maniobra",
"systems": [
{ "id": "windlass", "name": "Cabrestantes / molinetes", "name_en": "Windlasses" },
{ "id": "anchor_system", "name": "Sistema de anclas", "name_en": "Anchor system" },
{ "id": "mooring", "name": "Sistema de amarre", "name_en": "Mooring system" },
{ "id": "davits", "name": "Davits / pescantes", "name_en": "Davits" },
{ "id": "gangway", "name": "Pasarelas / portalones", "name_en": "Gangways" },
{ "id": "crane", "name": "Grúas", "name_en": "Cranes" }
]
},
{
"id": "vessel_specific",
"name": "Específicos por tipo de buque",
"systems": [
{ "id": "fishing_machinery", "name": "Maquinaria de pesca", "name_en": "Fishing machinery", "default_for": ["fishing"] },
{ "id": "large_fridge_holds", "name": "Cámaras frigoríficas grandes", "name_en": "Large refrigerated holds", "default_for": ["fishing"] },
{ "id": "rov", "name": "ROV / equipos sumergibles", "name_en": "ROV / submersibles", "default_for": ["offshore_support"] },
{ "id": "diving_system", "name": "Sistema de buceo", "name_en": "Diving system" }
]
}
],
"_excluded_from_vms_sailor": {
"note": "Estos pertenecen al AR-ECDIS, NO se incluyen en el VMS-Sailor.",
"items": [
"ECDIS / radar / AIS",
"Piloto automático",
"Comunicaciones VHF/HF/SatCom",
"GPS y sensores de actitud (vienen del AR-ECDIS por NMEA 2000)"
]
}
}
@@ -0,0 +1,22 @@
{
"id": "ferretti_850",
"name": "Ferretti 850",
"type": "yacht_motor",
"subtype": "planing",
"length_overall_m": 26.04,
"beam_max_m": 6.28,
"draft_m": 1.98,
"displacement_kg": 68000,
"description": "Yate motor planeo italiano de 26 m, casco semi-V con propulsión convencional, 3 cubiertas, 4 cabinas + tripulación. Motores en V centrales, 2 gensets, A/A multizona. Tope típico ~30 nudos.",
"data_source": "seed_estimate",
"decks": [
{ "id": "lower", "name": "Cubierta inferior", "z_bl_bottom": 0.5, "z_bl_top": 2.7, "polygon_xy": [] },
{ "id": "main", "name": "Cubierta principal", "z_bl_bottom": 2.7, "z_bl_top": 4.9, "polygon_xy": [] },
{ "id": "flybridge", "name": "Flybridge", "z_bl_bottom": 4.9, "z_bl_top": 6.6, "polygon_xy": [] }
],
"bulkheads": [
{ "id": "collision", "name": "Mamparo de colisión", "x_pp": 23.5, "description": "" },
{ "id": "er_fwd", "name": "Mamparo proa SM", "x_pp": 8.0, "description": "" },
{ "id": "er_aft", "name": "Mamparo popa SM", "x_pp": 4.0, "description": "" }
]
}
@@ -0,0 +1,40 @@
{
"id": "sunseeker_76",
"name": "Sunseeker 76 Yacht",
"type": "yacht_motor",
"subtype": "planing",
"length_overall_m": 23.45,
"beam_max_m": 5.65,
"draft_m": 1.85,
"displacement_kg": 55000,
"description": "Yate motor planeo británico de 23.4 m, casco semi-V profundo, 3 cubiertas (lower, main, flybridge), 4 cabinas. Sala de máquinas central con 2 motores principales en V, 1-2 gensets, sistemas de A/A y refrigeración. Tope típico ~32 nudos.",
"data_source": "seed_estimate",
"decks": [
{
"id": "lower",
"name": "Cubierta inferior (Lower Deck)",
"z_bl_bottom": 0.5,
"z_bl_top": 2.6,
"polygon_xy": []
},
{
"id": "main",
"name": "Cubierta principal (Main Deck)",
"z_bl_bottom": 2.6,
"z_bl_top": 4.8,
"polygon_xy": []
},
{
"id": "flybridge",
"name": "Flybridge",
"z_bl_bottom": 4.8,
"z_bl_top": 6.4,
"polygon_xy": []
}
],
"bulkheads": [
{ "id": "collision", "name": "Mamparo de colisión", "x_pp": 21.0, "description": "Mamparo de colisión (proa)" },
{ "id": "er_fwd", "name": "Mamparo proa sala de máquinas", "x_pp": 7.0, "description": "" },
{ "id": "er_aft", "name": "Mamparo popa sala de máquinas", "x_pp": 3.5, "description": "" }
]
}
+5
View File
@@ -0,0 +1,5 @@
"""vmssailor.runtime — Servidor 24/7 y cliente desktop a bordo (Sprint 4+).
En Sprint 0 está vacío. Sprint 4 trae el servicio Windows + drivers Modbus +
historian + alarm engine + API. Ver `VMS_Sailor_v2_Parte_03_Runtime.md`.
"""
+1
View File
@@ -0,0 +1 @@
"""vmssailor.shared — Utilidades compartidas (logging, IDs, etc)."""
+68
View File
@@ -0,0 +1,68 @@
"""Generación de IDs deterministas para el modelo de datos.
Reglas:
- IDs de Tag son **deterministas**: `{equipment.tag_prefix}.{sensor.id_upper}`
para que un mismo proyecto produzca siempre el mismo grafo de tags.
- IDs aleatorios de proyecto/equipo usan UUID4 si el integrador no provee
uno.
- IDs de Alarm (instancia activa) son timestamps + tag_id en hash corto.
"""
from __future__ import annotations
import re
import uuid
_TAG_PREFIX_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
_SENSOR_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
def make_tag_id(equipment_tag_prefix: str, sensor_id: str) -> str:
"""Compone un Tag.id determinista: 'ME_PORT' + 'oil_press''ME_PORT.OIL_PRESS'.
Reglas:
- `equipment_tag_prefix` debe ser MAYÚSCULAS y empezar con letra.
- `sensor_id` debe ser snake_case minúsculas.
- Resultado: prefix + "." + sensor.upper().
"""
if not _TAG_PREFIX_RE.match(equipment_tag_prefix):
raise ValueError(
f"equipment_tag_prefix '{equipment_tag_prefix}' debe ser MAYÚSCULAS "
"[A-Z][A-Z0-9_]*."
)
if not _SENSOR_ID_RE.match(sensor_id):
raise ValueError(
f"sensor_id '{sensor_id}' debe ser snake_case [a-z][a-z0-9_]*."
)
return f"{equipment_tag_prefix}.{sensor_id.upper()}"
def make_alarm_config_id(tag_id: str, level_name: str) -> str:
"""ID determinista para un AlarmConfig: 'ME_PORT.OIL_PRESS.LOW'."""
if not level_name.replace("_", "").isalnum():
raise ValueError(f"level_name '{level_name}' inválido.")
return f"{tag_id}.{level_name.upper()}"
def make_alarm_instance_id(tag_id: str, alarm_config_id: str, timestamp_unix: float) -> str:
"""ID determinista para una INSTANCIA activa de alarma.
Garantiza unicidad por timestamp y por config.
"""
short = hex(int(timestamp_unix * 1000))[2:][-10:]
return f"alm.{alarm_config_id}.{short}"
def new_uuid() -> str:
"""UUID4 para IDs de proyecto, equipos, cartas sin nombre semántico."""
return str(uuid.uuid4())
def make_project_id(customer_slug: str, vessel_id: str) -> str:
"""Compone un Project.id estable a partir de cliente + buque."""
customer = re.sub(r"[^a-z0-9]+", "_", customer_slug.lower()).strip("_")
vessel = re.sub(r"[^a-z0-9_-]+", "_", vessel_id.lower()).strip("_")
if not customer or not vessel:
raise ValueError("customer_slug y vessel_id no pueden quedar vacíos tras normalizar.")
return f"{customer}__{vessel}"
+43
View File
@@ -0,0 +1,43 @@
"""Logging unificado para Studio, Runtime y tools.
Sprint 0: configuración mínima. Sprint 4+ agregará rotación, file handlers,
journald, etc.
"""
from __future__ import annotations
import logging
import sys
from typing import Final
_DEFAULT_FORMAT: Final[str] = (
"%(asctime)s %(levelname)-7s %(name)-30s :: %(message)s"
)
_DEFAULT_DATEFMT: Final[str] = "%Y-%m-%d %H:%M:%S"
_configured: bool = False
def setup_logging(level: int = logging.INFO, *, verbose: bool = False) -> None:
"""Configura el root logger una sola vez.
Idempotente: llamadas posteriores son no-op (a menos que se cambie level).
"""
global _configured
root = logging.getLogger()
if _configured:
root.setLevel(level)
return
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter(_DEFAULT_FORMAT, datefmt=_DEFAULT_DATEFMT))
root.addHandler(handler)
root.setLevel(logging.DEBUG if verbose else level)
_configured = True
def get_logger(name: str) -> logging.Logger:
"""Logger de módulo. Asegura que setup_logging() se haya llamado."""
if not _configured:
setup_logging()
return logging.getLogger(name)
+5
View File
@@ -0,0 +1,5 @@
"""vmssailor.studio — Aplicación de escritorio del integrador (Sprint 1+).
En Sprint 0 está vacío. Sprint 1 trae la shell PySide6, ventana principal y
wizard pasos 1-4. Ver `VMS_Sailor_v2_Parte_02_Studio.md`.
"""
+7
View File
@@ -0,0 +1,7 @@
"""CLI tools del Sprint 0.
Estos módulos se exponen como scripts en `pyproject.toml`:
- `vms-validate-library` → vmssailor.tools.validate_library:main
- `vms-generate-test-project` → vmssailor.tools.generate_test_project:main
"""
+423
View File
@@ -0,0 +1,423 @@
"""CLI demo de Sprint 0: crea un proyecto completo, lo guarda y lo relee.
Demuestra el roundtrip de persistencia (criterio de aceptación Sprint 0).
Uso:
uv run vms-generate-test-project [--out PATH]
Salida:
- Crea `projects/_demo/test_project.vmsproj` por defecto.
- Imprime stats antes y después de roundtrip.
- Exit 0 si la integridad se verifica, 1 si algo diverge.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from vmssailor.core import (
AlarmConfig,
AlarmPriority,
AuthorityRequired,
Bus,
BusRole,
CardInstance,
ChannelType,
ControlMode,
Equipment,
FilterType,
PermissiveRule,
Project,
Protocol,
Scaling,
ShipCoord,
SignalType,
SystemId,
Tag,
TagBinding,
Topology,
UnitSI,
Vessel,
VesselSubtype,
VesselType,
)
from vmssailor.core.permissive import Condition
from vmssailor.core.persistence import load_project, save_project
from vmssailor.core.validation import validate_project
from vmssailor.core.vessel import Bulkhead, Deck
from vmssailor.shared.logging_setup import setup_logging
def build_demo_project() -> Project:
"""Construye un proyecto demo completo basado en Sunseeker 76 + MTU 12V."""
vessel = Vessel(
id="sunseeker_76_demo",
name="M/Y Aurora — Demo Sprint 0",
type=VesselType.YACHT_MOTOR,
subtype=VesselSubtype.PLANING,
length_overall_m=23.45,
beam_max_m=5.65,
draft_m=1.85,
displacement_kg=55000,
description="Buque de demo para verificar persistencia .vmsproj.",
data_source="seed_estimate",
decks=[
Deck(id="lower", name="Lower Deck", z_bl_bottom=0.5, z_bl_top=2.6),
Deck(id="main", name="Main Deck", z_bl_bottom=2.6, z_bl_top=4.8),
Deck(id="flybridge", name="Flybridge", z_bl_bottom=4.8, z_bl_top=6.4),
],
bulkheads=[
Bulkhead(id="collision", name="Mamparo de colisión", x_pp=21.0),
Bulkhead(id="er_fwd", name="Mamparo proa SM", x_pp=7.0),
],
)
# ---- Equipos ----
me_port = Equipment(
id="eq_me_port",
model_ref="mtu_12v_2000_m96",
tag_prefix="ME_PORT",
display_name="Motor principal babor",
location=ShipCoord(x_pp=5.5, y_cl=-0.9, z_bl=1.2),
deck_id="lower",
system_id=SystemId.MAIN_ENGINE,
)
me_stbd = Equipment(
id="eq_me_stbd",
model_ref="mtu_12v_2000_m96",
tag_prefix="ME_STBD",
display_name="Motor principal estribor",
location=ShipCoord(x_pp=5.5, y_cl=0.9, z_bl=1.2),
deck_id="lower",
system_id=SystemId.MAIN_ENGINE,
)
gen_1 = Equipment(
id="eq_gen_1",
model_ref="northern_lights_m65c13",
tag_prefix="GEN_1",
display_name="Genset 1",
location=ShipCoord(x_pp=4.5, y_cl=0.0, z_bl=1.0),
deck_id="lower",
system_id=SystemId.GENSET,
)
# ---- Buses y cartas ----
bus_main = Bus(
id="bus_main",
name="Bus principal Modbus RTU",
protocol=Protocol.MODBUS_RTU,
physical_port="COM3",
baud_rate=115200,
)
bus_n2k = Bus(
id="bus_n2k",
name="Backbone NMEA 2000",
protocol=Protocol.NMEA2000,
physical_port="USB-CAN0",
)
cards = [
CardInstance(
id="card_001",
slot_number=1,
bus_id="bus_main",
bus_role=BusRole.MODBUS_SLAVE,
modbus_address=1,
physical_location="Sala máquinas — junto a ME_PORT",
location=ShipCoord(x_pp=5.5, y_cl=-0.5, z_bl=1.4),
),
CardInstance(
id="card_002",
slot_number=2,
bus_id="bus_main",
bus_role=BusRole.MODBUS_SLAVE,
modbus_address=2,
physical_location="Sala máquinas — junto a ME_STBD",
location=ShipCoord(x_pp=5.5, y_cl=0.5, z_bl=1.4),
),
CardInstance(
id="card_003",
slot_number=3,
bus_id="bus_main",
bus_role=BusRole.MODBUS_SLAVE,
modbus_address=3,
physical_location="Sala máquinas — junto a Genset 1",
location=ShipCoord(x_pp=4.5, y_cl=0.0, z_bl=1.2),
),
]
topology = Topology(buses=[bus_main, bus_n2k], cards=cards)
# ---- Tags ----
tags: list[Tag] = []
for eq, card_id in [(me_port, "card_001"), (me_stbd, "card_002")]:
tags.append(
Tag(
id=f"{eq.tag_prefix}.OIL_PRESS",
equipment_id=eq.id,
description=f"Presión de aceite {eq.display_name}",
unit_si=UnitSI.BAR,
range_normal_min=3.5,
range_normal_max=6.5,
alarms=[
AlarmConfig(
id=f"{eq.tag_prefix}.OIL_PRESS.LOW",
threshold=1.5,
operator="<",
priority=AlarmPriority.EMERGENCY,
hysteresis=0.2,
delay_seconds=2.0,
message="Presión aceite crítica baja.",
)
],
protocol=Protocol.MODBUS_RTU,
physical_binding=TagBinding(
card_id=card_id,
channel_type=ChannelType.AI,
channel_number=1,
signal_type=SignalType.SIG_4_20_MA,
scaling=Scaling(raw_min=4.0, raw_max=20.0, eng_min=0.0, eng_max=10.0),
filter=FilterType.MOVING_AVG,
filter_param=4.0,
update_rate_ms=200,
),
)
)
tags.append(
Tag(
id=f"{eq.tag_prefix}.COOLANT_TEMP",
equipment_id=eq.id,
description=f"Temperatura refrigerante {eq.display_name}",
unit_si=UnitSI.DEGREE_CELSIUS,
range_normal_min=65.0,
range_normal_max=95.0,
alarms=[
AlarmConfig(
id=f"{eq.tag_prefix}.COOLANT_TEMP.HIGH",
threshold=100.0,
operator=">",
priority=AlarmPriority.EMERGENCY,
hysteresis=2.0,
delay_seconds=3.0,
message="Temperatura refrigerante crítica alta.",
)
],
protocol=Protocol.MODBUS_RTU,
physical_binding=TagBinding(
card_id=card_id,
channel_type=ChannelType.AI,
channel_number=2,
signal_type=SignalType.RTD_PT100,
scaling=Scaling(raw_min=0.0, raw_max=4095.0, eng_min=-50.0, eng_max=200.0),
),
)
)
tags.append(
Tag(
id=f"{eq.tag_prefix}.RPM",
equipment_id=eq.id,
description=f"RPM {eq.display_name}",
unit_si=UnitSI.RPM,
range_normal_min=0.0,
range_normal_max=2600.0,
protocol=Protocol.MODBUS_RTU,
physical_binding=TagBinding(
card_id=card_id,
channel_type=ChannelType.RPM,
channel_number=1,
signal_type=SignalType.PULSE_MAGNETIC_PICKUP,
),
)
)
tags.append(
Tag(
id=f"{eq.tag_prefix}.ESTOP_ACTIVE",
equipment_id=eq.id,
description="Pulsador E-stop activo",
unit_si=UnitSI.BOOL,
protocol=Protocol.MODBUS_RTU,
physical_binding=TagBinding(
card_id=card_id,
channel_type=ChannelType.DI,
channel_number=1,
signal_type=SignalType.DRY_CONTACT,
),
)
)
tags.append(
Tag(
id=f"{eq.tag_prefix}.START_CMD",
equipment_id=eq.id,
description=f"Arranque {eq.display_name}",
unit_si=UnitSI.BOOL,
controllable=True,
control_mode=ControlMode.MANUAL,
authority_required=AuthorityRequired.BRIDGE,
protocol=Protocol.MODBUS_RTU,
physical_binding=TagBinding(
card_id=card_id,
channel_type=ChannelType.DO,
channel_number=1,
signal_type=SignalType.RELAY_NO,
),
)
)
# Tags del genset
tags.append(
Tag(
id="GEN_1.VOLTAGE_L1",
equipment_id=gen_1.id,
description="Tensión L1 genset 1",
unit_si=UnitSI.VOLT,
range_normal_min=220.0,
range_normal_max=240.0,
alarms=[
AlarmConfig(
id="GEN_1.VOLTAGE_L1.LOW",
threshold=200.0,
operator="<",
priority=AlarmPriority.HIGH,
hysteresis=5.0,
),
AlarmConfig(
id="GEN_1.VOLTAGE_L1.HIGH",
threshold=250.0,
operator=">",
priority=AlarmPriority.HIGH,
hysteresis=5.0,
),
],
protocol=Protocol.MODBUS_RTU,
physical_binding=TagBinding(
card_id="card_003",
channel_type=ChannelType.AI,
channel_number=1,
signal_type=SignalType.VOLTAGE_DIVIDER,
scaling=Scaling(raw_min=0.0, raw_max=4095.0, eng_min=0.0, eng_max=300.0),
),
)
)
# ---- Permissives ----
permissive_start_me_port = PermissiveRule(
id="rule_start_me_port",
action_id="START_ME_PORT",
description="Pre-condiciones para arrancar motor principal babor.",
conditions=[
Condition(
tag_ref="ME_PORT.OIL_PRESS",
operator=">",
threshold=0.3,
severity="fail",
message_on_fail="Presión aceite muy baja para arranque seguro.",
),
Condition(
tag_ref="ME_PORT.COOLANT_TEMP",
operator=">",
threshold=5.0,
severity="fail",
message_on_fail="Refrigerante < 5°C — pre-calentar.",
),
Condition(
tag_ref="ME_PORT.ESTOP_ACTIVE",
operator="is_false",
severity="fail",
message_on_fail="E-stop activado — desbloquear.",
),
],
on_fail_message="No es seguro arrancar ME_PORT.",
)
project = Project(
id="demo_sprint0_aurora",
name="M/Y Aurora — Demo Sprint 0",
customer="Cliente demo (no real)",
notes="Proyecto sintético para verificar persistencia roundtrip.",
vessel=vessel,
systems_enabled=[SystemId.MAIN_ENGINE, SystemId.GENSET],
equipment=[me_port, me_stbd, gen_1],
tags=tags,
topology=topology,
permissive_rules=[permissive_start_me_port],
)
return project
def _projects_equal_for_demo(a: Project, b: Project) -> tuple[bool, str]:
"""Compara dos Project para roundtrip. Ignora updated_at por design."""
a_dump = a.model_dump(mode="json", exclude={"updated_at"})
b_dump = b.model_dump(mode="json", exclude={"updated_at"})
if a_dump == b_dump:
return True, "match"
# Localizar la primera diferencia
diffs: list[str] = []
keys = set(a_dump.keys()) | set(b_dump.keys())
for k in sorted(keys):
if a_dump.get(k) != b_dump.get(k):
diffs.append(f" - {k}: differs")
return False, "\n".join(diffs) or "structures differ"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="vms-generate-test-project",
description=(
"Genera un Project demo, lo guarda como .vmsproj y verifica el roundtrip. "
"Criterio de aceptación del Sprint 0."
),
)
parser.add_argument(
"--out",
type=Path,
default=Path("projects/_demo/test_project.vmsproj"),
help="Ruta del .vmsproj a escribir.",
)
parser.add_argument("--verbose", action="store_true")
args = parser.parse_args(argv)
setup_logging(verbose=args.verbose)
print("VMS-Sailor — Generador de proyecto demo (roundtrip)")
print("=" * 60)
project = build_demo_project()
stats_in = project.stats()
print("Proyecto en memoria:")
for k, v in stats_in.items():
print(f" {k:25s}: {v}")
# Validación cross-entity (informativa)
report = validate_project(project)
print()
print("Validación cross-entity:")
print(" " + report.format().replace("\n", "\n "))
print()
final = save_project(project, args.out)
print(f"Guardado en: {final}")
print(f"Tamaño : {final.stat().st_size:,} bytes")
print()
print("Releyendo desde disco...")
loaded = load_project(final)
stats_out = loaded.stats()
print("Proyecto releído:")
for k, v in stats_out.items():
print(f" {k:25s}: {v}")
ok, msg = _projects_equal_for_demo(project, loaded)
print()
if ok:
print("INTEGRIDAD OK — roundtrip perfecto.")
return 0
print("INTEGRIDAD FAIL — diferencias encontradas:")
print(msg)
return 1
if __name__ == "__main__":
sys.exit(main())
+74
View File
@@ -0,0 +1,74 @@
"""CLI para validar la biblioteca curada del Sprint 0.
Uso:
uv run vms-validate-library
# equivalente:
python -m vmssailor.tools.validate_library
Salida:
- Exit code 0 si no hay errores (warnings/info no bloquean).
- Exit code 1 si hay errores estructurales.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from vmssailor.library import load_library
from vmssailor.shared.logging_setup import setup_logging
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="vms-validate-library",
description="Valida la biblioteca curada de VMS-Sailor.",
)
parser.add_argument(
"--root",
type=Path,
default=None,
help="Ruta raíz de la biblioteca (defecto: vmssailor/library/).",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Logging DEBUG.",
)
parser.add_argument(
"--strict-warnings",
action="store_true",
help="Salir con error si hay warnings.",
)
args = parser.parse_args(argv)
setup_logging(verbose=args.verbose)
print("VMS-Sailor — Validador de biblioteca curada")
print("=" * 60)
result = load_library(args.root)
print(result.format())
print()
if not result.ok():
print(f"\nFAIL: {len(result.errors)} errores en la biblioteca.")
return 1
if args.strict_warnings and result.warnings:
print(f"\nFAIL (strict): {len(result.warnings)} warnings.")
return 1
info_count = len([i for i in result.issues if i.severity == "info"])
print(
f"\nOK: biblioteca válida "
f"({len(result.vessels)} vessels, {len(result.equipment_models)} equipment, "
f"{len(result.rules)} rules, {info_count} info)."
)
return 0
if __name__ == "__main__":
sys.exit(main())
+18
View File
@@ -0,0 +1,18 @@
"""Single source of truth para la versión del paquete.
Convención PEP 440: durante sprints de desarrollo usamos `0.X.0.devN`. El
campo `SPRINT_TAG` da contexto humano para changelogs y UI splash.
"""
__version__ = "0.1.0.dev0"
VERSION_MAJOR = 0
VERSION_MINOR = 1
VERSION_PATCH = 0
SPRINT_TAG = "sprint0"
VMSPROJ_SCHEMA_VERSION = 1
"""Versión del schema SQLite de archivos .vmsproj. Incrementar al cambiar tablas/columnas."""
VMSPACK_FORMAT_VERSION = 1
"""Versión del formato ZIP de paquetes .vmspack distribuibles."""