Files
alro65 deb04c9315 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>
2026-05-17 07:26:06 -04:00

155 lines
5.9 KiB
Python

"""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.",
)