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,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)
|
||||
Reference in New Issue
Block a user