sprint-0: fundaciones VMS-Sailor
Sprint 0 completo del producto VMS-Sailor (Vessel Management System integrado para buques 30-40m). Brief de referencia en VMS_Sailor_v2_Parte_*.md (intacto). Core (vmssailor.core, 95.17% coverage, 99 tests verde): - ShipCoord: sistema naval x_pp/y_cl/z_bl frozen - Vessel, Deck, Bulkhead - Equipment, EquipmentModel, Sensor, EquipmentSpec - Tag, AlarmConfig, TagBinding, Scaling - CardInstance, Bus, Topology con validacion 21 puntos I/O AR-NMEA-IO-v1.0 - Alarm, PermissiveRule, Condition - Project agregado raiz con validacion cross-entity - Persistencia portable .vmsproj (SQLite) con roundtrip verificable Biblioteca curada seed (vmssailor.library): - systems_catalog.json completo (catalogo maestro Parte 1 sec 7) - 2 vessels: Sunseeker 76, Ferretti 850 - 2 motores: MTU 12V 2000 M96, Volvo D13-900 - 1 genset: Northern Lights M65C13 - yacht_motor_planeo.yaml (reglas heuristicas) - TODO marcado data_source=seed_estimate - requiere validacion datasheets Tools: - vms-validate-library: CLI valida biblioteca completa - vms-generate-test-project: CLI demo + verificacion roundtrip persistencia Design System + 8 mockups HTML estaticos: - docs/design_system.md (paleta Deep Ocean, gradientes, typography, motion) - docs/brand/ (logo + variantes SVG) - docs/mockups/splash, studio_main, runtime_overview, runtime_mimic_fuel (P&ID animado), runtime_alarms, runtime_trim (panel estrella con horizonte artificial), mobile_overview, mobile_trim - docs/mockups/index.html (galeria) Firmware (Sprint 12+ implementacion): - firmware/ar_nmea_io_v1/src/config/pinout.h con macros GPIO Decisiones autonomas documentadas en docs/decisions_sprint0.md. Stack: Python 3.11 + uv + Pydantic v2 + SQLite stdlib + hatchling + pytest 9 + ruff + mypy. Sin PySide6, FastAPI, Flutter ni firmware funcional (entran en sprints siguientes). Criterio de aceptacion Sprint 0: cumplido. - uv sync: OK - pytest: 99/99 verde - cov vmssailor.core: 95.17% (objetivo >=80%) - ruff: clean - vms-validate-library: OK - vms-generate-test-project: INTEGRIDAD OK Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
"""VMS-Sailor — Vessel Management System.
|
||||
|
||||
Producto propietario de Álvaro. Ver LICENSE.txt.
|
||||
"""
|
||||
|
||||
from vmssailor.version import __version__
|
||||
|
||||
__all__ = ["__version__"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]"
|
||||
@@ -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"
|
||||
@@ -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.",
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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."
|
||||
@@ -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": "" }
|
||||
]
|
||||
}
|
||||
@@ -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`.
|
||||
"""
|
||||
@@ -0,0 +1 @@
|
||||
"""vmssailor.shared — Utilidades compartidas (logging, IDs, etc)."""
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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`.
|
||||
"""
|
||||
@@ -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
|
||||
"""
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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."""
|
||||
Reference in New Issue
Block a user