commit deb04c93153b3aadcfd3db68e409c36c84616301 Author: Aerom Date: Sun May 17 07:26:06 2026 -0400 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f21803 --- /dev/null +++ b/.gitignore @@ -0,0 +1,77 @@ +# --- Python --- +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +.venv/ +venv/ +env/ +.uv-cache/ +.python-version-cache + +# --- Build / dist --- +build/ +dist/ +*.egg-info/ +*.egg +pip-wheel-metadata/ + +# --- Test / coverage --- +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +.coverage.* +htmlcov/ +coverage.xml + +# --- IDE --- +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# --- OS --- +.DS_Store +Thumbs.db +desktop.ini + +# --- Runtime artifacts --- +logs/ +*.log +*.vmspack +*.vmsdelta +*.vmsproj.bak +licenses/local/ +secrets/ +historian.duckdb +audit.db +*.duckdb.wal +*.duckdb.tmp + +# --- Mobile (Flutter) --- +mobile/build/ +mobile/.dart_tool/ +mobile/.flutter-plugins +mobile/.flutter-plugins-dependencies +mobile/.packages + +# --- Firmware (PlatformIO / ESP-IDF) --- +firmware/**/.pio/ +firmware/**/.vscode/ +firmware/**/build/ +firmware/**/managed_components/ +firmware/**/sdkconfig.old +*.bin +*.elf +*.map + +# --- Generated artifacts --- +projects/_demo/ +*.vmsproj +!tests/**/*.vmsproj + +# --- Brief documents are kept tracked --- +!VMS_Sailor_v2_Parte_*.md diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0553d60 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,88 @@ +# Changelog VMS-Sailor + +Formato basado en [Keep a Changelog](https://keepachangelog.com/) + [SemVer](https://semver.org/). + +## [0.1.0-sprint0] — 2026-05-16 + +### Added — Fundaciones (Sprint 0) + +#### Estructura del proyecto +- `pyproject.toml` configurado con uv, ruff, mypy, pytest +- Python 3.11 fijado en `.python-version` +- `.gitignore` cubriendo Python, build, IDE, mobile (Flutter), firmware (PlatformIO), artefactos Runtime +- Estructura de carpetas completa: `vmssailor/`, `firmware/`, `mobile/`, `tests/`, `tools/`, `docs/`, `installer/` + +#### Modelo de datos core (`vmssailor/core/`) +- `coords.py`: `ShipCoord(x_pp, y_cl, z_bl)` frozen Pydantic model, sistema naval estándar +- `enums.py`: catálogo completo de enums (`SignalType`, `ChannelType`, `AlarmPriority`, `AlarmState`, `ControlMode`, `AuthorityRequired`, `Protocol`, `BusRole`, `FilterType`, `Quality`, `EquipmentCategory`, `SystemId`, `UnitSI`, `VesselType`, `VesselSubtype`) +- `vessel.py`: `Vessel`, `Deck`, `Bulkhead` +- `equipment.py`: `Equipment`, `EquipmentModel`, `Sensor`, `EquipmentSpec` +- `tag.py`: `Tag`, `AlarmConfig`, `TagBinding`, `Scaling` +- `card.py`: `CardInstance`, `Bus`, `Topology`, validación de capacidades 21 puntos I/O +- `alarm.py`: `Alarm` (instancia activa) +- `permissive.py`: `PermissiveRule`, `Condition` +- `project.py`: `Project` (agregado raíz) +- `validation.py`: invariantes cross-entity + +#### Persistencia (`vmssailor/core/persistence/`) +- Schema SQLite portable `.vmsproj` (15+ tablas, versionado en `schema_version`) +- Writer + Reader con roundtrip verificable +- `migrations.py` placeholder versionado + +#### Biblioteca curada seed (`vmssailor/library/`) +- `systems_catalog.json`: catálogo maestro completo con TODOS los sistemas de la Parte 1 sec 7 (propulsión, maniobra, generación eléctrica, fluidos, seguridad, ambiente, iluminación, tanques, cubierta, específicos por tipo) +- `vessels/sunseeker_76.json` +- `vessels/ferretti_850.json` +- `equipment/engines/mtu_12v_2000_m96.json` +- `equipment/engines/volvo_d13_900hp.json` +- `equipment/gensets/northern_lights_m65c13.json` +- `rules/yacht_motor_planeo.yaml`: reglas heurísticas +- `loader.py`: carga + validación de toda la biblioteca + +**NOTA:** todos los archivos seed traen `data_source: "seed_estimate"` — valores plausibles que requieren validación de Álvaro contra datasheets oficiales antes de Sprint 1. + +#### Tools +- `tools/validate_library.py`: CLI que valida integridad de toda `vmssailor/library/` +- `tools/generate_test_project.py`: demo que crea un proyecto completo (Sunseeker 76 + equipos + tags + cards) → guarda `.vmsproj` → lo relee → verifica integridad campo por campo + +#### Tests (`tests/`) +- `tests/core/` — unit tests por entidad (≥ 80% cobertura objetivo) +- `tests/core/persistence/test_roundtrip.py` — test crítico de serialización +- `tests/library/` — tests de integridad de la seed + +#### Design System +- `docs/design_system.md`: paleta (Deep Ocean + Sailor Cyan + Compass Amber + ...), tipografía (Inter UI + JetBrains Mono valores), gradientes (deep-sea, horizon, glow, glass), 5 niveles de sombra, motion principles, iconografía naval +- `docs/mockups/` 8 mockups HTML+CSS+SVG estáticos: + - `splash.html` — pantalla de bienvenida + - `studio_main.html` — Studio Sprint 1 preview + - `runtime_overview.html` — dashboard del buque + - `runtime_mimic_fuel.html` — mímico sistema combustible con válvulas y bombas + - `runtime_alarms.html` — panel de alarmas con prioridades + - `runtime_trim.html` — panel Trim & Maniobra (estrella) con sliders y gauge roll/pitch + - `mobile_overview.html` — versión iPhone + - `mobile_trim.html` — Trim móvil +- `docs/brand/` — logo SVG + variantes + +#### Documentación +- `docs/architecture.md` — overview 1-pager con links a briefs +- `docs/coords.md` — sistema de coordenadas navales explicado +- `docs/seed_data_notes.md` — qué entradas requieren validación de Álvaro +- `docs/decisions_sprint0.md` — decisiones tomadas autónomamente durante Sprint 0 + +#### Firmware +- `firmware/ar_nmea_io_v1/src/config/pinout.h`: definiciones GPIO según Parte 4 sec 1 (sin código compilable aún) +- `firmware/ar_nmea_io_v1/README.md` + +### Decisiones tomadas autónomamente (revisar en `docs/decisions_sprint0.md`) +- Pydantic v2 (no v1, no dataclasses) — validación + serialización built-in +- SQLite stdlib (no SQLAlchemy en Sprint 0) — `.vmsproj` es portable y plano +- `hatchling` como build backend (no setuptools) — moderno, integra con uv +- `data_source` flag obligatorio en entradas de biblioteca +- IDs de tags deterministas: `{equipment.tag_prefix}.{sensor.id}` +- Encoding de coordenadas: tres `float` Pydantic con validadores de rango razonable + +### Not Yet +- Sin PySide6, FastAPI, pymodbus, duckdb, pywin32 (entran en sprints siguientes) +- Sin compilador `.vmspack` (Sprint 7) +- Sin Flutter (Sprint 11) +- Sin firmware funcional (Sprint 12) diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..c2f9c5b --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,31 @@ +VMS-Sailor — Vessel Management System +Copyright (c) 2026 Álvaro (Aerom). All rights reserved. + +PROPRIETARY AND CONFIDENTIAL. + +This software, including its source code, binaries, configuration files, +curated library (vessel templates, equipment catalogs, heuristic rules, +mimics), firmware, mobile application, and accompanying documentation +("the Software"), is the proprietary intellectual property of Álvaro. + +NO LICENSE TO USE, COPY, MODIFY, DISTRIBUTE, SUBLICENSE, OR CREATE DERIVATIVE +WORKS IS GRANTED EXCEPT BY EXPRESS WRITTEN AGREEMENT WITH THE COPYRIGHT +HOLDER. + +The Software is distributed to end customers only in compiled, signed, +HWID-bound packages ("VMSPACK", "VMSDELTA"). Any inspection, reverse +engineering, decompilation, or extraction of the curated library content +is strictly prohibited. + +The Studio component MUST NOT be distributed to customers under any +circumstance. The Studio license is hardware-bound to the development +machines authorized by the copyright holder. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. THE +COPYRIGHT HOLDER SHALL NOT BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER +LIABILITY ARISING FROM USE OF THE SOFTWARE. + +Marine safety functions implemented herein follow the spirit of IEC +60092-504, IACS UR E22, ABYC E-11, NMEA 2000, SAE J1939, and IMO +MARPOL/SOLAS, but formal certification of compliance is the responsibility +of the vessel operator and the certifying authority of jurisdiction. diff --git a/README.md b/README.md new file mode 100644 index 0000000..221be1f --- /dev/null +++ b/README.md @@ -0,0 +1,143 @@ +# VMS-Sailor + +**Vessel Management System** integrado (IAS — Integrated Automation System) para buques de 30 a 40 metros. + +> Yates motor · Pesqueros · Patrulleros · Ferries pequeños · Offshore support pequeño + +Compite en el nicho que Kongsberg K-Chief, Praxis Mega-Guard, Bachmann M-Series y Wärtsilä NACOS desatienden por enfocarse en buques de 80+ m. + +--- + +## Componentes + +| Componente | Para quién | Stack | +|---|---|---| +| **VMS-Sailor Studio** | Ingeniería interna (Álvaro) | Python 3.11 + PySide6 | +| **VMS-Sailor Runtime** | A bordo del buque | Python 3.11 + FastAPI + SQLite + DuckDB | +| **VMS-Sailor Mobile** | Owner + tripulación (WiFi local) | Flutter 3.x + Dart 3.x | +| **AR-NMEA-IO-v1.0 firmware** | Tarjeta I/O distribuida | ESP32 + PlatformIO + C++ | + +--- + +## Estado actual + +**Sprint 0 — Fundaciones** ✓ + +Lo que existe en este commit: + +- Modelo de datos core (`vmssailor/core/`) con Pydantic v2 +- Persistencia portable a `.vmsproj` (SQLite) +- Biblioteca curada seed mínima (2 yates motor, 2 motores, 1 genset, reglas) +- Catálogo maestro completo de sistemas (`vmssailor/library/systems_catalog.json`) +- Validador de biblioteca + generador de proyecto demo +- Tests con cobertura ≥ 80% en `core` +- Design system + mockups HTML estáticos +- Firmware: solo `pinout.h` (Sprint 12 implementa el firmware) + +**Lo que NO existe aún** (sprints siguientes): + +- ❌ UI Studio operativa (Sprint 1) +- ❌ Runtime servidor con drivers Modbus/NMEA 2000 (Sprint 4) +- ❌ Cliente desktop (Sprint 6) +- ❌ App móvil Flutter (Sprint 11) +- ❌ Firmware funcional (Sprint 12) + +--- + +## Setup + +Requiere **Python 3.11** y [**uv**](https://github.com/astral-sh/uv). + +```bash +# Si no tienes Python 3.11 instalado, uv lo descarga solo: +uv python install 3.11 + +# Crear entorno virtual + instalar dependencias: +uv sync --extra dev + +# Validar la biblioteca curada: +uv run vms-validate-library + +# Generar proyecto demo y verificar roundtrip de persistencia: +uv run vms-generate-test-project + +# Tests: +uv run pytest + +# Lint + type check: +uv run ruff check vmssailor/ tests/ tools/ +uv run mypy vmssailor/core/ +``` + +--- + +## Estructura del repositorio + +``` +. +├── vmssailor/ # Código Python compartido (Studio + Runtime) +│ ├── core/ # ★ Modelo de datos del producto (Sprint 0) +│ ├── library/ # ★ Biblioteca curada seed (Sprint 0) +│ ├── shared/ # Utilidades comunes +│ ├── studio/ # App Studio (Sprint 1+) +│ └── runtime/ # App Runtime (Sprint 4+) +├── firmware/ # Firmware ESP32 (Sprint 12+) +│ └── ar_nmea_io_v1/ +│ └── src/config/pinout.h +├── mobile/ # App Flutter (Sprint 11) +├── tests/ # Tests Python (pytest) +├── tools/ # Scripts CLI +│ ├── validate_library.py +│ └── generate_test_project.py +├── docs/ # Documentación +│ ├── architecture.md +│ ├── coords.md +│ ├── design_system.md # ★ Sistema visual completo +│ ├── decisions_sprint0.md +│ ├── seed_data_notes.md +│ ├── mockups/ # ★ HTML mockups del producto +│ └── brand/ # ★ Logo y assets +├── installer/ # WiX MSI scripts (Sprint 7) +├── VMS_Sailor_v2_Parte_*.md # Brief original (intacto) +├── pyproject.toml +└── README.md +``` + +--- + +## Documentos de referencia (intactos) + +Los 6 archivos `VMS_Sailor_v2_Parte_*.md` son el **brief original** y son la verdad de referencia para todos los sprints. **NO se modifican sin acuerdo explícito.** + +- Parte 1 — Visión, arquitectura general +- Parte 2 — Studio en detalle +- Parte 3 — Runtime en detalle +- Parte 4 — Hardware AR-NMEA-IO + firmware +- Parte 5 — Mobile +- Parte 6 — Sprints + reglas de oro + +--- + +## Reglas de oro (recordatorio) + +1. Antes de cada sprint, plan + OK explícito antes de codear. +2. Tests obligatorios en `core` y `runtime/server`. +3. No agregar dependencias sin preguntar. +4. Documentar normativa (IEC 60092-504, IACS UR E22, ABYC E-11, NMEA 2000, SAE J1939). +5. Sin red de salida en Runtime (salvo activación inicial y VPN administrativa). +6. Biblioteca curada es **ORO** — cambios de formato requieren migración. +7. Runtime es **inmutable** para el cliente — solo deltas firmados. +8. Auditoría siempre activa. +9. Coordenadas navales `ShipCoord(x_pp, y_cl, z_bl)` en todo el código. +10. Unidades SI internas siempre. +11. Idioma: español por defecto. +12. AR-ECDIS es producto separado (no se desarrolla aquí). +13. Firmware y software van juntos en el mismo `.vmspack`. + +Detalle completo en `VMS_Sailor_v2_Parte_06_Sprints_y_reglas.md` sección 5. + +--- + +## Licencia + +Software **propietario** de Álvaro. Ver `LICENSE.txt`. diff --git a/VMS_Sailor_v2_Parte_01.md b/VMS_Sailor_v2_Parte_01.md new file mode 100644 index 0000000..ce8e2e3 --- /dev/null +++ b/VMS_Sailor_v2_Parte_01.md @@ -0,0 +1,416 @@ +# VMS-Sailor · Brief para Claude Code · Parte 1 de 6 + +## Visión, modelo de producto, arquitectura general + +> **Instrucciones para Álvaro (no para Code):** Este es el primero de seis documentos que conforman el brief completo. Pega este primero en una **nueva conversación** de Claude Code, espera a que confirme que entendió el alcance, y luego pasa las partes 2 a 6 secuencialmente. Cada parte construye sobre la anterior. +> +> Abre Claude Code en una carpeta padre (`D:/Proyectos Software/`) y deja que él cree la subcarpeta `VMS-Sailor`. No abras Code dentro de `D:/Proyectos Software/VMS-Sailor/` antes de tiempo. + +--- + +## 1. Qué es VMS-Sailor + +Eres mi co-desarrollador senior en Python, Dart y firmware embebido. Vamos a construir **VMS-Sailor**, un Vessel Management System completo: un sistema integrado de automatización marina (IAS — Integrated Automation System) para monitoreo y control de toda la planta del buque, dirigido a embarcaciones de 30 a 40 metros (yates motor, pesqueros, patrulleros, ferries pequeños, offshore support pequeño). + +VMS-Sailor es un **producto vertical completo**: hardware propio + firmware embebido + software de configuración + software runtime + app móvil + biblioteca curada de conocimiento naval. Compite con sistemas comerciales como Kongsberg K-Chief, Praxis Mega-Guard, Bachmann M-Series, Wärtsilä NACOS, en el nicho de buques medianos que esos fabricantes desatienden por enfocarse en buques de 80+ metros. + +--- + +## 2. Contexto comercial — relación con AR-ECDIS + +VMS-Sailor NO se vende solo. Forma parte de una oferta comercial mayor donde: + +- **AR-ECDIS** es el producto base de Álvaro: sistema de navegación con cartografía electrónica, que SIEMPRE incluye GPS y sensor giroscópico **Bosch BNO085** (9-DOF con fusión sensorial integrada, conectado por I2C/UART al microcontrolador del ECDIS). AR-ECDIS publica los datos de navegación y actitud al backbone **NMEA 2000** del buque mediante PGNs estándar (127250 Heading, 127251 Rate of Turn, 127257 Attitude, 129025/129029 Position, 129026 COG/SOG, 128259 Speed Water). + +- **VMS-Sailor** es producto opcional que se vende ENCIMA del AR-ECDIS. Asume que el backbone NMEA 2000 está disponible y los datos de actitud/navegación ya viajan por él. NO instala IMU adicional, NO instala GPS adicional — los lee del backbone. + +**Importante para Claude Code:** AR-ECDIS es un proyecto separado que NO se desarrolla en este repositorio. Solo lo mencionamos para que entiendas de dónde vienen los datos de navegación y actitud que VMS-Sailor consume. Si el cliente compra solo AR-ECDIS, los datos siguen viajando por el backbone (los lee el plotter del puente). Si compra VMS-Sailor además, los aprovecha gratis sin hardware redundante. + +--- + +## 3. Los cuatro componentes de VMS-Sailor + +### 3.1 VMS-Sailor Studio (herramienta de ingeniería de Álvaro) + +Aplicación de escritorio que YO uso en mi oficina cuando un armador me contrata. Incluye: + +- Wizard de pre-diseño guiado de 8 pasos +- Biblioteca curada de buques (Sunseeker 76, Ferretti 850, Princess Y85, Azimut Grande 32M, pesqueros y patrulleros 30-40m...) +- Catálogo de equipos comerciales (MTU, CAT, Volvo, Yanmar, Kohler, Northern Lights, Onan, Cummins...) +- Catálogo maestro de sistemas instalables +- Reglas heurísticas en YAML que capturan mi conocimiento de qué sistemas y equipos lleva típicamente cada tipo de buque +- Editor de mímicos (P&IDs interactivos) +- Configurador de tags, alarmas, direcciones Modbus/NMEA 2000, permissives, autoridad +- Editor de topología física: asignación de tarjetas AR-NMEA-IO al buque +- Simulador (test bench) sin hardware real +- Generador de firmware personalizado por tarjeta +- Compilador a `.vmspack` + instalador MSI +- Generador de deltas (`.vmsdelta`) para expansiones sin recompilar +- Versionado git interno por proyecto de buque + +**Estudio queda en mi PC. El cliente NUNCA lo ve. La biblioteca curada y las reglas son mi propiedad intelectual.** + +### 3.2 VMS-Sailor Runtime (a bordo del buque) + +Aplicación que se instala en el PC industrial del buque. Lo que el armador, capitán, jefe de máquinas y tripulación usan día a día. Incluye: + +- Servicio Windows 24/7 con motor de tiempo real, drivers Modbus RTU/TCP y CAN/NMEA 2000, motor de tags, historian (DuckDB), motor de alarmas, motor de permissives, motor de autoridad de control puente↔máquinas, API WebSocket + REST +- Motor de protección de escora con tres niveles (warning, auto-offer, auto-force) usando datos de actitud del backbone NMEA 2000 (PGN 127257) +- Cliente desktop nativo (Python + PySide6) para estaciones puente y máquinas +- Tres perfiles: Operador, Técnico, Admin del buque (= el owner) +- Log Book naval básico (registro automático de eventos del motor, arranques/paradas, alarmas, snapshots periódicos) +- Acceso remoto VPN para soporte técnico de Álvaro, auditado y visible al owner +- Activación HWID + firma criptográfica de paquetes +- Configuración inmutable para el cliente — solo Álvaro firma cambios + +### 3.3 VMS-Sailor Mobile (app nativa) + +App nativa iOS y Android en **Flutter**. Para owner, técnicos y tripulación autorizados, **solo dentro de la WiFi local del buque**, nunca expuesta a internet. + +- TOTP + biométrico del dispositivo (FaceID/TouchID/huella) +- Enrollment de dispositivos vía QR generado en estación principal por el Admin del buque +- Revocación remota de dispositivos perdidos +- Vista de mímicos optimizada para pantalla móvil +- Panel de alarmas con notificaciones push locales +- Control de algunos sistemas con permissives — los críticos solo desde estación fija +- Panel especial "Trim & Maniobra" con sliders visuales (caso de uso destacado) +- WebSocket al Runtime en WiFi local + +### 3.4 Hardware AR-NMEA-IO + firmware ESP32 + +Tarjeta de I/O distribuida que YO diseño y produzco. Basada en ESP32-DOWD. Una sola SKU física, configurable por software vía descarga de configuración desde el VMS. Distribuida cerca de cada equipo monitoreado o controlado, no centralizada en panel principal. + +**Capacidades fijas en hardware (21 puntos I/O totales):** + +- 10 salidas digitales con MOSFET IRLML6344TRPBF aisladas opto PC817, diodo flyback SS14, 30V/5A +- 5 entradas digitales aisladas opto +- 1 entrada de frecuencia (RPM, pickup magnético, tacómetro) +- 4 entradas analógicas con divisores y filtros V3061 +- Comunicación: RS485 (transceiver SN65HVD1781) + CAN/NMEA 2000 (transceiver MCP2562T-E_MF) + WiFi del ESP32 + USB para programación inicial +- Alimentación 12 VDC con protección TVS, bucks MP2338 a 5V y 3.3V +- Programación: USB inicial, OTA inalámbrica vía WiFi después + +**Filosofía clave del firmware:** la tarjeta sale de fábrica con firmware universal idéntico para todas. El rol y configuración (esclava N, qué puerto hace qué, filtros locales, permissives locales, alarmas locales) se descargan del VMS al conectarse al bus. Patrón **"plug-and-produce"** (estándar industrial moderno tipo Beckhoff EtherCAT, Wago I/O System). + +--- + +## 4. Jerarquía de roles por aplicación + +### Studio (mi propiedad) + +| Rol | Quién | Qué hace | +|---|---|---| +| Admin Studio | Yo (Álvaro) | Edita biblioteca curada, escribe reglas heurísticas, crea proyectos, compila paquetes, firma deltas | +| Ingeniero Studio | Colaboradores que contrate | Configura proyectos pero no edita biblioteca curada | + +### Runtime (propiedad del cliente) + +| Rol | Quién | Qué hace | +|---|---|---| +| Admin del buque | El owner / armador | Máximo nivel en SU buque: gestiona usuarios, ajusta límites menores de alarma, ve auditoría completa, activa modo manual de trim | +| Técnico | Jefe de máquinas, ingeniero mantenimiento | Opera, ack alarmas, ejecuta controles autorizados, ve trends | +| Operador | Tripulación general | Solo monitoreo, ack alarmas de baja prioridad | +| Super-Admin de fábrica | Yo (Álvaro), vía VPN | Acceso de fabricante para diagnóstico y actualizaciones. Cada conexión auditada e inalterable, visible para el Admin del buque en pestaña "Soporte y Auditoría" | + +**El cliente ES el dueño de SU sistema.** Yo entro solo cuando el Admin del buque me autoriza y queda registrado en SU auditoría. No soy "big brother" — soy soporte técnico. + +--- + +## 5. Arquitectura de configuración en capas + +Para soportar el modelo de servicio (entrega inicial + comisionado + personalizaciones del owner + expansiones futuras cobradas), la configuración del Runtime se compone de **capas apiladas**. Las capas superiores tienen prioridad. Cada actualización agrega capa nueva sin tocar las anteriores. + +**Capa 1 — Paquete base (`base.vmspack`):** Entrega original. Inmutable. Solo se reemplaza en upgrade mayor de versión (v1→v2). + +**Capa 2 — Comisionado de campo (`commissioning.vmsdelta`):** Ajustes específicos durante puesta en marcha en el buque real: direcciones Modbus reales, offsets de calibración de sensores, permissives ajustados a la planta real, modelo de respuesta de trim aprendido durante prueba de mar. + +**Capa 3 — Personalizaciones del owner (`owner_prefs.vmsdelta`):** Ajustes vivos durante operación: límites menores de alarma reajustados, renombres de equipos, dispositivos móviles enrolados, preferencias UI. + +**Capa 4 — Expansiones (`expansion_vN.vmsdelta`):** Cada vez que el cliente paga una expansión (instaló un nuevo equipo, agregó un sistema), Álvaro genera un delta firmado que se apila como nueva capa. + +**Capa siempre intocable — Datos operativos:** Historian completo (todos los registros históricos), log de alarmas, log de auditoría, dispositivos enrolados con sus secretos TOTP. NUNCA se tocan, ni en upgrade mayor. + +**Mecánica al arrancar el Runtime:** Carga capa 1, aplica capa 2 sobreescribiendo, aplica capa 3, aplica capa 4 en orden cronológico. Construye configuración efectiva en memoria. En disco las capas permanecen separadas. + +**Beneficios:** +- Ver qué cambió en cada actualización (diff entre capas) +- Rollback granular accesible al owner (todos los snapshots visibles, puede revertir a cualquier estado) +- Migrar Runtime a hardware nuevo (las capas se mueven y reaplican) +- Compactar capas viejas (operación manual mía con consentimiento del owner) + +**Aprobación de actualizaciones:** Antes de aplicar cualquier delta, debe existir: + +1. **Orden de Trabajo** firmada digitalmente entre Álvaro y owner: qué cambia, costo, riesgos, plan de rollback +2. **Disponibilidad operativa**: buque en puerto, sin alarmas críticas, sin operaciones críticas en curso. Si no se cumple, el owner debe dar doble confirmación con justificación textual. + +--- + +## 6. Modelo de negocio + +- Studio es mío. Software y biblioteca son propiedad intelectual de Álvaro. +- Runtime + Mobile se venden con licencia atada al HWID del PC industrial del buque. +- **Año 1 garantía**: mantenimiento correctivo y preventivo remoto incluido sin costo. +- **Año 2 en adelante**: contrato anual de mantenimiento (10-18% del valor inicial, estándar industria), cubre monitoreo remoto VPN, actualizaciones menores, soporte telefónico. +- **Expansiones**: cualquier sistema/equipo nuevo se cotiza aparte. Ejemplo: agregar controlador de louvers motorizados = 100 USD configuración software (cliente compra hardware aparte). +- **Telemetría de salud del sistema** (no de uso): el Runtime reporta proactivamente a Álvaro cuando algo va mal técnicamente (CPU, RAM, errores de drivers, sensores offline). NO reporta contenido operativo. El cliente ve transparentemente qué se envía y puede pausar/desactivar. + +--- + +## 7. Catálogo maestro de sistemas + +Lista de sistemas que el wizard del Studio ofrece como checklist. Lo que el integrador marca define el menú lateral del Runtime de ese buque (no aparecen sistemas no instalados). + +**Propulsión y maquinaria:** +- Máquina principal (motores principales diesel) +- Transmisiones / reductoras +- Ejes y hélices +- Hélices de proa/popa (thrusters) + +**Maniobra y trimado:** +- Trim de motores / sterndrives +- Trim tabs (Bennett, Lenco) +- Hélices de paso variable (CPP) +- Estabilizadores girostáticos (Seakeeper, Quick MC²) — integración como periférico Modbus/CAN propietario (sprint posterior) +- Estabilizadores de aletas activas +- Joystick docking + +**Generación eléctrica:** +- Gensets diésel +- Shore power con transferencia automática y transformador de aislamiento +- Inversores/cargadores combinados (Victron Quattro, Mastervolt Mass Combi) +- Bancos de baterías litio con BMS inteligente +- Cuadros principales (MSB) +- Cuadros de emergencia (ESB) +- UPS +- Paneles solares + MPPT +- Smart busbars DC (Lynx Smart BMS) +- Tableros inteligentes con monitoreo embebido (V, I, f, kWh por Modbus) + +**Aislamiento eléctrico:** +- Aislamiento por sectores (sectionalizing) +- Aislamiento total de emergencia +- Breakers configurables +- Lockout-tagout digital + +**Fluidos del buque:** +- Combustible (DO/MDO) +- Aceite lubricante +- Aceite hidráulico +- Refrigeración agua dulce +- Refrigeración agua salada +- Aire de arranque / aire comprimido +- Sentinas +- Lastre +- Aguas grises +- Aguas negras +- Agua potable +- Agua salada de servicio +- Watermaker (desalinizadora) + +**Seguridad:** +- Sistema contraincendios — detección +- Sistema contraincendios — extinción (CO₂, HiFog, espuma) +- FiFi externo (monitores de incendio) +- Achique de emergencia +- Detección de gases +- Hombre al agua (MOB) + +**Ambiente y confort:** +- HVAC / aire acondicionado +- Ventilación de máquinas +- Calefacción +- Refrigeración (cámaras, neveras) + +**Iluminación:** +- Luces de navegación +- Luces de cubierta +- Luces interiores por sector +- Luces de emergencia +- Reflectores + +**Tanques estructurales:** +- Tanques de combustible (por tanque) +- Tanques de agua (por tanque) +- Tanques de aguas grises/negras +- Voids +- Cofferdams + +**Cubierta y maniobra:** +- Cabrestantes / molinetes +- Sistema de anclas +- Sistema de amarre +- Davits / pescantes +- Pasarelas / portalones +- Grúas + +**NO incluidos** (parte del AR-ECDIS, NO de VMS-Sailor): +- ECDIS, radar, AIS +- Piloto automático +- Comunicaciones VHF/HF/SatCom +- GPS y sensores de actitud (vienen del AR-ECDIS por NMEA 2000) + +**Específicos por tipo de buque:** +- Maquinaria de pesca (pesqueros) +- Cámaras frigoríficas grandes (pesqueros/comerciales) +- ROV / equipos sumergibles (offshore) +- Sistema de buceo + +--- + +## 8. Sistema de protección de escora (Roll Safety) + +Aprovecha los datos de actitud que publica el AR-ECDIS al backbone NMEA 2000 (PGN 127257). El Runtime suscribe a este PGN y monitorea roll/pitch continuamente. + +**Tres niveles de respuesta automática:** + +**Nivel 1 — WARNING** (escora 8° sostenida por 10s) +- Alerta amarilla en todas las UIs +- Sugiere acción al operador, no actúa solo +- Registra en log book + +**Nivel 2 — AUTO-OFFER** (escora 12° por 5s) +- Alerta naranja crítica +- Ofrece corrección automática con 15s de gracia +- Si nadie responde → corrige hacia neutral automáticamente +- Si responde "manejo manual" → desactiva auto-correction temporal + +**Nivel 3 — AUTO-RESET FORZADO** (escora 18° o pitch peligroso) +- Alerta roja máxima +- Reset emergencia inmediato de todos los trims +- **Anula override manual del owner** +- Alarma sonora si hay buzzer en estación +- Registro inmediato en log book como evento crítico de seguridad + +**Modo Manual del Owner con safety envelope:** + +El owner puede activar "Control Manual 100%" desde su tablet/móvil con doble confirmación (PIN o biométrico, decisión final de Álvaro pendiente). Esto: +- Deshabilita Niveles 1 y 2 (no más warnings ni offers) +- Permite mover trim libremente +- **PERO** mantiene activo el Nivel 3 (seguridad crítica siempre prevalece) +- **Y** mantiene **safety envelope**: el sistema predice el efecto del comando antes de ejecutarlo. Si la predicción supera el límite configurado (default 10°), bloquea el comando con explicación: "Este ajuste llevaría a ~13° de escora, límite es 10°". + +**Botón de Reset de Emergencia:** + +Disponible siempre, físico en consola del puente Y virtual en UI. Un toque (no doble confirmación, es emergencia). Lleva todos los trims a posición neutra. Anula cualquier comando en curso. Registra evento con causa. + +**Modelo de respuesta del trim (calibración):** + +Cuánto cambia la escora resultante por grado de trim depende del buque. Se calibra automáticamente durante prueba de mar (el sistema observa cómo responde el barco a ajustes controlados) + ajuste fino manual del integrador en el Studio. Almacenado en capa 2 (comisionado) de la configuración. + +--- + +## 9. Permissive Engine (lógica de pre-condiciones) + +Cada acción de control crítica (arrancar motor, abrir válvula de mar, energizar thruster, mover trim) NO ejecuta directo. Antes se evalúa una lista de pre-condiciones que deben cumplirse TODAS. + +**Estados de cada permissive:** +- **OK**: condición cumplida, verde +- **FAIL**: condición no cumplida, rojo, bloquea acción +- **WARNING**: el sensor que debería verificarlo no existe o está mal, amarillo, requiere override consciente del Admin del buque +- **N/A**: la pre-condición no aplica a este buque + +**Ejecución:** En el servidor del Runtime, NO en la UI. Las mismas reglas aplican a todas las UIs (puente, máquinas, móvil) — única fuente de verdad. + +**Ejemplo arranque thruster de proa:** +``` +ACCIÓN: Arrancar bow thruster +PERMISSIVES: + ✓ Control en posición NEUTRAL [OK] + ✓ Nivel aceite hidráulico > 40% [OK: 65%] + ✗ Temperatura aceite > 5°C [FAIL: 3°C] + ✓ Válvula de mar refrigeración abierta [OK] + ⚠ Sensor presión hidráulica [WARNING: inactivo] + ✓ No alarmas críticas activas [OK] + +RESULTADO: BLOQUEADO +RAZÓN: Temperatura aceite hidráulico 3°C (mín 5°C) +``` + +--- + +## 10. Filosofía técnica clave + +**Monitor-now / control-later:** Cada tag del sistema se define con capacidad de control desde el día 1, aunque inicialmente solo se monitoree. Cuando el cliente instale el actuador físico en el futuro, basta cambiar un flag `control_mode: future → manual` sin tocar arquitectura. + +**Sistemas dinámicos:** El menú lateral del Runtime se genera según los sistemas que el integrador marcó en el wizard. Si el buque no tiene watermaker, el botón "Watermaker" ni aparece. Si después se instala, se agrega como delta. + +**Coordenadas navales SIEMPRE:** X desde Perpendicular de Popa (Pp) hacia proa positivo. Y desde Línea de Crujía (CL), estribor positivo, babor negativo. Z desde Línea Base (BL) hacia arriba positivo. Todo en metros. Clase `ShipCoord(x_pp, y_cl, z_bl)` en core. Cualquier transformada a pantalla pasa por método explícito. + +**Unidades SI internas siempre:** metros, kilogramos, Pascales, °C, segundos. Conversión a imperial (pies, AWG, psi, °F, nudos) solo en UI cuando el usuario lo pida. + +**Idioma:** Español por defecto, inglés como segunda opción. Strings en `vmssailor/i18n/`. + +**Sin red de salida:** Salvo activación inicial de licencia (Studio → servidor licencias de Álvaro) y VPN administrativa (cliente WireGuard externo). Sin telemetría implícita NUNCA — solo telemetría de salud técnica visible al cliente. + +**Auditoría siempre activa en Runtime:** Quién hizo ack de qué alarma, quién pidió control, quién hizo override, cada conexión VPN mía con timestamps y endpoints accedidos. + +--- + +## 11. Pila tecnológica (vista general) + +| Capa | Tecnología | +|---|---| +| Studio + Runtime cliente | Python 3.11 + PySide6 (Qt 6) | +| Canvas 2D (siluetas, mímicos) | QGraphicsScene / QGraphicsView | +| Runtime servidor | FastAPI + WebSockets + asyncio | +| Base de datos | SQLite + SQLAlchemy | +| Historian | DuckDB embebido | +| Drivers Modbus | pymodbus 3.x | +| Drivers CAN/NMEA 2000 | python-can + canopen + librería NMEA 2000 | +| Empaquetado | PyInstaller + WiX Toolset MSI | +| Servicio Windows | pywin32 + servicemanager | +| VPN | WireGuard externo | +| Mobile | Flutter 3.x + Dart 3.x | +| Firmware tarjeta | ESP-IDF (C/C++) o Arduino framework | +| Activación / HWID | UUID + MAC + Disk Serial firmados | +| Tests | pytest + pytest-qt + pytest-asyncio | + +Detalle completo en las Partes 2-5. + +--- + +## 12. Sprints (vista general) + +Plan de desarrollo aproximado. Detalle completo en Parte 6. + +- **Sprint 0** — Fundaciones: estructura, modelo de datos core, biblioteca seed inicial, tests +- **Sprint 1-3** — Studio: shell, wizard, editores (mímicos, equipos, tags, alarmas) +- **Sprint 4-5** — Runtime servidor: drivers, tag db, historian, alarm engine +- **Sprint 6** — Runtime cliente desktop: estaciones puente y máquinas +- **Sprint 7** — Empaquetador MSI, activación HWID +- **Sprint 8** — Permissive Engine completo + Authority transfer + Protección de escora +- **Sprint 9-10** — Layer Config Engine + deltas + rollback +- **Sprint 11** — VMS-Sailor Mobile (Flutter) +- **Sprint 12** — Firmware ESP32 + plug-and-produce + OTA +- **Sprint 13+** — Log Book regulatorio completo, integraciones Seakeeper, cotizador de expansiones, refinamientos + +--- + +## 13. Reglas de oro + +1. **Antes de cada sprint**, me presentas plan y esperas mi OK. No improvises features. +2. **Cada cambio importante** se discute primero. Yo decido alcance. +3. **Tests obligatorios** para todo lo que toca `core` y `runtime/server`. UI más relajada pero `pytest-qt` en flujos críticos. +4. **No agregues dependencias** sin preguntarme. +5. **Documenta normativa** cuando implementes algo regulado: IEC 60092-504 (automation), IACS UR E22 (computer-based systems), ABYC E-11 (electrical), NMEA 2000 (protocolos), IMO MARPOL/SOLAS (log book). +6. **Sin red de salida** en Runtime salvo activación y VPN. Sin telemetría implícita NUNCA. +7. **La biblioteca curada es ORO**. Cualquier cambio de formato requiere migración para proyectos existentes. +8. **El Runtime es inmutable para el cliente**. Solo deltas firmados por mí cambian configuración. +9. **Auditoría siempre activa** en Runtime. +10. **Coordenadas navales consistentes** en TODO el código. + +--- + +## 14. Cómo proceder ahora + +**Paso 1.** Confirma que entendiste el alcance general. Pregunta cualquier duda ANTES de tocar código. + +**Paso 2.** Verifica si existe `D:/Proyectos Software/VMS-Sailor/`. Si está vacía, OK. Si tiene contenido, pregúntame. + +**Paso 3.** Espera a que te envíe la **Parte 2** (VMS-Sailor Studio en detalle) antes de empezar a planificar Sprint 0. La Parte 1 es contexto general; las partes 2-6 traen los detalles técnicos por componente. + +**Paso 4.** Cuando tengas las 6 partes, presenta tu plan detallado del Sprint 0: lista de archivos a crear, librerías, tests. Espera mi OK antes de codear. + +--- + +**Fin de Parte 1 de 6.** Próxima: Parte 2 — VMS-Sailor Studio en detalle. diff --git a/VMS_Sailor_v2_Parte_02_Studio.md b/VMS_Sailor_v2_Parte_02_Studio.md new file mode 100644 index 0000000..08298e5 --- /dev/null +++ b/VMS_Sailor_v2_Parte_02_Studio.md @@ -0,0 +1,341 @@ +# VMS-Sailor · Brief para Claude Code · Parte 2 de 6 + +## VMS-Sailor Studio (herramienta de ingeniería de Álvaro) + +> Esta parte detalla el Studio. Asume que ya leíste la Parte 1 (visión general). + +--- + +## 1. Propósito + +El Studio es la herramienta de escritorio que YO (Álvaro) uso para configurar cada proyecto de buque que un armador me contrata. NUNCA llega al cliente. Es mi propiedad intelectual. + +Funciones principales: +- Cargar la biblioteca curada (buques, equipos, reglas, sistemas) +- Correr el wizard de pre-diseño de 8 pasos +- Editar mímicos, tags, alarmas, permissives, topología de tarjetas +- Simular el proyecto antes de empaquetar (test bench) +- Generar `.vmspack` base + instalador MSI Windows +- Generar deltas (`.vmsdelta`) para expansiones posteriores +- Mantener git interno con historial de versiones por proyecto de buque + +--- + +## 2. Estructura de carpetas del Studio + +``` +vmssailor/studio/ +├── __init__.py +├── app.py # QApplication principal +├── main_window.py # Ventana principal con menú/toolbar +│ +├── wizard/ # Wizard de pre-diseño 8 pasos +│ ├── __init__.py +│ ├── wizard.py # QWizard contenedor +│ ├── step_01_vessel_type.py # Tipo y subcategoría de buque +│ ├── step_02_template.py # Selección de plantilla de biblioteca +│ ├── step_03_dimensions.py # Eslora, manga, calado, cubiertas +│ ├── step_04_systems.py # Checklist de sistemas instalados +│ ├── step_05_equipment.py # Equipos sugeridos por reglas +│ ├── step_06_refinement.py # Sustituir genéricos por reales +│ ├── step_07_topology.py # Asignar tarjetas AR-NMEA-IO + puertos +│ └── step_08_confirm.py # Resumen + crear proyecto +│ +├── editors/ +│ ├── __init__.py +│ ├── vessel_editor.py # Edita silueta y cubiertas +│ ├── system_editor.py # Edita sistemas instalados +│ ├── equipment_editor.py # CRUD equipos +│ ├── mimic_editor.py # Editor visual de mímicos +│ ├── tag_editor.py # Edita tags, direcciones, escalado +│ ├── alarm_editor.py # Edita límites y prioridades +│ ├── permissive_editor.py # Reglas de permissives por acción +│ ├── topology_editor.py # Asignación tarjetas ↔ buque +│ └── rule_editor.py # Edita reglas heurísticas YAML +│ +├── designer/ # Motor de pre-diseño +│ ├── __init__.py +│ ├── rule_engine.py # Aplica reglas YAML al input del wizard +│ ├── layout_generator.py # Genera disposición inicial +│ ├── similarity_search.py # Busca buques similares cuando no hay match +│ ├── parametric_silhouette.py # Genera silueta paramétrica (sprint posterior) +│ └── port_auto_assigner.py # Asigna sensor a puerto de tarjeta +│ +├── simulator/ # Test bench +│ ├── __init__.py +│ ├── sim_engine.py # Inyecta valores en tags simulados +│ ├── sim_scenarios.py # Escenarios pre-armados +│ └── sim_panel.py # UI de control del simulador +│ +├── compiler/ # Compilador de proyecto +│ ├── __init__.py +│ ├── package_builder.py # Genera .vmspack +│ ├── delta_builder.py # Genera .vmsdelta para expansiones +│ ├── msi_builder.py # Invoca WiX para .msi +│ ├── hwid_binder.py # Liga paquete a HWID destino +│ ├── firmware_generator.py # Genera firmware personalizado por tarjeta +│ ├── manifest.py # Manifest del paquete con firma +│ └── signer.py # Firma criptográfica del paquete +│ +├── widgets/ # Widgets reutilizables +│ ├── __init__.py +│ ├── vessel_canvas.py # QGraphicsView con silueta y rejilla naval +│ ├── system_sidebar.py # Barra lateral dinámica de sistemas +│ ├── tag_browser.py +│ ├── alarm_list.py +│ ├── trends_view.py +│ └── card_topology_view.py # Visualización de buses + tarjetas +│ +└── i18n/ # Traducciones + ├── es.ts + └── en.ts +``` + +--- + +## 3. Flujo del wizard de pre-diseño + +### Paso 1 — Tipo de buque + +- Selector de categoría: yate motor, yate vela, pesquero, patrullero, ferry, offshore support +- Subcategoría según categoría: + - Yate motor: planeo, semi-planeo, desplazamiento + - Pesquero: cerquero, arrastrero, palangrero + - Patrullero: costero, oceánico + - Ferry: pasajeros, ro-ro + - Offshore support: AHTS, PSV +- Filtra plantillas disponibles en biblioteca + +### Paso 2 — Plantilla + +- Lista de plantillas compatibles con filtros del Paso 1 +- Si el modelo exacto existe (ej: "Sunseeker 76") → uso directo +- Si no: + - Buscar similares por similitud (eslora ±5%, tipo de casco) y proponer la más parecida como base editable + - Importar silueta nueva (DXF, SVG, imagen rasterizada con escala) + - Generar silueta paramétrica genérica con dimensiones (sprint posterior, no Sprint 0) + +### Paso 3 — Dimensiones + +- Eslora total, manga máxima, calado, número de cubiertas +- Ubicaciones aproximadas de mamparos principales (proa/popa sala de máquinas) +- Auto-rellenado por plantilla, todo editable + +### Paso 4 — Sistemas instalados (genera el menú del Runtime) + +- Checklist con TODOS los sistemas del catálogo maestro (ver Parte 1, sección 7) +- La plantilla pre-marca los típicos de su categoría +- El usuario afina (marca/desmarca según el buque real) +- Esta selección define qué aparece en el menú lateral del Runtime y qué genera tags + +### Paso 5 — Equipos sugeridos + +- Para cada sistema marcado en Paso 4, el `rule_engine.py` consulta `library/rules/*.yaml` y propone equipos típicos con ubicaciones y cantidades +- Tabla editable con: sistema, equipo genérico, cantidad, ubicación sugerida (coordenadas Pp/CL/BL) +- Ejemplo para yate motor planeo 20-30m, sistema "máquina principal": + - 2× motor HSDI 800-1400 kW + - Sugerencias en orden: MTU 10V 2000 M96, MTU 12V 2000 M96, Volvo D13 900hp, CAT C32 + +### Paso 6 — Refinamiento manual + +- Para cada equipo propuesto, sustituir genérico por modelo real (MTU 12V 2000 M96 específico) +- Reposicionar equipos en vista de planta de la silueta (drag-and-drop con coordenadas reales) +- Agregar o quitar equipos +- Validar que los equipos caben físicamente + +### Paso 7 — Topología de tarjetas AR-NMEA-IO y asignación de puertos + +Este paso es CRÍTICO porque define el hardware del proyecto. Hardware único (AR-NMEA-IO-v1.0, ver Parte 4), pero asignación de puertos y rol de cada tarjeta varía por proyecto. + +Procedimiento: + +1. **Inventario de I/O necesarios:** el sistema cuenta automáticamente cuántos DO, DI, RPM, AI necesita el proyecto basado en los equipos del Paso 6 y sus sensores. + +2. **Cantidad de tarjetas:** propone N tarjetas (cada una tiene 10 DO + 5 DI + 1 RPM + 4 AI = 20 + 1 frecuencia). Ejemplo para buque 30m con 2 motores + 2 gensets + tanques + auxiliares: 6-7 tarjetas típicamente. + +3. **Ubicación física de cada tarjeta:** drag-and-drop sobre la silueta del buque. Cerca de cada equipo principal (motor PORT, motor STBD, genset 1, genset 2, sala bombas, etc.). + +4. **Rol de cada tarjeta en el bus:** + - Maestra del bus Modbus RTU (típicamente 1 por proyecto, en PC industrial central) + - Esclava Modbus RTU con dirección 1-247 + - Nodo NMEA 2000 (cualquiera con NMEA 2000 activado publica al backbone) + - Modo dual (Modbus + NMEA 2000 simultáneo) — típico en motores y gensets + - Modo bridge (lee NMEA 2000 y expone por Modbus) + +5. **Asignación de puerto físico ↔ sensor/actuador:** por cada equipo del Paso 6, el sistema busca una tarjeta cercana con canal libre del tipo correcto. Te propone, tú validas o cambias. Ejemplo: + +``` +Equipo: ME_PORT (Motor principal babor) +Sensores: + - Temp aceite → Card_02.AI1 (RTD PT100, escalado -50 a 200°C) + - Presión aceite → Card_02.AI2 (4-20mA, escalado 0-10 bar) + - Temp refrigerante → Card_02.AI3 (RTD PT100, escalado -50 a 200°C) + - Voltaje batería → Card_02.AI4 (divisor, escalado 0-32V) + - RPM → Card_02.RPM1 (pickup magnético) + - Estado arranque → Card_02.DI1 (contacto) + - Estado parada → Card_02.DI2 (contacto) + - Pre-calentamiento → Card_02.DI3 (contacto) + - Comando arranque → Card_02.DO1 (relé) + - Comando parada → Card_02.DO2 (relé) + - Trim UP → Card_02.DO3 (relé) + - Trim DOWN → Card_02.DO4 (relé) + - Posición trim → Card_02.AI? — NO HAY MÁS AI libres, conflicto +``` + +6. **Validación de capacidades:** + - No exceder 4 AI por tarjeta + - No exceder 5 DI por tarjeta + - No exceder 10 DO por tarjeta + - No mezclar tipos de señal incompatibles en mismo canal + - Si hay conflictos, sugiere agregar otra tarjeta + +7. **Identificación física para reemplazo futuro:** Sistema combinado A+C (decisión Álvaro): + - Cada tarjeta tiene **dipswitches físicos** para que el técnico setee el número de slot (1-16 por bus) al instalarla + - El VMS confirma en pantalla del Runtime cuando se instala una nueva: "esta tarjeta reemplaza a la del motor PORT" + - Si los dos no coinciden, alarma "Tarjeta no esperada en slot N" + +### Paso 8 — Confirmación + +- Resumen: N sistemas, M equipos, K tags, P tarjetas, alarmas configuradas +- Validación final: todos los equipos tienen sus sensores asignados, no hay conflictos +- Crear proyecto → abre el editor principal del Studio con todo cargado + +--- + +## 4. Editores principales (post-wizard) + +Después del wizard, el integrador puede refinar todo en editores específicos: + +### Editor de equipos +Tabla y vista de planta. CRUD de equipos. Cambiar ubicación, modelo, sistema asignado. + +### Editor de mímicos (uno por sistema) +Canvas QGraphicsView donde se dibuja el P&ID del sistema. Símbolos arrastrables (motor, bomba, válvula, tanque, sensor, indicador). Cada símbolo se vincula a un tag o varios. + +Símbolos disponibles desde el primer sprint: +- Motor (ISO 14617) +- Bomba (centrífuga, engranajes, pistón) +- Válvula (manual, motorizada, retención, alivio) +- Tanque (rectangular, cilíndrico) +- Intercambiador de calor +- Filtro / separador +- Compresor +- Sensor (T, P, L, F) +- Indicador (display digital, gauge, bar graph, lamp) +- Línea (líquido, gas, eléctrica, datos) + +### Editor de tags +Tabla con filtros por sistema/equipo. Columnas: ID, descripción, tipo, unidad, rango normal, alarmas, controllable, control_mode, authority_required, protocol, card+port, escalado. + +### Editor de alarmas +Por cada tag con alarmas: límite alto/bajo, prioridad (Emergency/High/Low/Info), histéresis, retraso, mensaje, escalación. + +### Editor de permissives +Por cada acción crítica del sistema, define lista de pre-condiciones (ver Parte 1, sección 9). Editor visual estilo if-then con drag-and-drop de tags como condiciones. + +### Editor de topología +Vista del buque con tarjetas ubicadas, buses dibujados como líneas, cada tarjeta clickable muestra sus puertos asignados. + +### Editor de reglas heurísticas +YAML editor con syntax highlighting para `library/rules/*.yaml`. Permite a Álvaro agregar nuevas reglas o refinar las existentes basado en experiencia con clientes. + +--- + +## 5. Simulador (test bench) + +Antes de empaquetar y entregar al cliente, el integrador prueba TODO el proyecto sin necesidad de hardware real. + +- Crea instancias virtuales de cada tarjeta del proyecto +- Inyecta valores en los tags (manuales o por escenarios) +- El motor de alarmas, permissives, autoridad funcionan exactamente como funcionarán en producción +- Escenarios pre-armados: arranque normal, falla de sensor, alarma crítica, blackout, transferencia puente↔máquinas, escora peligrosa +- Si funciona en simulador → se puede empaquetar con confianza + +--- + +## 6. Compilador de proyecto + +Genera artefactos distribuibles: + +### `.vmspack` (paquete base) + +Archivo ZIP firmado criptográficamente que contiene: +- `manifest.json` — versión, HWID destino, fecha compilación, firma SHA-256 con clave privada de Álvaro +- `project.db` — SQLite con toda la configuración congelada del buque +- `mimics/` — SVG/JSON de mímicos por sistema +- `assets/` — silueta del buque, iconos personalizados +- `runtime_config.json` — parámetros del servidor (puertos serie, drivers, baud rates...) +- `firmware/` — binarios de firmware para cada tarjeta del proyecto + +### `.vmsdelta` (expansión sobre base existente) + +Solo contiene lo agregado/cambiado respecto al estado conocido del Runtime cliente. Firmado igual. + +### Instalador MSI + +WiX scripts en `installer/wix/`. Genera instalador Windows estándar que: +- Instala el Runtime como servicio Windows que arranca al boot +- Configura permisos +- Crea accesos directos del cliente desktop +- Lee el `.vmspack` durante instalación inicial + +### Generador de firmware + +Para cada tarjeta del proyecto, genera un `.bin` que ya viene preconfigurado con: +- Su `card_id` y posición lógica en el bus +- Dirección Modbus o ID CAN +- Tipo de señal de cada uno de los 10 puertos +- Filtros locales activos +- Permissives locales +- Alarmas locales con sus umbrales + +Esto significa que al cliente le llegan tarjetas "ready to plug" — no necesita programarlas con USB. + +--- + +## 7. Versionado por proyecto de buque + +Cada cliente tiene su carpeta de proyecto dentro del Studio con su propio git interno: + +``` +projects/ +└── m_y_aurora_sunseeker_76/ + ├── .git/ # Historial completo del proyecto + ├── project.db + ├── mimics/ + ├── assets/ + ├── deltas/ # Históricos de deltas generados + │ ├── v1.0_base.vmspack + │ ├── v1.1_louvers.vmsdelta + │ └── v2.0_watermaker_solar.vmsdelta + └── README.md # Notas del cliente, contacto, contratos +``` + +Cada delta generado se commit con mensaje descriptivo: `"v1.1 - Agregado controlador louvers MAS-LC4 - OT-2027-042"`. + +--- + +## 8. Activación / licencia del Studio + +El Studio mismo requiere activación (es MI propiedad, no puede correr en cualquier PC): +- Archivo de licencia `studio_license.lic` con HWID de mi PC, firmado por mí mismo +- Al arrancar, valida HWID contra licencia +- Sin licencia válida → no arranca +- Esto protege contra robo del Studio (si alguien copia los archivos a su PC, no funciona) + +--- + +## 9. Sprints relacionados con Studio + +- **Sprint 0**: estructura, modelo de datos core compartido con Runtime +- **Sprint 1**: shell del Studio + ventana principal + wizard pasos 1-4 +- **Sprint 2**: wizard pasos 5-8 + editor de equipos + biblioteca seed ampliada +- **Sprint 3**: editor de mímicos + editor de tags + editor de alarmas +- **Sprint 7**: compilador completo + activación HWID + MSI +- **Sprint 8** (parte): editor de permissives + +Detalle completo en Parte 6. + +--- + +**Fin de Parte 2 de 6.** Próxima: Parte 3 — VMS-Sailor Runtime en detalle. diff --git a/VMS_Sailor_v2_Parte_03_Runtime.md b/VMS_Sailor_v2_Parte_03_Runtime.md new file mode 100644 index 0000000..af251ea --- /dev/null +++ b/VMS_Sailor_v2_Parte_03_Runtime.md @@ -0,0 +1,421 @@ +# VMS-Sailor · Brief para Claude Code · Parte 3 de 6 + +## VMS-Sailor Runtime (a bordo del buque) + +> Esta parte detalla el Runtime. Asume que ya leíste Partes 1 y 2. + +--- + +## 1. Propósito + +El Runtime es lo que se instala en el PC industrial del buque del cliente. Opera 24/7. Es lo que el armador, capitán, jefe de máquinas y tripulación usan día a día. + +Dos componentes principales: +- **Servidor**: servicio Windows con motor de tiempo real, drivers, alarm engine, etc. +- **Cliente desktop**: aplicación PySide6 en cada estación de operación (puente, máquinas, eventualmente camarote del owner) + +--- + +## 2. Estructura de carpetas del Runtime + +``` +vmssailor/runtime/ +├── __init__.py +│ +├── server/ # SERVIDOR (servicio Windows) +│ ├── __init__.py +│ ├── service.py # Servicio Windows (pywin32) +│ ├── main_loop.py # Loop asíncrono principal +│ │ +│ ├── drivers/ # Drivers de protocolo +│ │ ├── __init__.py +│ │ ├── base_driver.py +│ │ ├── modbus_tcp.py +│ │ ├── modbus_rtu.py # Bus principal AR-NMEA-IO +│ │ ├── nmea2000.py # CAN/NMEA 2000 (publicar y suscribir) +│ │ ├── j1939.py # Para motores que hablan J1939 nativo +│ │ └── card_discovery.py # Auto-descubrimiento plug-and-produce +│ │ +│ ├── tag_db/ # Base de tags +│ │ ├── __init__.py +│ │ ├── tag_store.py # En memoria + persistencia periódica +│ │ ├── tag_resolver.py # Resuelve id → valor, calidad, timestamp +│ │ └── historian.py # DuckDB con series temporales +│ │ +│ ├── alarm_engine/ +│ │ ├── __init__.py +│ │ ├── alarm_evaluator.py # Evalúa límites contra tags +│ │ ├── alarm_priorities.py # Emergency / High / Low / Info +│ │ ├── alarm_state.py # Active / Ack / Cleared +│ │ ├── acknowledgement.py +│ │ ├── escalation.py # Escala si no se ack en X tiempo +│ │ └── notification_dispatcher.py # A clientes desktop y móvil +│ │ +│ ├── permissive_engine/ # Pre-condiciones para acciones +│ │ ├── __init__.py +│ │ ├── permissive_evaluator.py +│ │ ├── condition_parser.py # Lee reglas YAML del paquete +│ │ └── override_handler.py # Override consciente del Admin del buque +│ │ +│ ├── authority/ # Command Authority +│ │ ├── __init__.py +│ │ ├── authority_manager.py +│ │ ├── transfer.py # Bilateral handshake puente↔máquinas +│ │ └── override.py # Override de emergencia +│ │ +│ ├── stability/ # Protección de escora (Parte 1 sec 8) +│ │ ├── __init__.py +│ │ ├── attitude_reader.py # Lee PGN 127257 del backbone NMEA 2000 +│ │ ├── roll_monitor.py # Filtros + detección sostenida +│ │ ├── safety_levels.py # Lógica niveles 1/2/3 +│ │ ├── envelope_predictor.py # Predice efecto de comando antes de ejecutar +│ │ ├── auto_corrector.py # Corrección suave hacia neutral +│ │ ├── emergency_reset.py # Reset físico + virtual +│ │ └── owner_override.py # Modo manual con safety envelope +│ │ +│ ├── config/ # Layer Config Engine (capas) +│ │ ├── __init__.py +│ │ ├── layer_loader.py # Carga capas en orden +│ │ ├── delta_applier.py # Aplica deltas firmados +│ │ ├── snapshot_manager.py # Snapshots automáticos +│ │ ├── rollback_engine.py # Rollback granular +│ │ ├── schema_validator.py +│ │ └── signature_verifier.py # Verifica firma de Álvaro +│ │ +│ ├── logbook/ # Log Book naval (Parte 1 sec 6) +│ │ ├── __init__.py +│ │ ├── engine_log_writer.py # Auto-detección arranque/parada motores +│ │ ├── alarm_log_writer.py +│ │ ├── manual_entry_handler.py +│ │ ├── snapshot_periodic.py # Snapshots cada 15 min con motor corriendo +│ │ ├── log_signer.py # Firma digital inmutable +│ │ └── log_exporter.py # Exporta a PDF formato oficial +│ │ +│ ├── licensing/ # Activación HWID +│ │ ├── __init__.py +│ │ ├── hwid_generator.py # MAC + Disk Serial + UUID +│ │ ├── activator.py # Activación online inicial +│ │ ├── verifier.py # Verifica firma del paquete vs HWID +│ │ └── license_file.py +│ │ +│ ├── vpn/ # Soporte VPN administrativa +│ │ ├── __init__.py +│ │ └── wg_helper.py # Helpers para WireGuard externo +│ │ +│ ├── audit/ # Auditoría +│ │ ├── __init__.py +│ │ ├── audit_log.py # Eventos auditados inmutables +│ │ └── vpn_session_tracker.py # Registra cada sesión VPN de Álvaro +│ │ +│ ├── telemetry/ # Telemetría de salud técnica +│ │ ├── __init__.py +│ │ ├── health_reporter.py +│ │ └── client_transparency.py # Lo que se envía es visible al owner +│ │ +│ └── api/ +│ ├── __init__.py +│ ├── fastapi_app.py +│ ├── ws_endpoints.py # WebSockets tiempo real +│ ├── rest_endpoints.py # REST configuración + históricos +│ ├── auth.py # JWT local + TOTP para móvil +│ └── mobile_endpoints.py # Endpoints específicos Mobile +│ +├── client/ # CLIENTE DESKTOP (PySide6) +│ ├── __init__.py +│ ├── app.py +│ ├── login.py # Login Operador/Técnico/Admin +│ ├── main_window.py +│ ├── ws_client.py # Cliente WebSocket +│ │ +│ ├── views/ # Vistas principales +│ │ ├── overview.py # Panel general del buque +│ │ ├── mimic_view.py # Mímico de un sistema +│ │ ├── alarm_panel.py # Lista alarmas viva con ack +│ │ ├── trends_panel.py # Gráficas tiempo real +│ │ ├── logbook_view.py # Log book +│ │ ├── audit_view.py # Auditoría (Admin solo) +│ │ ├── support_view.py # Pestaña "Soporte y Auditoría" del Admin +│ │ ├── trim_panel.py # Control trim y maniobra +│ │ └── settings.py # Settings limitados del cliente +│ │ +│ └── widgets/ # Widgets reutilizables +│ ├── system_sidebar.py # Menú lateral dinámico +│ ├── tag_widget.py # Display individual con color/alarma +│ ├── gauge.py +│ ├── lamp.py # Indicador on/off +│ ├── bargraph.py +│ ├── valve.py # Símbolo válvula con estado +│ ├── motor.py # Símbolo motor +│ ├── pump.py +│ ├── tank.py +│ └── alarm_badge.py +``` + +--- + +## 3. Motor de tiempo real (Server) + +### Loop principal + +Servicio Windows arranca al boot. Main loop asíncrono con `asyncio`. Múltiples tareas concurrentes: + +- **Driver Modbus RTU** poolea las tarjetas AR-NMEA-IO en el bus (cada 100ms por defecto, configurable por tag) +- **Driver NMEA 2000** suscribe a PGNs configurados, lee del bus CAN +- **Tag store** recibe updates de drivers, mantiene en memoria valores actuales con timestamp y calidad +- **Historian** guarda en DuckDB cada N segundos (configurable por tag, default 1s para críticos, 60s para no-críticos) +- **Alarm engine** evalúa cambios de tags contra límites +- **Permissive engine** evalúa cuando se solicita acción +- **Stability monitor** lee PGN 127257 cada 100ms, monitorea roll/pitch +- **API server** atiende WebSockets de clientes desktop y Mobile, REST para configuración +- **VPN listener** atiende endpoints administrativos solo desde IP del túnel +- **Health reporter** envía telemetría técnica si está habilitada + +### Tag store + +Estructura en memoria optimizada: +- Dict por `tag_id` → objeto `Tag` con: valor, calidad, timestamp, unidad SI, rango normal, alarmas configuradas, `controllable`, `control_mode`, `authority_required`, protocolo, dirección, escalado +- Pub/Sub interno: cuando un valor cambia, se notifica a suscriptores (alarm engine, historian, API clients) +- Calidad: GOOD, BAD, UNCERTAIN. Si un sensor da timeout 3 veces seguidas → calidad BAD, valor último conocido marcado como stale. + +### Historian (DuckDB) + +DuckDB embebido en archivo `%PROGRAMDATA%/VMS-Sailor/historian.duckdb`. Esquema simple: +``` +tag_id (string), timestamp (datetime), value (double), quality (enum) +``` + +Retención configurable por tipo de tag: +- Tags críticos: alta resolución (1 muestra/s) durante 30 días, downsampled a 1/min después de 30 días, retenidos 5 años +- Tags no críticos: 1 muestra/min durante 90 días, downsampled a 1/hora después +- Eventos discretos (alarmas, comandos): retención completa, todos guardados + +### Alarm engine + +Cada tag con alarmas configuradas se evalúa cuando cambia su valor: +- Compara contra límites: `low_low`, `low`, `high`, `high_high` +- Cada límite tiene prioridad: Emergency, High, Low, Info +- Histéresis: tag no sale de alarma hasta cruzar el límite +/- N (configurable) +- Retraso: tag no entra en alarma hasta que la condición persiste X segundos (anti-flicker) +- Mensaje configurable por alarma +- Estados: ACTIVE (no ack), ACK (ack pero condición persiste), CLEARED (condición resuelta) +- Escalación: si una alarma Emergency lleva X minutos sin ack, escala (sonido más fuerte, notificación móvil push) + +### Authority Manager (puente ↔ máquinas) + +Estado: cuál estación tiene autoridad actualmente (BRIDGE, ENGINE, NONE en blackout). + +Transferencia bilateral: +1. Estación A solicita autoridad +2. Estación B recibe notificación, debe confirmar o rechazar +3. Si confirma, autoridad cambia +4. Timeout: si B no responde en 30s, autoridad permanece en A + +Override de emergencia: botón E-stop físico cablea directo a tarjeta cerca del motor (permissive local). Anula cualquier autoridad. Detiene equipo críticamente. Registra evento crítico en logbook. + +### Stability Monitor (detalle Parte 1 sec 8) + +Suscribe a PGN 127257 del backbone NMEA 2000 (que publica el AR-ECDIS). Lee roll/pitch cada 100ms. Aplica filtros (mediana 5 muestras + promedio 1s). Evalúa tres niveles: + +```python +async def stability_loop(): + while running: + attitude = await read_pgn_127257() # del backbone + roll = filter(attitude.roll) + pitch = filter(attitude.pitch) + + await level_1_check(roll, pitch) # WARNING + await level_2_check(roll, pitch) # AUTO-OFFER + await level_3_check(roll, pitch) # AUTO-RESET FORZADO + + await asyncio.sleep(0.1) +``` + +--- + +## 4. Cliente desktop (PySide6) + +### Login + +Tres perfiles fijos. Login con usuario + password (gestionados por el Admin del buque). En sprint posterior se puede agregar SSO o LDAP. + +### Layout principal + +- Barra superior: nombre del buque, estado conexión servidor, alarmas activas (badge), autoridad actual, usuario logueado +- Barra lateral: menú dinámico de sistemas (generado a partir del `.vmspack` activo). Solo aparecen los sistemas marcados en el wizard del Studio +- Vista central: mímico del sistema seleccionado, o vista de overview por defecto +- Barra inferior: ticker de alarmas, hora del sistema, estado VPN + +### Vistas por sistema + +Cada sistema marcado tiene su propio mímico (SVG/JSON empaquetado en `.vmspack`). El mímico se renderiza con PySide6 + QGraphicsView. Los símbolos (motor, válvula, bomba, etc.) muestran: +- Estado en color (verde = OK, amarillo = warning, rojo = alarma, gris = offline) +- Valores en displays digitales sobre el símbolo +- Click → menú contextual con acciones disponibles según permissives + autoridad + +### Panel de alarmas + +Lista cronológica de alarmas activas y recientes: +- Color por prioridad +- Botón ACK por alarma +- Filtros por sistema, prioridad, estado +- Histórico completo accesible (DuckDB) + +### Panel de trends + +Gráficas tiempo real (último 1 hora, 24 horas, 7 días). Hasta 8 tags simultáneos en mismo eje. Zoom y pan. Exportable a CSV. + +### Panel de Trim y maniobra (caso de uso destacado) + +- Sliders visuales para cada actuador de trim (2 motores + 2 trim tabs típicamente) +- Indicador en tiempo real de roll y pitch (desde PGN 127257) +- Botón "Reset Emergencia" muy visible +- Toggle "Modo Manual del Owner" (con PIN/biométrico) +- Indicador visual del envelope de seguridad activo + +### Vista de Soporte y Auditoría (solo Admin del buque) + +- Log de mis conexiones VPN: fecha, duración, endpoints accedidos, mensaje mío sobre qué hice +- Configuración de telemetría: qué se envía, pausar/desactivar +- Gestión de usuarios del Runtime +- Gestión de dispositivos móviles enrolados (revocar si se pierde) +- Historial de snapshots con botón rollback + +--- + +## 5. API del servidor + +### WebSockets (tiempo real) + +`ws://localhost:8765/realtime` — todos los clientes desktop y Mobile conectan aquí. + +Mensajes que envía el servidor: +- `tag_update`: id, value, quality, timestamp +- `alarm_event`: alarm_id, state, priority +- `authority_change`: new_authority +- `stability_event`: level, roll, pitch +- `permissive_check_result`: action_id, status, reasons + +Mensajes que recibe del cliente: +- `subscribe_tags`: lista de IDs para suscribirse +- `request_action`: solicita ejecutar acción de control (genera permissive check) +- `ack_alarm`: ack de alarma +- `request_authority`: solicita autoridad +- `commit_logbook_entry`: entrada manual al logbook + +### REST (configuración y consultas) + +- `GET /tags` — lista de tags con configuración +- `GET /tags/{id}/history?from=X&to=Y` — históricos +- `GET /alarms?state=active|all` — alarmas +- `GET /logbook?from=X&to=Y` — entradas logbook +- `POST /logbook` — entrada manual (Técnico/Admin) +- `GET /audit/vpn_sessions` — sesiones VPN históricas (Admin solo) +- `POST /telemetry/pause` — owner pausa telemetría + +### Endpoints administrativos VPN (acceso solo desde IP del túnel) + +- `POST /admin/upload_delta` — Álvaro sube un .vmsdelta firmado +- `POST /admin/apply_delta/{id}` — aplica delta (owner debe haber aprobado en su UI antes) +- `GET /admin/diagnostics` — diagnóstico del sistema +- `POST /admin/rollback/{snapshot_id}` — rollback (registrado en auditoría) + +--- + +## 6. Layer Config Engine + +Detalle de cómo se cargan y aplican las capas (Parte 1 sec 5): + +```python +async def boot_runtime(): + config = empty_config() + + # Capa 1 + base = load_vmspack(path_base) + verify_signature(base, alvaro_pubkey) + verify_hwid(base, current_hwid) + config.apply(base) + + # Capa 2 + if exists(commissioning_delta): + config.apply(load_vmsdelta(commissioning_delta)) + + # Capa 3 + if exists(owner_prefs_delta): + config.apply(load_vmsdelta(owner_prefs_delta)) + + # Capa 4 — expansiones en orden cronológico + for delta in sorted(list_expansion_deltas()): + verify_signature(delta, alvaro_pubkey) + config.apply(delta) + + # Configuración efectiva lista + start_services(config) +``` + +Snapshots automáticos ANTES de aplicar cualquier delta. Rollback granular permite volver a cualquier snapshot (visible al Admin del buque). + +--- + +## 7. Telemetría de salud (transparente al cliente) + +Solo se envía estado técnico, NO contenido operativo: + +```json +{ + "timestamp": "2027-03-15T14:32:00Z", + "runtime_version": "1.4.2", + "package_version": "v2.0", + "cpu_pct": 23, + "ram_mb": 458, + "disk_free_gb": 145, + "uptime_hours": 1284, + "drivers": { + "modbus_rtu": {"status": "ok", "errors_24h": 12}, + "nmea2000": {"status": "ok", "errors_24h": 0} + }, + "tags_offline": ["TANK_FUEL_2.LEVEL"], + "alarms_unack_critical": 0, + "vpn_status": "available" +} +``` + +El owner ve en su UI **exactamente** lo que se está enviando en tiempo real, puede pausar o desactivar. Si desactiva, yo solo doy soporte reactivo cuando él me llame. + +--- + +## 8. Log Book naval (módulo básico para Sprint 5/6) + +Por decisión del Sprint, arrancamos con módulo básico — regulatorio completo viene después. + +**Lo que SÍ implementamos en Sprint 5/6:** +- Auto-detección arranque/parada motores: cuando `Card_02.DO1` se energiza y luego `Card_02.RPM1 > 600` por 10s → entrada automática +- Snapshots periódicos cada 15 min con motor corriendo: RPM, temp aceite, temp agua, presión aceite, voltaje batería, horas acumuladas +- Auto-registro de alarmas con timestamp + ack +- Auto-registro de eventos de seguridad (autoridad, override, escora, trim reset) +- Entradas manuales del Técnico/Admin +- Firma digital inmutable +- Export CSV básico + +**Lo que dejamos para Sprint 13+:** +- Formato PDF oficial según regulación IMO MARPOL +- Oil Record Book separado +- Garbage Record Book +- Cumplimiento estricto MARPOL Annex I/V +- Firma con certificado X.509 del capitán + +--- + +## 9. Sprints relacionados con Runtime + +- **Sprint 4**: drivers Modbus RTU/TCP + tag_db + historian + alarm engine básico + API base +- **Sprint 5**: driver NMEA 2000 + log book básico + simulador para test bench +- **Sprint 6**: cliente desktop completo (estaciones puente y máquinas) + panel alarmas + trends +- **Sprint 8**: Permissive engine + Authority transfer + Stability monitor (protección escora) +- **Sprint 9-10**: Layer Config Engine + deltas + rollback + activación HWID + telemetría +- **Sprint 11** (parte): endpoints específicos para Mobile + +Detalle completo en Parte 6. + +--- + +**Fin de Parte 3 de 6.** Próxima: Parte 4 — Hardware AR-NMEA-IO + Firmware ESP32. diff --git a/VMS_Sailor_v2_Parte_04_Hardware_Firmware.md b/VMS_Sailor_v2_Parte_04_Hardware_Firmware.md new file mode 100644 index 0000000..0e8a299 --- /dev/null +++ b/VMS_Sailor_v2_Parte_04_Hardware_Firmware.md @@ -0,0 +1,401 @@ +# VMS-Sailor · Brief para Claude Code · Parte 4 de 6 + +## Hardware AR-NMEA-IO-v1.0 + Firmware ESP32 + +> Esta parte detalla la tarjeta de I/O y su firmware. Asume que ya leíste Partes 1-3. + +--- + +## 1. Descripción física de la tarjeta + +La tarjeta `AR-NMEA-IO-v1.0` es hardware diseñado por Álvaro, una sola SKU física que se replica idénticamente para todos los proyectos. Lo que varía entre proyectos es la **configuración** descargada desde el VMS, no el hardware. + +### Capacidades fijas + +| Recurso | Cantidad | Detalle | +|---|---|---| +| Salidas digitales (DO) | 10 | MOSFET IRLML6344TRPBF, opto PC817, diodo flyback SS14, 30V/5A | +| Entradas digitales (DI) | 5 | Optoacopladas PC817, contacto o 24VDC, resistencia limitadora 4.7kΩ | +| Entrada frecuencia (RPM) | 1 | Optoacoplada, pickup magnético / tacómetro / inductivo | +| Entradas analógicas (AI) | 4 | Con divisores y filtros V3061, ADC del ESP32 (12 bits + oversampling) | +| Microcontrolador | 1 | ESP32-DOWD (módulo ESP32 DevKit china) | +| Bus serie | RS485 | Transceiver SN65HVD1781 (TI), ESD ±16kV, conector RJ45 | +| Bus CAN | NMEA 2000 | Transceiver MCP2562T-E_MF (Microchip), ESD SP0502, conector estándar | +| WiFi | Integrado | Del ESP32, usado para OTA y MQTT opcional | +| USB | 1 | Para programación inicial (CH340 o CP2102 del DevKit) | +| Alimentación | 12 VDC | Conector Phoenix de 2 vías, TVS SMET24A, ferrita B2U-1000P | +| Reguladores | 2× MP2338 | Bucks 12V→5V y 5V→3.3V | + +**Aislamiento galvánico:** TODOS los I/O (DI, DO, RPM) están aislados con optoacopladores PC817. Un fallo eléctrico en un sensor o actuador NO daña el ESP32. + +**Total: 21 puntos I/O por tarjeta.** + +### GPIOs del ESP32 usados + +(Información extraída del esquemático real que me pasó Álvaro:) + +- **DO**: GPIO15, GPIO5, GPIO17, GPIO16 (cuidado: este es Rx2), GPIO18, GPIO22, GPIO25, GPIO26, GPIO27, GPIO34 (cuidado: solo entrada normalmente) +- **DI**: GPIO13, GPIO2, GPIO0, GPIO4, GPIO14 (algunos son boot-strap) +- **AI**: GPIO33 (BAT), GPIO34 (SPARE2), GPIO32 (WATER), GPIO35 (OILP) +- **RPM**: GPIO15 o similar (verificar en esquemático) +- **RS485**: GPIO04 (DE/RE), pines TX/RX UART +- **CAN**: GPIO21 (TX), GPIO23 (RX), GPIO22 (STBY) + +**Nota para Claude Code:** Los pines exactos los verificaremos en el firmware. Algunos GPIO del ESP32 tienen restricciones (strapping, solo entrada, conflictos con boot). El firmware debe respetar las asignaciones reales del schematic. + +--- + +## 2. Filosofía del firmware + +### Universal y autoconfigurable + +**Una sola SKU física = un solo firmware base = una sola variante de tarjeta.** + +La tarjeta sale de fábrica con firmware idéntico. Su rol y configuración se descargan del VMS al conectarse al bus. Patrón **"plug-and-produce"** estándar industrial moderno (similar a Beckhoff EtherCAT, Wago I/O System). + +### Lo que NO se programa por hardware + +- Qué hace cada DO (eso lo decide el VMS) +- Qué lee cada DI (eso lo decide el VMS) +- Qué tipo de sensor entra en cada AI (eso lo decide el VMS, dentro de los rangos físicos que el divisor permite) +- Dirección Modbus de la tarjeta (la asigna el VMS, junto con confirmación del dipswitch físico) +- Filtros, alarmas locales, permissives locales (todo desde el VMS) + +### Lo que SÍ va fijo en el firmware + +- Pinout: qué GPIO corresponde a qué función (DO1=GPIO15, AI1=GPIO33, etc.) +- Stack de protocolos: Modbus RTU, NMEA 2000, J1939, MQTT (siempre disponibles, se activan según config) +- Stack de WiFi y OTA +- Boot sequence con discovery broadcast +- Cliente OTA seguro +- Watchdog y recuperación ante fallos +- Buffer offline (60s en flash) + +--- + +## 3. Estructura del firmware + +``` +firmware/ar_nmea_io_v1/ +├── platformio.ini # PlatformIO o ESP-IDF +├── README.md +├── src/ +│ ├── main.cpp # Entry point + boot sequence +│ ├── config/ +│ │ ├── pinout.h # Definición física de GPIOs +│ │ ├── card_identity.cpp # Serial + HWID de cada tarjeta +│ │ ├── dipswitch_reader.cpp # Lee dipswitches físicos +│ │ └── runtime_config.cpp # Aplica config descargada del VMS +│ │ +│ ├── channels/ # Manejo de los 21 puntos I/O +│ │ ├── do_handler.cpp # 10 DO +│ │ ├── di_handler.cpp # 5 DI +│ │ ├── rpm_handler.cpp # 1 entrada frecuencia +│ │ └── ai_handler.cpp # 4 AI con oversampling +│ │ +│ ├── filters/ # Procesamiento local +│ │ ├── moving_average.cpp +│ │ ├── median.cpp +│ │ ├── deadband.cpp +│ │ └── rate_limit.cpp +│ │ +│ ├── protocols/ +│ │ ├── modbus_slave.cpp # Modbus RTU esclava +│ │ ├── modbus_master.cpp # Modbus RTU maestra (si así se configura) +│ │ ├── nmea2000_node.cpp # Publica y suscribe PGNs +│ │ ├── j1939_listener.cpp # Escucha J1939 (motores) +│ │ ├── mqtt_client.cpp # MQTT WiFi (opcional) +│ │ └── discovery.cpp # Discovery broadcast al arrancar +│ │ +│ ├── local_logic/ # Lógica local en la tarjeta +│ │ ├── local_alarms.cpp # Alarmas locales con umbrales +│ │ ├── local_permissives.cpp # Permissives locales críticos (E-stop) +│ │ └── safety_outputs.cpp # Salidas de seguridad (cortar relé si E-stop) +│ │ +│ ├── storage/ +│ │ ├── nvs_config.cpp # Configuración en NVS (flash) +│ │ └── offline_buffer.cpp # Buffer 60s en flash si bus cae +│ │ +│ ├── system/ +│ │ ├── watchdog.cpp +│ │ ├── ota_client.cpp # Cliente OTA seguro +│ │ ├── health_reporter.cpp # Reporta estado al maestro +│ │ └── reboot_handler.cpp +│ │ +│ └── utils/ +│ ├── crc.cpp +│ ├── logger.cpp +│ └── time_sync.cpp +│ +├── common/ # Librerías compartidas con futuros firmwares +│ ├── modbus_lib/ +│ ├── nmea2000_lib/ +│ └── ota_lib/ +│ +└── test/ # Tests del firmware (PlatformIO test framework) + ├── test_pinout.cpp + ├── test_modbus.cpp + ├── test_filters.cpp + └── test_discovery.cpp +``` + +--- + +## 4. Boot sequence (plug-and-produce) + +Cuando la tarjeta arranca por primera vez (o después de un reset): + +### Paso 1 — Identidad + +- Lee su serial number del NVS (asignado en fábrica) +- Lee los dipswitches físicos para obtener su "slot number" en el bus (1-16) +- Lee firmware version + +### Paso 2 — Inicialización de hardware + +- Inicializa GPIOs según `pinout.h` +- Pone todos los DO en estado seguro (apagados) +- Inicializa transceivers RS485 y CAN +- Inicializa ADC para AI +- Inicia WiFi en modo cliente (si hay credenciales guardadas) o AP (si no) + +### Paso 3 — Discovery broadcast + +Por RS485 envía un mensaje broadcast Modbus: +``` +[broadcast addr 0] +function: 65 (custom) +payload: { + serial: "ARC-2026-00153", + hw_version: "1.0", + fw_version: "1.4.2", + dipswitch_slot: 5, + capabilities: { do:10, di:5, rpm:1, ai:4 } +} +``` + +### Paso 4 — Espera config del maestro + +- Si el maestro (VMS) responde con `CONFIG_PUSH` → aplica la configuración recibida +- Si no responde en 10s → modo "huérfana": espera silencio, reintenta cada 30s + +### Paso 5 — Config aplicada + +La configuración recibida incluye: +- Su rol: ESCLAVA con dirección Modbus N +- Modo de bus: solo Modbus, solo NMEA 2000, dual, o bridge +- Asignación de cada uno de los 21 puntos a un tag específico (con su escalado, tipo de señal, filtros) +- Permissives locales (lista de condiciones if-then críticas) +- Alarmas locales (umbrales que dispararán acciones locales sin esperar al VMS) +- Frecuencia de reporte +- Configuración WiFi para OTA y MQTT (opcional) + +La guarda en NVS para persistencia. + +### Paso 6 — Modo operativo + +A partir de aquí, ciclo principal: +- Lee canales según frecuencia configurada +- Aplica filtros locales +- Evalúa alarmas locales y permissives locales +- Responde a queries Modbus del maestro +- Publica PGNs NMEA 2000 si está habilitado +- Envía health beacon cada 60s + +--- + +## 5. Modos de operación de bus + +Configurable por software desde el VMS: + +### Modo "Modbus solo" + +- Solo RS485 activo, CAN apagado +- Esclava Modbus con dirección N +- Útil para tarjetas en sistemas auxiliares (tanques, FiFi, A/A, luces) + +### Modo "NMEA 2000 solo" + +- Solo CAN activo, RS485 apagado (o solo escucha) +- Publica PGNs estándar al backbone +- Útil cuando la tarjeta es de un sistema que se quiere exponer al ecosistema marino (motor, genset) + +### Modo "Dual" (recomendado para motores y gensets) + +- RS485 + CAN ambos activos +- Reporta al VMS por Modbus para control fino +- Publica al backbone NMEA 2000 para que plotters Garmin/Raymarine vean datos del motor +- Doble alcance, mismo dato + +### Modo "Bridge" + +- Escucha CAN/NMEA 2000 y traduce a Modbus +- Útil para tener un equipo que solo habla NMEA 2000 (ej: sensor de viento Airmar) y que el VMS pueda leerlo + +--- + +## 6. PGNs NMEA 2000 que publica la tarjeta + +Cuando una tarjeta está configurada en modo NMEA 2000 (típicamente motores y gensets): + +| PGN | Nombre | Datos publicados | +|---|---|---| +| 127488 | Engine Parameters Rapid | RPM, boost pressure, trim | +| 127489 | Engine Parameters Dynamic | Presión aceite, temp aceite, temp refrigerante, voltaje alternador, % carga, horas | +| 127493 | Transmission Parameters | Presión aceite caja, temperatura | +| 127505 | Fluid Level | Tanques combustible/agua/aceite (si la tarjeta es de tanques) | +| 127508 | Battery Status | Voltaje, corriente, temp batería | +| 127257 | Attitude | (consume del backbone, no publica — viene del AR-ECDIS) | + +Esto le da al cliente un beneficio gratis: el plotter del puente muestra automáticamente datos del motor sin configuración extra. + +--- + +## 7. Procesamiento local en la tarjeta + +Esto es lo que diferencia tu sistema de tarjetas Modbus pasivas: + +### Filtros locales + +Configurables por canal desde el VMS: +- **Moving average**: promedia últimas N muestras +- **Median**: filtra spikes (excelente para señales con ruido EMI) +- **Deadband**: solo reporta si el cambio supera X +- **Rate limit**: rechaza cambios bruscos (anti-glitch) + +Beneficio: reduce tráfico Modbus 10× típicamente, y mejora calidad de la señal. + +### Alarmas locales + +La tarjeta tiene umbrales propios y puede activar una salida si una entrada se sale de rango, sin esperar al VMS. Ejemplo: + +``` +IF AI1 (temp aceite) > 110°C +THEN DO1 (relé alarma local) = ON +AND envía mensaje urgente al VMS por Modbus +``` + +Tiempo de reacción: <50ms (vs 200-500ms si pasa por el VMS). + +### Permissives locales críticos + +Cuando un permissive es de seguridad funcional (E-stop, sobre-velocidad), se ejecuta LOCAL en la tarjeta: + +``` +IF DI2 (pulsador E-stop) == ACTIVO +THEN DO1 (relé arranque motor) = OFF inmediato +AND DO2 (parada emergencia) = ON +AND envía evento al VMS +``` + +Esto cumple con SIL (Safety Integrity Level) básico — el corte no depende de que el bus esté disponible. + +--- + +## 8. OTA (Over-The-Air firmware update) + +El ESP32 soporta OTA nativo y maduro. Stack del firmware: + +### Flujo OTA + +1. Álvaro publica nueva versión de firmware en el Studio (ej: 1.5.0) +2. Al generar el `.vmspack` o `.vmsdelta`, incluye el `.bin` +3. El Runtime, vía VPN o presencial, recibe el paquete +4. El Admin del buque aprueba la actualización +5. El Runtime envía el `.bin` a cada tarjeta vía Modbus o WiFi +6. La tarjeta: + - Verifica firma criptográfica del firmware (clave pública de Álvaro embebida) + - Verifica que la versión es compatible + - Guarda en partición OTA + - Reinicia + - Si arranca OK → confirma al VMS + - Si falla → rollback automático a la partición anterior + +### Seguridad OTA + +- Firma RSA-2048 o Ed25519 del firmware +- Verificación obligatoria antes de aplicar +- Sin firma válida = rechazado +- Rollback automático si nuevo firmware no responde en 60s + +### Tiempo de actualización + +- Por Modbus RTU a 115200 baud: ~2 min por tarjeta de 500KB +- Por WiFi: ~10 segundos +- Para 7 tarjetas, total ~5-15 minutos + +--- + +## 9. Comunicación tarjeta ↔ VMS + +### Mensajes Modbus custom (function codes propietarios) + +Además del Modbus estándar (read holding, write coils, etc.), agregamos function codes custom para gestión: + +| FC | Nombre | Propósito | +|---|---|---| +| 65 | DISCOVERY_BROADCAST | Tarjeta anuncia presencia | +| 66 | CONFIG_PUSH | VMS empuja configuración | +| 67 | CONFIG_ACK | Tarjeta confirma config aplicada | +| 68 | HEALTH_BEACON | Tarjeta reporta su salud cada 60s | +| 69 | OTA_BEGIN | Inicia transferencia firmware | +| 70 | OTA_CHUNK | Bloque de firmware | +| 71 | OTA_END | Finalizar y reiniciar | +| 72 | LOCAL_ALARM_EVENT | Tarjeta notifica alarma local | +| 73 | LOCAL_PERMISSIVE_TRIGGERED | Tarjeta notifica permissive ejecutado | + +--- + +## 10. Identificación de tarjetas (reemplazo de tarjeta dañada) + +Combinación A+C (decisión Álvaro): + +### Dipswitches físicos + +Cada tarjeta tiene 4-5 dipswitches que el técnico setea al instalarla. Indican el "slot number" en el bus (1-16). La tarjeta envía este número en su discovery. + +### Confirmación en pantalla del Runtime + +Cuando se conecta una tarjeta nueva (slot que estaba vacío o slot que estaba con otra tarjeta serial), el Runtime alerta: +``` +Nueva tarjeta detectada: + Serial: ARC-2026-00187 + Slot dipswitch: 5 + Reemplaza a: ARC-2026-00153 (Motor PORT) [confirmado por Álvaro o Técnico] + + [Aceptar y reasignar rol] [Rechazar - no esperaba esta tarjeta] +``` + +Si los dipswitches no coinciden con lo esperado, alarma: "Tarjeta con dipswitch 7 conectada en slot 5 - corregir dipswitch o reasignar en Studio". + +--- + +## 11. Sprints relacionados con firmware + +- **Sprint 0** (parte): definición de `pinout.h` y estructura básica del firmware +- **Sprint 12**: firmware completo Modbus RTU esclava + discovery + config push + filtros locales +- **Sprint 13**: NMEA 2000 publishing + J1939 listening +- **Sprint 14**: OTA + health reporter + permissives locales + alarmas locales +- **Sprint 15**: pruebas de stress, watchdog, recovery + +**Nota:** El firmware ES parte del proyecto VMS-Sailor (mismo repositorio, mismo control de versiones). Va junto con el resto. + +--- + +## 12. Stack tecnológico del firmware + +| Componente | Tecnología | +|---|---| +| Framework | PlatformIO + Arduino framework O ESP-IDF (a decidir en Sprint 12) | +| Lenguaje | C++ | +| Modbus RTU | `eModbus` o `ArduinoModbus` | +| NMEA 2000 | `NMEA2000_esp32` library | +| J1939 | librería específica | +| OTA | `ArduinoOTA` o `esp_ota` de ESP-IDF | +| MQTT | `PubSubClient` o `AsyncMqttClient` | +| Filesystem (NVS) | nativo ESP32 | +| Tests | Unity + PlatformIO test runner | + +--- + +**Fin de Parte 4 de 6.** Próxima: Parte 5 — VMS-Sailor Mobile (Flutter). diff --git a/VMS_Sailor_v2_Parte_05_Mobile.md b/VMS_Sailor_v2_Parte_05_Mobile.md new file mode 100644 index 0000000..435d0ac --- /dev/null +++ b/VMS_Sailor_v2_Parte_05_Mobile.md @@ -0,0 +1,359 @@ +# VMS-Sailor · Brief para Claude Code · Parte 5 de 6 + +## VMS-Sailor Mobile (app nativa iOS y Android) + +> Esta parte detalla la app móvil. Asume que ya leíste Partes 1-4. + +--- + +## 1. Propósito + +App nativa que permite al **Admin del buque** (owner), técnicos y tripulación autorizados ver y operar el VMS desde tablets y smartphones. Diseñada para uso a bordo, no remoto desde tierra. + +### Casos de uso principales + +- **Owner en cubierta o camarote**: revisar estado general del buque sin ir al puente +- **Owner durante navegación**: ajustar trim desde el flybridge mientras conduce +- **Técnico durante mantenimiento**: ver lecturas en tiempo real estando físicamente en sala de máquinas +- **Tripulación**: recibir notificaciones de alarmas en el teléfono cuando no están en estación fija +- **Owner en muelle**: revisar estado del buque amarrado desde su casa cercana (siempre que esté en WiFi local) + +--- + +## 2. Restricciones críticas de seguridad + +### Solo WiFi local del buque + +La app **JAMÁS** se expone a internet. El servidor del Runtime escucha solo en la subred WiFi local (192.168.x.x típicamente). La app móvil descubre el servidor por mDNS/Bonjour o IP fija configurada al enrolar. + +**Implicaciones:** +- No se requiere certificado SSL público (TLS interno con CA propia) +- Sin cuentas en la nube, sin datos saliendo del buque +- Si la WiFi del buque está apagada, la app no funciona +- Si el dueño quiere acceso remoto desde tierra → debe pasar por VPN del armador (instalación separada, no parte de este proyecto) + +### Autenticación TOTP + biométrico + +Doble factor obligatorio: +1. **Algo que sabes**: usuario + password +2. **Algo que tienes**: el dispositivo enrolado (TOTP de 30s) +3. **Algo que eres**: biométrico del dispositivo (FaceID, TouchID, huella Android) — antes de revelar el TOTP + +### Enrollment controlado + +Solo el Admin del buque (owner) puede autorizar nuevos dispositivos. Proceso: +1. Owner en su estación principal del Runtime → "Gestión de dispositivos móviles" +2. Crea nuevo dispositivo: nombre (ej: "iPhone Carlos"), usuario asociado +3. Sistema genera QR con secreto TOTP único +4. Usuario escanea con la app VMS-Sailor Mobile en su dispositivo +5. App pide configurar biométrico local +6. Dispositivo queda enrolado +7. Owner puede revocar en cualquier momento desde la estación + +### Permissives respetados igual que estación fija + +Los permissives se evalúan en el SERVIDOR, no en el cliente. Móvil y desktop son solo UIs. Si un permissive bloquea una acción, bloquea desde cualquier UI. No hay forma de saltarse permissives desde móvil. + +--- + +## 3. Arquitectura técnica + +### Stack tecnológico + +| Componente | Tecnología | +|---|---| +| Framework | Flutter 3.x | +| Lenguaje | Dart 3.x | +| State management | Riverpod 2.x | +| Comunicación | WebSocket (web_socket_channel) + HTTP/REST (dio) | +| TOTP | `otp` package | +| Biométrico | `local_auth` | +| Almacenamiento seguro | `flutter_secure_storage` (Keychain iOS, Keystore Android) | +| Gráficas | `fl_chart` | +| SVG (mímicos) | `flutter_svg` | +| Notificaciones locales | `flutter_local_notifications` | +| Cliente HTTPS con CA propia | configurar en `dio` con `IOHttpClientAdapter` | + +### Por qué Flutter (no React Native ni nativo puro) + +- **Performance gráfico superior**: motor Skia/Impeller propio. Para mímicos con muchas lecturas refrescándose en tiempo real, esto es decisivo. RN sufre con UIs densas y actualizaciones rápidas +- **Un solo código para iOS + Android + posible escritorio**: a futuro se puede tener "VMS-Sailor Mobile Lite" como app de escritorio liviana con el mismo código +- **Coherencia visual**: misma UI en iPhone, iPad y Android. Flutter renderiza por sí mismo, no usa widgets nativos +- **Dart bien diseñado**: tipado fuerte, async/await nativo, null safety. Reduce bugs runtime en sistema crítico +- **Soporte enterprise**: Google + BMW + Toyota lo usan en industria + +### Estructura de carpetas Flutter + +``` +mobile/ +├── pubspec.yaml +├── README.md +├── android/ +├── ios/ +│ +└── lib/ + ├── main.dart + ├── app.dart # Material/Cupertino app + │ + ├── core/ + │ ├── models/ + │ │ ├── tag.dart + │ │ ├── alarm.dart + │ │ ├── system.dart + │ │ ├── permissive.dart + │ │ └── user.dart + │ ├── api/ + │ │ ├── ws_client.dart # Cliente WebSocket + │ │ ├── rest_client.dart # Cliente REST + │ │ └── auth_client.dart # Auth + TOTP + │ ├── auth/ + │ │ ├── totp_manager.dart + │ │ ├── biometric_gate.dart # local_auth wrapper + │ │ └── secure_storage.dart # secretos en Keychain/Keystore + │ ├── discovery/ + │ │ └── mdns_finder.dart # Encuentra servidor en WiFi + │ └── theme/ + │ ├── light_theme.dart + │ └── dark_theme.dart + │ + ├── features/ + │ ├── enrollment/ + │ │ ├── qr_scanner.dart # Escanea QR del Runtime + │ │ └── enrollment_flow.dart + │ ├── login/ + │ │ ├── login_screen.dart + │ │ └── biometric_unlock.dart + │ ├── overview/ + │ │ └── overview_screen.dart # Dashboard inicial + │ ├── systems/ + │ │ ├── system_list.dart # Lista de sistemas activos + │ │ ├── mimic_view.dart # Mímico de un sistema + │ │ └── tag_detail.dart + │ ├── alarms/ + │ │ ├── alarm_list.dart + │ │ ├── alarm_detail.dart + │ │ └── alarm_notifications.dart + │ ├── trends/ + │ │ └── trends_screen.dart # Gráficas con fl_chart + │ ├── trim/ + │ │ ├── trim_control_screen.dart # Panel destacado de trim + │ │ ├── trim_slider_widget.dart + │ │ ├── stability_indicator.dart # Roll/pitch en vivo + │ │ └── emergency_reset_button.dart + │ ├── logbook/ + │ │ ├── logbook_view.dart + │ │ └── manual_entry.dart + │ └── settings/ + │ └── settings_screen.dart + │ + └── widgets/ # Widgets reutilizables + ├── tag_card.dart + ├── alarm_badge.dart + ├── gauge_widget.dart + ├── lamp_indicator.dart + ├── bar_graph.dart + └── permissive_status.dart +``` + +--- + +## 4. Pantallas principales + +### Login + +- Detecta si hay servidor VMS-Sailor en la WiFi local (mDNS) +- Si no, pide IP manual +- Login con usuario + password +- Pide biométrico para revelar TOTP +- Verifica TOTP contra servidor +- Si OK, guarda token de sesión + +### Overview (dashboard inicial) + +Lo primero que ve el usuario después de loguear: +- Nombre del buque + estado general (verde/amarillo/rojo) +- Resumen: motores estado, gensets estado, tanques niveles, alarmas activas +- Roll/pitch actual (gauge visual) +- Acceso rápido a sistemas más usados + +### Lista de sistemas (menú lateral o pestaña) + +Generado dinámicamente desde el `.vmspack` activo. Solo muestra los sistemas que el integrador marcó en el wizard. + +### Vista de mímico por sistema + +Carga el SVG del mímico empaquetado en el `.vmspack`. Se renderiza con `flutter_svg`. Los elementos del mímico se actualizan con WebSocket en tiempo real: +- Cambios de color por estado +- Valores numéricos sobre los símbolos +- Tap en un elemento → muestra detalle del tag + +### Panel de Trim y Maniobra (destacado) + +Esta es la pantalla estrella para el owner. UI optimizada para uso con una mano mientras conduce: +- Sliders verticales grandes para cada actuador de trim (touch-friendly) +- Indicador horizontal de roll en vivo (animado) +- Indicador de pitch +- Botón grande rojo de "RESET EMERGENCIA" +- Toggle de "Modo Manual del Owner" (requiere biométrico para activar) +- Indicación clara del envelope activo (línea visual donde el sistema empieza a bloquear) + +### Panel de alarmas + +- Lista cronológica con color por prioridad +- Notificaciones push locales cuando entra alarma crítica (incluso con la app en background) +- Botón ACK por alarma (respeta permisos del rol) +- Filtros y búsqueda + +### Trends + +- Gráficas con fl_chart +- Múltiples tags simultáneos +- Zoom y pan +- Selector de rango temporal (1h, 24h, 7d) + +### Log Book + +- Lista de entradas cronológica +- Filtros por categoría (eventos motor, alarmas, manuales) +- Botón "Agregar entrada manual" (Técnico/Admin) + +### Settings + +- Cambiar password +- Cerrar sesión / desautorizar dispositivo +- Configuración de notificaciones +- Idioma +- Modo oscuro + +--- + +## 5. Permisos por rol en la app + +Mismos roles que en estación desktop, pero con restricciones adicionales por ser móvil: + +| Acción | Operador | Técnico | Admin (owner) | +|---|---|---|---| +| Ver mímicos | ✓ | ✓ | ✓ | +| Ver alarmas | ✓ | ✓ | ✓ | +| ACK alarmas Info/Low | ✓ | ✓ | ✓ | +| ACK alarmas High/Emergency | ✗ | ✓ | ✓ | +| Ver trends | ✓ | ✓ | ✓ | +| Ver logbook | ✓ | ✓ | ✓ | +| Agregar entrada manual logbook | ✗ | ✓ | ✓ | +| Control de luces, A/A (no críticos) | ✗ | ✓ | ✓ | +| Control trim | ✗ | ✓ | ✓ | +| Activar modo manual del owner | ✗ | ✗ | ✓ | +| Reset emergencia | ✓ | ✓ | ✓ | +| Arrancar motores / thrusters | ✗ | ✗ | ✗ (solo estación fija) | +| Override de permissives WARNING | ✗ | ✗ | ✓ | +| Gestión de usuarios y dispositivos | ✗ | ✗ | ✓ | + +**Regla de oro**: acciones de propulsión y maquinaria principal (arranque/parada motores, thrusters, parada de emergencia general) **solo desde estación fija puente o máquinas**, nunca desde móvil. Por seguridad. + +--- + +## 6. Notificaciones push locales + +Sin servicios externos (no Firebase, no APNs externos). La app mantiene WebSocket persistente con el Runtime. Cuando recibe evento crítico: +- Si la app está en foreground → notificación dentro de la app +- Si la app está en background → notificación local del sistema operativo (`flutter_local_notifications`) +- Sonido distintivo por prioridad +- Tocar la notificación → abre la app en el evento específico + +Limitación: si el usuario fuerza-cierra la app, no llegan notificaciones. Documentar al owner. + +--- + +## 7. Modo offline degradado + +Si la conexión WebSocket cae: +- Banner persistente "Sin conexión al VMS" +- Sigue mostrando los últimos valores conocidos (con badge "desactualizado X minutos") +- Permite ver histórico cached +- No permite enviar comandos +- Intenta reconectar automáticamente cada 10s + +--- + +## 8. Empaquetado y distribución + +### iOS + +- Build con Xcode +- Distribución vía TestFlight para clientes específicos (no App Store público inicialmente) +- Requiere cuenta Apple Developer ($99/año) — gasto del integrador (Álvaro) +- Cada armador recibe invitación TestFlight con el binario + +### Android + +- Build APK firmado +- Distribución directa por enlace (no Google Play inicialmente) +- O via Play Store con cuenta de desarrollador ($25 una vez) si se decide más adelante + +**Por qué no stores públicas inicialmente:** +- Las stores requieren accesibilidad pública (cualquiera puede instalar) +- Eso introduce riesgo de seguridad (cualquiera podría intentar usar la app contra cualquier IP) +- Distribución privada (TestFlight + APK firmado) garantiza que solo clientes autorizados tienen acceso + +--- + +## 9. Sprints relacionados con Mobile + +- **Sprint 11**: VMS-Sailor Mobile completo + - Setup Flutter project + - Enrollment flow + QR scan + - Login + TOTP + biométrico + - WebSocket client + REST client + - Pantallas: Overview, Lista sistemas, Mímico simple + - Panel alarmas con notificaciones locales + - Panel Trim (caso destacado) + - Permisos por rol + - Build iOS + Android + +- **Sprint posterior**: refinamientos, trends avanzados, modo offline robusto, logbook completo + +--- + +## 10. Consideraciones de UI/UX móvil + +### Touch targets + +Mínimo 44×44 pt en iOS, 48×48 dp en Android. Los sliders de trim deben ser grandes para uso con guantes o manos mojadas. + +### Lecturabilidad bajo sol + +- Modo de pantalla "outdoor" con contraste aumentado +- Texto grande por defecto (configurable) +- Evitar colores pastel para datos críticos + +### Orientación + +- Tablets: soportar landscape y portrait +- Smartphones: portrait principalmente; landscape para mímicos complejos y trends + +### Accesibilidad + +- Soporte de screen readers (VoiceOver iOS, TalkBack Android) +- Tamaño de fuente respetando configuración del sistema +- Contraste WCAG AA mínimo + +--- + +## 11. Privacidad y datos + +La app NO recolecta: +- Datos de telemetría de uso +- Analítica +- Crash reports a servidores externos + +La app guarda LOCALMENTE en almacenamiento seguro del dispositivo: +- Secreto TOTP (cifrado en Keychain/Keystore) +- Token de sesión +- Últimos valores cached (para modo offline) +- Configuración del usuario + +Si el dispositivo se restablece o se borra la app → todo se pierde. El owner debe enrolar nuevamente. + +--- + +**Fin de Parte 5 de 6.** Próxima: Parte 6 — Plan de Sprints completo. diff --git a/VMS_Sailor_v2_Parte_06_Sprints_y_reglas.md b/VMS_Sailor_v2_Parte_06_Sprints_y_reglas.md new file mode 100644 index 0000000..8b7cee6 --- /dev/null +++ b/VMS_Sailor_v2_Parte_06_Sprints_y_reglas.md @@ -0,0 +1,483 @@ +# VMS-Sailor · Brief para Claude Code · Parte 6 de 6 + +## Plan de sprints completo, modelo de datos core, reglas de oro + +> Esta es la última parte. Asume que ya leíste Partes 1-5. + +--- + +## 1. Ubicación del proyecto + +**Todo va en `D:/Proyectos Software/VMS-Sailor/`.** + +- Si la carpeta no existe, créala. +- Si existe vacía, úsala. +- Si tiene contenido previo, pregúntame antes de tocar nada. +- Git desde el primer commit. Cada sprint termina con commit etiquetado (`sprint-0`, `sprint-1`, etc.). + +`.gitignore` debe incluir: +``` +__pycache__/ +*.pyc +.venv/ +dist/ +build/ +*.egg-info/ +.idea/ +.vscode/ +logs/ +*.vmspack +*.vmsdelta +licenses/local/ +mobile/build/ +firmware/.pio/ +firmware/.vscode/ +*.bin +historian.duckdb +secrets/ +``` + +--- + +## 2. Estructura raíz del repositorio + +``` +D:/Proyectos Software/VMS-Sailor/ +├── README.md +├── LICENSE.txt +├── CHANGELOG.md +├── pyproject.toml +├── requirements.txt +├── requirements-dev.txt +├── .gitignore +│ +├── studio_main.py # Entry point del Studio +├── runtime_server_main.py # Entry point del servicio Windows +├── runtime_client_main.py # Entry point del cliente desktop +│ +├── vmssailor/ # Código Python compartido +│ ├── __init__.py +│ ├── version.py +│ ├── core/ # Modelo de datos compartido (CRÍTICO) +│ ├── library/ # Biblioteca curada (solo Studio) +│ ├── studio/ # App Studio (Parte 2) +│ ├── runtime/ # App Runtime (Parte 3) +│ ├── shared/ # Utilidades compartidas +│ └── tests/ # Tests Python +│ +├── firmware/ # Firmware ESP32 (Parte 4) +│ └── ar_nmea_io_v1/ +│ +├── mobile/ # App Flutter (Parte 5) +│ └── lib/ +│ +├── docs/ # Documentación técnica +├── installer/ # WiX MSI scripts +├── tools/ # Scripts auxiliares +└── projects/ # Proyectos de buques específicos (git-ignored excepto seed) +``` + +--- + +## 3. Modelo de datos core (Sprint 0) + +Este es el corazón del sistema. Lo construimos en Sprint 0 antes de cualquier UI. + +### Coordenadas navales + +```python +from dataclasses import dataclass + +@dataclass(frozen=True) +class ShipCoord: + """Coordenadas en el marco del buque. + + x_pp : metros desde Perpendicular de Popa (Pp), positivo hacia proa. + y_cl : metros desde Línea de Crujía (CL), positivo a estribor, negativo a babor. + z_bl : metros desde Línea Base (BL), positivo hacia arriba. + """ + x_pp: float + y_cl: float + z_bl: float +``` + +### Entidades principales + +``` +Vessel + └── Deck[] Cubiertas con polígono 2D vista planta + altura + +Project (instancia de un buque específico para un cliente) + ├── vessel_ref referencia a Vessel de biblioteca o importado + ├── name "M/Y Aurora" + ├── customer + ├── systems_enabled List[SystemId] + ├── equipment List[Equipment] + ├── tags List[Tag] + ├── cards List[CardInstance] + └── topology Topology + +Equipment + ├── id + ├── model_ref referencia a EquipmentModel + ├── tag_prefix "ME_PORT", "GEN_1" + ├── location: ShipCoord + ├── deck_id + └── system_id + +EquipmentModel (en biblioteca) + ├── id "mtu_12v_2000_m96" + ├── manufacturer "MTU" + ├── model_name "12V 2000 M96" + ├── category ENGINE_MAIN | GENSET | PUMP | ... + ├── specs potencia, RPM nominales, peso, dimensiones + └── default_sensors List[Sensor] + +Sensor + ├── id + ├── name "Oil Pressure" + ├── type TEMPERATURE | PRESSURE | LEVEL | RPM | VOLTAGE | ... + ├── unit_si "bar", "C", "rpm" + ├── range_normal (min, max) + ├── alarm_low (value, priority) + ├── alarm_high (value, priority) + └── default_signal_type RTD_PT100 | 4-20MA | 0-10V | PULSE | ... + +Tag (punto I/O concreto) + ├── id autogenerado "ME_PORT.OIL_PRESS" + ├── description + ├── value float | bool | str + ├── quality GOOD | BAD | UNCERTAIN + ├── timestamp + ├── unit_si + ├── range_normal + ├── alarms List[AlarmConfig] + ├── controllable bool + ├── control_mode MONITOR | MANUAL | AUTO | FUTURE + ├── authority_required BRIDGE | ENGINE | EITHER + ├── protocol MODBUS_RTU | MODBUS_TCP | NMEA2000 | J1939 + ├── address dirección Modbus o ID CAN + ├── scaling raw → engineering + └── physical_binding TagBinding + +TagBinding (cómo un tag se mapea a un canal físico) + ├── card_id + ├── channel_type AI | DI | DO | RPM + ├── channel_number 1-10 (o 1-5 para DI, 1-4 para AI) + ├── signal_type 4-20MA | 0-10V | RTD_PT100 | RELAY_NO | ... + ├── scaling raw_min, raw_max, eng_min, eng_max + ├── filter NONE | MOVING_AVG | MEDIAN | DEADBAND | RATE_LIMIT + └── update_rate_ms + +CardInstance (una tarjeta AR-NMEA-IO en un proyecto concreto) + ├── id "card_001" + ├── serial_number "ARC-2026-00153" + ├── slot_number 1-16 (dipswitch físico) + ├── physical_location "Sala máquinas, panel principal" + ├── ship_coord ShipCoord aproximada + ├── bus_role MODBUS_SLAVE | MODBUS_MASTER | NMEA2000_NODE | DUAL | BRIDGE + ├── modbus_address 1-247 si esclava + ├── bus_segment "bus_main" + └── firmware_version + +Topology (la red completa del proyecto) + ├── buses List[Bus] + └── cards List[CardInstance] + +Bus + ├── id "bus_main" + ├── protocol MODBUS_RTU | NMEA2000 + ├── physical_port "COM3" | "USB-CAN0" + ├── baud_rate + ├── parity, stop_bits (Modbus RTU) + └── termination + +Alarm (instancia activa) + ├── id + ├── tag_id + ├── priority EMERGENCY | HIGH | LOW | INFO + ├── state ACTIVE | ACK | CLEARED + ├── timestamp_active + ├── timestamp_ack + ├── timestamp_cleared + ├── acknowledged_by + └── message + +PermissiveRule + ├── action_id "START_ME_PORT" + ├── conditions List[Condition] + └── on_fail_message + +Condition + ├── tag_ref + ├── operator EQ | NEQ | GT | LT | BETWEEN | IS_TRUE | IS_FALSE + ├── threshold + ├── severity FAIL | WARNING + └── message_on_fail +``` + +### Persistencia + +- **Biblioteca curada**: archivos JSON/YAML versionados en git +- **Proyecto en Studio**: archivo único `.vmsproj` (SQLite portable) +- **Paquete distribuible**: `.vmspack` = ZIP firmado con `manifest.json` + `project.db` + mímicos + firmware + assets +- **Runtime a bordo**: descomprime `.vmspack` en `%PROGRAMDATA%/VMS-Sailor/project/` +- **Capas**: cada capa es un `.vmsdelta` separado en `%PROGRAMDATA%/VMS-Sailor/layers/` +- **Historian**: DuckDB en `%PROGRAMDATA%/VMS-Sailor/historian.duckdb` +- **Auditoría**: SQLite append-only en `%PROGRAMDATA%/VMS-Sailor/audit.db` + +--- + +## 4. Plan completo de sprints + +### Sprint 0 — Fundaciones (2-3 semanas) + +**Objetivo**: estructura completa del proyecto + modelo de datos + biblioteca seed mínima. + +**Entregables:** +- Repositorio Git inicializado con la estructura completa (stubs creados) +- `pyproject.toml`, `requirements.txt`, `.gitignore` +- Módulo `vmssailor.core` completo con tests (cobertura ≥80%) +- Persistencia: serialización/deserialización a `.vmsproj` +- Biblioteca seed mínima: + - 2 yates motor (Sunseeker 76, Ferretti 850) + - 2 motores principales (MTU 12V 2000 M96, Volvo D13 900hp) + - 1 genset (Northern Lights M65C13) + - 1 archivo de reglas (yacht_motor_planeo.yaml) + - `systems_catalog.json` completo (todos los sistemas de la Parte 1) +- Validador de biblioteca: `tools/validate_library.py` +- Tests unitarios para `core` (cobertura ≥80%) +- README con setup +- Pinout inicial del firmware en `firmware/ar_nmea_io_v1/src/config/pinout.h` (sin firmware funcional aún) + +**Criterio de aceptación**: `pytest` pasa todo, `validate_library.py` pasa todo, se puede crear/guardar/leer un Project programáticamente desde script. + +### Sprint 1 — Studio shell + wizard básico (3-4 semanas) + +- QApplication del Studio con ventana principal y menú +- Wizard pasos 1-4 funcional (tipo, plantilla, dimensiones, sistemas) +- Visualización de silueta en QGraphicsView con sistema de coordenadas naval (rejilla en metros desde Pp) +- Sin paso 5-8 aún (mocked) +- Se puede crear proyecto desde wizard y guardarlo como `.vmsproj` + +### Sprint 2 — Wizard completo + editor de equipos (3-4 semanas) + +- Pasos 5-8 del wizard: motor de reglas YAML, propuesta de equipos, refinamiento manual con drag-and-drop, paso 7 de topología +- Editor de equipos: CRUD, vista planta, vista lista +- Más buques en biblioteca: 5-7 yates motor + 2 pesqueros + 1 patrullero +- Más equipos en biblioteca: 10+ motores, 6+ gensets, bombas básicas + +### Sprint 3 — Editor de mímicos + tags + alarmas (3-4 semanas) + +- Editor visual de mímicos por sistema (QGraphicsView con símbolos arrastrables) +- Editor de tags: tabla con filtros, edición de direcciones, escalado, control_mode +- Editor de alarmas: límites, prioridades, histéresis, mensajes +- Símbolos básicos: motor, bomba, válvula, tanque, sensor, indicador, línea + +### Sprint 4 — Runtime servidor base (4-5 semanas) + +- Servicio Windows operativo (pywin32) arrancando al boot +- Driver Modbus TCP funcional (pymodbus) +- Driver Modbus RTU funcional +- `tag_store` en memoria con pub/sub +- Historian básico DuckDB con retención configurable +- Alarm engine con evaluación de límites, prioridades, ack +- API REST/WebSocket básica +- Simulador del Studio que inyecta valores en servidor de pruebas + +### Sprint 5 — NMEA 2000 + Log Book básico (3-4 semanas) + +- Driver NMEA 2000 (CAN) suscribiendo a PGNs configurados +- Log Book básico: + - Auto-detección arranque/parada motores + - Snapshots periódicos cada 15 min + - Registro de alarmas con ack + - Entradas manuales + - Firma digital inmutable +- Cliente desktop básico (login + overview + lista de sistemas) + +### Sprint 6 — Cliente desktop completo (3-4 semanas) + +- Renderizado completo de mímicos desde `.vmspack` +- Panel de alarmas viviente con ack +- Trends básicos (gráficas tiempo real con fl_chart Python equivalente: pyqtgraph) +- Login con tres perfiles +- Permisos por rol +- Vista de logbook +- Vista de soporte y auditoría (Admin solo) + +### Sprint 7 — Empaquetador + activación + MSI (3 semanas) + +- `package_builder` genera `.vmspack` desde proyecto del Studio +- `delta_builder` genera `.vmsdelta` +- `hwid_binder` liga paquete a HWID destino +- Activación online inicial del Runtime +- WiX scripts para generar instalador MSI Windows con servicio +- Activación del Studio (protegerlo a mi PC) + +### Sprint 8 — Permissive + Authority + Stability (3-4 semanas) + +- Permissive engine completo con todas las pre-condiciones +- Editor de permissives en Studio +- Authority manager: transferencia bilateral puente↔máquinas +- Override de emergencia (E-stop) +- Stability monitor con los 3 niveles + envelope + modo manual owner +- UI de autoridad y stability en clientes desktop + +### Sprint 9 — Layer Config Engine (3 semanas) + +- Carga de capas en orden al boot +- Aplicación de deltas firmados con verificación criptográfica +- Snapshots automáticos antes de aplicar +- Rollback granular accesible al owner +- Schema validation entre versiones +- UI de gestión de actualizaciones en cliente desktop (orden de trabajo + disponibilidad) + +### Sprint 10 — Telemetría + VPN + auditoría (2-3 semanas) + +- Telemetría de salud transparente (visible al owner, pausable) +- Soporte WireGuard externo (cliente VPN en Runtime) +- Endpoints administrativos solo desde IP del túnel +- Auditoría completa de sesiones VPN +- Vista de "Soporte y Auditoría" en cliente + +### Sprint 11 — VMS-Sailor Mobile (Flutter) (4-5 semanas) + +- Setup proyecto Flutter +- Enrollment flow con QR +- Login + TOTP + biométrico +- Cliente WebSocket + REST +- Pantallas: Overview, Lista sistemas, Mímico, Alarmas con notificaciones +- Panel Trim y Maniobra destacado +- Build iOS + Android + +### Sprint 12 — Firmware ESP32 base (4 semanas) + +- Setup PlatformIO o ESP-IDF +- Modbus RTU esclava funcional +- Discovery broadcast + config push desde VMS +- Lectura de 5 DI + 4 AI con oversampling +- Comando de 10 DO +- Lectura de RPM +- Filtros locales (moving average, median, deadband) +- Tests con simulador y banco de pruebas + +### Sprint 13 — Firmware NMEA 2000 + J1939 (3 semanas) + +- Stack NMEA 2000 funcional +- Publicación de PGNs estándar (127488, 127489, 127505, 127508) +- Listener J1939 para motores que hablan ese protocolo +- Modo dual (Modbus + NMEA 2000 simultáneo) + +### Sprint 14 — OTA + permissives locales + alarmas locales (3 semanas) + +- Cliente OTA seguro con verificación de firma +- Rollback automático si firmware nuevo falla +- Permissives locales críticos (E-stop) ejecutándose en la tarjeta +- Alarmas locales con umbrales +- Health reporter cada 60s + +### Sprint 15 — Pruebas integradas + hardening (3 semanas) + +- Pruebas de integración VMS + tarjetas reales +- Pruebas de stress (alta carga de tags, alarmas masivas) +- Pruebas de fallo (cable cortado, tarjeta sin energía, bus colgado) +- Watchdog y recovery automático +- Documentación de operador y mantenimiento + +### Sprint 16+ — Refinamientos y extensiones + +- Log Book regulatorio completo (MARPOL, IMO) +- Integración Seakeeper, Quick MC², CMC stabilizers +- Cotizador de expansiones +- Generador paramétrico de silueta +- Plantillas adicionales (offshore support, ferries más grandes) +- Edge cases y polishing +- Manuales de usuario completos + +--- + +## 5. Reglas de oro (recordatorio) + +1. **Antes de cada sprint**, me presentas plan detallado de lo que vas a hacer y esperas mi OK. No improvises features. + +2. **Cada cambio importante** se discute primero en chat. Yo decido alcance. + +3. **Tests obligatorios** para todo lo que toca `core` y `runtime/server`. UI más relajada pero `pytest-qt` en flujos críticos. Firmware: `Unity` + PlatformIO test runner. Mobile: tests de widget Flutter. + +4. **No agregues dependencias** sin preguntarme primero. + +5. **Mantén consistencia** con mis otras apps (AR ShipDesign, AR-ElecArrangement, AR-StabCol): misma filosofía UX, mismo idioma por defecto (español), mismo manejo de coordenadas navales. + +6. **Documenta normativa** cuando implementes algo regulado: + - IEC 60092-504 (electrical installations in ships - automation) + - IACS UR E22 (computer-based systems) + - ABYC E-11 (electrical) + - NMEA 2000 (protocolo, PGNs) + - SAE J1939 (CAN motores) + - IMO MARPOL/SOLAS (log book — sprint 16+) + +7. **Sin red de salida en Runtime** salvo: + - Activación inicial de licencia (Studio → mi servidor) + - Túnel VPN administrativa (cliente WireGuard externo) + - Telemetría técnica solo si el owner la habilita + + Sin telemetría implícita NUNCA. + +8. **La biblioteca curada es ORO**. Cualquier cambio en formato de archivos de biblioteca requiere migración para los proyectos existentes. + +9. **El Runtime debe ser inmutable para el cliente**. El cliente no puede editar tags, alarmas, mímicos, topología. Solo opera lo que el Studio le entregó vía paquetes y deltas firmados. + +10. **Auditoría siempre activa** en Runtime: + - Cada ack de alarma + - Cada solicitud de autoridad + - Cada override de permissive + - Cada conexión VPN mía con timestamps y endpoints accedidos + - Cada aplicación de delta + - Cada rollback + - Cada enrolamiento/revocación de dispositivo móvil + +11. **Coordenadas navales consistentes** en TODO el código: ShipCoord siempre, conversión a pantalla solo en renderers. + +12. **Unidades SI internas siempre**: m, kg, Pa, °C, s. Conversión a imperial solo en UI cuando el usuario lo pida. + +13. **Idioma**: español por defecto, inglés como segundo. Strings en `vmssailor/i18n/`. + +14. **AR-ECDIS es producto separado**, no se desarrolla en este repositorio. Solo se asume que publica al backbone NMEA 2000 datos de navegación y actitud (PGN 127257) que el VMS-Sailor consume. + +15. **Firmware y software van juntos**. El mismo `.vmspack` que configura el Runtime incluye los binarios de firmware para las tarjetas del proyecto. Versionados consistentemente. + +--- + +## 6. Cómo proceder ahora + +**Paso 1.** Confirma que entendiste el alcance global de las 6 partes. Pregunta cualquier duda ANTES de tocar código. + +**Paso 2.** Verifica si existe `D:/Proyectos Software/VMS-Sailor/`. Si está vacía, OK. Si tiene contenido, pregúntame. + +**Paso 3.** Presenta tu plan detallado del **Sprint 0**: lista de archivos que vas a crear, librerías que vas a usar, tests que vas a escribir. Espera mi OK. + +**Paso 4.** Cuando dé OK, ejecutas el Sprint 0 completo. Al final me presentas: +- Repositorio completo con commit `sprint-0` +- Resultado de `pytest` (todo en verde) +- Resultado de `validate_library.py` (todo en verde) +- Script de demo `tools/generate_test_project.py` que crea proyecto de prueba, lo guarda como `.vmsproj` y lo lee de vuelta para verificar integridad + +**Paso 5.** Yo reviso, doy feedback, y cuando apruebo, taggeas `sprint-0-approved` y pasamos al Sprint 1. + +--- + +## 7. Información adicional + +- Mi código LiveCode original de VMS-Sailor: **NO TENGO**. Construimos desde cero. +- Capturas de pantalla de mímicos comerciales (Kongsberg K-Chief, Praxis Mega-Guard, Wärtsilä NACOS) como referencia visual: te los pasaré en mensajes posteriores si hace falta. +- El servidor de licencias para activación es proyecto APARTE que no se construye aquí; solo el cliente de activación dentro del Runtime y dentro del Studio. +- WireGuard se gestiona externamente; solo helpers en Runtime, no embebido. +- Cualquier feature que se te ocurra que NO esté en estas 6 partes, lo discutes conmigo antes. +- El hardware AR-NMEA-IO-v1.0 ya está diseñado (PCB esquemático en archivos del proyecto), no se modifica en este desarrollo. El firmware sí lo escribimos desde cero. + +--- + +**Fin del brief completo (Partes 1-6).** + +Comienza confirmando que entendiste el alcance global, y luego preséntame tu plan del Sprint 0. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..8761bc6 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,84 @@ +# VMS-Sailor — Arquitectura (vista general) + +> One-pager con links a las partes del brief. Para detalles ir a los +> documentos originales (`VMS_Sailor_v2_Parte_*.md`). + +## Producto vertical completo + +``` + ┌──────────────────────────┐ + │ VMS-Sailor Studio │ + │ (PC de Álvaro) │ + │ │ + │ · Biblioteca curada IP │ + │ · Wizard 8 pasos │ + │ · Editor mímicos/tags │ + │ · Compilador .vmspack │ + └─────────────┬────────────┘ + │ .vmspack firmado + │ .vmsdelta firmado + ▼ + ┌─────────────────────────────────────────────────────────────┐ + │ PC INDUSTRIAL DEL BUQUE │ + │ │ + │ ┌──────────────────────┐ ┌──────────────────────┐ │ + │ │ Runtime servidor │ WS │ Runtime cliente │ │ + │ │ · servicio Windows │ ◄────► │ desktop (PySide6) │ │ + │ │ · drivers Modbus │ REST │ puente + máquinas │ │ + │ │ · drivers NMEA 2000 │ └──────────────────────┘ │ + │ │ · alarm engine │ │ + │ │ · permissive engine │ ┌──────────────────────┐ │ + │ │ · authority manager │ ◄────► │ VMS-Sailor Mobile │ │ + │ │ · stability monitor │ WSS │ Flutter (WiFi local)│ │ + │ │ · historian DuckDB │ └──────────────────────┘ │ + │ └──────────┬───────────┘ │ + └──────────────┼──────────────────────────────────────────────┘ + │ Modbus RTU + NMEA 2000 + ┌──────────────┴──────────────────────────────────────────────┐ + │ AR-NMEA-IO-v1.0 (×N tarjetas distribuidas) │ + │ ESP32 + 10 DO + 5 DI + 1 RPM + 4 AI │ + │ · plug-and-produce (config descargada del VMS) │ + │ · OTA seguro firmado │ + │ · alarmas y permissives locales críticos │ + └──────────────┬──────────────────────────────────────────────┘ + │ I/O físico + ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ Motores │ │ Genset │ │ Tanques, │ + │ MTU/Volvo│ │ │ │ bombas...│ + └──────────┘ └──────────┘ └──────────┘ +``` + +## Productos relacionados + +- **AR-ECDIS** (producto separado, no se desarrolla aquí). Publica al + backbone NMEA 2000 los PGNs de navegación y actitud que VMS-Sailor + consume: 127250 Heading, 127251 ROT, 127257 Attitude, 129025/129029 + Position, 129026 COG/SOG, 128259 Speed Water. + +## Componentes por sprint + +| Sprint | Foco | Componente | +|---|---|---| +| 0 | Fundaciones | core + persistencia + biblioteca seed + design system | +| 1-3 | Studio | wizard + editores + biblioteca completa | +| 4-5 | Runtime servidor | drivers + tag store + historian + alarm engine | +| 6 | Runtime cliente | desktop completo | +| 7 | Empaquetado | compilador + MSI + activación HWID | +| 8 | Seguridad operativa | permissives + authority + stability monitor | +| 9-10 | Capas + telemetría | layer config engine + VPN + audit | +| 11 | Mobile | Flutter completo | +| 12-15 | Firmware | Modbus + NMEA 2000 + OTA + hardening | +| 16+ | Refinamiento | logbook regulatorio, integraciones Seakeeper, etc. | + +## Reglas de oro (recordatorio) + +Ver `VMS_Sailor_v2_Parte_06_Sprints_y_reglas.md` sección 5. Las críticas: + +1. Tests obligatorios en `core` y `runtime/server` +2. Sin red de salida en Runtime (salvo activación + VPN admin) +3. Auditoría siempre activa +4. Coordenadas navales `ShipCoord(x_pp, y_cl, z_bl)` siempre +5. Unidades SI internas siempre +6. Idioma español por defecto +7. Runtime inmutable para el cliente (solo deltas firmados) diff --git a/docs/brand/favicon.svg b/docs/brand/favicon.svg new file mode 100644 index 0000000..4f20a4d --- /dev/null +++ b/docs/brand/favicon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/docs/brand/logo-mark.svg b/docs/brand/logo-mark.svg new file mode 100644 index 0000000..fa4903f --- /dev/null +++ b/docs/brand/logo-mark.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/brand/logo-mono.svg b/docs/brand/logo-mono.svg new file mode 100644 index 0000000..9241eb4 --- /dev/null +++ b/docs/brand/logo-mono.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + VMS · Sailor + VESSEL · MANAGEMENT · SYSTEM + + diff --git a/docs/brand/logo.svg b/docs/brand/logo.svg new file mode 100644 index 0000000..86d2a59 --- /dev/null +++ b/docs/brand/logo.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + VMS + · + Sailor + + VESSEL · MANAGEMENT · SYSTEM + + + diff --git a/docs/coords.md b/docs/coords.md new file mode 100644 index 0000000..e2b209d --- /dev/null +++ b/docs/coords.md @@ -0,0 +1,94 @@ +# Coordenadas navales — convención del proyecto + +> Regla de oro #11: **coordenadas navales consistentes en TODO el código.** + +## Sistema `ShipCoord` + +```python +from vmssailor.core import ShipCoord + +position = ShipCoord(x_pp=10.5, y_cl=-0.9, z_bl=1.4) # metros +``` + +### Ejes + +- **`x_pp`**: distancia desde la **Perpendicular de Popa (Pp)**, positivo hacia **proa**. +- **`y_cl`**: distancia desde la **Línea de Crujía (CL)**, positivo a **estribor**, negativo a **babor**. +- **`z_bl`**: altura sobre la **Línea Base (BL)**, positivo hacia **arriba**. + +### Unidad + +**Metros (SI)** siempre. Sin excepciones internas. La conversión a pies/yardas se hace exclusivamente en renderers de UI cuando el usuario lo pida. + +### Visualización mental + +``` + +y (estribor) + ↑ + │ + │ + Pp │ Proa + (popa)─────────────────┼─────────────────────► +x + │ + │ + │ + ↓ + −y (babor) +``` + +Y la altura: + +``` + +z (arriba, mástil) + ↑ + │ + ───────────────┼────── línea de cubierta + │ + ═══════════════│══════ línea de flotación (aprox z_bl ≈ draft) + │ + ═══════════════│══════ Línea Base (BL = z_bl 0) + │ + ↓ −z (no se usa en práctica) +``` + +## Buques típicos del segmento 30-40 m + +- Eslora total: **20-60 m** → x_pp en [0, 60] +- Manga máxima: **5-15 m** → y_cl en [-7.5, +7.5] +- Calado: 0.5-5 m → z_bl en [0.5, 5] para el casco bajo flotación +- Punto más alto (mástil): hasta z_bl +20 m + +Validators de `ShipCoord` permiten márgenes (x ∈ [-5, 200], y ∈ [-30, +30], z ∈ [-10, +50]) para cubrir buques en desarrollo y errores tipográficos del integrador. + +## Por qué este sistema y no otro + +- **Estándar de la industria naval/arquitectura naval**. La Pp es el origen natural usado en arquitectura, despacho, regulación. +- **Independiente de UI / pantalla.** Los pixels y rotaciones de cámara viven en renderers, no en core. +- **Compatible con NMEA 2000.** El backbone publica posición geodésica (PGN 129025/129029) y actitud (PGN 127257). Para transformar a ShipCoord local del buque, se aplica el lay-out del buque (mounting offset y rotación de la IMU/GPS respecto al origen del buque) registrado en config del proyecto. + +## Reglas para escribir código + +✅ **Sí:** +```python +location = ShipCoord(x_pp=5.5, y_cl=-0.9, z_bl=1.2) +distance = a.distance_to(b) # metros, 3D euclídea +``` + +❌ **No:** +```python +# ¡No! No mezcles coordenadas de pantalla con coordenadas del buque. +location = (mouse_x, mouse_y) + +# ¡No! Las transformaciones a pantalla viven en renderers, no en core. +class ShipCoord: + def to_screen_xy(self, ...): ... # ← esto NO va en core +``` + +## Conversión a pantalla (UI sprints) + +En Sprint 1+ los renderers (QGraphicsView, Flutter Canvas) implementan **una sola** función `ship_to_screen(coord: ShipCoord, viewport) → QPointF` y todo lo demás trabaja en ShipCoord. + +## Tests relevantes + +- `tests/core/test_coords.py` — comportamiento de `ShipCoord` +- `tests/core/test_validation.py::test_equipment_out_of_hull_warning` — chequea que equipos no caigan fuera de la envolvente del buque diff --git a/docs/decisions_sprint0.md b/docs/decisions_sprint0.md new file mode 100644 index 0000000..86c98b4 --- /dev/null +++ b/docs/decisions_sprint0.md @@ -0,0 +1,159 @@ +# Decisiones tomadas autónomamente durante Sprint 0 + +> Bitácora de decisiones que tomé esta noche sin poder consultar a Álvaro. Cada una con su justificación. Revisar al despertar — cualquiera puede revertirse si no convence. + +--- + +## D-1 · Pydantic v2 como base del modelo de datos core + +**Decisión:** todas las entidades de `vmssailor.core` heredan de `pydantic.BaseModel` (v2.x). + +**Alternativas consideradas:** +- `dataclasses` puros — más livianos pero sin validación de runtime +- `attrs` — similar a Pydantic pero menos ecosistema +- Pydantic v1 — viejo, ya no se desarrolla activamente + +**Justificación:** +- Validación automática gratis (rangos, patrones regex, enums) +- Serialización JSON built-in (`model_dump()`, `model_validate()`) — crítica para `.vmsproj` y `.vmspack` +- Cobertura amplia del ecosistema (FastAPI Sprint 4 lo usa nativamente) +- Validators (`@field_validator`, `@model_validator`) son ergonómicos +- `ConfigDict(frozen=True, extra="forbid")` da inmutabilidad y rechazo de campos extra + +**Costo:** ligera penalización de performance en construcción de objetos (no relevante en Sprint 0). + +--- + +## D-2 · SQLite stdlib en vez de SQLAlchemy para `.vmsproj` + +**Decisión:** el writer y reader de `.vmsproj` usan `sqlite3` stdlib directamente, sin ORM. + +**Justificación:** +- `.vmsproj` es un archivo plano portable. No hay queries complejas, joins, relaciones evolucionando con el tiempo. +- SQL crudo es más legible y mantenible para serialización 1:1. +- Sin dependencia extra en Sprint 0. +- SQLAlchemy entrará en Sprint 4 cuando aparezca el Runtime servidor con más tablas dinámicas (historian, audit, alarms log). + +--- + +## D-3 · `hatchling` como build backend (no setuptools) + +**Decisión:** `[build-system]` usa `hatchling`. + +**Justificación:** +- Moderno (PEP 517/518), recomendado por la comunidad Python actual. +- Integra perfecto con `uv`. +- Configuración mínima (`packages = ["vmssailor"]` es suficiente). +- Setuptools es viable pero más verboso. + +--- + +## D-4 · Version PEP 440: `0.1.0.dev0` en pyproject, `SPRINT_TAG = "sprint0"` aparte + +**Decisión:** PEP 440 obliga formato como `0.1.0.dev0`. El "sprint0" que figuraba en el brief lo expuse en `vmssailor.version.SPRINT_TAG` y en el README para que el contexto humano persista. + +**Justificación:** `uv` rechaza versiones no-PEP440, no había alternativa. + +--- + +## D-5 · Roundtrip por `rowid` de SQLite, sin columna `seq` explícita + +**Decisión:** los readers (`vmsproj_reader.py`) hacen `ORDER BY rowid` en lugar de `ORDER BY id`. SQLite asigna rowids incrementales en orden de inserción, preservando el orden de la lista original en memoria. + +**Alternativas consideradas:** +- Agregar columna `seq INTEGER` explícita a cada tabla: más verboso, más cambios. +- Sortear ambos lados por ID al comparar: cambia la semántica del modelo (las listas tendrían orden incidental, no significativo). + +**Justificación:** +- Solución mínima (1 línea por tabla) +- El orden de inserción es exactamente lo que el integrador escribió en el Project — preservarlo es correcto. +- Si en sprints futuros necesitamos re-ordenar (drag & drop en editor), agregamos `seq` en ese momento (migración schema_version v1→v2). + +--- + +## D-6 · `IDs` de tags deterministas `{prefix}.{sensor_id.upper()}` + +**Decisión:** `make_tag_id("ME_PORT", "oil_press")` → `"ME_PORT.OIL_PRESS"`. Patrón regex obliga mayúsculas. + +**Justificación:** +- Determinismo: dado un proyecto, sus tags siempre tienen el mismo ID. Facilita diffs, comparaciones, alarmas configurables por config externa. +- Convención de la industria: tags en mayúsculas con `.` jerárquicos (estilo OPC UA, JESE/IEC 61131-3). +- IDs jerárquicos permiten queries naturales `WHERE id LIKE 'ME_PORT.%'`. + +--- + +## D-7 · Pinout firmware con macros `#define` (no `enum class` C++) + +**Decisión:** `firmware/ar_nmea_io_v1/src/config/pinout.h` usa `#define` clásicos. + +**Justificación:** +- En Sprint 0 no hay todavía decisión PlatformIO vs ESP-IDF (eso es Sprint 12). +- Macros funcionan tanto con Arduino framework como con ESP-IDF. +- Lo importante es que el header sea la ÚNICA fuente de verdad. Cuando se elija toolchain, se puede refactor a `constexpr` o `enum class` si conviene. + +**TODOs documentados en el header:** +- Verificar pines contra esquemático real en Sprint 12. +- Conflictos potenciales (DO3=GPIO16 también es Rx2 UART2; CAN_STBY=GPIO22 también es DO6). + +--- + +## D-8 · Mockups HTML estáticos con CSS variables compartidas + +**Decisión:** los 8 mockups en `docs/mockups/` son HTML+CSS+SVG vanilla. Comparten `_tokens.css` con todos los design tokens. + +**Alternativas consideradas:** +- React/Vue: overkill para mockups estáticos +- Figma export: pierde interactividad y tokens son menos legibles +- Storybook: requiere build pipeline para Sprint 0 + +**Justificación:** +- Cero build, cero dependencias. Abre en cualquier navegador. +- Los design tokens del CSS coinciden 1:1 con los del `design_system.md`. Lo que se vea en mockup, así debe verse en PySide6/Flutter. +- Si quiero animaciones, SVG nativo + CSS keyframes suficiente. + +--- + +## D-9 · Top-level `tools/` con wrappers + `vmssailor/tools/` paquete real + +**Decisión:** los CLIs viven en `vmssailor/tools/` como módulos Python (expuestos por `[project.scripts]` para `uv run`). Hay además `tools/*.py` en la raíz que son wrappers que llaman al paquete via `sys.path` insertion. + +**Justificación:** +- `uv run vms-validate-library` (canónico, profesional) +- `python tools/validate_library.py` (compatible con la estructura que el brief mencionaba en Parte 6) +- Funcionan ambos. Sin duplicación de lógica. + +--- + +## D-10 · Tests con `pytest 9.x` (en lugar de 7.4 sugerido) + +**Decisión:** `uv sync` resolvió a `pytest 9.0.3` (latest). Lo dejé. + +**Justificación:** API estable entre 7.x y 9.x para nuestros casos. Si en Sprint 4+ hay incompatibilidad, se downgradeará puntualmente. + +--- + +## D-11 · Cobertura 95.17% en `vmssailor.core` (criterio era ≥80%) + +No es decisión sino resultado — todas las branches críticas del modelo están cubiertas. Las líneas no cubiertas son mensajes de error legibles o ramas que requerirían escenarios artificiales. + +--- + +## D-12 · `pulse-emergency` y `pulse-warn` como animaciones CSS + +**Decisión:** las alarmas críticas en mockups usan keyframes CSS para pulsar. En PySide6 (Sprint 6) se replicará con `QPropertyAnimation`. + +**Justificación:** La animación de pulso es funcional, no decorativa — llama la atención del operador a una condición crítica. Se cumple con `prefers-reduced-motion` deshabilitando solo en Sprint 6+. + +--- + +## Lo que NO decidí (espera tu OK al despertar) + +- **Stack de UI desktop** (Sprint 1+): asumí PySide6 porque está en el brief, pero confirmar antes de Sprint 1. +- **Toolchain firmware**: deferred a Sprint 12 — no toqué. +- **Servidor de licencias**: deferred a Sprint 7 — sólo dejé activación HWID en el modelo. +- **Estilo definitivo del logo**: hice una propuesta funcional (compass + casco + glow cyan). Si no te gusta, lo rediseñamos. +- **Datasheets reales**: ninguno cargado. Todo `seed_estimate`. Ver `seed_data_notes.md`. + +--- + +**Cualquiera de estas decisiones puede revertirse en una sesión normal de diálogo. La idea era no bloquear el progreso mientras dormías.** diff --git a/docs/design_system.md b/docs/design_system.md new file mode 100644 index 0000000..64a0b0e --- /dev/null +++ b/docs/design_system.md @@ -0,0 +1,369 @@ +# VMS-Sailor — Design System + +> Sistema visual completo del producto. Aplica a Studio (PySide6), Runtime +> cliente desktop (PySide6) y Mobile (Flutter). Los mockups HTML en +> `docs/mockups/` son la **referencia visual canónica** para los sprints +> de UI (Sprint 1, 6, 11). + +--- + +## 1. Filosofía visual + +**"Deep ocean meets technical precision."** + +VMS-Sailor opera 24/7 en cabinas de mando del puente y de la sala de +máquinas. La identidad visual debe transmitir: + +1. **Confianza profesional** — esto no es un toy. Es un sistema crítico + que mueve toneladas y combustible bajo presión. +2. **Calma de mar profundo** — la paleta default es oscura para no + fatigar al operador en turnos largos de noche. +3. **Claridad inequívoca** — colores de alarma estandarizados (cerca de + las prácticas IMO), tipografía técnica para valores. +4. **Personalidad** — sin ser un producto enterprise gris. Detalles de + marca (compás, casco, glow cyan) le dan alma de "navegante". + +**No es:** ni industrial-feo (PLC años 90), ni consumer-juguete (apps +fintech), ni nave-espacial fantasioso (sci-fi). + +**Sí es:** Lufthansa cockpit ↔ Tesla Roadster cabin ↔ B&G Vulcan +plotter. + +--- + +## 2. Paleta — VMS Ocean + +### Modo oscuro (default) + +| Token | Hex | Uso | +|---|---|---| +| `--c-abyss` | `#04111F` | Background base del app, casi negro navy | +| `--c-midnight` | `#0A1A2E` | Cards, paneles | +| `--c-steel` | `#1A2B42` | Bordes, divisores, hover background | +| `--c-iron` | `#2C3E5C` | Bordes activos, foreground muted | +| `--c-fog` | `#7C8B9F` | Texto secundario, iconos inactivos | +| `--c-sand` | `#E6EAF0` | Texto primario sobre dark | +| `--c-foam` | `#F2F5F9` | Texto alta jerarquía, headlines | +| `--c-cyan` | `#00D9FF` | **Accent primario** — marca, links, actividad | +| `--c-cyan-deep` | `#1B7FB5` | Cyan profundo para gradientes | +| `--c-horizon` | `#5BC0EB` | Accent secundario, gauges | + +### Estado / semántico + +| Token | Hex | Uso | +|---|---|---| +| `--c-ok` | `#00E08A` | Estados OK, valores en rango | +| `--c-info` | `#5BC0EB` | Alarma INFO | +| `--c-warn` | `#FFB020` | Alarma LOW + WARNING (ámbar IMO) | +| `--c-high` | `#FF8030` | Alarma HIGH (naranja escalada) | +| `--c-emergency` | `#FF3B47` | Alarma EMERGENCY (rojo SOS) | +| `--c-emergency-deep` | `#A11220` | Reset emergencia hover/active | + +### Modo claro (alternativo "outdoor") + +Sólo para uso bajo sol fuerte en cubierta. Mismos accent colors, fondos +invertidos. + +| Token | Hex | Uso | +|---|---|---| +| `--c-foam-light` | `#FFFFFF` | Background base | +| `--c-sand-light` | `#F2F5F9` | Cards | +| `--c-iron-light` | `#D1D8E0` | Bordes | +| `--c-abyss-light` | `#04111F` | Texto primario | +| `--c-fog-light` | `#5A6B7F` | Texto secundario | + +--- + +## 3. Gradientes + +| Token | CSS | Uso | +|---|---|---| +| `--g-deep-sea` | `linear-gradient(135deg, #04111F 0%, #0A1A2E 60%, #1A2B42 100%)` | Background app | +| `--g-horizon` | `linear-gradient(180deg, #04111F 0%, #1B3E6E 60%, #3A6BA8 90%, #5BC0EB 100%)` | Hero, splash | +| `--g-cyan-glow` | `radial-gradient(circle at 50% 50%, rgba(0,217,255,0.4) 0%, transparent 70%)` | Aura tras logo | +| `--g-glass` | `linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02))` | Glassmorphism cards | +| `--g-glass-edge` | `linear-gradient(135deg, rgba(255,255,255,0.18), rgba(255,255,255,0.02))` | Borde 1px de glass | +| `--g-emergency` | `linear-gradient(135deg, #FF3B47, #A11220)` | Botón emergency | +| `--g-cyan` | `linear-gradient(135deg, #00D9FF 0%, #5BC0EB 50%, #1B7FB5 100%)` | Logo, accent fill | + +--- + +## 4. Tipografía + +### Familia + +| Rol | Familia | Fallback | +|---|---|---| +| Display (H1, splash, branding) | **Space Grotesk** | `Inter, system-ui, sans-serif` | +| UI / cuerpo | **Inter** | `system-ui, -apple-system, "Segoe UI", Roboto, sans-serif` | +| Valores numéricos / mono | **JetBrains Mono** | `"Cascadia Mono", Consolas, "Courier New", monospace` | + +### Escalas (modo dark, sobre `--c-abyss`) + +| Token | Size | Line-height | Weight | Letter-spacing | Color | +|---|---|---|---|---|---| +| `--t-display-xl` | 56px | 1.05 | 700 | -1.5px | `--c-foam` | +| `--t-display-lg` | 40px | 1.1 | 700 | -1.0px | `--c-foam` | +| `--t-h1` | 32px | 1.2 | 600 | -0.5px | `--c-foam` | +| `--t-h2` | 24px | 1.25 | 600 | -0.3px | `--c-foam` | +| `--t-h3` | 18px | 1.35 | 600 | -0.2px | `--c-sand` | +| `--t-body-lg` | 16px | 1.5 | 400 | 0 | `--c-sand` | +| `--t-body` | 14px | 1.5 | 400 | 0 | `--c-sand` | +| `--t-caption` | 12px | 1.4 | 500 | 0.4px | `--c-fog` | +| `--t-overline` | 11px | 1.2 | 600 | 2.4px (uppercase) | `--c-fog` | +| `--t-value-xl` | 48px | 1.0 | 600 | -1.5px | `--c-foam` (mono) | +| `--t-value-lg` | 28px | 1.0 | 500 | -0.5px | `--c-foam` (mono) | +| `--t-value` | 16px | 1.0 | 500 | 0 | `--c-sand` (mono) | + +### Tono y estilo del copy + +- **Español por defecto** (regla de oro #13) +- **Verbos en imperativo** para botones: "Acuse recibo", "Reset emergencia" +- **Sin exclamaciones**. Esto es industrial, no Black Friday +- **Valores con unidad SI siempre adyacente**: `87.2 bar`, `1450 rpm` +- **Timestamps en ISO local**: `2026-05-17 03:42:18` + +--- + +## 5. Espaciado + +Sistema base de **4px**. Tokens: + +| Token | px | Uso típico | +|---|---|---| +| `--s-0` | 0 | reset | +| `--s-1` | 4 | gap interno mínimo | +| `--s-2` | 8 | padding small | +| `--s-3` | 12 | gap cards | +| `--s-4` | 16 | padding default | +| `--s-5` | 24 | sección | +| `--s-6` | 32 | bloques | +| `--s-7` | 48 | hero | +| `--s-8` | 64 | grandes layouts | +| `--s-9` | 96 | splash | + +--- + +## 6. Border-radius + +| Token | px | Uso | +|---|---|---| +| `--r-1` | 4 | inputs, chips small | +| `--r-2` | 8 | botones default | +| `--r-3` | 12 | cards | +| `--r-4` | 16 | paneles grandes | +| `--r-5` | 24 | hero, modals | +| `--r-pill` | 9999 | badges, switches | + +--- + +## 7. Sombras y elevación + +5 niveles de elevación + 2 efectos especiales: + +| Token | CSS | +|---|---| +| `--e-1` | `0 1px 3px rgba(0,0,0,0.32)` | +| `--e-2` | `0 4px 12px rgba(0,0,0,0.40)` | +| `--e-3` | `0 8px 24px rgba(0,0,0,0.50)` | +| `--e-4` | `0 16px 48px rgba(0,0,0,0.60)` | +| `--e-5` (modal) | `0 32px 80px rgba(0,0,0,0.70)` | +| `--glow-cyan` | `0 0 24px rgba(0,217,255,0.45)` | +| `--glow-warn` | `0 0 24px rgba(255,176,32,0.45)` | +| `--glow-emergency` | `0 0 32px rgba(255,59,71,0.55)` | +| `--inner-stroke` | `inset 0 0 0 1px rgba(255,255,255,0.06)` | + +--- + +## 8. Glassmorphism + +Para paneles superpuestos (notificaciones, dropdowns, modales sobre +mímicos): + +```css +background: var(--g-glass); +border: 1px solid rgba(255,255,255,0.08); +backdrop-filter: blur(16px) saturate(1.4); +box-shadow: var(--e-3), var(--inner-stroke); +``` + +**Uso prudente.** No abusar — los mímicos críticos deben ser sólidos +para lecturabilidad. + +--- + +## 9. Iconografía + +- **Estilo**: 2px stroke, esquinas redondeadas (1px radius), 24×24px + canvas base +- **Familia base**: Lucide / Phosphor (libres). Custom para símbolos + navales (válvulas ISO 14617, motor, bomba, intercambiador, etc.) +- **Color**: `currentColor` para que herede del contexto +- **Inactivos**: opacity 0.5 + +--- + +## 10. Componentes clave + +### Botones + +| Variante | Background | Border | Color | Sombra | +|---|---|---|---|---| +| Primary | `--g-cyan` | none | `#04111F` | `--glow-cyan` | +| Secondary | transparent | `1px solid --c-iron` | `--c-sand` | none | +| Ghost | transparent | none | `--c-cyan` | none | +| Danger | `--g-emergency` | none | `--c-foam` | `--glow-emergency` | +| Disabled | `--c-steel` | none | `--c-fog` | none | + +Padding: `12px 20px` default. Min-width 96px. Border-radius `--r-2`. + +### Cards / Paneles + +```css +background: var(--c-midnight); +border: 1px solid var(--c-steel); +border-radius: var(--r-3); +padding: var(--s-5); +box-shadow: var(--e-2); +``` + +### Badges de prioridad de alarma + +| Prioridad | Background | Color | Border | +|---|---|---|---| +| EMERGENCY | `#FF3B47` | `#FFFFFF` | inset 0 0 0 1px #FF8090 | +| HIGH | `#FF8030` | `#04111F` | inset 0 0 0 1px #FFA060 | +| LOW | `#FFB020` | `#04111F` | inset 0 0 0 1px #FFCB60 | +| INFO | `#5BC0EB` | `#04111F` | inset 0 0 0 1px #8DDBF2 | + +Forma pill (`border-radius: --r-pill`), uppercase, letter-spacing 0.5px, +padding `4px 10px`. + +### Gauges (visión general) + +- Bar gauge horizontal: ancho variable, alto 8-12px, fondo `--c-steel`, + fill con `--g-cyan` o color de estado +- Arc gauge: 220° arc, stroke 8px, valor central en `--t-value-xl` mono +- Donut: 100% completo, fill por segmentos con colores semánticos + +### Indicador Roll/Pitch (Trim panel) + +- Pantalla negra circular con grilla de horizonte +- Línea de horizonte animada que rota según roll +- Marcas cada 5°, números cada 10° +- Bandas de color: -8°a +8° (`--c-ok`), 8°-12° (`--c-warn`), 12°-18° + (`--c-high`), >18° (`--c-emergency`) +- Glow del color de estado activo + +--- + +## 11. Motion principles + +| Acción | Duration | Easing | +|---|---|---| +| Hover/focus | 120ms | `cubic-bezier(0.4, 0, 0.2, 1)` | +| Entrada de elemento | 240ms | `cubic-bezier(0.0, 0, 0.2, 1)` (ease-out) | +| Salida | 200ms | `cubic-bezier(0.4, 0, 1, 1)` (ease-in) | +| Modal | 280ms | `cubic-bezier(0.4, 0, 0.2, 1)` | +| Alarma crítica entry | 320ms | `cubic-bezier(0.34, 1.56, 0.64, 1)` (bounce sutil) | +| Roll/pitch indicator | live | damped, sin animation (refresh ≤100ms) | +| Pulse de alarma activa | 1200ms | `ease-in-out` infinite | + +**Sin animaciones decorativas en valores críticos.** El RPM no "se anima" +de 1200 a 1450 — salta. La animación introduce delay perceptivo. + +--- + +## 12. Layout patterns + +### Studio shell (Sprint 1) + +``` +┌────────────────────────────────────────────────────────────┐ +│ topbar: logo · proyecto activo · acciones │ 48px +├──────────┬─────────────────────────────────────┬───────────┤ +│ sidebar │ │ inspector │ +│ (sistemas)│ canvas central │ (props) │ +│ 256px │ (silueta + mímico) │ 320px │ +│ │ │ │ +├──────────┴─────────────────────────────────────┴───────────┤ +│ statusbar: cobertura tests · version · sprint │ 32px +└────────────────────────────────────────────────────────────┘ +``` + +### Runtime cliente (Sprint 6) + +``` +┌────────────────────────────────────────────────────────────┐ +│ vessel name · status · alarms badge · authority · user │ 56px +├──────────┬─────────────────────────────────────────────────┤ +│ system │ │ +│ sidebar │ active view: overview | mimic | alarms | │ +│ 240px │ trends | trim | logbook | audit │ +│ │ │ +├──────────┴─────────────────────────────────────────────────┤ +│ ticker alarmas · hora · VPN status │ 32px +└────────────────────────────────────────────────────────────┘ +``` + +### Mobile (Sprint 11) + +- Bottom tab nav: Overview · Mímicos · Alarmas · Trim · Más +- Top bar mínima con nombre del buque y alarma count +- Single-column layout, swipe gestures + +--- + +## 13. Accesibilidad + +- Contraste mínimo WCAG **AA** (4.5:1 texto, 3:1 large). El amber sobre + navy lo cumple. +- Touch targets móviles: **44×44pt iOS / 48×48dp Android** mínimo +- Estados de focus visibles: outline 2px `--c-cyan` + offset 2px +- Color **nunca** como único indicador — siempre acompañado por ícono o + texto + +--- + +## 14. Don't list + +- ❌ Sin "neon glow" excesivo — sólo en logo y elementos de marca +- ❌ Sin emojis en UI — son ambiguos visualmente para alarmas +- ❌ Sin animaciones decorativas en valores críticos +- ❌ Sin gradientes en texto pequeño (lecturabilidad pobre) +- ❌ Sin fuentes serif — no encaja con el carácter técnico +- ❌ Sin scrollbars custom invasivos — respetar plataforma +- ❌ Sin tooltips obligatorios para entender la UI — diseña explícito + +--- + +## 15. Mockups de referencia + +| Archivo | Descripción | +|---|---| +| `docs/mockups/splash.html` | Pantalla de bienvenida con logo + tagline | +| `docs/mockups/studio_main.html` | Studio (Sprint 1) — wizard + canvas | +| `docs/mockups/runtime_overview.html` | Dashboard del buque (Sprint 6) | +| `docs/mockups/runtime_mimic_fuel.html` | Mímico sistema combustible | +| `docs/mockups/runtime_alarms.html` | Panel de alarmas | +| `docs/mockups/runtime_trim.html` | Trim & Maniobra (panel destacado) | +| `docs/mockups/mobile_overview.html` | App móvil overview | +| `docs/mockups/mobile_trim.html` | App móvil trim | + +Todos son HTML+CSS estáticos. Abrir directamente en navegador. + +--- + +## 16. Brand assets (`docs/brand/`) + +| Archivo | Uso | +|---|---| +| `logo.svg` | Horizontal completo (320×80), color | +| `logo-mark.svg` | Solo compass mark (96×96), color | +| `logo-mono.svg` | Horizontal monocromático (`currentColor`) | +| `favicon.svg` | Cuadrado 64×64 con fondo navy | + +--- + +**Versión 1.0 — Sprint 0.** Cambios mayores requieren acuerdo explícito +de Álvaro (es la base visual de todo el producto). diff --git a/docs/mockups/_tokens.css b/docs/mockups/_tokens.css new file mode 100644 index 0000000..fbb023a --- /dev/null +++ b/docs/mockups/_tokens.css @@ -0,0 +1,258 @@ +/* VMS-Sailor Design Tokens - canonical reference for all UI sprints. */ + +:root { + /* ---- Colors: Deep Ocean ---- */ + --c-abyss: #04111F; + --c-midnight: #0A1A2E; + --c-steel: #1A2B42; + --c-iron: #2C3E5C; + --c-fog: #7C8B9F; + --c-sand: #E6EAF0; + --c-foam: #F2F5F9; + --c-cyan: #00D9FF; + --c-cyan-deep: #1B7FB5; + --c-horizon: #5BC0EB; + + /* ---- Semantic ---- */ + --c-ok: #00E08A; + --c-info: #5BC0EB; + --c-warn: #FFB020; + --c-high: #FF8030; + --c-emergency: #FF3B47; + --c-emergency-deep: #A11220; + + /* ---- Gradients ---- */ + --g-deep-sea: linear-gradient(135deg, #04111F 0%, #0A1A2E 60%, #1A2B42 100%); + --g-horizon: linear-gradient(180deg, #04111F 0%, #1B3E6E 60%, #3A6BA8 90%, #5BC0EB 100%); + --g-cyan-glow: radial-gradient(circle at 50% 50%, rgba(0,217,255,0.4) 0%, transparent 70%); + --g-glass: linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02)); + --g-glass-edge: linear-gradient(135deg, rgba(255,255,255,0.18), rgba(255,255,255,0.02)); + --g-emergency: linear-gradient(135deg, #FF3B47, #A11220); + --g-warn: linear-gradient(135deg, #FFB020, #C0760F); + --g-cyan: linear-gradient(135deg, #00D9FF 0%, #5BC0EB 50%, #1B7FB5 100%); + --g-ok: linear-gradient(135deg, #00E08A, #007F4E); + + /* ---- Spacing 4px base ---- */ + --s-1: 4px; + --s-2: 8px; + --s-3: 12px; + --s-4: 16px; + --s-5: 24px; + --s-6: 32px; + --s-7: 48px; + --s-8: 64px; + --s-9: 96px; + + /* ---- Radius ---- */ + --r-1: 4px; + --r-2: 8px; + --r-3: 12px; + --r-4: 16px; + --r-5: 24px; + --r-pill: 9999px; + + /* ---- Elevation ---- */ + --e-1: 0 1px 3px rgba(0,0,0,0.32); + --e-2: 0 4px 12px rgba(0,0,0,0.40); + --e-3: 0 8px 24px rgba(0,0,0,0.50); + --e-4: 0 16px 48px rgba(0,0,0,0.60); + --e-5: 0 32px 80px rgba(0,0,0,0.70); + --glow-cyan: 0 0 24px rgba(0,217,255,0.45); + --glow-warn: 0 0 24px rgba(255,176,32,0.45); + --glow-emergency: 0 0 32px rgba(255,59,71,0.55); + --inner-stroke: inset 0 0 0 1px rgba(255,255,255,0.06); + + /* ---- Typography families ---- */ + --f-display: "Space Grotesk", "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --f-ui: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --f-mono: "JetBrains Mono", "Cascadia Mono", Consolas, "Courier New", monospace; + + /* ---- Motion ---- */ + --ease-standard: cubic-bezier(0.4, 0, 0.2, 1); + --ease-out: cubic-bezier(0.0, 0, 0.2, 1); + --ease-in: cubic-bezier(0.4, 0, 1, 1); + --ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); +} + +/* ---- Reset ---- */ +*, *::before, *::after { box-sizing: border-box; } +html, body { + margin: 0; + padding: 0; + background: var(--g-deep-sea); + color: var(--c-sand); + font-family: var(--f-ui); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + min-height: 100vh; +} +body::before { + /* subtle horizon glow at the top */ + content: ""; + position: fixed; + inset: 0; + background: radial-gradient(ellipse at 20% -20%, rgba(0,217,255,0.10), transparent 50%), + radial-gradient(ellipse at 80% 110%, rgba(91,192,235,0.06), transparent 50%); + pointer-events: none; + z-index: 0; +} +button { font-family: inherit; cursor: pointer; } +a { color: var(--c-cyan); text-decoration: none; } + +/* ---- Utility classes ---- */ +.mono { font-family: var(--f-mono); } +.display { font-family: var(--f-display); } +.overline { + font-size: 11px; + font-weight: 600; + letter-spacing: 2.4px; + text-transform: uppercase; + color: var(--c-fog); +} +.caption { + font-size: 12px; + letter-spacing: 0.4px; + color: var(--c-fog); +} + +/* ---- Cards ---- */ +.card { + background: var(--c-midnight); + border: 1px solid var(--c-steel); + border-radius: var(--r-3); + padding: var(--s-5); + box-shadow: var(--e-2); +} +.card-glass { + background: var(--g-glass); + border: 1px solid rgba(255,255,255,0.08); + border-radius: var(--r-3); + backdrop-filter: blur(16px) saturate(1.4); + -webkit-backdrop-filter: blur(16px) saturate(1.4); + box-shadow: var(--e-3), var(--inner-stroke); + padding: var(--s-5); +} + +/* ---- Buttons ---- */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--s-2); + min-width: 96px; + padding: 12px 20px; + border: none; + border-radius: var(--r-2); + font-size: 14px; + font-weight: 600; + letter-spacing: 0.2px; + transition: transform 120ms var(--ease-standard), + box-shadow 120ms var(--ease-standard), + background 120ms var(--ease-standard); +} +.btn:hover { transform: translateY(-1px); } +.btn:active { transform: translateY(0); } +.btn-primary { + background: var(--g-cyan); + color: #04111F; + box-shadow: var(--glow-cyan); +} +.btn-secondary { + background: transparent; + color: var(--c-sand); + border: 1px solid var(--c-iron); +} +.btn-secondary:hover { background: var(--c-steel); } +.btn-ghost { + background: transparent; + color: var(--c-cyan); + min-width: 0; + padding: 8px 12px; +} +.btn-danger { + background: var(--g-emergency); + color: var(--c-foam); + box-shadow: var(--glow-emergency); +} +.btn-icon { + min-width: 0; + padding: 8px; + width: 36px; + height: 36px; + background: transparent; + border: 1px solid var(--c-iron); + border-radius: var(--r-2); + color: var(--c-sand); +} +.btn-icon:hover { background: var(--c-steel); } + +/* ---- Badges ---- */ +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: var(--r-pill); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.6px; + text-transform: uppercase; +} +.badge-emergency { background: var(--c-emergency); color: #FFFFFF; box-shadow: inset 0 0 0 1px #FF8090; } +.badge-high { background: var(--c-high); color: #04111F; box-shadow: inset 0 0 0 1px #FFA060; } +.badge-low { background: var(--c-warn); color: #04111F; box-shadow: inset 0 0 0 1px #FFCB60; } +.badge-info { background: var(--c-info); color: #04111F; box-shadow: inset 0 0 0 1px #8DDBF2; } +.badge-ok { background: var(--c-ok); color: #04111F; box-shadow: inset 0 0 0 1px #5BEFB8; } +.badge-muted { background: var(--c-steel); color: var(--c-fog); } + +/* ---- Tag (chip) ---- */ +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--c-steel); + border: 1px solid var(--c-iron); + border-radius: var(--r-pill); + font-size: 12px; + color: var(--c-sand); +} + +/* ---- Pulse animation for active critical alarms ---- */ +@keyframes pulseEmergency { + 0%, 100% { box-shadow: var(--glow-emergency); } + 50% { box-shadow: 0 0 48px rgba(255,59,71,0.85); } +} +.pulse-emergency { animation: pulseEmergency 1200ms ease-in-out infinite; } + +@keyframes pulseWarn { + 0%, 100% { box-shadow: var(--glow-warn); } + 50% { box-shadow: 0 0 40px rgba(255,176,32,0.7); } +} +.pulse-warn { animation: pulseWarn 1600ms ease-in-out infinite; } + +/* ---- Dot indicator ---- */ +.dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--c-fog); +} +.dot.ok { background: var(--c-ok); box-shadow: 0 0 8px rgba(0,224,138,0.6); } +.dot.warn { background: var(--c-warn); box-shadow: 0 0 8px rgba(255,176,32,0.6); } +.dot.emergency { + background: var(--c-emergency); + box-shadow: 0 0 12px rgba(255,59,71,0.8); + animation: pulseEmergency 1200ms ease-in-out infinite; +} +.dot.cyan { background: var(--c-cyan); box-shadow: 0 0 8px rgba(0,217,255,0.6); } + +/* ---- App shell common ---- */ +.app-root { + position: relative; + z-index: 1; + min-height: 100vh; +} diff --git a/docs/mockups/index.html b/docs/mockups/index.html new file mode 100644 index 0000000..7141ac6 --- /dev/null +++ b/docs/mockups/index.html @@ -0,0 +1,280 @@ + + + + + + VMS-Sailor · Galería de mockups + + + + + +
+ +
+ VMS-Sailor +

Vessel Management System

+

VMS · Sailor

+

+ Sprint 0 visual reference. 8 mockups HTML estáticos que muestran el aspecto futuro de Studio, Runtime desktop y Mobile. + Mismo design system (tokens en _tokens.css) aplicará en Sprint 1 (PySide6), Sprint 6 (PySide6) y Sprint 11 (Flutter). +

+
+ 99/99 tests · 95% cov + Python 3.11 · uv + Pydantic v2 + SQLite portable +
+
+ +
+ + +
+ SPLASH +
+
+
+

Splash · Bienvenida

+

Pantalla de arranque con logo, olas animadas, loading scan y certificaciones (IEC, IACS, NMEA 2000).

+ Studio + Runtime · todos los sprints +
+
+ + +
+ STUDIO +
+
+
+

Studio · Topología

+

Shell del Studio con wizard 8 pasos, sidebar de sistemas habilitados, canvas con silueta del buque y inspector de tarjetas AR-NMEA-IO.

+ Sprint 1-3 +
+
+ + +
+ RUNTIME · OVERVIEW +
+
+
+

Dashboard del buque

+

Vista general con estado de motores, gensets, tanques, alarmas recientes y horizonte artificial con roll/pitch en vivo.

+ Sprint 6 +
+
+ + +
+ MÍMICO · COMBUSTIBLE +
+
+
+

P&ID animado

+

Mímico del sistema de combustible con flujo animado en tuberías, bomba rotando, tanques con gradiente de nivel.

+ Sprint 3 + 6 +
+
+ + +
+ ALARMAS +
+
+
+

Panel de alarmas

+

Lista cronológica con prioridades IMO (Emergency / High / Low / Info), ACK por alarma, filtros y escalación.

+ Sprint 4 + 6 +
+
+ + +
+ ⭐ TRIM & MANIOBRA +
+
+
+

Trim & Maniobra

+

Pantalla destacada. Horizonte artificial con bandas de seguridad, 4 sliders de trim, predicción de envelope, botón reset emergencia y modo manual owner.

+ Sprint 8 +
+
+ + +
+ MOBILE · OVERVIEW +
+
+
+

Mobile · iOS overview

+

App nativa Flutter con dashboard del buque, motores, horizonte mini y alarmas. WiFi local del buque.

+ Sprint 11 +
+
+ + +
+ MOBILE · TRIM +
+
+
+

Mobile · Trim del owner

+

Panel destacado en móvil. Sliders táctiles grandes, FaceID para modo manual, botón reset emergencia siempre visible.

+ Sprint 11 +
+
+ +
+ +
+ VMS-Sailor v0.1.0.dev0 · Sprint 0 · 2026
+ Propiedad intelectual de Álvaro · Todos los derechos reservados +
+ +
+ + diff --git a/docs/mockups/mobile_overview.html b/docs/mockups/mobile_overview.html new file mode 100644 index 0000000..912d1f7 --- /dev/null +++ b/docs/mockups/mobile_overview.html @@ -0,0 +1,582 @@ + + + + + + VMS-Sailor Mobile · Overview + + + + + + +
+
+ +
+ 03:42 +
+ + +
+
+
+ + + + + +
+ +
+
⚡ Estado general
+
Normal · todo en rango
+
+
Velocidad12.4 kn
+
Rumbo148°
+
Profundidad34 m
+
+
+ +
+
+
Combustible
+
2,840L
+ +
+
+
Baterías
+
27.8V
+ +
+
+
Gen
+
28.4kW
+ +
+
+
Sentinas
+
12%
+ +
+
+ +
+

Máquinas

+
+
+
ME_PORT
+
+ Aceite 4.8 bar + 82°C +
+
+
1,520 rpm
+
+
+
+
ME_STBD
+
+ Aceite 4.9 bar + 81°C +
+
+
1,498 rpm
+
+
+
+
GEN_1
+
+ L1 231 V + 89°C ▲ + 88% +
+
+
1,800 rpm
+
+
+ +
+

Actitud

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
Roll
+
-4.1°
+
+
+
Pitch
+
+1.8°
+
+
+
+ +
+

Alarmas recientes

+
+
+
+
+ Coolant 89°C ▲ + GEN_1.COOLANT_TEMP +
+
+
5m
+
+
+
+
+
+ Bilge 12% + BILGE_MID.LEVEL +
+
+
12m
+
+
+
+
+
+ Resuelto · oil temp 88°C + ME_PORT.OIL_TEMP +
+
+
31m
+
+
+
+ + + +
+
+ +
+ Mockup móvil +

VMS-Sailor Mobile

+

App nativa Flutter para iOS y Android. Solo WiFi local del buque — nunca expuesta a internet. Autenticación TOTP + biométrico del dispositivo.

+
    +
  • Enrollment por QR desde estación principal
  • +
  • Permissives evaluados en el servidor — el móvil es solo UI
  • +
  • Notificaciones push locales (sin Firebase ni APNs externos)
  • +
  • Modo offline degradado con últimos valores cached
  • +
  • Acciones de propulsión solo desde estación fija
  • +
+

+ Sprint 11 · Build iOS via TestFlight · Android APK firmado distribuido directo. +

+
+ + + diff --git a/docs/mockups/mobile_trim.html b/docs/mockups/mobile_trim.html new file mode 100644 index 0000000..bfa2454 --- /dev/null +++ b/docs/mockups/mobile_trim.html @@ -0,0 +1,570 @@ + + + + + + VMS-Sailor Mobile · Trim + + + + + + +
+
+
+ 03:42 +
+ + +
+
+
+ +
+
+ +
+

+ Trim & Maniobra + Authority MÁQUINAS +

+ SAFE +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 10 + 10 + + + 10 + 10 + + + + + + + + + + + + + + + + + + + +
+
+
Roll
+
-4.1°
+
babor
+
+
+
Pitch
+
+1.8°
+
popa abajo
+
+
+
+ +
+

Sliders de trim

+
+
+ ME_PORT +
+12
+ +12% +
+
+ ME_STBD +
+8
+ +8% +
+
+ TAB_PORT +
−30
+ −30% +
+
+ TAB_STBD +
0
+ 0% +
+
+
+ +
+

Safety envelope

+
+
+
+
+ −18° + −12° + −8° + + +8° + +12° + +18° +
+
+ + Roll dentro del rango seguro +
+
+ +
+
+
Modo manual del owner
+
Desactiva L1/L2. Mantiene L3 + envelope ±10°.
+
+ + Requiere FaceID + TOTP +
+
+
+
+ + + +
+ + +
+
+ +
+ Panel destacado +

Trim desde la palma de la mano

+

El owner ajusta el trim desde el flybridge mientras conduce. Botones grandes pensados para manos mojadas y guantes. Touch-targets ≥44pt.

+

El sistema mantiene L3 + envelope ±10° incluso en modo manual. La predicción del envelope bloquea movimientos peligrosos antes de ejecutarse.

+
+ "El RESET es físico en consola + virtual en cada UI. Un toque, sin doble confirmación — es emergencia." +
— Parte 1 sec 8, Reset de Emergencia
+
+
+ + + diff --git a/docs/mockups/runtime_alarms.html b/docs/mockups/runtime_alarms.html new file mode 100644 index 0000000..66b900e --- /dev/null +++ b/docs/mockups/runtime_alarms.html @@ -0,0 +1,490 @@ + + + + + + VMS-Sailor · Panel de alarmas + + + + + +
+ +
+ +
+
+
1Emergency
+
0High
+
2Low
+
1Info
+
+ + +
+ + + +
+
+
+
🚨 Acción inmediata
+
1
+
ME_PORT.OIL_PRESS < 1.5 bar
+
+
+
High
+
0
+
Sin alarmas alta
+
+
+
Low
+
2
+
GEN coolant · BILGE
+
+
+
Info
+
1
+
Shore power offline
+
+
+
MTBF 7 días
+
42 h
+
▲ +6h vs semana ant.
+
+
+ +
+ + + + + + +
+ +
+ +
+ +
+
03:43:01
hace 12 s
+
ME_PORT.OIL_PRESS
Máquina principal
+
+ Presión aceite crítica baja — 1.2 bar + threshold < 1.5 bar · hysteresis 0.2 · delay 2 s +
+
+ EMERGENCY + ⚠ ACTIVE · sin ACK +
+
+ + +
+
+ +
+
03:38:42
hace 5 min
+
GEN_1.COOLANT_TEMP
Generación
+
+ Temperatura refrigerante alta — 89°C + threshold > 88°C · approaching emergency > 92°C +
+
+ LOW + ACTIVE · sin ACK +
+
+ + +
+
+ +
+
03:31:17
hace 12 min
+
BILGE_MID.LEVEL
Sentinas
+
+ Nivel sentina central — 12% + threshold > 10% · verificar pump cycle +
+
+ LOW + ACTIVE · sin ACK +
+
+ + +
+
+ +
+
02:58:30
hace 45 min
+
SHORE_POWER.STATUS
Generación
+
+ Transferencia a generador — desconexión muelle + automatic transfer switch · GEN_1 picked up load +
+
+ INFO + ACTIVE · sin ACK +
+
+ + +
+
+ +
+
03:12:04
hace 31 min
+
ME_PORT.OIL_TEMP
Máquina principal
+
+ Temperatura aceite recuperada — 88°C + cleared at 03:18 · ACK por Carlos · duración 6 min +
+
+ CLEARED + ✓ resolved +
+
+ +
+
+ +
+
02:42:11
hace 1 h
+
GEN_1.VOLTAGE_L1
Generación
+
+ Tensión L1 normalizada — 231 V + cleared at 02:45 · ACK por sistema · duración 3 min +
+
+ CLEARED + ✓ resolved +
+
+ +
+
+ +
+
+ +
+ + ALARMA CRÍTICA SIN ACK · ME_PORT.OIL_PRESS + | + Próxima escalación en 4:48 + + Operador: Álvaro + | + v0.1.0.dev0 +
+ +
+ + diff --git a/docs/mockups/runtime_mimic_fuel.html b/docs/mockups/runtime_mimic_fuel.html new file mode 100644 index 0000000..c93a39f --- /dev/null +++ b/docs/mockups/runtime_mimic_fuel.html @@ -0,0 +1,548 @@ + + + + + + VMS-Sailor · Mímico Combustible + + + + + +
+ +
+ + + 6 tags · 4 OK · 1 watch · 0 alarmas + Autoridad: MÁQUINAS +
+ + +
+
+ + + +
+
+ +
+

Sistema de combustible

+
2 tanques estructurales · 2 motores principales · 1 genset · Diesel marino MDO
+
+ +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + P&ID · FUEL + Rev 1.0 · Sprint 0 + + + + + + + + + TANK_FUEL_1 + Capacidad 3500 L + + + + + 100% + 75% + 50% + 25% + 0% + + + + + + 78% + 2,730 L + + + + + + + + + TANK_FUEL_2 + Capacidad 3500 L + + + + 64% + 2,240 L + + + + + + + + + + + + + + + + + + V_T1_OUT + + + + + + + V_T2_OUT + + + + + + + + + FILTER 30µm + Δp 0.4 bar + + + + + + + + + + + PUMP_MAIN + RUNNING · 2.8 bar + + + + + + + + + + + + + + + + + + + + + + + ME_PORT + 1,520 rpm · 62% + Consumo 14.2 L/h + + + + ME_STBD + 1,498 rpm · 58% + Consumo 13.4 L/h + + + + GEN_1 + 1,800 rpm · 88% + Consumo 16.3 L/h + + + + + + + + + + + + + + + + + + + DAY TANK + 64% + + + + + + + RETURN LINE + + + + + 2.8 + bar + + + + + + + CONSUMO TOTAL ÚLTIMA HORA + 43.9 L/h + + + ME_PORT 14.2 + ME_STBD 13.4 + GEN_1 16.3 + + Autonomía a régimen: 113 h + + + + + + + + + i + + + +
+ Válvula abierta + Válvula cerrada + Flujo activo + Retorno + Info / watch +
+
+ + + +
+ + WebSocket LIVE + | + 187 tags activos + | + 2 alarmas + + v0.1.0.dev0 · Sprint 0 +
+ +
+ + diff --git a/docs/mockups/runtime_overview.html b/docs/mockups/runtime_overview.html new file mode 100644 index 0000000..b96d2f2 --- /dev/null +++ b/docs/mockups/runtime_overview.html @@ -0,0 +1,774 @@ + + + + + + VMS-Sailor Runtime · M/Y Aurora · Overview + + + + + +
+ +
+
+ +
+

M/Y Aurora

+
Sunseeker 76 · 23.4 m
+
+
+ + Normal · todo en rango + + + + + 2 alarmas activas + + + + Autoridad: PUENTE + + + A + Álvaro · Admin + +
+ + + +
+
+
+

Estado general del buque

+
Última actualización: hace 1.2 s · Sin desconexiones
+
+
+
03:42:18
+
2026-05-17 · UTC-04
+
+
+ +
+ +
+
+ Combustible + +
+
2,840L
+
▼ 18 L/h consumo medio
+
+ +
+
+ Generación + +
+
28.4kW
+
GEN_1 · 55% carga
+
+ +
+
+ Baterías + +
+
27.8V
+
▲ Cargando 12 A
+
+ +
+
+ Sentinas + +
+
12%
+
BILGE_MID en watch
+
+ + +
+
+ Máquina principal · 2× MTU 12V 2000 M96 + Ver mímico → +
+
+ +
+
+
ME_PORT
+ RUNNING +
+
+
+
+ Aceite 4.8 bar + Coolant 82°C + Carga 62% + Horas 1,284 +
+
+
1,520 rpm
+
+ +
+
+
ME_STBD
+ RUNNING +
+
+
+
+ Aceite 4.9 bar + Coolant 81°C + Carga 58% + Horas 1,287 +
+
+
1,498 rpm
+
+ +
+
+
GEN_1
+ RUNNING +
+
+
+
+ L1 231 V + Coolant 89°C + Carga 88% + Horas 3,418 +
+
+
1,800 rpm
+
+
+
+ + +
+
+ Actitud (NMEA 2000 · PGN 127257) + SAFE +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + 10° + + 10° + + + + + + + + + + + + + + + + +
+ +
+
+
Roll
+
-4.1°
+
+
+
Pitch
+
+1.8°
+
+
+ +
+ Envelope ±10° + L3 trigger 18° +
+
+ + +
+
+ Tanques estructurales + Detalle → +
+
+
+
+
+
+
78%
+
FUEL 1
+
+
+
+
+
+
64%
+
FUEL 2
+
+
+
+
+
+
91%
+
WATER
+
+
+
+
+
+
12%
+
BILGE
+
+
+
+
+
+
28%
+
BLACK
+
+
+
+ + +
+
+ Alarmas recientes + Ver todas (2) → +
+
+
+ 03:38:42 +
+ GEN_1.COOLANT_TEMP + 89°C alta — aproximando límite 92°C +
+ LOW +
+
+ 03:31:17 +
+ BILGE_MID.LEVEL + Nivel 12% — verificar bomba +
+ INFO +
+
+ 03:12:04 +
+ ME_PORT.OIL_TEMP + Recuperado a 88°C · resolved +
+ CLEARED +
+
+ 02:58:30 +
+ SHORE_POWER.STATUS + Transferencia a gen — desconexión muelle +
+ CLEARED +
+
+
+
+
+ +
+ + WebSocket LIVE + | + Latencia 42 ms + | + Driver Modbus RTU OK + | + Driver NMEA 2000 OK + | + Tags activos 187 + + VPN soporte INACTIVA + | + Telemetría activa (visible) + | + v0.1.0.dev0 +
+ +
+ + diff --git a/docs/mockups/runtime_trim.html b/docs/mockups/runtime_trim.html new file mode 100644 index 0000000..a7624d5 --- /dev/null +++ b/docs/mockups/runtime_trim.html @@ -0,0 +1,763 @@ + + + + + + VMS-Sailor · Trim & Maniobra + + + + + +
+ +
+ +
+ + ENVELOPE ACTIVO · ±10° + + Roll Safety L1 monitor + +
+ +
+ + +
+
+

Actitud del buque

+ NMEA 2000 · PGN 127257 · 10 Hz · ←AR-ECDIS +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + 10 + 10 + + + + + + + + + + + + + + + + + + 10 + 10 + + + 20 + 20 + + 10 + 10 + + + 20 + 20 + + + + + + + + + + + + + + + + + + + + + + + + + + + + SAFE · L1 + + +
+ +
+
+
Roll
+
-4.1°
+
babor · estable
+
+
+
Pitch
+
+1.8°
+
popa abajo
+
+
+ +
+
+
L1
+
Warning
+
> 8° / 10 s
+
+
+
L2
+
Auto-offer
+
> 12° / 5 s
+
+
+
L3
+
Forced reset
+
> 18°
+
+
+
+ + +
+
+

Control de trim

+ 4 actuadores · authority MÁQUINAS +
+ +
+
+ ME_PORT +
+
+12
+
+ +12% + −100 / +100 +
+ + +
+
+ +
+ ME_STBD +
+
+8
+
+ +8% + −100 / +100 +
+ + +
+
+ +
+ TAB_PORT +
+
−30
+
+ −30% + −100 / +100 +
+ + +
+
+ +
+ TAB_STBD +
+
0
+
+ 0% + −100 / +100 +
+ + +
+
+
+ +
+
⚲ Predicción safety envelope
+
+ Mover TAB_PORT a −60% llevaría a roll estimado + −9.2°. Está dentro del envelope (±10°). Acción + permitida. +
+
+
+ + +
+
+

Reset emergencia

+ cableado físico + virtual +
+ + + +
+
+
Modo manual del owner
+
Desactiva L1/L2 · mantiene L3 + envelope ±10°
+
+
+
+ +
+
Safety envelope (predicción)
+
+
+
+
+ −18° + −12° + −8° + + +8° + +12° + +18° +
+
✓ Roll actual dentro del rango seguro (verde)
+
+ +
+
Eventos de seguridad recientes
+
+
02:14 · L1 warning · roll 9.2° / 6 s · auto cleared
+
01:33 · L1 warning · roll 8.4° / 11 s · ACK Carlos
+
23:51 · L3 reset forzado · roll 19.1° · auto-reset · ✓
+
+
+
+ +
+ +
+ + Roll -4.1° + | + Pitch +1.8° + | + Rate 0.3 deg/s + | + Predicción envelope SAFE + + Calibración modelo trim: v1 (mar pruebas 2026-03) + | + v0.1.0.dev0 +
+ +
+ + diff --git a/docs/mockups/splash.html b/docs/mockups/splash.html new file mode 100644 index 0000000..722c8fc --- /dev/null +++ b/docs/mockups/splash.html @@ -0,0 +1,187 @@ + + + + + + VMS-Sailor · Splash + + + + + +
+
+ +
+ +

VMS · Sailor

+

Vessel Management System

+ +
+ v0.1.0 sprint0 + | + IEC 60092-504 + | + IACS UR E22 + | + NMEA 2000 +
+ +
+
+ +
+
+ + + +
+
+ + + +
+
+ + +
+ + diff --git a/docs/mockups/studio_main.html b/docs/mockups/studio_main.html new file mode 100644 index 0000000..8688294 --- /dev/null +++ b/docs/mockups/studio_main.html @@ -0,0 +1,442 @@ + + + + + + VMS-Sailor Studio · Mockup Sprint 1 + + + + + +
+ +
+ VMS-Sailor + +
+
+ Sin cambios sin guardar + + + + +
+
+ + + +
+
+ +
+ + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + x_pp 0 + x_pp 23.5 m + + + + + + + ME_PORT + + + + ME_STBD + + + GEN_1 + + + TANK_FUEL_1 + + + TANK_FUEL_2 + + + BILGE_AFT + + + BILGE_MID + + + BILGE_FWD + + + + + + CARD_001 · slot 1 · addr 1 + + +
+
+ + + + + +
+ + diff --git a/docs/seed_data_notes.md b/docs/seed_data_notes.md new file mode 100644 index 0000000..22c5952 --- /dev/null +++ b/docs/seed_data_notes.md @@ -0,0 +1,55 @@ +# Notas sobre la biblioteca seed — Sprint 0 + +> Todos los archivos de `vmssailor/library/vessels/` y `vmssailor/library/equipment/` están marcados con `data_source: "seed_estimate"`. Antes de Sprint 1, **Álvaro debe revisar y validar contra datasheets oficiales**. + +## Filosofía + +Para no bloquear el Sprint 0 esperando datasheets de fabricantes, todas las entradas de biblioteca seed traen: + +- Valores plausibles tomados de información pública (catálogos de fabricantes, especificaciones técnicas accesibles) +- `data_source: "seed_estimate"` como bandera explícita +- Estructura completa de campos para no requerir migraciones tras validación + +El validador `vms-validate-library` emite **INFO** (no error, no warning) por cada entrada `seed_estimate` para que el integrador sepa exactamente qué revisar. + +## Entradas a validar + +### Vessels + +| ID | Modelo | Campos críticos a verificar | +|---|---|---| +| `sunseeker_76` | Sunseeker 76 Yacht | LOA, beam, draft, displacement, bulkhead positions, deck heights | +| `ferretti_850` | Ferretti 850 | Idem | + +Específicamente, las **posiciones de mamparos** (`bulkheads[].x_pp`) y las **alturas de cubierta** (`decks[].z_bl_bottom`, `z_bl_top`) son **estimaciones** — el integrador real medirá esto en el GA del buque. + +### Equipment models + +| ID | Modelo | Campos a verificar | +|---|---|---| +| `mtu_12v_2000_m96` | MTU 12V 2000 M96 | power_kw, rpm_nominal, weight_kg, dimensions, fuel_consumption_lph, **sensores específicos** y sus rangos / alarmas | +| `volvo_d13_900hp` | Volvo Penta D13-900 | Idem | +| `northern_lights_m65c13` | Northern Lights M65C13 | power_kw, voltaje nominal (¿230V o 120/240V split?), corriente, rpm, **sensores y rangos** | + +### Sensores típicos (`default_sensors`) + +Para cada `EquipmentModel`, los sensores listados con sus rangos normales y umbrales de alarma son **plantillas** sugeridas. Cada buque real puede tener: + +- Sensores adicionales no contemplados (RTDs específicos, sondas en lugares no estándar) +- Sensores omitidos por costo +- Umbrales ajustados según armador / sociedad de clasificación + +En Sprint 1 (Studio wizard paso 6) el integrador refina los sensores reales para cada equipo concreto. + +### Reglas heurísticas + +`rules/yacht_motor_planeo.yaml` contiene la heurística inicial de Álvaro. Es esperable que la afine con experiencia de campo. La rule_engine de Sprint 2 las consume. + +## Cómo validar al despertar + +1. Ejecutar `uv run vms-validate-library --verbose` para ver la lista de INFOs +2. Para cada modelo, abrir su JSON y comparar contra el datasheet oficial: + - Ajustar valores + - Cambiar `data_source` a `"manufacturer_datasheet"` cuando esté verificado +3. Correr `uv run pytest tests/library/` para que sigan los tests verdes +4. Si cambia algún rango o umbral de alarma, actualizar también las reglas heurísticas si afectan diff --git a/firmware/ar_nmea_io_v1/README.md b/firmware/ar_nmea_io_v1/README.md new file mode 100644 index 0000000..0a71143 --- /dev/null +++ b/firmware/ar_nmea_io_v1/README.md @@ -0,0 +1,27 @@ +# AR-NMEA-IO-v1.0 firmware + +> Hardware: tarjeta I/O distribuida diseñada por Álvaro. ESP32-DOWD + RS485 + CAN/NMEA 2000 + 21 puntos I/O. + +**Sprint 0 (este commit):** sólo `src/config/pinout.h` con las macros de GPIO. +**Sprint 12:** implementación completa (Modbus RTU esclava + discovery + filtros locales). +**Sprint 13:** NMEA 2000 publishing + J1939 listening. +**Sprint 14:** OTA seguro + permissives locales + alarmas locales. +**Sprint 15:** pruebas integradas + hardening. + +Detalle completo en `../../VMS_Sailor_v2_Parte_04_Hardware_Firmware.md`. + +## Toolchain (cuando arranque Sprint 12) + +- PlatformIO O ESP-IDF (decisión pendiente en Sprint 12) +- C/C++ +- Tests: Unity + PlatformIO test runner + +## Filosofía clave + +**Una sola SKU. Un solo firmware base. Plug-and-produce.** + +La tarjeta sale de fábrica con firmware idéntico. Su rol y configuración +(esclava N, qué hace cada puerto, filtros, permissives locales, alarmas +locales) se descargan del VMS al conectarse al bus por primera vez. + +Patrón estándar industrial (Beckhoff EtherCAT, Wago I/O System). diff --git a/firmware/ar_nmea_io_v1/src/config/pinout.h b/firmware/ar_nmea_io_v1/src/config/pinout.h new file mode 100644 index 0000000..86a8904 --- /dev/null +++ b/firmware/ar_nmea_io_v1/src/config/pinout.h @@ -0,0 +1,183 @@ +/* + * AR-NMEA-IO-v1.0 — pinout.h + * + * Header de configuración de pines GPIO para la tarjeta I/O distribuida + * del producto VMS-Sailor. Hardware diseñado por Álvaro. + * + * Este header es la ÚNICA fuente de verdad para la asignación de GPIOs. + * Sprint 0 sólo define las macros — el firmware funcional viene en Sprint 12. + * + * Referencias: + * - VMS_Sailor_v2_Parte_04_Hardware_Firmware.md sec 1 + * - Esquemático PCB AR-NMEA-IO-v1.0 (en archivos del proyecto) + * + * Capacidades por tarjeta (Parte 4 sec 1): + * - 10 DO (MOSFET IRLML6344TRPBF + opto PC817 + diodo flyback SS14) + * - 5 DI (opto PC817, contacto o 24VDC, R-limit 4.7kΩ) + * - 1 RPM (opto, pickup magnético / tacómetro / inductivo) + * - 4 AI (divisor + filtro V3061, ADC ESP32 12 bits + oversampling) + * - 1 RS485 (SN65HVD1781) + * - 1 CAN/NMEA 2000 (MCP2562T-E_MF) + * - 1 WiFi (integrado ESP32) + * - 1 USB (programación inicial CH340/CP2102) + * + * ATENCIÓN — restricciones del ESP32: + * - GPIO0, GPIO2, GPIO5, GPIO12, GPIO15 son boot strapping. Cuidar + * niveles al arranque para no romper boot sequence. + * - GPIO34, GPIO35, GPIO36, GPIO39 son input-only (sin pull-up/down + * internos, sin output). Usar sólo para AI / DI. + * - GPIO6-GPIO11 conectados a la SPI flash interna — NO usar. + * + * NOTA SPRINT 0: los pines exactos están sujetos a verificación contra + * el esquemático real durante Sprint 12. Si hay discrepancias, ESTE + * archivo manda; el firmware se debe ajustar a estos defines. + */ + +#ifndef VMSSAILOR_AR_NMEA_IO_PINOUT_H +#define VMSSAILOR_AR_NMEA_IO_PINOUT_H + +/* ===================================================================== */ +/* Identidad del hardware */ +/* ===================================================================== */ + +#define AR_NMEA_IO_HW_VERSION "1.0" +#define AR_NMEA_IO_BOARD_NAME "AR-NMEA-IO-v1.0" + +#define CAP_DO_COUNT 10 +#define CAP_DI_COUNT 5 +#define CAP_RPM_COUNT 1 +#define CAP_AI_COUNT 4 +#define TOTAL_IO_POINTS (CAP_DO_COUNT + CAP_DI_COUNT + CAP_RPM_COUNT + CAP_AI_COUNT) +/* = 20 + 1 frecuencia = 21 (Parte 4 sec 1) */ + +/* ===================================================================== */ +/* Salidas digitales (DO) — MOSFET IRLML6344 a través de opto PC817 */ +/* ===================================================================== */ +/* Provisional. Cuidado con boot-straps: DO1=GPIO15, DO9=GPIO5 son STRAP. */ +/* Firmware debe forzar OFF (estado seguro) antes de subir WiFi/CAN. */ + +#define DO1_PIN 15 +#define DO2_PIN 17 +#define DO3_PIN 16 /* atento: Rx2 si UART2 activo */ +#define DO4_PIN 18 +#define DO5_PIN 19 +#define DO6_PIN 22 +#define DO7_PIN 25 +#define DO8_PIN 26 +#define DO9_PIN 27 +#define DO10_PIN 5 /* boot-strap */ + +#define DO_ACTIVE_HIGH 1 +#define DO_INITIAL_STATE 0 /* safe state on boot = off */ + +/* ===================================================================== */ +/* Entradas digitales (DI) — opto PC817 */ +/* ===================================================================== */ + +#define DI1_PIN 13 +#define DI2_PIN 12 /* boot-strap si HIGH al boot fuerza 1.8V flash */ +#define DI3_PIN 14 +#define DI4_PIN 32 +#define DI5_PIN 33 + +#define DI_DEBOUNCE_MS 25 + +/* ===================================================================== */ +/* Entrada de frecuencia (RPM) — pickup magnético / tacómetro / inductivo */ +/* ===================================================================== */ + +#define RPM1_PIN 4 +#define RPM_MAX_FREQ_HZ 5000 /* margen amplio */ +#define RPM_FILTER_DEFAULT FILTER_MOVING_AVG + +/* ===================================================================== */ +/* Entradas analógicas (AI) — ADC ESP32 12 bits + oversampling */ +/* ===================================================================== */ +/* Todos son ADC1 (ADC2 conflicta con WiFi). Input-only OK aquí. */ + +#define AI1_PIN 34 /* SPARE / BAT divider */ +#define AI2_PIN 35 /* OIL_PRESS divider */ +#define AI3_PIN 36 /* WATER_TEMP sensor */ +#define AI4_PIN 39 /* SPARE2 / RTD bias */ + +#define AI_RESOLUTION_BITS 12 +#define AI_OVERSAMPLE_FACTOR 8 +#define AI_VREF_MV 3300 +#define AI_FILTER_DEFAULT FILTER_MOVING_AVG +#define AI_DEFAULT_UPDATE_RATE_MS 200 + +/* ===================================================================== */ +/* RS485 — SN65HVD1781 (Modbus RTU) */ +/* ===================================================================== */ + +#define RS485_UART_NUM 1 +#define RS485_TX_PIN 10 +#define RS485_RX_PIN 9 +#define RS485_DE_PIN 2 /* boot-strap, pull-down externo */ +#define RS485_DEFAULT_BAUD 115200 +#define RS485_DEFAULT_PARITY 'N' +#define RS485_DEFAULT_STOP_BITS 1 + +/* ===================================================================== */ +/* CAN / NMEA 2000 — MCP2562T-E_MF */ +/* ===================================================================== */ + +#define CAN_TX_PIN 21 +#define CAN_RX_PIN 23 +#define CAN_STBY_PIN 22 /* compartido con DO6 — revisar mux en schematic */ +#define CAN_DEFAULT_BITRATE_HZ 250000 /* NMEA 2000 estándar */ + +/* ===================================================================== */ +/* Dipswitches físicos para slot number (1-16) */ +/* ===================================================================== */ +/* 4 dipswitches binarios. Se leen UNA vez al boot, no en bucle. */ +/* TODO Sprint 12: revisar contra schematic — provisional. */ + +#define DIPSW_BIT0_PIN 0 /* boot-strap! pull-up externo */ +#define DIPSW_BIT1_PIN 3 /* RX0 — desconectar USB serial al booteo */ +#define DIPSW_BIT2_PIN 1 /* TX0 — idem */ +#define DIPSW_BIT3_PIN 7 /* atención: SPI flash CMD si presente */ + +/* ===================================================================== */ +/* LED de estado (firmware health) */ +/* ===================================================================== */ + +#define LED_STATUS_PIN 2 /* coincide con onboard LED del DevKit */ +#define LED_BLINK_OK_HZ 1 +#define LED_BLINK_ERROR_HZ 5 + +/* ===================================================================== */ +/* Power monitoring (opcional) */ +/* ===================================================================== */ + +#define VIN_MONITOR_PIN AI1_PIN +#define VIN_DIVIDER_RATIO (1.0f / 11.0f) /* 10k+1k típico */ +#define VIN_NOMINAL_V 12.0f +#define VIN_LOW_THRESHOLD_V 10.5f +#define VIN_HIGH_THRESHOLD_V 15.0f + +/* ===================================================================== */ +/* Watchdog y timing */ +/* ===================================================================== */ + +#define WDT_TIMEOUT_S 30 +#define HEALTH_BEACON_PERIOD_S 60 +#define DISCOVERY_RETRY_S 30 + +/* ===================================================================== */ +/* Capacidades reportadas en discovery broadcast */ +/* ===================================================================== */ +/* Se envían al maestro Modbus en el mensaje function-code 65 */ +/* (DISCOVERY_BROADCAST) al arranque (Parte 4 sec 4). */ + +#define DISCOVERY_FC 65 +#define CONFIG_PUSH_FC 66 +#define CONFIG_ACK_FC 67 +#define HEALTH_BEACON_FC 68 +#define OTA_BEGIN_FC 69 +#define OTA_CHUNK_FC 70 +#define OTA_END_FC 71 +#define LOCAL_ALARM_FC 72 +#define LOCAL_PERMISSIVE_FC 73 + +#endif /* VMSSAILOR_AR_NMEA_IO_PINOUT_H */ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..40da37e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,129 @@ +[project] +name = "vmssailor" +version = "0.1.0.dev0" +description = "VMS-Sailor — Vessel Management System integrado (IAS) para buques 30-40m. Studio + Runtime + Mobile + Firmware." +readme = "README.md" +requires-python = ">=3.11,<3.12" +license = { text = "Proprietary — Álvaro" } +authors = [ + { name = "Álvaro (Aerom)" } +] +keywords = ["marine", "vessel-management", "ias", "automation", "nmea2000", "modbus", "ecdis"] + +dependencies = [ + "pydantic>=2.5,<3.0", + "pyyaml>=6.0", + "python-dateutil>=2.8", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.4", + "pytest-cov>=4.1", + "pytest-asyncio>=0.23", + "ruff>=0.4.0", + "mypy>=1.10", + "types-PyYAML", + "types-python-dateutil", +] + +[project.scripts] +vms-validate-library = "vmssailor.tools.validate_library:main" +vms-generate-test-project = "vmssailor.tools.generate_test_project:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["vmssailor"] + +# ---------- ruff ---------- +[tool.ruff] +line-length = 100 +target-version = "py311" +extend-exclude = ["projects", "firmware", "mobile", "docs/mockups"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # bugbear + "C4", # comprehensions + "UP", # pyupgrade + "SIM", # simplify + "RUF", # ruff-specific +] +ignore = [ + "E501", # handled by formatter +] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["B011"] +"tools/**" = ["E402"] + +[tool.ruff.format] +quote-style = "double" + +# ---------- mypy ---------- +[tool.mypy] +python_version = "3.11" +strict = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_return_any = true +warn_unreachable = true +exclude = ["^build/", "^dist/", "^projects/", "^firmware/", "^mobile/", "^docs/"] + +[[tool.mypy.overrides]] +module = ["tests.*", "tools.*"] +disallow_untyped_defs = false + +[[tool.mypy.overrides]] +module = ["yaml.*", "dateutil.*"] +ignore_missing_imports = true + +# ---------- pytest ---------- +[tool.pytest.ini_options] +minversion = "7.4" +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "-ra", + "--strict-markers", + "--strict-config", + "--tb=short", +] +markers = [ + "slow: marks tests as slow", + "integration: cross-module integration tests", +] +asyncio_mode = "auto" + +[tool.coverage.run] +source = ["vmssailor"] +omit = [ + "vmssailor/studio/*", + "vmssailor/runtime/*", + "vmssailor/__init__.py", + "vmssailor/version.py", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] +fail_under = 80 +show_missing = true diff --git a/runtime_client_main.py b/runtime_client_main.py new file mode 100644 index 0000000..608255d --- /dev/null +++ b/runtime_client_main.py @@ -0,0 +1,18 @@ +"""Entry point del cliente desktop Runtime (stub Sprint 0). + +Sprint 6 lo reemplaza con `vmssailor.runtime.client.app:main`. +""" + +from __future__ import annotations + +import sys + + +def main() -> int: + print("VMS-Sailor Runtime cliente — Sprint 6 trae la UI PySide6.") + print("En Sprint 0 solo existe el modelo de datos core.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/runtime_server_main.py b/runtime_server_main.py new file mode 100644 index 0000000..d882d74 --- /dev/null +++ b/runtime_server_main.py @@ -0,0 +1,18 @@ +"""Entry point del servicio Runtime (stub Sprint 0). + +Sprint 4 lo reemplaza con `vmssailor.runtime.server.service:main`. +""" + +from __future__ import annotations + +import sys + + +def main() -> int: + print("VMS-Sailor Runtime servidor — Sprint 4 trae el servicio Windows.") + print("En Sprint 0 solo existe el modelo de datos core.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/studio_main.py b/studio_main.py new file mode 100644 index 0000000..29098ed --- /dev/null +++ b/studio_main.py @@ -0,0 +1,19 @@ +"""Entry point del Studio (stub Sprint 0). + +Sprint 1 lo reemplaza con `vmssailor.studio.app:main`. +""" + +from __future__ import annotations + +import sys + + +def main() -> int: + print("VMS-Sailor Studio — Sprint 1 trae la UI PySide6.") + print("En Sprint 0 solo existe el modelo de datos core.") + print("Ver `python tools/generate_test_project.py` para verificar el roundtrip.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c409bfa --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,145 @@ +"""Fixtures comunes para los tests de vmssailor.core.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from vmssailor.core import ( + AlarmConfig, + AlarmPriority, + Bus, + BusRole, + CardInstance, + ChannelType, + Equipment, + Project, + Protocol, + Scaling, + ShipCoord, + SignalType, + SystemId, + Tag, + TagBinding, + Topology, + UnitSI, + Vessel, + VesselSubtype, + VesselType, +) +from vmssailor.core.vessel import Deck + + +@pytest.fixture() +def repo_root() -> Path: + return Path(__file__).resolve().parents[1] + + +@pytest.fixture() +def sample_ship_coord() -> ShipCoord: + return ShipCoord(x_pp=10.0, y_cl=-1.5, z_bl=2.0) + + +@pytest.fixture() +def minimal_vessel() -> Vessel: + return Vessel( + id="test_vessel", + name="Test Vessel", + type=VesselType.YACHT_MOTOR, + subtype=VesselSubtype.PLANING, + length_overall_m=24.0, + beam_max_m=5.5, + draft_m=1.8, + decks=[ + Deck(id="main", name="Main", z_bl_bottom=2.0, z_bl_top=4.5), + ], + ) + + +@pytest.fixture() +def sample_card() -> CardInstance: + return CardInstance( + id="card_001", + slot_number=1, + bus_id="bus_main", + bus_role=BusRole.MODBUS_SLAVE, + modbus_address=1, + physical_location="SM panel A", + ) + + +@pytest.fixture() +def sample_bus() -> Bus: + return Bus( + id="bus_main", + name="Bus principal", + protocol=Protocol.MODBUS_RTU, + physical_port="COM3", + ) + + +@pytest.fixture() +def sample_topology(sample_bus: Bus, sample_card: CardInstance) -> Topology: + return Topology(buses=[sample_bus], cards=[sample_card]) + + +@pytest.fixture() +def sample_equipment() -> Equipment: + return Equipment( + id="eq_me_port", + model_ref="mtu_12v_2000_m96", + tag_prefix="ME_PORT", + display_name="Motor babor", + location=ShipCoord(x_pp=6.0, y_cl=-0.9, z_bl=1.2), + deck_id="main", + system_id=SystemId.MAIN_ENGINE, + ) + + +@pytest.fixture() +def sample_tag(sample_equipment: Equipment) -> Tag: + return Tag( + id=f"{sample_equipment.tag_prefix}.OIL_PRESS", + equipment_id=sample_equipment.id, + description="Presión aceite", + unit_si=UnitSI.BAR, + range_normal_min=3.5, + range_normal_max=6.5, + alarms=[ + AlarmConfig( + id=f"{sample_equipment.tag_prefix}.OIL_PRESS.LOW", + threshold=1.5, + operator="<", + priority=AlarmPriority.EMERGENCY, + hysteresis=0.2, + ) + ], + protocol=Protocol.MODBUS_RTU, + physical_binding=TagBinding( + card_id="card_001", + channel_type=ChannelType.AI, + channel_number=1, + signal_type=SignalType.SIG_4_20_MA, + scaling=Scaling(raw_min=4.0, raw_max=20.0, eng_min=0.0, eng_max=10.0), + ), + ) + + +@pytest.fixture() +def sample_project( + minimal_vessel: Vessel, + sample_equipment: Equipment, + sample_tag: Tag, + sample_topology: Topology, +) -> Project: + return Project( + id="test_project", + name="Test project", + customer="Pytest", + vessel=minimal_vessel, + systems_enabled=[SystemId.MAIN_ENGINE], + equipment=[sample_equipment], + tags=[sample_tag], + topology=sample_topology, + ) diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/persistence/__init__.py b/tests/core/persistence/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/persistence/test_roundtrip.py b/tests/core/persistence/test_roundtrip.py new file mode 100644 index 0000000..bc9e129 --- /dev/null +++ b/tests/core/persistence/test_roundtrip.py @@ -0,0 +1,90 @@ +"""Test crítico Sprint 0: roundtrip de persistencia .vmsproj. + +Criterio de aceptación de la Parte 6: `load_project(save_project(p))` produce +un Project equivalente campo por campo. +""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +import pytest + +from vmssailor.core import Project +from vmssailor.core.persistence import load_project, save_project +from vmssailor.core.persistence.vmsproj_reader import VmsProjError +from vmssailor.tools.generate_test_project import build_demo_project + + +def _projects_equivalent(a: Project, b: Project) -> bool: + return a.model_dump(mode="json", exclude={"updated_at"}) == b.model_dump( + mode="json", exclude={"updated_at"} + ) + + +def test_roundtrip_minimal(sample_project: Project, tmp_path: Path) -> None: + out = tmp_path / "minimal.vmsproj" + save_project(sample_project, out) + assert out.exists() + loaded = load_project(out) + assert _projects_equivalent(sample_project, loaded) + + +def test_roundtrip_full_demo(tmp_path: Path) -> None: + project = build_demo_project() + out = tmp_path / "demo.vmsproj" + save_project(project, out) + loaded = load_project(out) + assert _projects_equivalent(project, loaded), ( + "Roundtrip falló — el proyecto releído no coincide con el original." + ) + # Stats deben coincidir 1:1 + assert project.stats() == loaded.stats() + + +def test_vmsproj_is_real_sqlite(sample_project: Project, tmp_path: Path) -> None: + out = tmp_path / "x.vmsproj" + save_project(sample_project, out) + # Cualquier herramienta SQLite debe abrirlo + conn = sqlite3.connect(str(out)) + try: + version = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0] + assert version == 1 + # Meta esperada + meta_rows = dict(conn.execute("SELECT key, value FROM meta")) + assert meta_rows["file_format"] == "vmsproj" + finally: + conn.close() + + +def test_load_nonexistent_raises(tmp_path: Path) -> None: + with pytest.raises(VmsProjError): + load_project(tmp_path / "does_not_exist.vmsproj") + + +def test_load_invalid_db_raises(tmp_path: Path) -> None: + bad = tmp_path / "bad.vmsproj" + bad.write_bytes(b"not a sqlite database at all") + with pytest.raises(Exception): # noqa: B017 -- sqlite3 levanta varias clases distintas + load_project(bad) + + +def test_save_overwrites_existing(sample_project: Project, tmp_path: Path) -> None: + out = tmp_path / "ow.vmsproj" + save_project(sample_project, out) + first_size = out.stat().st_size + # Modifico el proyecto y vuelvo a guardar + sample_project.notes = "Cambiado en memoria" + save_project(sample_project, out) + loaded = load_project(out) + assert loaded.notes == "Cambiado en memoria" + # El archivo sigue siendo válido + assert out.stat().st_size > 0 + _ = first_size # no relevante para el assert principal + + +def test_save_creates_parent_dirs(sample_project: Project, tmp_path: Path) -> None: + out = tmp_path / "deep" / "nested" / "out.vmsproj" + save_project(sample_project, out) + assert out.exists() diff --git a/tests/core/test_alarm.py b/tests/core/test_alarm.py new file mode 100644 index 0000000..7d6b94c --- /dev/null +++ b/tests/core/test_alarm.py @@ -0,0 +1,82 @@ +"""Tests de Alarm (instancia activa).""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +import pytest +from pydantic import ValidationError + +from vmssailor.core import Alarm +from vmssailor.core.enums import AlarmPriority, AlarmState + + +def _now() -> datetime: + return datetime.now(UTC) + + +def test_alarm_active_basic() -> None: + a = Alarm( + id="alm_1", + tag_id="ME_PORT.OIL_PRESS", + alarm_config_id="ME_PORT.OIL_PRESS.LOW", + priority=AlarmPriority.EMERGENCY, + state=AlarmState.ACTIVE, + timestamp_active=_now(), + message="Oil pressure low", + ) + assert a.state == AlarmState.ACTIVE + assert a.timestamp_ack is None + + +def test_alarm_ack_requires_timestamp_ack() -> None: + with pytest.raises(ValidationError): + Alarm( + id="a", + tag_id="t", + alarm_config_id="t.low", + priority=AlarmPriority.HIGH, + state=AlarmState.ACK, + timestamp_active=_now(), + # timestamp_ack faltante + ) + + +def test_alarm_cleared_requires_timestamp_cleared() -> None: + with pytest.raises(ValidationError): + Alarm( + id="a", + tag_id="t", + alarm_config_id="t.low", + priority=AlarmPriority.HIGH, + state=AlarmState.CLEARED, + timestamp_active=_now(), + ) + + +def test_alarm_ack_timestamp_must_be_after_active() -> None: + t0 = _now() + with pytest.raises(ValidationError): + Alarm( + id="a", + tag_id="t", + alarm_config_id="t.low", + priority=AlarmPriority.HIGH, + state=AlarmState.ACK, + timestamp_active=t0, + timestamp_ack=t0 - timedelta(seconds=1), + acknowledged_by="op1", + ) + + +def test_alarm_acknowledged_by_without_ts_inconsistent() -> None: + with pytest.raises(ValidationError): + Alarm( + id="a", + tag_id="t", + alarm_config_id="t.low", + priority=AlarmPriority.HIGH, + state=AlarmState.ACTIVE, + timestamp_active=_now(), + acknowledged_by="op1", + ) diff --git a/tests/core/test_card.py b/tests/core/test_card.py new file mode 100644 index 0000000..4c4649a --- /dev/null +++ b/tests/core/test_card.py @@ -0,0 +1,86 @@ +"""Tests de Bus, CardInstance, Topology.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from vmssailor.core import ( + Bus, + BusRole, + CardInstance, + Protocol, + Topology, +) + + +def test_bus_modbus_rtu_ok() -> None: + Bus(id="b", name="b", protocol=Protocol.MODBUS_RTU, physical_port="COM3") + + +def test_bus_invalid_protocol_rejected() -> None: + with pytest.raises(ValidationError): + Bus(id="b", name="b", protocol=Protocol.J1939, physical_port="COM3") + + +def test_card_slave_requires_modbus_addr() -> None: + with pytest.raises(ValidationError): + CardInstance( + id="c", slot_number=1, bus_id="bm", bus_role=BusRole.MODBUS_SLAVE + ) + + +def test_card_n2k_node_no_modbus_addr() -> None: + CardInstance( + id="c", slot_number=1, bus_id="bm", bus_role=BusRole.NMEA2000_NODE + ) + with pytest.raises(ValidationError): + CardInstance( + id="c", slot_number=1, bus_id="bm", + bus_role=BusRole.NMEA2000_NODE, modbus_address=5, + ) + + +def test_topology_unique_slots_per_bus() -> None: + b = Bus(id="bm", name="bm", protocol=Protocol.MODBUS_RTU, physical_port="COM3") + c1 = CardInstance( + id="c1", slot_number=1, bus_id="bm", + bus_role=BusRole.MODBUS_SLAVE, modbus_address=1, + ) + c2 = CardInstance( + id="c2", slot_number=1, bus_id="bm", + bus_role=BusRole.MODBUS_SLAVE, modbus_address=2, + ) + with pytest.raises(ValidationError): + Topology(buses=[b], cards=[c1, c2]) + + +def test_topology_unique_modbus_addresses_per_bus() -> None: + b = Bus(id="bm", name="bm", protocol=Protocol.MODBUS_RTU, physical_port="COM3") + c1 = CardInstance( + id="c1", slot_number=1, bus_id="bm", + bus_role=BusRole.MODBUS_SLAVE, modbus_address=5, + ) + c2 = CardInstance( + id="c2", slot_number=2, bus_id="bm", + bus_role=BusRole.MODBUS_SLAVE, modbus_address=5, + ) + with pytest.raises(ValidationError): + Topology(buses=[b], cards=[c1, c2]) + + +def test_topology_card_references_existing_bus() -> None: + b = Bus(id="bm", name="bm", protocol=Protocol.MODBUS_RTU, physical_port="COM3") + c = CardInstance( + id="c", slot_number=1, bus_id="other_bus", + bus_role=BusRole.MODBUS_SLAVE, modbus_address=1, + ) + with pytest.raises(ValidationError): + Topology(buses=[b], cards=[c]) + + +def test_topology_helpers(sample_topology: Topology) -> None: + assert sample_topology.bus_by_id("bus_main") is not None + assert sample_topology.bus_by_id("nope") is None + assert sample_topology.card_by_id("card_001") is not None + assert sample_topology.card_by_id("nope") is None diff --git a/tests/core/test_coords.py b/tests/core/test_coords.py new file mode 100644 index 0000000..bceed24 --- /dev/null +++ b/tests/core/test_coords.py @@ -0,0 +1,69 @@ +"""Tests del sistema de coordenadas naval.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from vmssailor.core import ShipCoord + + +def test_construct_valid() -> None: + c = ShipCoord(x_pp=10.0, y_cl=-1.5, z_bl=2.0) + assert c.x_pp == 10.0 + assert c.y_cl == -1.5 + assert c.z_bl == 2.0 + + +def test_frozen() -> None: + c = ShipCoord(x_pp=10.0, y_cl=0.0, z_bl=1.0) + with pytest.raises(ValidationError): + c.x_pp = 99.0 # type: ignore[misc] + + +def test_as_tuple() -> None: + c = ShipCoord(x_pp=1.0, y_cl=2.0, z_bl=3.0) + assert c.as_tuple() == (1.0, 2.0, 3.0) + + +def test_is_starboard_port_centerline() -> None: + starboard = ShipCoord(x_pp=5.0, y_cl=1.0, z_bl=0.0) + port = ShipCoord(x_pp=5.0, y_cl=-1.0, z_bl=0.0) + centerline = ShipCoord(x_pp=5.0, y_cl=0.0, z_bl=0.0) + assert starboard.is_starboard() + assert not starboard.is_port() + assert not starboard.is_centerline() + assert port.is_port() + assert not port.is_starboard() + assert centerline.is_centerline() + assert not centerline.is_starboard() + assert not centerline.is_port() + + +def test_distance_to() -> None: + a = ShipCoord(x_pp=0.0, y_cl=0.0, z_bl=0.0) + b = ShipCoord(x_pp=3.0, y_cl=4.0, z_bl=0.0) + assert a.distance_to(b) == pytest.approx(5.0) + + +def test_out_of_range_rejected() -> None: + with pytest.raises(ValidationError): + ShipCoord(x_pp=999.0, y_cl=0.0, z_bl=0.0) + with pytest.raises(ValidationError): + ShipCoord(x_pp=10.0, y_cl=999.0, z_bl=0.0) + with pytest.raises(ValidationError): + ShipCoord(x_pp=10.0, y_cl=0.0, z_bl=-999.0) + + +def test_extra_fields_forbidden() -> None: + with pytest.raises(ValidationError): + ShipCoord(x_pp=1.0, y_cl=0.0, z_bl=0.0, foo="bar") # type: ignore[call-arg] + + +def test_str_representation() -> None: + c = ShipCoord(x_pp=10.0, y_cl=-1.5, z_bl=2.0) + s = str(c) + assert "10.00" in s + assert "-1.50" in s + assert "+2.00" in s + assert "[m]" in s diff --git a/tests/core/test_enums.py b/tests/core/test_enums.py new file mode 100644 index 0000000..8f7be3b --- /dev/null +++ b/tests/core/test_enums.py @@ -0,0 +1,53 @@ +"""Smoke tests para los enums del core.""" + +from __future__ import annotations + +from vmssailor.core.enums import ( + AlarmPriority, + BusRole, + ChannelType, + ControlMode, + SystemId, + UnitSI, + VesselType, +) + + +def test_systemid_main_engine_value() -> None: + assert SystemId.MAIN_ENGINE.value == "main_engine" + + +def test_channel_type_values() -> None: + assert ChannelType.AI.value == "ai" + assert ChannelType.DI.value == "di" + assert ChannelType.DO.value == "do" + assert ChannelType.RPM.value == "rpm" + + +def test_alarm_priority_complete_set() -> None: + values = {p.value for p in AlarmPriority} + assert values == {"emergency", "high", "low", "info"} + + +def test_busrole_includes_dual_and_bridge() -> None: + assert BusRole.DUAL.value == "dual" + assert BusRole.BRIDGE.value == "bridge" + + +def test_controlmode_default_states_present() -> None: + assert ControlMode.MONITOR.value == "monitor" + assert ControlMode.MANUAL.value == "manual" + assert ControlMode.AUTO.value == "auto" + assert ControlMode.FUTURE.value == "future" + + +def test_vesseltype_complete() -> None: + values = {v.value for v in VesselType} + assert {"yacht_motor", "yacht_sail", "fishing", "patrol", "ferry", "offshore_support"} <= values + + +def test_unit_si_si_only() -> None: + # No deben aparecer unidades imperiales en el enum interno. + forbidden = {"ft", "psi", "F", "kt"} + values = {u.value.lower() for u in UnitSI} + assert not (forbidden & values), f"UnitSI debe ser solo SI. Encontrado: {forbidden & values}" diff --git a/tests/core/test_equipment.py b/tests/core/test_equipment.py new file mode 100644 index 0000000..2c7ed5a --- /dev/null +++ b/tests/core/test_equipment.py @@ -0,0 +1,88 @@ +"""Tests de Equipment, EquipmentModel, Sensor.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from vmssailor.core import ( + EquipmentCategory, + EquipmentModel, + EquipmentSpec, + Sensor, + SystemId, + UnitSI, +) + + +def test_sensor_basic() -> None: + s = Sensor( + id="oil_press", + name="Oil Pressure", + unit_si=UnitSI.BAR, + range_normal_min=3.0, + range_normal_max=5.5, + ) + assert s.unit_si == UnitSI.BAR + + +def test_sensor_range_max_below_min_rejected() -> None: + with pytest.raises(ValidationError): + Sensor( + id="x", + name="X", + unit_si=UnitSI.BAR, + range_normal_min=5.0, + range_normal_max=2.0, + ) + + +def test_equipment_model_with_sensors() -> None: + em = EquipmentModel( + id="mtu_test", + manufacturer="MTU", + model_name="Test", + category=EquipmentCategory.ENGINE_MAIN, + typical_systems=[SystemId.MAIN_ENGINE], + specs=EquipmentSpec(power_kw=1432, rpm_nominal=2450), + default_sensors=[ + Sensor(id="rpm", name="RPM", unit_si=UnitSI.RPM), + Sensor(id="oil_press", name="Oil P", unit_si=UnitSI.BAR), + ], + ) + assert em.specs.power_kw == 1432 + assert len(em.default_sensors) == 2 + + +def test_equipment_model_duplicate_sensor_ids_rejected() -> None: + with pytest.raises(ValidationError): + EquipmentModel( + id="x", + manufacturer="X", + model_name="X", + category=EquipmentCategory.ENGINE_MAIN, + default_sensors=[ + Sensor(id="rpm", name="RPM"), + Sensor(id="rpm", name="RPM dup"), + ], + ) + + +def test_equipment_instance(sample_equipment) -> None: + assert sample_equipment.tag_prefix == "ME_PORT" + assert sample_equipment.system_id == SystemId.MAIN_ENGINE + assert sample_equipment.installed + + +def test_equipment_invalid_tag_prefix_rejected() -> None: + with pytest.raises(ValidationError): + from vmssailor.core import Equipment, ShipCoord + + Equipment( + id="x", + model_ref="m", + tag_prefix="lowercase_bad", # debe ser mayúsculas + display_name="X", + location=ShipCoord(x_pp=1.0, y_cl=0.0, z_bl=0.0), + system_id=SystemId.MAIN_ENGINE, + ) diff --git a/tests/core/test_permissive.py b/tests/core/test_permissive.py new file mode 100644 index 0000000..c1aace3 --- /dev/null +++ b/tests/core/test_permissive.py @@ -0,0 +1,49 @@ +"""Tests de PermissiveRule y Condition.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from vmssailor.core import PermissiveRule +from vmssailor.core.permissive import Condition + + +def test_condition_basic() -> None: + c = Condition( + tag_ref="ME_PORT.OIL_PRESS", + operator=">", + threshold=0.3, + severity="fail", + ) + assert c.severity == "fail" + + +def test_condition_between_requires_both_thresholds() -> None: + with pytest.raises(ValidationError): + Condition( + tag_ref="x", + operator="between", + threshold_low=1.0, + # threshold_high faltante + ) + + +def test_permissive_rule_empty_conditions_rejected() -> None: + with pytest.raises(ValidationError): + PermissiveRule(id="r", action_id="START", conditions=[]) + + +def test_permissive_rule_with_multiple_conditions() -> None: + r = PermissiveRule( + id="rule_start", + action_id="START_ME_PORT", + conditions=[ + Condition(tag_ref="ME_PORT.OIL_PRESS", operator=">", threshold=0.3), + Condition(tag_ref="ME_PORT.COOLANT_TEMP", operator=">", threshold=5.0), + Condition(tag_ref="ME_PORT.ESTOP_ACTIVE", operator="is_false"), + ], + on_fail_message="No es seguro arrancar.", + ) + assert len(r.conditions) == 3 + assert r.conditions[2].operator == "is_false" diff --git a/tests/core/test_project.py b/tests/core/test_project.py new file mode 100644 index 0000000..a4d7ea2 --- /dev/null +++ b/tests/core/test_project.py @@ -0,0 +1,194 @@ +"""Tests del agregado raíz Project.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from vmssailor.core import ( + Bus, + BusRole, + CardInstance, + Equipment, + PermissiveRule, + Project, + Protocol, + ShipCoord, + SystemId, + Tag, + Topology, + UnitSI, + Vessel, +) +from vmssailor.core.permissive import Condition + + +def test_project_stats(sample_project: Project) -> None: + stats = sample_project.stats() + assert stats["systems"] == 1 + assert stats["equipment"] == 1 + assert stats["tags"] == 1 + assert stats["tags_with_alarms"] == 1 + assert stats["buses"] == 1 + assert stats["cards"] == 1 + + +def test_project_equipment_requires_enabled_system( + minimal_vessel: Vessel, sample_topology: Topology +) -> None: + eq = Equipment( + id="eq", + model_ref="m", + tag_prefix="X", + display_name="x", + location=ShipCoord(x_pp=5.0, y_cl=0.0, z_bl=1.0), + system_id=SystemId.WATERMAKER, # NO en systems_enabled + ) + with pytest.raises(ValidationError): + Project( + id="p", + name="p", + vessel=minimal_vessel, + systems_enabled=[SystemId.MAIN_ENGINE], + equipment=[eq], + topology=sample_topology, + ) + + +def test_project_tag_equipment_id_must_exist( + minimal_vessel: Vessel, sample_topology: Topology, sample_equipment: Equipment +) -> None: + with pytest.raises(ValidationError): + Project( + id="p", + name="p", + vessel=minimal_vessel, + systems_enabled=[SystemId.MAIN_ENGINE], + equipment=[sample_equipment], + tags=[ + Tag( + id="GHOST.X", + equipment_id="ghost_eq", # no existe + unit_si=UnitSI.BAR, + protocol=Protocol.MODBUS_RTU, + address=1, + ) + ], + topology=sample_topology, + ) + + +def test_project_tag_binding_must_reference_existing_card( + minimal_vessel: Vessel, +) -> None: + from vmssailor.core import ChannelType, SignalType, TagBinding + + b = Bus(id="bm", name="bm", protocol=Protocol.MODBUS_RTU, physical_port="COM3") + c = CardInstance( + id="card_001", slot_number=1, bus_id="bm", + bus_role=BusRole.MODBUS_SLAVE, modbus_address=1, + ) + topo = Topology(buses=[b], cards=[c]) + eq = Equipment( + id="eq", + model_ref="m", + tag_prefix="X", + display_name="x", + location=ShipCoord(x_pp=5.0, y_cl=0.0, z_bl=1.0), + system_id=SystemId.MAIN_ENGINE, + ) + bad_tag = Tag( + id="X.PRESS", + equipment_id="eq", + unit_si=UnitSI.BAR, + protocol=Protocol.MODBUS_RTU, + physical_binding=TagBinding( + card_id="ghost_card", + channel_type=ChannelType.AI, + channel_number=1, + signal_type=SignalType.SIG_4_20_MA, + ), + ) + with pytest.raises(ValidationError): + Project( + id="p", + name="p", + vessel=minimal_vessel, + systems_enabled=[SystemId.MAIN_ENGINE], + equipment=[eq], + tags=[bad_tag], + topology=topo, + ) + + +def test_project_permissive_condition_must_reference_existing_tag( + sample_project: Project, +) -> None: + bad_rule = PermissiveRule( + id="bad", + action_id="DO_X", + conditions=[Condition(tag_ref="GHOST.TAG", operator=">", threshold=1.0)], + ) + with pytest.raises(ValidationError): + Project( + id=sample_project.id, + name=sample_project.name, + vessel=sample_project.vessel, + systems_enabled=sample_project.systems_enabled, + equipment=sample_project.equipment, + tags=sample_project.tags, + topology=sample_project.topology, + permissive_rules=[bad_rule], + ) + + +def test_project_helpers(sample_project: Project) -> None: + eq = sample_project.equipment_by_id("eq_me_port") + assert eq is not None + assert eq.tag_prefix == "ME_PORT" + tag = sample_project.tag_by_id("ME_PORT.OIL_PRESS") + assert tag is not None + tags_eq = sample_project.tags_for_equipment("eq_me_port") + assert len(tags_eq) == 1 + tags_sys = sample_project.tags_for_system(SystemId.MAIN_ENGINE) + assert len(tags_sys) == 1 + + +def test_project_touch_updates_timestamp(sample_project: Project) -> None: + original = sample_project.updated_at + sample_project.touch() + assert sample_project.updated_at >= original + + +def test_project_equipment_unique_tag_prefix(minimal_vessel: Vessel) -> None: + e1 = Equipment( + id="e1", model_ref="m", tag_prefix="ME", + display_name="A", location=ShipCoord(x_pp=1, y_cl=0, z_bl=0), + system_id=SystemId.MAIN_ENGINE, + ) + e2 = Equipment( + id="e2", model_ref="m", tag_prefix="ME", # mismo prefix + display_name="B", location=ShipCoord(x_pp=2, y_cl=0, z_bl=0), + system_id=SystemId.MAIN_ENGINE, + ) + with pytest.raises(ValidationError): + Project( + id="p", name="p", vessel=minimal_vessel, + systems_enabled=[SystemId.MAIN_ENGINE], + equipment=[e1, e2], + ) + + +def test_project_equipment_deck_must_exist(minimal_vessel: Vessel) -> None: + eq = Equipment( + id="eq", model_ref="m", tag_prefix="X", + display_name="x", location=ShipCoord(x_pp=1, y_cl=0, z_bl=0), + deck_id="non_existent_deck", + system_id=SystemId.MAIN_ENGINE, + ) + with pytest.raises(ValidationError): + Project( + id="p", name="p", vessel=minimal_vessel, + systems_enabled=[SystemId.MAIN_ENGINE], + equipment=[eq], + ) diff --git a/tests/core/test_tag.py b/tests/core/test_tag.py new file mode 100644 index 0000000..71d4b49 --- /dev/null +++ b/tests/core/test_tag.py @@ -0,0 +1,141 @@ +"""Tests de Tag, AlarmConfig, TagBinding, Scaling.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from vmssailor.core import ( + AlarmConfig, + AlarmPriority, + ChannelType, + ControlMode, + Protocol, + Scaling, + SignalType, + Tag, + TagBinding, + UnitSI, +) + + +def test_scaling_apply_and_invert() -> None: + s = Scaling(raw_min=4.0, raw_max=20.0, eng_min=0.0, eng_max=10.0) + assert s.apply(4.0) == pytest.approx(0.0) + assert s.apply(12.0) == pytest.approx(5.0) + assert s.apply(20.0) == pytest.approx(10.0) + assert s.invert(5.0) == pytest.approx(12.0) + + +def test_scaling_equal_raw_rejected() -> None: + with pytest.raises(ValidationError): + Scaling(raw_min=4.0, raw_max=4.0, eng_min=0.0, eng_max=10.0) + + +def test_scaling_equal_eng_rejected() -> None: + with pytest.raises(ValidationError): + Scaling(raw_min=4.0, raw_max=20.0, eng_min=5.0, eng_max=5.0) + + +def test_tag_binding_channel_within_capacity() -> None: + TagBinding( + card_id="card_001", + channel_type=ChannelType.AI, + channel_number=4, + signal_type=SignalType.SIG_4_20_MA, + ) + + +def test_tag_binding_channel_exceeds_capacity_rejected() -> None: + with pytest.raises(ValidationError): + TagBinding( + card_id="card_001", + channel_type=ChannelType.AI, + channel_number=5, # cap = 4 + signal_type=SignalType.SIG_4_20_MA, + ) + with pytest.raises(ValidationError): + TagBinding( + card_id="card_001", + channel_type=ChannelType.RPM, + channel_number=2, # cap = 1 + signal_type=SignalType.PULSE_MAGNETIC_PICKUP, + ) + + +def test_alarm_config_basic() -> None: + a = AlarmConfig( + id="x.low", + threshold=1.5, + operator="<", + priority=AlarmPriority.EMERGENCY, + hysteresis=0.2, + ) + assert a.priority == AlarmPriority.EMERGENCY + + +def test_alarm_config_invalid_operator() -> None: + with pytest.raises(ValidationError): + AlarmConfig(id="x", threshold=1.0, operator="??", priority=AlarmPriority.HIGH) + + +def test_tag_controllable_must_not_be_monitor() -> None: + with pytest.raises(ValidationError): + Tag( + id="X.START", + unit_si=UnitSI.BOOL, + controllable=True, + control_mode=ControlMode.MONITOR, # inconsistente + protocol=Protocol.MODBUS_RTU, + address=1, + ) + + +def test_tag_non_controllable_must_be_monitor() -> None: + with pytest.raises(ValidationError): + Tag( + id="X.PRESS", + unit_si=UnitSI.BAR, + controllable=False, + control_mode=ControlMode.MANUAL, + protocol=Protocol.MODBUS_RTU, + address=1, + ) + + +def test_tag_internal_no_address() -> None: + Tag( + id="VIRT.STATE", + unit_si=UnitSI.NONE, + protocol=Protocol.INTERNAL, + address=None, + ) + with pytest.raises(ValidationError): + Tag( + id="VIRT.STATE", + unit_si=UnitSI.NONE, + protocol=Protocol.INTERNAL, + address=42, # INTERNAL no debe tener address + ) + + +def test_tag_modbus_requires_address_or_binding() -> None: + with pytest.raises(ValidationError): + Tag( + id="X.OIL", + unit_si=UnitSI.BAR, + protocol=Protocol.MODBUS_RTU, + address=None, + physical_binding=None, + ) + + +def test_tag_id_pattern() -> None: + with pytest.raises(ValidationError): + Tag(id="lowercase.bad", unit_si=UnitSI.NONE, protocol=Protocol.INTERNAL) + + +def test_tag_alarms_unique(sample_tag: Tag) -> None: + assert len(sample_tag.alarms) == 1 + a = sample_tag.alarms[0] + assert a.priority == AlarmPriority.EMERGENCY diff --git a/tests/core/test_validation.py b/tests/core/test_validation.py new file mode 100644 index 0000000..3906c3a --- /dev/null +++ b/tests/core/test_validation.py @@ -0,0 +1,131 @@ +"""Tests del validador cross-entity.""" + +from __future__ import annotations + +from vmssailor.core import ( + Bus, + BusRole, + CardInstance, + ChannelType, + Equipment, + Project, + Protocol, + Scaling, + ShipCoord, + SignalType, + SystemId, + Tag, + TagBinding, + Topology, + UnitSI, + Vessel, + VesselSubtype, + VesselType, +) +from vmssailor.core.validation import Severity, validate_project + + +def _make_vessel() -> Vessel: + return Vessel( + id="v", + name="V", + type=VesselType.YACHT_MOTOR, + subtype=VesselSubtype.PLANING, + length_overall_m=20.0, + beam_max_m=5.0, + draft_m=1.5, + ) + + +def _basic_topology() -> Topology: + b = Bus(id="bm", name="bm", protocol=Protocol.MODBUS_RTU, physical_port="COM3") + c = CardInstance( + id="c1", slot_number=1, bus_id="bm", + bus_role=BusRole.MODBUS_SLAVE, modbus_address=1, + ) + return Topology(buses=[b], cards=[c]) + + +def test_orphan_system_warning() -> None: + p = Project( + id="p", name="p", vessel=_make_vessel(), + systems_enabled=[SystemId.MAIN_ENGINE, SystemId.WATERMAKER], + equipment=[], + topology=_basic_topology(), + ) + r = validate_project(p) + assert r.ok() + assert any(i.code == "SYSTEM_WITHOUT_EQUIPMENT" for i in r.warnings) + + +def test_card_capacity_high_info() -> None: + topo = _basic_topology() + eq = Equipment( + id="eq", model_ref="m", tag_prefix="X", + display_name="x", location=ShipCoord(x_pp=5, y_cl=0, z_bl=1), + system_id=SystemId.MAIN_ENGINE, + ) + + # 4 AI = 100% (at-limit -> warning) + tags = [] + for i in range(4): + tags.append( + Tag( + id=f"X.AI{i + 1}", + equipment_id="eq", + unit_si=UnitSI.BAR, + protocol=Protocol.MODBUS_RTU, + physical_binding=TagBinding( + card_id="c1", + channel_type=ChannelType.AI, + channel_number=i + 1, + signal_type=SignalType.SIG_4_20_MA, + scaling=Scaling(raw_min=4, raw_max=20, eng_min=0, eng_max=10), + ), + ) + ) + p = Project( + id="p", name="p", vessel=_make_vessel(), + systems_enabled=[SystemId.MAIN_ENGINE], + equipment=[eq], + tags=tags, + topology=topo, + ) + r = validate_project(p) + codes = {i.code for i in r.issues} + assert "CARD_CAPACITY_AT_LIMIT" in codes + + +def test_equipment_out_of_hull_warning() -> None: + eq = Equipment( + id="eq", model_ref="m", tag_prefix="X", + display_name="x", + location=ShipCoord(x_pp=100.0, y_cl=0.0, z_bl=1.0), # fuera del buque + system_id=SystemId.MAIN_ENGINE, + ) + p = Project( + id="p", name="p", vessel=_make_vessel(), + systems_enabled=[SystemId.MAIN_ENGINE], + equipment=[eq], + topology=_basic_topology(), + ) + r = validate_project(p) + codes = {i.code for i in r.issues} + assert "EQUIPMENT_OUT_OF_HULL" in codes + + +def test_validation_report_format() -> None: + p = Project( + id="p", name="p", vessel=_make_vessel(), + systems_enabled=[], + equipment=[], + topology=_basic_topology(), + ) + r = validate_project(p) + assert "OK" in r.format() or "Total" in r.format() + + +def test_severity_values() -> None: + assert Severity.ERROR.value == "error" + assert Severity.WARNING.value == "warning" + assert Severity.INFO.value == "info" diff --git a/tests/core/test_vessel.py b/tests/core/test_vessel.py new file mode 100644 index 0000000..8d9d76b --- /dev/null +++ b/tests/core/test_vessel.py @@ -0,0 +1,79 @@ +"""Tests de Vessel, Deck, Bulkhead.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from vmssailor.core import Vessel, VesselSubtype, VesselType +from vmssailor.core.vessel import Bulkhead, Deck + + +def test_deck_height() -> None: + d = Deck(id="main", name="Main", z_bl_bottom=2.0, z_bl_top=4.5) + assert d.height() == pytest.approx(2.5) + + +def test_deck_polygon_empty_ok() -> None: + Deck(id="main", name="Main", z_bl_bottom=2.0, z_bl_top=4.5) + + +def test_deck_polygon_too_few_vertices_rejected() -> None: + with pytest.raises(ValidationError): + Deck( + id="main", + name="Main", + z_bl_bottom=2.0, + z_bl_top=4.5, + polygon_xy=[(0.0, 0.0), (1.0, 0.0)], + ) + + +def test_minimal_vessel(minimal_vessel: Vessel) -> None: + assert minimal_vessel.length_overall_m == 24.0 + assert minimal_vessel.type == VesselType.YACHT_MOTOR + assert minimal_vessel.subtype == VesselSubtype.PLANING + assert len(minimal_vessel.decks) == 1 + + +def test_vessel_duplicate_deck_ids_rejected() -> None: + with pytest.raises(ValidationError): + Vessel( + id="x", + name="X", + type=VesselType.YACHT_MOTOR, + subtype=VesselSubtype.PLANING, + length_overall_m=20.0, + beam_max_m=5.0, + draft_m=1.5, + decks=[ + Deck(id="main", name="A", z_bl_bottom=0.0, z_bl_top=2.0), + Deck(id="main", name="B", z_bl_bottom=2.0, z_bl_top=4.0), + ], + ) + + +def test_vessel_position_helpers(minimal_vessel: Vessel) -> None: + bow = minimal_vessel.position_at_bow() + assert bow.x_pp == minimal_vessel.length_overall_m + origin = minimal_vessel.position_at_origin() + assert origin.x_pp == 0.0 + + +def test_vessel_invalid_length_rejected() -> None: + with pytest.raises(ValidationError): + Vessel( + id="x", + name="X", + type=VesselType.YACHT_MOTOR, + subtype=VesselSubtype.PLANING, + length_overall_m=-1.0, + beam_max_m=5.0, + draft_m=1.5, + ) + + +def test_bulkhead_basic() -> None: + b = Bulkhead(id="col", name="Collision", x_pp=21.0) + assert b.x_pp == 21.0 + assert b.description == "" diff --git a/tests/library/__init__.py b/tests/library/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/library/test_loader.py b/tests/library/test_loader.py new file mode 100644 index 0000000..8153cc5 --- /dev/null +++ b/tests/library/test_loader.py @@ -0,0 +1,39 @@ +"""Tests del library loader.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from vmssailor.library.loader import load_library + + +def test_loader_returns_result_with_format() -> None: + r = load_library() + s = r.format() + assert "Library load summary" in s + assert "Vessels" in s + assert "EquipmentModels" in s + + +def test_loader_handles_corrupt_json(tmp_path: Path) -> None: + # Construimos una biblioteca falsa con un JSON malo + fake_lib = tmp_path / "lib" + (fake_lib / "vessels").mkdir(parents=True) + (fake_lib / "vessels" / "broken.json").write_text("{ not valid json", encoding="utf-8") + (fake_lib / "equipment").mkdir(parents=True) + (fake_lib / "rules").mkdir(parents=True) + # systems_catalog.json mínimo válido + (fake_lib / "systems_catalog.json").write_text( + json.dumps({"_meta": {"version": 1}, "categories": []}), encoding="utf-8" + ) + r = load_library(fake_lib) + assert not r.ok() + assert any("broken.json" in i.path for i in r.errors) + + +def test_loader_handles_missing_systems_catalog(tmp_path: Path) -> None: + fake_lib = tmp_path / "lib" + fake_lib.mkdir() + r = load_library(fake_lib) + assert any("systems_catalog.json" in i.path for i in r.errors) diff --git a/tests/library/test_seed_integrity.py b/tests/library/test_seed_integrity.py new file mode 100644 index 0000000..26b5607 --- /dev/null +++ b/tests/library/test_seed_integrity.py @@ -0,0 +1,80 @@ +"""Tests de integridad de la biblioteca seed.""" + +from __future__ import annotations + +from vmssailor.core.enums import EquipmentCategory, SystemId +from vmssailor.library import load_library, load_systems_catalog + + +def test_systems_catalog_loads() -> None: + cat = load_systems_catalog() + assert "categories" in cat + assert len(cat["categories"]) >= 10 # 11 categorías mínimo + + +def test_systems_catalog_covers_all_enum_values() -> None: + cat = load_systems_catalog() + json_ids = set() + for c in cat["categories"]: + for s in c["systems"]: + json_ids.add(s["id"]) + enum_ids = {s.value for s in SystemId} + missing_in_json = enum_ids - json_ids + missing_in_enum = json_ids - enum_ids + assert not missing_in_json, f"SystemId enum tiene valores no en catálogo: {missing_in_json}" + assert not missing_in_enum, f"Catálogo tiene IDs no en SystemId enum: {missing_in_enum}" + + +def test_library_loads_without_errors() -> None: + result = load_library() + assert result.ok(), f"Biblioteca con errores: {result.errors}" + + +def test_library_has_minimum_seed() -> None: + result = load_library() + vessel_ids = {v.id for v in result.vessels} + assert "sunseeker_76" in vessel_ids + assert "ferretti_850" in vessel_ids + + eq_ids = {e.id for e in result.equipment_models} + assert "mtu_12v_2000_m96" in eq_ids + assert "volvo_d13_900hp" in eq_ids + assert "northern_lights_m65c13" in eq_ids + + +def test_seed_engines_are_engine_main() -> None: + result = load_library() + for em in result.equipment_models: + if em.id.startswith("mtu_") or em.id.startswith("volvo_"): + assert em.category == EquipmentCategory.ENGINE_MAIN, em.id + + +def test_seed_genset_category() -> None: + result = load_library() + nl = next(e for e in result.equipment_models if e.id == "northern_lights_m65c13") + assert nl.category == EquipmentCategory.GENSET + + +def test_rules_yacht_motor_planeo_present() -> None: + result = load_library() + assert "yacht_motor_planeo" in result.rules + + +def test_rules_reference_existing_equipment_models() -> None: + result = load_library() + # Filtrar warnings que no son del cross-ref + cross_ref_warns = [ + w + for w in result.warnings + if "no existe en equipment_models" in w.message + ] + assert not cross_ref_warns, f"Rules con model_ref roto: {cross_ref_warns}" + + +def test_seed_marked_as_estimate() -> None: + """Todos los archivos seed deben venir marcados como seed_estimate.""" + result = load_library() + for v in result.vessels: + assert v.data_source == "seed_estimate", v.id + for em in result.equipment_models: + assert em.data_source == "seed_estimate", em.id diff --git a/tests/shared/__init__.py b/tests/shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/shared/test_ids.py b/tests/shared/test_ids.py new file mode 100644 index 0000000..8427cf0 --- /dev/null +++ b/tests/shared/test_ids.py @@ -0,0 +1,51 @@ +"""Tests de generación de IDs.""" + +from __future__ import annotations + +import pytest + +from vmssailor.shared.ids import ( + make_alarm_config_id, + make_alarm_instance_id, + make_project_id, + make_tag_id, + new_uuid, +) + + +def test_make_tag_id_basic() -> None: + assert make_tag_id("ME_PORT", "oil_press") == "ME_PORT.OIL_PRESS" + + +def test_make_tag_id_rejects_lowercase_prefix() -> None: + with pytest.raises(ValueError): + make_tag_id("me_port", "oil_press") + + +def test_make_tag_id_rejects_uppercase_sensor() -> None: + with pytest.raises(ValueError): + make_tag_id("ME_PORT", "OIL_PRESS") + + +def test_alarm_config_id() -> None: + cid = make_alarm_config_id("ME_PORT.OIL_PRESS", "low") + assert cid == "ME_PORT.OIL_PRESS.LOW" + + +def test_alarm_instance_id_deterministic() -> None: + a = make_alarm_instance_id("ME_PORT.OIL_PRESS", "ME_PORT.OIL_PRESS.LOW", 1234567890.0) + assert a.startswith("alm.ME_PORT.OIL_PRESS.LOW.") + + +def test_new_uuid_unique() -> None: + a = new_uuid() + b = new_uuid() + assert a != b + assert len(a) == 36 # UUID canonical + + +def test_project_id_normalization() -> None: + pid = make_project_id("Acme Yachts S.A.", "Sunseeker_76") + # Espacios y puntuación se normalizan + assert pid == "acme_yachts_s_a___sunseeker_76" or pid.startswith("acme_yachts_s_a") + assert "__" in pid # separador customer__vessel diff --git a/tools/generate_test_project.py b/tools/generate_test_project.py new file mode 100644 index 0000000..05bb6eb --- /dev/null +++ b/tools/generate_test_project.py @@ -0,0 +1,21 @@ +"""Wrapper top-level. Implementación en `vmssailor.tools.generate_test_project`. + +Uso: + + python tools/generate_test_project.py + python tools/generate_test_project.py --out projects/_demo/foo.vmsproj +""" + +from __future__ import annotations + +import sys + +if __name__ == "__main__": + from pathlib import Path + + repo_root = Path(__file__).resolve().parent.parent + sys.path.insert(0, str(repo_root)) + + from vmssailor.tools.generate_test_project import main + + sys.exit(main(sys.argv[1:])) diff --git a/tools/validate_library.py b/tools/validate_library.py new file mode 100644 index 0000000..21f8e20 --- /dev/null +++ b/tools/validate_library.py @@ -0,0 +1,26 @@ +"""Wrapper top-level. La implementación vive en `vmssailor.tools.validate_library`. + +Permite ejecutar: + + python tools/validate_library.py + +Equivalente a: + + uv run vms-validate-library + python -m vmssailor.tools.validate_library +""" + +from __future__ import annotations + +import sys + +if __name__ == "__main__": + # Permite ejecutar desde la raíz sin tener instalado el paquete: + from pathlib import Path + + repo_root = Path(__file__).resolve().parent.parent + sys.path.insert(0, str(repo_root)) + + from vmssailor.tools.validate_library import main + + sys.exit(main(sys.argv[1:])) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..9810c00 --- /dev/null +++ b/uv.lock @@ -0,0 +1,423 @@ +version = 1 +revision = 3 +requires-python = "==3.11.*" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/1f/50f241d4e01fe75f4bba6a209edd4047c4b26acf70992ff885fd161f79cb/ast_serialize-0.4.0.tar.gz", hash = "sha256:74e4e634ab82d1466acf0be27043178570b98ebeaa3165f9240a6fad4c286471", size = 60687, upload-time = "2026-05-14T22:44:38.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/d5/044c5f995ef75807a0effb56fc288cfdedeeb571222450fb6f7d94fd52f1/ast_serialize-0.4.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dcded5056d9f3d201df7833082c07ebcbc566ffc3d4105c9fc9fe278fa086ecb", size = 1189800, upload-time = "2026-05-14T22:44:09.333Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5a/52163557789d59a8197c10912ab4a1791c9143731ba0e3d9283ac0791db6/ast_serialize-0.4.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bd50d201098aae0d202805fe9606c0545492f69a3ec4403337e32c54ad29fc41", size = 1181713, upload-time = "2026-05-14T22:44:11.286Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c3/678ce3b6cb594b01c361da87f6c5679d26c1dae1583a082a8cd190e7232e/ast_serialize-0.4.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6615b39cd747967c3aabe68bf3f5f26748e823cc6b474ddc1510ed188a824149", size = 1243258, upload-time = "2026-05-14T22:44:13.345Z" }, + { url = "https://files.pythonhosted.org/packages/3d/dd/4810fbeb81c47b7e4e65db15ca65c71330efc59b460bd10c12338dc6012e/ast_serialize-0.4.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:91362c0a9fdf1c344b7f50a5b0508b11a0732102998fbd754a191f7187e77031", size = 1239226, upload-time = "2026-05-14T22:44:15.811Z" }, + { url = "https://files.pythonhosted.org/packages/28/38/13a88d90b664c009ed208346ec2ed248b0ab2cb0b582ae467acaa7f44fa4/ast_serialize-0.4.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70d9c5d527bbfa69bd3c7d17dac11fb6781e36186a434a06d7d5892e0b2f88f9", size = 1448867, upload-time = "2026-05-14T22:44:17.99Z" }, + { url = "https://files.pythonhosted.org/packages/4c/19/a069dba1a634b703bf07fb49df8f7e3c04e9ba8ef3f0d9f4495f72630f92/ast_serialize-0.4.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4738790cf54d8b416de992b87ee567056980bc82134d52458bd4985f389d1658", size = 1264135, upload-time = "2026-05-14T22:44:19.8Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4c/76ec4279fecd7e78b60c3c99321f944c43cd11e5ff09c952746f5f9c0f4c/ast_serialize-0.4.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:faa008dccfcb793ae9101325e4d6d026caaa5d845c2182f03749c759834b0a3a", size = 1269060, upload-time = "2026-05-14T22:44:21.894Z" }, + { url = "https://files.pythonhosted.org/packages/33/c5/9230ef7481e5cb63b93a1f7738e959586202b081caf32b8bc5d9f673ef56/ast_serialize-0.4.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1c5245228e65d38cb48e1251f0ca71b0fa417e527141491e8c92f740e8e2d121", size = 1309654, upload-time = "2026-05-14T22:44:23.725Z" }, + { url = "https://files.pythonhosted.org/packages/b9/54/7d7397528d181ad68e476e0c81aa3ceff7d1f1b5c7fa958d6be28628ef16/ast_serialize-0.4.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8f5153e9c44a02e61f4042c5f9249d2e8a759773d621a0b2f445a899e536e181", size = 1418855, upload-time = "2026-05-14T22:44:25.415Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8f/87d6428adaa0986b817404f09329b64f8d2614cfe061ebf4951b4a7e0d19/ast_serialize-0.4.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1e1fb90def261f6a0db885876f7e1a49ad2dbac38ad9f2f62dba2f9543af16e7", size = 1516040, upload-time = "2026-05-14T22:44:27.535Z" }, + { url = "https://files.pythonhosted.org/packages/b5/bb/5aaa41a21314c8b0d6dee54867b16535682c6660dd28cac64dba1380062d/ast_serialize-0.4.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf2ff7b654c8e95143e20f5d75878cbb78b65b928b26c4d58ef71cdba9d6d981", size = 1511450, upload-time = "2026-05-14T22:44:29.522Z" }, + { url = "https://files.pythonhosted.org/packages/87/16/cc729b5bb4b21da99db1379266cc367512e82ba10f9b3300a6f3e9941325/ast_serialize-0.4.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:90fc5c0d35a22f1a92dd33635508626d50f8fc64deb897c23e78e666a60804c9", size = 1463654, upload-time = "2026-05-14T22:44:31.265Z" }, + { url = "https://files.pythonhosted.org/packages/43/97/7198321b0244d011093387b41affea934d58bda08d59a2adfde72976b6c4/ast_serialize-0.4.0-cp39-abi3-win32.whl", hash = "sha256:9ecd6a1fc1b86f1f4e8ae206759b6319c10019706b3496b01b54d02b9b2cd918", size = 1068636, upload-time = "2026-05-14T22:44:33.189Z" }, + { url = "https://files.pythonhosted.org/packages/10/09/3b868f6d8df4bbe452903a5e0e039ebcec9ea0045f1a77951546205097e8/ast_serialize-0.4.0-cp39-abi3-win_amd64.whl", hash = "sha256:79c8d015c771c8bfdb1208003b227b27c40034790a2c29c09f2317a041825ce2", size = 1107137, upload-time = "2026-05-14T22:44:35.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/78/9387dffccdc55a12734f83aaccc4a987404a217a2a12a1920d8d4585950b/ast_serialize-0.4.0-cp39-abi3-win_arm64.whl", hash = "sha256:1026f565a7ab846337c630909089b3346a2fe417bf1552b1581ab01852137407", size = 1079199, upload-time = "2026-05-14T22:44:36.816Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/e4/649c8d4f7f1709b6dbfc474358aa1bba02f67bcd52e2fec291a5014006cd/coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480", size = 219795, upload-time = "2026-05-10T17:59:48.198Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8d/46692d24b3f395d4cbf17bfcc57136b4f2f9c0c0df864b0bddfc1d71a014/coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4", size = 220299, upload-time = "2026-05-10T17:59:49.683Z" }, + { url = "https://files.pythonhosted.org/packages/12/c2/a40f5cb295bbcbb697a76947a56081c494c61950366294ee426ffe261099/coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7", size = 250721, upload-time = "2026-05-10T17:59:51.494Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/202235eb5c3c14c212462cd91d61b7386bf8fc44bc7a77f4742d2a69174b/coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed", size = 252633, upload-time = "2026-05-10T17:59:53.244Z" }, + { url = "https://files.pythonhosted.org/packages/bb/80/5f596e8995785124ee191c42535664c5e62c65995b66f4ca21e28ae04c81/coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980", size = 254743, upload-time = "2026-05-10T17:59:55.021Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6d/0d178825be2350f0adb27984d0aa7cf84bbdab201f6fb926b535d23a8f5f/coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0", size = 256700, upload-time = "2026-05-10T17:59:56.511Z" }, + { url = "https://files.pythonhosted.org/packages/19/5b/9e549c2f6e9dfea472adadba06c294e64735dabc2dd19015fac082095013/coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742", size = 250854, upload-time = "2026-05-10T17:59:57.94Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1c/b94f9f5f36396021ee2f62c5834b12e6a3d31f0bed5d6fc6d1c3caec087c/coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5", size = 252433, upload-time = "2026-05-10T17:59:59.688Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cb/d192cd8e1345eccabc32016f2d39072ecd10cb4f4b983ed8d0ebdeaf00dc/coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327", size = 250494, upload-time = "2026-05-10T18:00:01.953Z" }, + { url = "https://files.pythonhosted.org/packages/53/c5/aac9f460a41d835dbddef1d377f105f6ac2311d0f3c1588e9f51046d8813/coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d", size = 254261, upload-time = "2026-05-10T18:00:03.779Z" }, + { url = "https://files.pythonhosted.org/packages/23/aa/7af7c0081980a9cb3d289c5a435a4b7657dcecbd128e25c580e6a50389b5/coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20", size = 250216, upload-time = "2026-05-10T18:00:05.262Z" }, + { url = "https://files.pythonhosted.org/packages/35/60/a4257538ce2f6b978aeb51870d6c4208c510928a03db7e0339bb625dccb7/coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c", size = 251125, upload-time = "2026-05-10T18:00:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ab/f91af47642ec1aa53490e835a95847168d9c77fc39aa58527604c051e145/coverage-7.14.0-cp311-cp311-win32.whl", hash = "sha256:731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3", size = 222300, upload-time = "2026-05-10T18:00:08.608Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f0/a71ddbd874431e7a7cd96071f0c331cfbbad07704833c765d24ffbab8a67/coverage-7.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1", size = 223241, upload-time = "2026-05-10T18:00:10.746Z" }, + { url = "https://files.pythonhosted.org/packages/d8/6e/d9d312a5151a96cd110efee32efc3fc97b01ebd86203fe618ccb29cf4c92/coverage-7.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627", size = 221908, upload-time = "2026-05-10T18:00:12.242Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "librt" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, + { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, + { url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" }, + { url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" }, +] + +[[package]] +name = "mypy" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" }, + { url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" }, + { url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" }, + { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" }, + { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" }, + { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" }, + { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" }, + { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" }, + { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" }, + { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "types-python-dateutil" +version = "2.9.0.20260508" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/9b/ee1674cbe9ec50bb824f35a5dc0ce5fd1f1b6196ba1213e3fe6f33b4ce32/types_python_dateutil-2.9.0.20260508.tar.gz", hash = "sha256:596a6d63d81f587bf04c8254fb78df9d2344e915ce67948d7400512e3a6206d5", size = 17033, upload-time = "2026-05-08T04:47:08.248Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/7c/1788ff10edf56031d74ad3fba40947e0e8b82e3a529b30e6b7d71c191dec/types_python_dateutil-2.9.0.20260508-py3-none-any.whl", hash = "sha256:bfc6fd2d81aa86e5ac97206a64304f6bd247426eedbca9b98619bbc48c6a1c10", size = 18425, upload-time = "2026-05-08T04:47:07.207Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260510" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/85/0d9fafce21be112e977a89677f1ce9d1aef921d745b17c758c93e861c11f/types_pyyaml-6.0.12.20260510.tar.gz", hash = "sha256:09c1f1cb65a6eebea1e2e51ccf4918b8288e152909609a35cdb0d805efd125ad", size = 17831, upload-time = "2026-05-10T05:26:28.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/ad/fd618a218925daada7b8a5e7326e662599fa5fdff4a4c44ab2795bd2d9ca/types_pyyaml-6.0.12.20260510-py3-none-any.whl", hash = "sha256:3492eb9ba4d9d833473214c4d5736cccf5f37d93f5854059721e1c84f785309d", size = 20304, upload-time = "2026-05-10T05:26:26.981Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "vmssailor" +version = "0.1.0.dev0" +source = { editable = "." } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, +] + +[package.optional-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "types-python-dateutil" }, + { name = "types-pyyaml" }, +] + +[package.metadata] +requires-dist = [ + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, + { name = "pydantic", specifier = ">=2.5,<3.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1" }, + { name = "python-dateutil", specifier = ">=2.8" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" }, + { name = "types-python-dateutil", marker = "extra == 'dev'" }, + { name = "types-pyyaml", marker = "extra == 'dev'" }, +] +provides-extras = ["dev"] diff --git a/vmssailor/__init__.py b/vmssailor/__init__.py new file mode 100644 index 0000000..cea56a2 --- /dev/null +++ b/vmssailor/__init__.py @@ -0,0 +1,8 @@ +"""VMS-Sailor — Vessel Management System. + +Producto propietario de Álvaro. Ver LICENSE.txt. +""" + +from vmssailor.version import __version__ + +__all__ = ["__version__"] diff --git a/vmssailor/core/__init__.py b/vmssailor/core/__init__.py new file mode 100644 index 0000000..2908b96 --- /dev/null +++ b/vmssailor/core/__init__.py @@ -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", +] diff --git a/vmssailor/core/alarm.py b/vmssailor/core/alarm.py new file mode 100644 index 0000000..dec4a13 --- /dev/null +++ b/vmssailor/core/alarm.py @@ -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 diff --git a/vmssailor/core/card.py b/vmssailor/core/card.py new file mode 100644 index 0000000..266e55a --- /dev/null +++ b/vmssailor/core/card.py @@ -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 diff --git a/vmssailor/core/coords.py b/vmssailor/core/coords.py new file mode 100644 index 0000000..0fc6ba6 --- /dev/null +++ b/vmssailor/core/coords.py @@ -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]" diff --git a/vmssailor/core/enums.py b/vmssailor/core/enums.py new file mode 100644 index 0000000..e34c972 --- /dev/null +++ b/vmssailor/core/enums.py @@ -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" diff --git a/vmssailor/core/equipment.py b/vmssailor/core/equipment.py new file mode 100644 index 0000000..7490b6f --- /dev/null +++ b/vmssailor/core/equipment.py @@ -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.", + ) diff --git a/vmssailor/core/permissive.py b/vmssailor/core/permissive.py new file mode 100644 index 0000000..964c772 --- /dev/null +++ b/vmssailor/core/permissive.py @@ -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 diff --git a/vmssailor/core/persistence/__init__.py b/vmssailor/core/persistence/__init__.py new file mode 100644 index 0000000..cc09ab9 --- /dev/null +++ b/vmssailor/core/persistence/__init__.py @@ -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"] diff --git a/vmssailor/core/persistence/migrations.py b/vmssailor/core/persistence/migrations.py new file mode 100644 index 0000000..bcd045d --- /dev/null +++ b/vmssailor/core/persistence/migrations.py @@ -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) diff --git a/vmssailor/core/persistence/schema.sql b/vmssailor/core/persistence/schema.sql new file mode 100644 index 0000000..69f481b --- /dev/null +++ b/vmssailor/core/persistence/schema.sql @@ -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); diff --git a/vmssailor/core/persistence/vmsproj_reader.py b/vmssailor/core/persistence/vmsproj_reader.py new file mode 100644 index 0000000..5b12e5c --- /dev/null +++ b/vmssailor/core/persistence/vmsproj_reader.py @@ -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 diff --git a/vmssailor/core/persistence/vmsproj_writer.py b/vmssailor/core/persistence/vmsproj_writer.py new file mode 100644 index 0000000..5eba15c --- /dev/null +++ b/vmssailor/core/persistence/vmsproj_writer.py @@ -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, + ), + ) diff --git a/vmssailor/core/project.py b/vmssailor/core/project.py new file mode 100644 index 0000000..e123f1d --- /dev/null +++ b/vmssailor/core/project.py @@ -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), + } diff --git a/vmssailor/core/tag.py b/vmssailor/core/tag.py new file mode 100644 index 0000000..d046578 --- /dev/null +++ b/vmssailor/core/tag.py @@ -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 diff --git a/vmssailor/core/validation.py b/vmssailor/core/validation.py new file mode 100644 index 0000000..040eb50 --- /dev/null +++ b/vmssailor/core/validation.py @@ -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, + ) diff --git a/vmssailor/core/vessel.py b/vmssailor/core/vessel.py new file mode 100644 index 0000000..b7b9e81 --- /dev/null +++ b/vmssailor/core/vessel.py @@ -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) diff --git a/vmssailor/i18n/en.json b/vmssailor/i18n/en.json new file mode 100644 index 0000000..6ae4e63 --- /dev/null +++ b/vmssailor/i18n/en.json @@ -0,0 +1,39 @@ +{ + "_meta": { + "locale": "en", + "name": "English", + "description": "Secondary language. Regla de oro #13." + }, + "app": { + "name": "VMS-Sailor", + "tagline": "Vessel Management System" + }, + "common": { + "ok": "OK", + "cancel": "Cancel", + "save": "Save", + "delete": "Delete", + "yes": "Yes", + "no": "No", + "loading": "Loading…", + "error": "Error" + }, + "alarms": { + "priority_emergency": "Emergency", + "priority_high": "High", + "priority_low": "Low", + "priority_info": "Info", + "state_active": "Active", + "state_ack": "Acknowledged", + "state_cleared": "Cleared", + "ack_button": "Acknowledge" + }, + "trim": { + "screen_title": "Trim & Maneuver", + "reset_emergency": "Emergency reset", + "owner_manual_mode": "Owner manual mode", + "roll": "Roll", + "pitch": "Pitch", + "envelope": "Safety envelope" + } +} diff --git a/vmssailor/i18n/es.json b/vmssailor/i18n/es.json new file mode 100644 index 0000000..e8fb6c6 --- /dev/null +++ b/vmssailor/i18n/es.json @@ -0,0 +1,39 @@ +{ + "_meta": { + "locale": "es", + "name": "Español", + "description": "Idioma por defecto del producto. Regla de oro #13." + }, + "app": { + "name": "VMS-Sailor", + "tagline": "Vessel Management System" + }, + "common": { + "ok": "Aceptar", + "cancel": "Cancelar", + "save": "Guardar", + "delete": "Eliminar", + "yes": "Sí", + "no": "No", + "loading": "Cargando…", + "error": "Error" + }, + "alarms": { + "priority_emergency": "Emergencia", + "priority_high": "Alta", + "priority_low": "Baja", + "priority_info": "Información", + "state_active": "Activa", + "state_ack": "Reconocida", + "state_cleared": "Resuelta", + "ack_button": "Reconocer" + }, + "trim": { + "screen_title": "Trim y Maniobra", + "reset_emergency": "Reset emergencia", + "owner_manual_mode": "Modo manual del owner", + "roll": "Escora", + "pitch": "Cabeceo", + "envelope": "Sobre de seguridad" + } +} diff --git a/vmssailor/library/__init__.py b/vmssailor/library/__init__.py new file mode 100644 index 0000000..263c6ef --- /dev/null +++ b/vmssailor/library/__init__.py @@ -0,0 +1,29 @@ +"""vmssailor.library — Biblioteca curada (Sprint 0). + +Contiene: +- `systems_catalog.json` — catálogo maestro de sistemas instalables +- `vessels/` — plantillas de buques (Sunseeker 76, Ferretti 850, ...) +- `equipment/` — modelos de equipos (motores, gensets, bombas, ...) +- `rules/` — reglas heurísticas YAML por tipo de buque +- `loader.py` — carga y validación de toda la biblioteca + +**REGLA DE ORO #7:** La biblioteca curada es ORO. Cambios de formato +requieren migración para los proyectos existentes. + +**REGLA DE ORO #5:** Esta biblioteca pertenece a Álvaro (propiedad +intelectual). El cliente final nunca la ve cruda — sólo el resultado de +aplicarla en el .vmspack. +""" + +from pathlib import Path + +LIBRARY_ROOT: Path = Path(__file__).parent +"""Raíz del directorio de biblioteca curada (paquete instalado o source).""" + +from vmssailor.library.loader import ( # noqa: E402 + LibraryLoadResult, + load_library, + load_systems_catalog, +) + +__all__ = ["LIBRARY_ROOT", "LibraryLoadResult", "load_library", "load_systems_catalog"] diff --git a/vmssailor/library/equipment/engines/mtu_12v_2000_m96.json b/vmssailor/library/equipment/engines/mtu_12v_2000_m96.json new file mode 100644 index 0000000..ab49419 --- /dev/null +++ b/vmssailor/library/equipment/engines/mtu_12v_2000_m96.json @@ -0,0 +1,130 @@ +{ + "id": "mtu_12v_2000_m96", + "manufacturer": "MTU", + "model_name": "12V 2000 M96", + "category": "engine_main", + "typical_systems": ["main_engine"], + "specs": { + "power_kw": 1432, + "rpm_nominal": 2450, + "weight_kg": 2600, + "length_m": 2.13, + "width_m": 1.18, + "height_m": 1.27, + "fuel_consumption_lph": 320 + }, + "description": "MTU Series 2000, 12 cilindros en V, 24.0 L, 2-stage turbo. Aplicación yates rápidos / patrulleros / fast ferries. Common Rail. Habla J1939 nativo en su MCU.", + "data_source": "seed_estimate", + "default_sensors": [ + { + "id": "rpm", + "name": "RPM", + "unit_si": "rpm", + "range_normal_min": 0, + "range_normal_max": 2600, + "alarm_high_value": 2550, + "alarm_high_priority": "high", + "default_signal_type": "pulse_magnetic_pickup", + "description": "Régimen del cigüeñal." + }, + { + "id": "oil_press", + "name": "Presión de aceite", + "unit_si": "bar", + "range_normal_min": 3.5, + "range_normal_max": 6.5, + "alarm_low_value": 1.5, + "alarm_low_priority": "emergency", + "default_signal_type": "4-20ma", + "description": "Presión aceite lubricante en galería principal." + }, + { + "id": "oil_temp", + "name": "Temperatura de aceite", + "unit_si": "C", + "range_normal_min": 60, + "range_normal_max": 110, + "alarm_high_value": 120, + "alarm_high_priority": "high", + "default_signal_type": "rtd_pt100" + }, + { + "id": "coolant_temp", + "name": "Temperatura refrigerante", + "unit_si": "C", + "range_normal_min": 65, + "range_normal_max": 95, + "alarm_high_value": 100, + "alarm_high_priority": "emergency", + "default_signal_type": "rtd_pt100" + }, + { + "id": "boost_press", + "name": "Presión de sobrealimentación", + "unit_si": "bar", + "range_normal_min": 0.0, + "range_normal_max": 2.5, + "default_signal_type": "4-20ma" + }, + { + "id": "fuel_press", + "name": "Presión de combustible", + "unit_si": "bar", + "range_normal_min": 3.0, + "range_normal_max": 6.0, + "alarm_low_value": 2.0, + "alarm_low_priority": "high", + "default_signal_type": "4-20ma" + }, + { + "id": "alternator_v", + "name": "Voltaje alternador", + "unit_si": "V", + "range_normal_min": 27.0, + "range_normal_max": 29.0, + "alarm_low_value": 24.0, + "alarm_low_priority": "high", + "default_signal_type": "voltage_divider" + }, + { + "id": "load_pct", + "name": "Carga del motor", + "unit_si": "%", + "range_normal_min": 0, + "range_normal_max": 100, + "default_signal_type": "4-20ma" + }, + { + "id": "running_hours", + "name": "Horas totales", + "unit_si": "h", + "range_normal_min": 0, + "range_normal_max": 80000, + "default_signal_type": "4-20ma" + }, + { + "id": "start_cmd", + "name": "Comando arranque", + "unit_si": "bool", + "default_signal_type": "relay_no" + }, + { + "id": "stop_cmd", + "name": "Comando parada", + "unit_si": "bool", + "default_signal_type": "relay_no" + }, + { + "id": "running_state", + "name": "Estado motor en marcha", + "unit_si": "bool", + "default_signal_type": "dry_contact" + }, + { + "id": "estop_active", + "name": "E-stop activado", + "unit_si": "bool", + "default_signal_type": "dry_contact" + } + ] +} diff --git a/vmssailor/library/equipment/engines/volvo_d13_900hp.json b/vmssailor/library/equipment/engines/volvo_d13_900hp.json new file mode 100644 index 0000000..b234d4a --- /dev/null +++ b/vmssailor/library/equipment/engines/volvo_d13_900hp.json @@ -0,0 +1,112 @@ +{ + "id": "volvo_d13_900hp", + "manufacturer": "Volvo Penta", + "model_name": "D13-900", + "category": "engine_main", + "typical_systems": ["main_engine"], + "specs": { + "power_kw": 662, + "rpm_nominal": 2300, + "weight_kg": 1430, + "length_m": 1.69, + "width_m": 0.99, + "height_m": 1.16, + "fuel_consumption_lph": 160 + }, + "description": "Volvo Penta D13 inline-6, 12.8 L, common rail, twin-entry turbo. Aplicación yates motor 60-90 pies. Habla J1939 nativo.", + "data_source": "seed_estimate", + "default_sensors": [ + { + "id": "rpm", + "name": "RPM", + "unit_si": "rpm", + "range_normal_min": 0, + "range_normal_max": 2400, + "alarm_high_value": 2350, + "alarm_high_priority": "high", + "default_signal_type": "pulse_magnetic_pickup" + }, + { + "id": "oil_press", + "name": "Presión de aceite", + "unit_si": "bar", + "range_normal_min": 3.0, + "range_normal_max": 5.5, + "alarm_low_value": 1.5, + "alarm_low_priority": "emergency", + "default_signal_type": "4-20ma" + }, + { + "id": "oil_temp", + "name": "Temperatura de aceite", + "unit_si": "C", + "range_normal_min": 60, + "range_normal_max": 110, + "alarm_high_value": 120, + "alarm_high_priority": "high", + "default_signal_type": "rtd_pt100" + }, + { + "id": "coolant_temp", + "name": "Temperatura refrigerante", + "unit_si": "C", + "range_normal_min": 70, + "range_normal_max": 92, + "alarm_high_value": 98, + "alarm_high_priority": "emergency", + "default_signal_type": "rtd_pt100" + }, + { + "id": "boost_press", + "name": "Presión de sobrealimentación", + "unit_si": "bar", + "range_normal_min": 0.0, + "range_normal_max": 2.2, + "default_signal_type": "4-20ma" + }, + { + "id": "alternator_v", + "name": "Voltaje alternador", + "unit_si": "V", + "range_normal_min": 13.5, + "range_normal_max": 14.5, + "alarm_low_value": 12.0, + "alarm_low_priority": "high", + "default_signal_type": "voltage_divider" + }, + { + "id": "load_pct", + "name": "Carga del motor", + "unit_si": "%", + "range_normal_min": 0, + "range_normal_max": 100, + "default_signal_type": "4-20ma" + }, + { + "id": "running_hours", + "name": "Horas totales", + "unit_si": "h", + "range_normal_min": 0, + "range_normal_max": 80000, + "default_signal_type": "4-20ma" + }, + { + "id": "start_cmd", + "name": "Comando arranque", + "unit_si": "bool", + "default_signal_type": "relay_no" + }, + { + "id": "stop_cmd", + "name": "Comando parada", + "unit_si": "bool", + "default_signal_type": "relay_no" + }, + { + "id": "running_state", + "name": "Estado motor en marcha", + "unit_si": "bool", + "default_signal_type": "dry_contact" + } + ] +} diff --git a/vmssailor/library/equipment/gensets/northern_lights_m65c13.json b/vmssailor/library/equipment/gensets/northern_lights_m65c13.json new file mode 100644 index 0000000..4ce4994 --- /dev/null +++ b/vmssailor/library/equipment/gensets/northern_lights_m65c13.json @@ -0,0 +1,114 @@ +{ + "id": "northern_lights_m65c13", + "manufacturer": "Northern Lights", + "model_name": "M65C13", + "category": "genset", + "typical_systems": ["genset"], + "specs": { + "power_kw": 52, + "rpm_nominal": 1800, + "weight_kg": 880, + "length_m": 1.55, + "width_m": 0.74, + "height_m": 0.96, + "voltage_v": 230, + "current_a": 226, + "fuel_consumption_lph": 16.3 + }, + "description": "Genset marino diésel Northern Lights M65C13 (Lugger), 65 kVA / 52 kW @ 1800 rpm 60 Hz (válido también a 50 Hz con curva diferente). Motor John Deere 4045 base. Aplicación yates motor 70-90 pies y patrulleros medianos.", + "data_source": "seed_estimate", + "default_sensors": [ + { + "id": "rpm", + "name": "RPM", + "unit_si": "rpm", + "range_normal_min": 0, + "range_normal_max": 1850, + "default_signal_type": "pulse_magnetic_pickup" + }, + { + "id": "oil_press", + "name": "Presión de aceite", + "unit_si": "bar", + "range_normal_min": 2.5, + "range_normal_max": 4.5, + "alarm_low_value": 1.0, + "alarm_low_priority": "emergency", + "default_signal_type": "4-20ma" + }, + { + "id": "coolant_temp", + "name": "Temperatura refrigerante", + "unit_si": "C", + "range_normal_min": 70, + "range_normal_max": 95, + "alarm_high_value": 102, + "alarm_high_priority": "emergency", + "default_signal_type": "rtd_pt100" + }, + { + "id": "voltage_l1", + "name": "Tensión L1", + "unit_si": "V", + "range_normal_min": 220, + "range_normal_max": 240, + "alarm_low_value": 200, + "alarm_low_priority": "high", + "alarm_high_value": 250, + "alarm_high_priority": "high", + "default_signal_type": "voltage_divider" + }, + { + "id": "current_l1", + "name": "Corriente L1", + "unit_si": "A", + "range_normal_min": 0, + "range_normal_max": 230, + "alarm_high_value": 250, + "alarm_high_priority": "high", + "default_signal_type": "4-20ma" + }, + { + "id": "freq", + "name": "Frecuencia", + "unit_si": "Hz", + "range_normal_min": 49.5, + "range_normal_max": 60.5, + "default_signal_type": "pulse_inductive" + }, + { + "id": "load_pct", + "name": "Carga", + "unit_si": "%", + "range_normal_min": 0, + "range_normal_max": 100, + "default_signal_type": "4-20ma" + }, + { + "id": "running_hours", + "name": "Horas totales", + "unit_si": "h", + "range_normal_min": 0, + "range_normal_max": 80000, + "default_signal_type": "4-20ma" + }, + { + "id": "start_cmd", + "name": "Comando arranque", + "unit_si": "bool", + "default_signal_type": "relay_no" + }, + { + "id": "stop_cmd", + "name": "Comando parada", + "unit_si": "bool", + "default_signal_type": "relay_no" + }, + { + "id": "breaker_status", + "name": "Estado breaker principal", + "unit_si": "bool", + "default_signal_type": "dry_contact" + } + ] +} diff --git a/vmssailor/library/loader.py b/vmssailor/library/loader.py new file mode 100644 index 0000000..78c932a --- /dev/null +++ b/vmssailor/library/loader.py @@ -0,0 +1,214 @@ +"""Carga y validación de la biblioteca curada. + +Funciones públicas: + +- `load_systems_catalog()` — devuelve el dict del catálogo maestro +- `load_library(root=None)` — carga TODA la biblioteca y devuelve un + `LibraryLoadResult` con vessels, equipment, + rules y la lista de issues. + +El loader es defensivo: NO levanta excepciones por archivos individuales +malos. Acumula issues y deja al caller decidir si abortar. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml +from pydantic import ValidationError + +from vmssailor.core.equipment import EquipmentModel +from vmssailor.core.vessel import Vessel + + +@dataclass(slots=True) +class LibraryIssue: + """Un problema encontrado al cargar la biblioteca.""" + + severity: str # "error" | "warning" | "info" + path: str + message: str + + def __str__(self) -> str: + return f"{self.severity.upper():7s} {self.path:60s} {self.message}" + + +@dataclass(slots=True) +class LibraryLoadResult: + """Resultado de cargar la biblioteca completa.""" + + vessels: list[Vessel] = field(default_factory=list) + equipment_models: list[EquipmentModel] = field(default_factory=list) + rules: dict[str, dict[str, Any]] = field(default_factory=dict) + systems_catalog: dict[str, Any] = field(default_factory=dict) + issues: list[LibraryIssue] = field(default_factory=list) + + @property + def errors(self) -> list[LibraryIssue]: + return [i for i in self.issues if i.severity == "error"] + + @property + def warnings(self) -> list[LibraryIssue]: + return [i for i in self.issues if i.severity == "warning"] + + def ok(self) -> bool: + return len(self.errors) == 0 + + def format(self) -> str: + lines: list[str] = [] + lines.append("Library load summary:") + lines.append(f" Vessels : {len(self.vessels)}") + lines.append(f" EquipmentModels: {len(self.equipment_models)}") + lines.append(f" Rules : {len(self.rules)}") + lines.append(f" Issues : {len(self.errors)} errors, {len(self.warnings)} warnings") + if self.issues: + lines.append("") + for i in self.issues: + lines.append(f" {i}") + return "\n".join(lines) + + +def load_systems_catalog(root: Path | None = None) -> dict[str, Any]: + """Carga el catálogo maestro de sistemas.""" + base = root or _default_root() + catalog_path = base / "systems_catalog.json" + if not catalog_path.exists(): + raise FileNotFoundError(f"systems_catalog.json no encontrado en {base}") + return json.loads(catalog_path.read_text(encoding="utf-8")) + + +def load_library(root: Path | None = None) -> LibraryLoadResult: + """Carga la biblioteca completa y devuelve un `LibraryLoadResult`.""" + base = root or _default_root() + result = LibraryLoadResult() + + # systems_catalog + try: + result.systems_catalog = load_systems_catalog(base) + except Exception as exc: + result.issues.append( + LibraryIssue("error", "systems_catalog.json", f"{type(exc).__name__}: {exc}") + ) + + # vessels/ + vessels_dir = base / "vessels" + if vessels_dir.exists(): + for f in sorted(vessels_dir.glob("*.json")): + try: + data = json.loads(f.read_text(encoding="utf-8")) + v = Vessel(**data) + result.vessels.append(v) + if v.data_source == "seed_estimate": + result.issues.append( + LibraryIssue( + "info", + str(f.relative_to(base)), + "data_source=seed_estimate — requiere validación de Álvaro.", + ) + ) + except ValidationError as exc: + result.issues.append( + LibraryIssue("error", str(f.relative_to(base)), f"Pydantic: {exc.errors()}") + ) + except Exception as exc: + result.issues.append( + LibraryIssue( + "error", str(f.relative_to(base)), f"{type(exc).__name__}: {exc}" + ) + ) + + # equipment/**/*.json + eq_dir = base / "equipment" + if eq_dir.exists(): + for f in sorted(eq_dir.glob("**/*.json")): + try: + data = json.loads(f.read_text(encoding="utf-8")) + em = EquipmentModel(**data) + result.equipment_models.append(em) + if em.data_source == "seed_estimate": + result.issues.append( + LibraryIssue( + "info", + str(f.relative_to(base)), + "data_source=seed_estimate — requiere validación.", + ) + ) + except ValidationError as exc: + result.issues.append( + LibraryIssue("error", str(f.relative_to(base)), f"Pydantic: {exc.errors()}") + ) + except Exception as exc: + result.issues.append( + LibraryIssue( + "error", str(f.relative_to(base)), f"{type(exc).__name__}: {exc}" + ) + ) + + # rules/*.yaml + rules_dir = base / "rules" + if rules_dir.exists(): + for f in sorted(rules_dir.glob("*.yaml")): + try: + data = yaml.safe_load(f.read_text(encoding="utf-8")) or {} + meta = data.get("meta") or {} + rule_id = meta.get("rule_id") or f.stem + result.rules[rule_id] = data + except Exception as exc: + result.issues.append( + LibraryIssue( + "error", str(f.relative_to(base)), f"{type(exc).__name__}: {exc}" + ) + ) + + # Validación cross-references + _validate_cross_refs(result) + + return result + + +def _validate_cross_refs(result: LibraryLoadResult) -> None: + """Chequea referencias entre rules → equipment models → systems.""" + em_ids = {em.id for em in result.equipment_models} + valid_systems = set() + for cat in result.systems_catalog.get("categories", []): + for s in cat.get("systems", []): + valid_systems.add(s["id"]) + + # Chequear que equipment.typical_systems referencien sistemas válidos + for em in result.equipment_models: + for sys_id in em.typical_systems: + if sys_id.value not in valid_systems: + result.issues.append( + LibraryIssue( + "error", + f"equipment/.../{em.id}.json", + f"typical_systems contiene '{sys_id.value}' que no existe en " + "systems_catalog.json.", + ) + ) + + # Chequear que rules referencien EquipmentModel.id existentes + for rule_id, rule_data in result.rules.items(): + proposals = rule_data.get("equipment_proposals") or {} + for _sys_id, sys_proposals in proposals.items(): + if not isinstance(sys_proposals, dict): + continue + for cand in sys_proposals.get("candidates", []) or []: + model_ref = cand.get("model_ref") + if model_ref and model_ref not in em_ids: + result.issues.append( + LibraryIssue( + "warning", + f"rules/{rule_id}.yaml", + f"Rule referencia model_ref='{model_ref}' que no existe en " + "equipment_models cargados.", + ) + ) + + +def _default_root() -> Path: + return Path(__file__).parent diff --git a/vmssailor/library/rules/yacht_motor_planeo.yaml b/vmssailor/library/rules/yacht_motor_planeo.yaml new file mode 100644 index 0000000..17e6ad0 --- /dev/null +++ b/vmssailor/library/rules/yacht_motor_planeo.yaml @@ -0,0 +1,161 @@ +# Reglas heurísticas para yates motor planeo 20-30 m. +# +# Este archivo captura el conocimiento de Álvaro sobre qué sistemas y qué +# equipos lleva típicamente un yate motor planeo del segmento objetivo. +# El motor de reglas del Studio (Sprint 2) consulta este archivo en el +# Paso 5 del wizard para proponer equipos al integrador. +# +# Filosofía: PROPONE, no impone. El integrador siempre puede ajustar. +# data_source de cada propuesta es "seed_estimate" y queda en +# docs/seed_data_notes.md hasta que Álvaro lo valide contra proyectos reales. + +meta: + version: 1 + rule_id: yacht_motor_planeo + applies_to: + vessel_types: ["yacht_motor"] + vessel_subtypes: ["planing", "semi_planing"] + length_overall_m: + min: 18.0 + max: 32.0 + data_source: seed_estimate + +# ----- Sistemas que típicamente se incluyen ------------------------------- + +systems_default_enabled: + - main_engine + - transmission + - shaft_propeller + - thruster + - trim_tabs + - genset + - shore_power + - msb + - solar + - fuel + - lube_oil + - fw_cooling + - sw_cooling + - bilge + - potable_water + - watermaker + - fire_detection + - fire_extinguishing + - hvac + - engine_vent + - nav_lights + - deck_lights + - interior_lights + - emergency_lights + - fuel_tanks + - water_tanks + - grey_black_tanks + - windlass + - anchor_system + +systems_optional: + - gyrostabilizer # Seakeeper se vuelve muy común en este rango + - joystick_docking + - inverter_charger + - battery_bank + - searchlights + - davits + - gangway + +# ----- Equipos propuestos por sistema -------------------------------------- + +equipment_proposals: + + main_engine: + # Para yates de 20-25 m, MTU o Volvo en pares. Para 25-32 m, MTU. + count: 2 + candidates: + - model_ref: mtu_12v_2000_m96 + when: + length_overall_m: { min: 22.0, max: 32.0 } + rationale: "Estándar de oro en este rango. Buena disponibilidad de partes y servicio." + - model_ref: volvo_d13_900hp + when: + length_overall_m: { min: 18.0, max: 26.0 } + rationale: "Más liviano y económico que MTU 2000. Servicio mundial Volvo Penta." + location_template: + port: { x_pp_pct: 0.25, y_cl: -0.9, z_bl: 1.2 } + starboard: { x_pp_pct: 0.25, y_cl: 0.9, z_bl: 1.2 } + tag_prefix_template: "ME_{side}" + sides: ["PORT", "STBD"] + + genset: + count: 1 + candidates: + - model_ref: northern_lights_m65c13 + when: + length_overall_m: { min: 18.0, max: 30.0 } + rationale: "Confiabilidad probada. Aceptado por clase RINA/Lloyd's con poco trámite." + location_template: + default: { x_pp_pct: 0.20, y_cl: 0.0, z_bl: 1.0 } + tag_prefix_template: "GEN_{idx}" + + fuel: + # Sin modelo concreto — el integrador definirá tanques estructurales en Paso 6. + sensors_per_tank: + - level + - temperature + typical_tank_count: 2 + tag_prefix_template: "TANK_FUEL_{idx}" + + bilge: + typical_pump_count: 3 + tag_prefix_template: "BILGE_{location}" + locations_template: ["FWD", "MID", "AFT"] + +# ----- Permissives típicos a sugerir --------------------------------------- + +permissives_template: + + - id: start_main_engine + action_id_template: "START_{tag_prefix}" + apply_to: ["main_engine"] + conditions: + - tag_ref_template: "{tag_prefix}.OIL_PRESS" + operator: ">" + threshold: 0.3 + message_on_fail: "Presión aceite previa al arranque demasiado baja (lubricación insuficiente)." + - tag_ref_template: "{tag_prefix}.COOLANT_TEMP" + operator: ">" + threshold: 5.0 + message_on_fail: "Refrigerante por debajo de 5°C — pre-calentar antes de arrancar." + - tag_ref_template: "{tag_prefix}.ESTOP_ACTIVE" + operator: "is_false" + message_on_fail: "Pulsador E-stop activado — desbloquear antes de arrancar." + on_fail_message: "Pre-condiciones de arranque del motor principal no cumplidas." + + - id: start_genset + action_id_template: "START_{tag_prefix}" + apply_to: ["genset"] + conditions: + - tag_ref_template: "{tag_prefix}.OIL_PRESS" + operator: ">" + threshold: 0.3 + message_on_fail: "Presión aceite previa baja." + - tag_ref_template: "{tag_prefix}.COOLANT_TEMP" + operator: ">" + threshold: 0.0 + message_on_fail: "Refrigerante demasiado frío para arranque seguro." + on_fail_message: "Pre-condiciones de arranque del genset no cumplidas." + +# ----- Topología sugerida de tarjetas AR-NMEA-IO -------------------------- + +topology_template: + # Patrón típico para yate planeo con 2 motores + 1 genset + tanques + auxiliares: + # 5-7 tarjetas distribuidas. Una maestra Modbus en el PC industrial. + cards_estimate: + min: 5 + typical: 6 + max: 8 + buses: + - id: bus_main + protocol: modbus_rtu + role: "Maestra en PC industrial central. Esclavas distribuidas." + - id: bus_n2k + protocol: nmea2000 + role: "Backbone NMEA 2000 del buque. Motores y gensets en modo dual." diff --git a/vmssailor/library/systems_catalog.json b/vmssailor/library/systems_catalog.json new file mode 100644 index 0000000..a13eeb5 --- /dev/null +++ b/vmssailor/library/systems_catalog.json @@ -0,0 +1,151 @@ +{ + "_meta": { + "version": 1, + "source": "VMS_Sailor_v2_Parte_01.md section 7", + "notes": "Catálogo maestro completo. Sirve de checklist para el wizard del Studio (paso 4). Lo que el integrador marca define el menú lateral del Runtime. SystemId values deben coincidir con vmssailor.core.enums.SystemId." + }, + "categories": [ + { + "id": "propulsion", + "name": "Propulsión y maquinaria", + "systems": [ + { "id": "main_engine", "name": "Máquina principal", "name_en": "Main engine", "default_for": ["yacht_motor", "fishing", "patrol", "ferry", "offshore_support"] }, + { "id": "transmission", "name": "Transmisiones / reductoras", "name_en": "Transmissions" }, + { "id": "shaft_propeller", "name": "Ejes y hélices", "name_en": "Shafts & propellers" }, + { "id": "thruster", "name": "Hélices de proa/popa", "name_en": "Bow/stern thrusters" } + ] + }, + { + "id": "maneuvering", + "name": "Maniobra y trimado", + "systems": [ + { "id": "trim_sterndrive", "name": "Trim de motores / sterndrives", "name_en": "Engine trim" }, + { "id": "trim_tabs", "name": "Trim tabs", "name_en": "Trim tabs (Bennett, Lenco)" }, + { "id": "cpp", "name": "Hélices de paso variable", "name_en": "Controllable Pitch Propellers" }, + { "id": "gyrostabilizer", "name": "Estabilizadores girostáticos", "name_en": "Gyrostabilizers (Seakeeper, Quick MC²)" }, + { "id": "fin_stabilizer", "name": "Estabilizadores de aletas activas", "name_en": "Active fin stabilizers" }, + { "id": "joystick_docking", "name": "Joystick docking", "name_en": "Joystick docking" } + ] + }, + { + "id": "electrical_generation", + "name": "Generación eléctrica", + "systems": [ + { "id": "genset", "name": "Gensets diésel", "name_en": "Diesel gensets", "default_for": ["yacht_motor", "fishing", "patrol", "ferry", "offshore_support"] }, + { "id": "shore_power", "name": "Shore power con transferencia", "name_en": "Shore power with ATS" }, + { "id": "inverter_charger", "name": "Inversores/cargadores combinados", "name_en": "Inverter/chargers (Victron, Mastervolt)" }, + { "id": "battery_bank", "name": "Bancos de baterías litio con BMS", "name_en": "Lithium battery banks with BMS" }, + { "id": "msb", "name": "Cuadros principales (MSB)", "name_en": "Main switchboards", "default_for": ["yacht_motor", "fishing", "patrol", "ferry"] }, + { "id": "esb", "name": "Cuadros de emergencia (ESB)", "name_en": "Emergency switchboards" }, + { "id": "ups", "name": "UPS", "name_en": "UPS" }, + { "id": "solar", "name": "Paneles solares + MPPT", "name_en": "Solar + MPPT" }, + { "id": "smart_dc_busbar", "name": "Smart busbars DC", "name_en": "Smart DC busbars (Lynx Smart BMS)" }, + { "id": "smart_panel", "name": "Tableros inteligentes con monitoreo", "name_en": "Smart panels with embedded monitoring" } + ] + }, + { + "id": "electrical_isolation", + "name": "Aislamiento eléctrico", + "systems": [ + { "id": "sectionalizing", "name": "Aislamiento por sectores", "name_en": "Sectionalizing" }, + { "id": "emergency_isolation", "name": "Aislamiento total de emergencia", "name_en": "Total emergency isolation" }, + { "id": "breakers", "name": "Breakers configurables", "name_en": "Configurable breakers" }, + { "id": "lockout_tagout", "name": "Lockout-tagout digital", "name_en": "Digital lockout-tagout" } + ] + }, + { + "id": "fluids", + "name": "Fluidos del buque", + "systems": [ + { "id": "fuel", "name": "Combustible (DO/MDO)", "name_en": "Fuel oil", "default_for": ["yacht_motor", "fishing", "patrol", "ferry", "offshore_support"] }, + { "id": "lube_oil", "name": "Aceite lubricante", "name_en": "Lube oil" }, + { "id": "hydraulic_oil", "name": "Aceite hidráulico", "name_en": "Hydraulic oil" }, + { "id": "fw_cooling", "name": "Refrigeración agua dulce", "name_en": "Fresh water cooling" }, + { "id": "sw_cooling", "name": "Refrigeración agua salada", "name_en": "Sea water cooling" }, + { "id": "starting_air", "name": "Aire de arranque / aire comprimido", "name_en": "Starting / compressed air" }, + { "id": "bilge", "name": "Sentinas", "name_en": "Bilge", "default_for": ["yacht_motor", "fishing", "patrol", "ferry", "offshore_support"] }, + { "id": "ballast", "name": "Lastre", "name_en": "Ballast" }, + { "id": "grey_water", "name": "Aguas grises", "name_en": "Grey water" }, + { "id": "black_water", "name": "Aguas negras", "name_en": "Black water" }, + { "id": "potable_water", "name": "Agua potable", "name_en": "Potable water", "default_for": ["yacht_motor", "ferry"] }, + { "id": "sw_service", "name": "Agua salada de servicio", "name_en": "Sea water service" }, + { "id": "watermaker", "name": "Watermaker", "name_en": "Watermaker (desalinator)" } + ] + }, + { + "id": "safety", + "name": "Seguridad", + "systems": [ + { "id": "fire_detection", "name": "Detección de incendio", "name_en": "Fire detection", "default_for": ["yacht_motor", "fishing", "patrol", "ferry", "offshore_support"] }, + { "id": "fire_extinguishing", "name": "Extinción (CO₂, HiFog, espuma)", "name_en": "Fire extinguishing" }, + { "id": "fifi_external", "name": "FiFi externo", "name_en": "External FiFi monitors" }, + { "id": "emergency_bilge", "name": "Achique de emergencia", "name_en": "Emergency bilge" }, + { "id": "gas_detection", "name": "Detección de gases", "name_en": "Gas detection" }, + { "id": "mob", "name": "Hombre al agua (MOB)", "name_en": "Man overboard" } + ] + }, + { + "id": "environment", + "name": "Ambiente y confort", + "systems": [ + { "id": "hvac", "name": "HVAC / aire acondicionado", "name_en": "HVAC", "default_for": ["yacht_motor", "ferry"] }, + { "id": "engine_vent", "name": "Ventilación de máquinas", "name_en": "Engine room ventilation", "default_for": ["yacht_motor", "fishing", "patrol", "ferry", "offshore_support"] }, + { "id": "heating", "name": "Calefacción", "name_en": "Heating" }, + { "id": "refrigeration", "name": "Refrigeración (cámaras, neveras)", "name_en": "Refrigeration" } + ] + }, + { + "id": "lighting", + "name": "Iluminación", + "systems": [ + { "id": "nav_lights", "name": "Luces de navegación", "name_en": "Navigation lights", "default_for": ["yacht_motor", "fishing", "patrol", "ferry", "offshore_support"] }, + { "id": "deck_lights", "name": "Luces de cubierta", "name_en": "Deck lights" }, + { "id": "interior_lights", "name": "Luces interiores por sector", "name_en": "Interior lights" }, + { "id": "emergency_lights", "name": "Luces de emergencia", "name_en": "Emergency lights" }, + { "id": "searchlights", "name": "Reflectores", "name_en": "Searchlights" } + ] + }, + { + "id": "structural_tanks", + "name": "Tanques estructurales", + "systems": [ + { "id": "fuel_tanks", "name": "Tanques de combustible", "name_en": "Fuel tanks", "default_for": ["yacht_motor", "fishing", "patrol", "ferry", "offshore_support"] }, + { "id": "water_tanks", "name": "Tanques de agua", "name_en": "Water tanks" }, + { "id": "grey_black_tanks", "name": "Tanques de aguas grises/negras", "name_en": "Grey/black water tanks" }, + { "id": "voids", "name": "Voids", "name_en": "Voids" }, + { "id": "cofferdams", "name": "Cofferdams", "name_en": "Cofferdams" } + ] + }, + { + "id": "deck_maneuvering", + "name": "Cubierta y maniobra", + "systems": [ + { "id": "windlass", "name": "Cabrestantes / molinetes", "name_en": "Windlasses" }, + { "id": "anchor_system", "name": "Sistema de anclas", "name_en": "Anchor system" }, + { "id": "mooring", "name": "Sistema de amarre", "name_en": "Mooring system" }, + { "id": "davits", "name": "Davits / pescantes", "name_en": "Davits" }, + { "id": "gangway", "name": "Pasarelas / portalones", "name_en": "Gangways" }, + { "id": "crane", "name": "Grúas", "name_en": "Cranes" } + ] + }, + { + "id": "vessel_specific", + "name": "Específicos por tipo de buque", + "systems": [ + { "id": "fishing_machinery", "name": "Maquinaria de pesca", "name_en": "Fishing machinery", "default_for": ["fishing"] }, + { "id": "large_fridge_holds", "name": "Cámaras frigoríficas grandes", "name_en": "Large refrigerated holds", "default_for": ["fishing"] }, + { "id": "rov", "name": "ROV / equipos sumergibles", "name_en": "ROV / submersibles", "default_for": ["offshore_support"] }, + { "id": "diving_system", "name": "Sistema de buceo", "name_en": "Diving system" } + ] + } + ], + "_excluded_from_vms_sailor": { + "note": "Estos pertenecen al AR-ECDIS, NO se incluyen en el VMS-Sailor.", + "items": [ + "ECDIS / radar / AIS", + "Piloto automático", + "Comunicaciones VHF/HF/SatCom", + "GPS y sensores de actitud (vienen del AR-ECDIS por NMEA 2000)" + ] + } +} diff --git a/vmssailor/library/vessels/ferretti_850.json b/vmssailor/library/vessels/ferretti_850.json new file mode 100644 index 0000000..663b62a --- /dev/null +++ b/vmssailor/library/vessels/ferretti_850.json @@ -0,0 +1,22 @@ +{ + "id": "ferretti_850", + "name": "Ferretti 850", + "type": "yacht_motor", + "subtype": "planing", + "length_overall_m": 26.04, + "beam_max_m": 6.28, + "draft_m": 1.98, + "displacement_kg": 68000, + "description": "Yate motor planeo italiano de 26 m, casco semi-V con propulsión convencional, 3 cubiertas, 4 cabinas + tripulación. Motores en V centrales, 2 gensets, A/A multizona. Tope típico ~30 nudos.", + "data_source": "seed_estimate", + "decks": [ + { "id": "lower", "name": "Cubierta inferior", "z_bl_bottom": 0.5, "z_bl_top": 2.7, "polygon_xy": [] }, + { "id": "main", "name": "Cubierta principal", "z_bl_bottom": 2.7, "z_bl_top": 4.9, "polygon_xy": [] }, + { "id": "flybridge", "name": "Flybridge", "z_bl_bottom": 4.9, "z_bl_top": 6.6, "polygon_xy": [] } + ], + "bulkheads": [ + { "id": "collision", "name": "Mamparo de colisión", "x_pp": 23.5, "description": "" }, + { "id": "er_fwd", "name": "Mamparo proa SM", "x_pp": 8.0, "description": "" }, + { "id": "er_aft", "name": "Mamparo popa SM", "x_pp": 4.0, "description": "" } + ] +} diff --git a/vmssailor/library/vessels/sunseeker_76.json b/vmssailor/library/vessels/sunseeker_76.json new file mode 100644 index 0000000..3fd207f --- /dev/null +++ b/vmssailor/library/vessels/sunseeker_76.json @@ -0,0 +1,40 @@ +{ + "id": "sunseeker_76", + "name": "Sunseeker 76 Yacht", + "type": "yacht_motor", + "subtype": "planing", + "length_overall_m": 23.45, + "beam_max_m": 5.65, + "draft_m": 1.85, + "displacement_kg": 55000, + "description": "Yate motor planeo británico de 23.4 m, casco semi-V profundo, 3 cubiertas (lower, main, flybridge), 4 cabinas. Sala de máquinas central con 2 motores principales en V, 1-2 gensets, sistemas de A/A y refrigeración. Tope típico ~32 nudos.", + "data_source": "seed_estimate", + "decks": [ + { + "id": "lower", + "name": "Cubierta inferior (Lower Deck)", + "z_bl_bottom": 0.5, + "z_bl_top": 2.6, + "polygon_xy": [] + }, + { + "id": "main", + "name": "Cubierta principal (Main Deck)", + "z_bl_bottom": 2.6, + "z_bl_top": 4.8, + "polygon_xy": [] + }, + { + "id": "flybridge", + "name": "Flybridge", + "z_bl_bottom": 4.8, + "z_bl_top": 6.4, + "polygon_xy": [] + } + ], + "bulkheads": [ + { "id": "collision", "name": "Mamparo de colisión", "x_pp": 21.0, "description": "Mamparo de colisión (proa)" }, + { "id": "er_fwd", "name": "Mamparo proa sala de máquinas", "x_pp": 7.0, "description": "" }, + { "id": "er_aft", "name": "Mamparo popa sala de máquinas", "x_pp": 3.5, "description": "" } + ] +} diff --git a/vmssailor/runtime/__init__.py b/vmssailor/runtime/__init__.py new file mode 100644 index 0000000..274c87d --- /dev/null +++ b/vmssailor/runtime/__init__.py @@ -0,0 +1,5 @@ +"""vmssailor.runtime — Servidor 24/7 y cliente desktop a bordo (Sprint 4+). + +En Sprint 0 está vacío. Sprint 4 trae el servicio Windows + drivers Modbus + +historian + alarm engine + API. Ver `VMS_Sailor_v2_Parte_03_Runtime.md`. +""" diff --git a/vmssailor/shared/__init__.py b/vmssailor/shared/__init__.py new file mode 100644 index 0000000..a22b87e --- /dev/null +++ b/vmssailor/shared/__init__.py @@ -0,0 +1 @@ +"""vmssailor.shared — Utilidades compartidas (logging, IDs, etc).""" diff --git a/vmssailor/shared/ids.py b/vmssailor/shared/ids.py new file mode 100644 index 0000000..5462fb6 --- /dev/null +++ b/vmssailor/shared/ids.py @@ -0,0 +1,68 @@ +"""Generación de IDs deterministas para el modelo de datos. + +Reglas: + +- IDs de Tag son **deterministas**: `{equipment.tag_prefix}.{sensor.id_upper}` + para que un mismo proyecto produzca siempre el mismo grafo de tags. +- IDs aleatorios de proyecto/equipo usan UUID4 si el integrador no provee + uno. +- IDs de Alarm (instancia activa) son timestamps + tag_id en hash corto. +""" + +from __future__ import annotations + +import re +import uuid + +_TAG_PREFIX_RE = re.compile(r"^[A-Z][A-Z0-9_]*$") +_SENSOR_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$") + + +def make_tag_id(equipment_tag_prefix: str, sensor_id: str) -> str: + """Compone un Tag.id determinista: 'ME_PORT' + 'oil_press' → 'ME_PORT.OIL_PRESS'. + + Reglas: + - `equipment_tag_prefix` debe ser MAYÚSCULAS y empezar con letra. + - `sensor_id` debe ser snake_case minúsculas. + - Resultado: prefix + "." + sensor.upper(). + """ + if not _TAG_PREFIX_RE.match(equipment_tag_prefix): + raise ValueError( + f"equipment_tag_prefix '{equipment_tag_prefix}' debe ser MAYÚSCULAS " + "[A-Z][A-Z0-9_]*." + ) + if not _SENSOR_ID_RE.match(sensor_id): + raise ValueError( + f"sensor_id '{sensor_id}' debe ser snake_case [a-z][a-z0-9_]*." + ) + return f"{equipment_tag_prefix}.{sensor_id.upper()}" + + +def make_alarm_config_id(tag_id: str, level_name: str) -> str: + """ID determinista para un AlarmConfig: 'ME_PORT.OIL_PRESS.LOW'.""" + if not level_name.replace("_", "").isalnum(): + raise ValueError(f"level_name '{level_name}' inválido.") + return f"{tag_id}.{level_name.upper()}" + + +def make_alarm_instance_id(tag_id: str, alarm_config_id: str, timestamp_unix: float) -> str: + """ID determinista para una INSTANCIA activa de alarma. + + Garantiza unicidad por timestamp y por config. + """ + short = hex(int(timestamp_unix * 1000))[2:][-10:] + return f"alm.{alarm_config_id}.{short}" + + +def new_uuid() -> str: + """UUID4 para IDs de proyecto, equipos, cartas sin nombre semántico.""" + return str(uuid.uuid4()) + + +def make_project_id(customer_slug: str, vessel_id: str) -> str: + """Compone un Project.id estable a partir de cliente + buque.""" + customer = re.sub(r"[^a-z0-9]+", "_", customer_slug.lower()).strip("_") + vessel = re.sub(r"[^a-z0-9_-]+", "_", vessel_id.lower()).strip("_") + if not customer or not vessel: + raise ValueError("customer_slug y vessel_id no pueden quedar vacíos tras normalizar.") + return f"{customer}__{vessel}" diff --git a/vmssailor/shared/logging_setup.py b/vmssailor/shared/logging_setup.py new file mode 100644 index 0000000..6a1b405 --- /dev/null +++ b/vmssailor/shared/logging_setup.py @@ -0,0 +1,43 @@ +"""Logging unificado para Studio, Runtime y tools. + +Sprint 0: configuración mínima. Sprint 4+ agregará rotación, file handlers, +journald, etc. +""" + +from __future__ import annotations + +import logging +import sys +from typing import Final + +_DEFAULT_FORMAT: Final[str] = ( + "%(asctime)s %(levelname)-7s %(name)-30s :: %(message)s" +) +_DEFAULT_DATEFMT: Final[str] = "%Y-%m-%d %H:%M:%S" + +_configured: bool = False + + +def setup_logging(level: int = logging.INFO, *, verbose: bool = False) -> None: + """Configura el root logger una sola vez. + + Idempotente: llamadas posteriores son no-op (a menos que se cambie level). + """ + global _configured + root = logging.getLogger() + if _configured: + root.setLevel(level) + return + + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(logging.Formatter(_DEFAULT_FORMAT, datefmt=_DEFAULT_DATEFMT)) + root.addHandler(handler) + root.setLevel(logging.DEBUG if verbose else level) + _configured = True + + +def get_logger(name: str) -> logging.Logger: + """Logger de módulo. Asegura que setup_logging() se haya llamado.""" + if not _configured: + setup_logging() + return logging.getLogger(name) diff --git a/vmssailor/studio/__init__.py b/vmssailor/studio/__init__.py new file mode 100644 index 0000000..a62a183 --- /dev/null +++ b/vmssailor/studio/__init__.py @@ -0,0 +1,5 @@ +"""vmssailor.studio — Aplicación de escritorio del integrador (Sprint 1+). + +En Sprint 0 está vacío. Sprint 1 trae la shell PySide6, ventana principal y +wizard pasos 1-4. Ver `VMS_Sailor_v2_Parte_02_Studio.md`. +""" diff --git a/vmssailor/tools/__init__.py b/vmssailor/tools/__init__.py new file mode 100644 index 0000000..5ea854e --- /dev/null +++ b/vmssailor/tools/__init__.py @@ -0,0 +1,7 @@ +"""CLI tools del Sprint 0. + +Estos módulos se exponen como scripts en `pyproject.toml`: + +- `vms-validate-library` → vmssailor.tools.validate_library:main +- `vms-generate-test-project` → vmssailor.tools.generate_test_project:main +""" diff --git a/vmssailor/tools/generate_test_project.py b/vmssailor/tools/generate_test_project.py new file mode 100644 index 0000000..be4915c --- /dev/null +++ b/vmssailor/tools/generate_test_project.py @@ -0,0 +1,423 @@ +"""CLI demo de Sprint 0: crea un proyecto completo, lo guarda y lo relee. + +Demuestra el roundtrip de persistencia (criterio de aceptación Sprint 0). + +Uso: + + uv run vms-generate-test-project [--out PATH] + +Salida: +- Crea `projects/_demo/test_project.vmsproj` por defecto. +- Imprime stats antes y después de roundtrip. +- Exit 0 si la integridad se verifica, 1 si algo diverge. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from vmssailor.core import ( + AlarmConfig, + AlarmPriority, + AuthorityRequired, + Bus, + BusRole, + CardInstance, + ChannelType, + ControlMode, + Equipment, + FilterType, + PermissiveRule, + Project, + Protocol, + Scaling, + ShipCoord, + SignalType, + SystemId, + Tag, + TagBinding, + Topology, + UnitSI, + Vessel, + VesselSubtype, + VesselType, +) +from vmssailor.core.permissive import Condition +from vmssailor.core.persistence import load_project, save_project +from vmssailor.core.validation import validate_project +from vmssailor.core.vessel import Bulkhead, Deck +from vmssailor.shared.logging_setup import setup_logging + + +def build_demo_project() -> Project: + """Construye un proyecto demo completo basado en Sunseeker 76 + MTU 12V.""" + vessel = Vessel( + id="sunseeker_76_demo", + name="M/Y Aurora — Demo Sprint 0", + type=VesselType.YACHT_MOTOR, + subtype=VesselSubtype.PLANING, + length_overall_m=23.45, + beam_max_m=5.65, + draft_m=1.85, + displacement_kg=55000, + description="Buque de demo para verificar persistencia .vmsproj.", + data_source="seed_estimate", + decks=[ + Deck(id="lower", name="Lower Deck", z_bl_bottom=0.5, z_bl_top=2.6), + Deck(id="main", name="Main Deck", z_bl_bottom=2.6, z_bl_top=4.8), + Deck(id="flybridge", name="Flybridge", z_bl_bottom=4.8, z_bl_top=6.4), + ], + bulkheads=[ + Bulkhead(id="collision", name="Mamparo de colisión", x_pp=21.0), + Bulkhead(id="er_fwd", name="Mamparo proa SM", x_pp=7.0), + ], + ) + + # ---- Equipos ---- + me_port = Equipment( + id="eq_me_port", + model_ref="mtu_12v_2000_m96", + tag_prefix="ME_PORT", + display_name="Motor principal babor", + location=ShipCoord(x_pp=5.5, y_cl=-0.9, z_bl=1.2), + deck_id="lower", + system_id=SystemId.MAIN_ENGINE, + ) + me_stbd = Equipment( + id="eq_me_stbd", + model_ref="mtu_12v_2000_m96", + tag_prefix="ME_STBD", + display_name="Motor principal estribor", + location=ShipCoord(x_pp=5.5, y_cl=0.9, z_bl=1.2), + deck_id="lower", + system_id=SystemId.MAIN_ENGINE, + ) + gen_1 = Equipment( + id="eq_gen_1", + model_ref="northern_lights_m65c13", + tag_prefix="GEN_1", + display_name="Genset 1", + location=ShipCoord(x_pp=4.5, y_cl=0.0, z_bl=1.0), + deck_id="lower", + system_id=SystemId.GENSET, + ) + + # ---- Buses y cartas ---- + bus_main = Bus( + id="bus_main", + name="Bus principal Modbus RTU", + protocol=Protocol.MODBUS_RTU, + physical_port="COM3", + baud_rate=115200, + ) + bus_n2k = Bus( + id="bus_n2k", + name="Backbone NMEA 2000", + protocol=Protocol.NMEA2000, + physical_port="USB-CAN0", + ) + cards = [ + CardInstance( + id="card_001", + slot_number=1, + bus_id="bus_main", + bus_role=BusRole.MODBUS_SLAVE, + modbus_address=1, + physical_location="Sala máquinas — junto a ME_PORT", + location=ShipCoord(x_pp=5.5, y_cl=-0.5, z_bl=1.4), + ), + CardInstance( + id="card_002", + slot_number=2, + bus_id="bus_main", + bus_role=BusRole.MODBUS_SLAVE, + modbus_address=2, + physical_location="Sala máquinas — junto a ME_STBD", + location=ShipCoord(x_pp=5.5, y_cl=0.5, z_bl=1.4), + ), + CardInstance( + id="card_003", + slot_number=3, + bus_id="bus_main", + bus_role=BusRole.MODBUS_SLAVE, + modbus_address=3, + physical_location="Sala máquinas — junto a Genset 1", + location=ShipCoord(x_pp=4.5, y_cl=0.0, z_bl=1.2), + ), + ] + topology = Topology(buses=[bus_main, bus_n2k], cards=cards) + + # ---- Tags ---- + tags: list[Tag] = [] + for eq, card_id in [(me_port, "card_001"), (me_stbd, "card_002")]: + tags.append( + Tag( + id=f"{eq.tag_prefix}.OIL_PRESS", + equipment_id=eq.id, + description=f"Presión de aceite {eq.display_name}", + unit_si=UnitSI.BAR, + range_normal_min=3.5, + range_normal_max=6.5, + alarms=[ + AlarmConfig( + id=f"{eq.tag_prefix}.OIL_PRESS.LOW", + threshold=1.5, + operator="<", + priority=AlarmPriority.EMERGENCY, + hysteresis=0.2, + delay_seconds=2.0, + message="Presión aceite crítica baja.", + ) + ], + protocol=Protocol.MODBUS_RTU, + physical_binding=TagBinding( + card_id=card_id, + channel_type=ChannelType.AI, + channel_number=1, + signal_type=SignalType.SIG_4_20_MA, + scaling=Scaling(raw_min=4.0, raw_max=20.0, eng_min=0.0, eng_max=10.0), + filter=FilterType.MOVING_AVG, + filter_param=4.0, + update_rate_ms=200, + ), + ) + ) + tags.append( + Tag( + id=f"{eq.tag_prefix}.COOLANT_TEMP", + equipment_id=eq.id, + description=f"Temperatura refrigerante {eq.display_name}", + unit_si=UnitSI.DEGREE_CELSIUS, + range_normal_min=65.0, + range_normal_max=95.0, + alarms=[ + AlarmConfig( + id=f"{eq.tag_prefix}.COOLANT_TEMP.HIGH", + threshold=100.0, + operator=">", + priority=AlarmPriority.EMERGENCY, + hysteresis=2.0, + delay_seconds=3.0, + message="Temperatura refrigerante crítica alta.", + ) + ], + protocol=Protocol.MODBUS_RTU, + physical_binding=TagBinding( + card_id=card_id, + channel_type=ChannelType.AI, + channel_number=2, + signal_type=SignalType.RTD_PT100, + scaling=Scaling(raw_min=0.0, raw_max=4095.0, eng_min=-50.0, eng_max=200.0), + ), + ) + ) + tags.append( + Tag( + id=f"{eq.tag_prefix}.RPM", + equipment_id=eq.id, + description=f"RPM {eq.display_name}", + unit_si=UnitSI.RPM, + range_normal_min=0.0, + range_normal_max=2600.0, + protocol=Protocol.MODBUS_RTU, + physical_binding=TagBinding( + card_id=card_id, + channel_type=ChannelType.RPM, + channel_number=1, + signal_type=SignalType.PULSE_MAGNETIC_PICKUP, + ), + ) + ) + tags.append( + Tag( + id=f"{eq.tag_prefix}.ESTOP_ACTIVE", + equipment_id=eq.id, + description="Pulsador E-stop activo", + unit_si=UnitSI.BOOL, + protocol=Protocol.MODBUS_RTU, + physical_binding=TagBinding( + card_id=card_id, + channel_type=ChannelType.DI, + channel_number=1, + signal_type=SignalType.DRY_CONTACT, + ), + ) + ) + tags.append( + Tag( + id=f"{eq.tag_prefix}.START_CMD", + equipment_id=eq.id, + description=f"Arranque {eq.display_name}", + unit_si=UnitSI.BOOL, + controllable=True, + control_mode=ControlMode.MANUAL, + authority_required=AuthorityRequired.BRIDGE, + protocol=Protocol.MODBUS_RTU, + physical_binding=TagBinding( + card_id=card_id, + channel_type=ChannelType.DO, + channel_number=1, + signal_type=SignalType.RELAY_NO, + ), + ) + ) + + # Tags del genset + tags.append( + Tag( + id="GEN_1.VOLTAGE_L1", + equipment_id=gen_1.id, + description="Tensión L1 genset 1", + unit_si=UnitSI.VOLT, + range_normal_min=220.0, + range_normal_max=240.0, + alarms=[ + AlarmConfig( + id="GEN_1.VOLTAGE_L1.LOW", + threshold=200.0, + operator="<", + priority=AlarmPriority.HIGH, + hysteresis=5.0, + ), + AlarmConfig( + id="GEN_1.VOLTAGE_L1.HIGH", + threshold=250.0, + operator=">", + priority=AlarmPriority.HIGH, + hysteresis=5.0, + ), + ], + protocol=Protocol.MODBUS_RTU, + physical_binding=TagBinding( + card_id="card_003", + channel_type=ChannelType.AI, + channel_number=1, + signal_type=SignalType.VOLTAGE_DIVIDER, + scaling=Scaling(raw_min=0.0, raw_max=4095.0, eng_min=0.0, eng_max=300.0), + ), + ) + ) + + # ---- Permissives ---- + permissive_start_me_port = PermissiveRule( + id="rule_start_me_port", + action_id="START_ME_PORT", + description="Pre-condiciones para arrancar motor principal babor.", + conditions=[ + Condition( + tag_ref="ME_PORT.OIL_PRESS", + operator=">", + threshold=0.3, + severity="fail", + message_on_fail="Presión aceite muy baja para arranque seguro.", + ), + Condition( + tag_ref="ME_PORT.COOLANT_TEMP", + operator=">", + threshold=5.0, + severity="fail", + message_on_fail="Refrigerante < 5°C — pre-calentar.", + ), + Condition( + tag_ref="ME_PORT.ESTOP_ACTIVE", + operator="is_false", + severity="fail", + message_on_fail="E-stop activado — desbloquear.", + ), + ], + on_fail_message="No es seguro arrancar ME_PORT.", + ) + + project = Project( + id="demo_sprint0_aurora", + name="M/Y Aurora — Demo Sprint 0", + customer="Cliente demo (no real)", + notes="Proyecto sintético para verificar persistencia roundtrip.", + vessel=vessel, + systems_enabled=[SystemId.MAIN_ENGINE, SystemId.GENSET], + equipment=[me_port, me_stbd, gen_1], + tags=tags, + topology=topology, + permissive_rules=[permissive_start_me_port], + ) + return project + + +def _projects_equal_for_demo(a: Project, b: Project) -> tuple[bool, str]: + """Compara dos Project para roundtrip. Ignora updated_at por design.""" + a_dump = a.model_dump(mode="json", exclude={"updated_at"}) + b_dump = b.model_dump(mode="json", exclude={"updated_at"}) + if a_dump == b_dump: + return True, "match" + + # Localizar la primera diferencia + diffs: list[str] = [] + keys = set(a_dump.keys()) | set(b_dump.keys()) + for k in sorted(keys): + if a_dump.get(k) != b_dump.get(k): + diffs.append(f" - {k}: differs") + return False, "\n".join(diffs) or "structures differ" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="vms-generate-test-project", + description=( + "Genera un Project demo, lo guarda como .vmsproj y verifica el roundtrip. " + "Criterio de aceptación del Sprint 0." + ), + ) + parser.add_argument( + "--out", + type=Path, + default=Path("projects/_demo/test_project.vmsproj"), + help="Ruta del .vmsproj a escribir.", + ) + parser.add_argument("--verbose", action="store_true") + args = parser.parse_args(argv) + + setup_logging(verbose=args.verbose) + + print("VMS-Sailor — Generador de proyecto demo (roundtrip)") + print("=" * 60) + + project = build_demo_project() + stats_in = project.stats() + print("Proyecto en memoria:") + for k, v in stats_in.items(): + print(f" {k:25s}: {v}") + + # Validación cross-entity (informativa) + report = validate_project(project) + print() + print("Validación cross-entity:") + print(" " + report.format().replace("\n", "\n ")) + + print() + final = save_project(project, args.out) + print(f"Guardado en: {final}") + print(f"Tamaño : {final.stat().st_size:,} bytes") + + print() + print("Releyendo desde disco...") + loaded = load_project(final) + stats_out = loaded.stats() + print("Proyecto releído:") + for k, v in stats_out.items(): + print(f" {k:25s}: {v}") + + ok, msg = _projects_equal_for_demo(project, loaded) + print() + if ok: + print("INTEGRIDAD OK — roundtrip perfecto.") + return 0 + + print("INTEGRIDAD FAIL — diferencias encontradas:") + print(msg) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/vmssailor/tools/validate_library.py b/vmssailor/tools/validate_library.py new file mode 100644 index 0000000..8755b81 --- /dev/null +++ b/vmssailor/tools/validate_library.py @@ -0,0 +1,74 @@ +"""CLI para validar la biblioteca curada del Sprint 0. + +Uso: + + uv run vms-validate-library + + # equivalente: + python -m vmssailor.tools.validate_library + +Salida: +- Exit code 0 si no hay errores (warnings/info no bloquean). +- Exit code 1 si hay errores estructurales. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from vmssailor.library import load_library +from vmssailor.shared.logging_setup import setup_logging + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="vms-validate-library", + description="Valida la biblioteca curada de VMS-Sailor.", + ) + parser.add_argument( + "--root", + type=Path, + default=None, + help="Ruta raíz de la biblioteca (defecto: vmssailor/library/).", + ) + parser.add_argument( + "--verbose", + action="store_true", + help="Logging DEBUG.", + ) + parser.add_argument( + "--strict-warnings", + action="store_true", + help="Salir con error si hay warnings.", + ) + args = parser.parse_args(argv) + + setup_logging(verbose=args.verbose) + + print("VMS-Sailor — Validador de biblioteca curada") + print("=" * 60) + result = load_library(args.root) + print(result.format()) + print() + + if not result.ok(): + print(f"\nFAIL: {len(result.errors)} errores en la biblioteca.") + return 1 + + if args.strict_warnings and result.warnings: + print(f"\nFAIL (strict): {len(result.warnings)} warnings.") + return 1 + + info_count = len([i for i in result.issues if i.severity == "info"]) + print( + f"\nOK: biblioteca válida " + f"({len(result.vessels)} vessels, {len(result.equipment_models)} equipment, " + f"{len(result.rules)} rules, {info_count} info)." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/vmssailor/version.py b/vmssailor/version.py new file mode 100644 index 0000000..b807b8e --- /dev/null +++ b/vmssailor/version.py @@ -0,0 +1,18 @@ +"""Single source of truth para la versión del paquete. + +Convención PEP 440: durante sprints de desarrollo usamos `0.X.0.devN`. El +campo `SPRINT_TAG` da contexto humano para changelogs y UI splash. +""" + +__version__ = "0.1.0.dev0" + +VERSION_MAJOR = 0 +VERSION_MINOR = 1 +VERSION_PATCH = 0 +SPRINT_TAG = "sprint0" + +VMSPROJ_SCHEMA_VERSION = 1 +"""Versión del schema SQLite de archivos .vmsproj. Incrementar al cambiar tablas/columnas.""" + +VMSPACK_FORMAT_VERSION = 1 +"""Versión del formato ZIP de paquetes .vmspack distribuibles."""