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
+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)