sprint-2: rule engine + auto-assigner + equipment editor + biblioteca

Wizard pasos 5-7 ahora funcionales (1-4 ya estaban en Sprint 1).

vmssailor/studio/designer/rule_engine.py
- RuleContext, RuleEngine, EquipmentProposal
- Lee library/rules/*.yaml y aplica reglas heuristicas
- Filtra por vessel_type, vessel_subtype, length_overall_m range
- Selecciona candidato segun condiciones 'when' (loa min/max)
- Genera tag_prefix con sustitucion {side}/{idx}

vmssailor/studio/designer/port_auto_assigner.py
- auto_assign() greedy: 1 bus Modbus RTU + tarjetas dedicadas para motores/gensets
- Tarjeta auxiliar compartida para resto de equipos
- Mapea SignalType -> ChannelType (AI/DI/DO/RPM)
- Genera TagBindings con scaling apropiado por tipo de senal
- Respeta capacidades 10/5/4/1 de AR-NMEA-IO-v1.0
- AssignmentReport con cards + tags + warnings

vmssailor/studio/wizard/step_05_equipment.py
- Tabla con propuestas del rule engine
- Checkboxes accept/reject + edicion inline de columnas
- Boton 'Regenerar' para re-aplicar reglas

vmssailor/studio/wizard/step_06_refinement.py
- Vista resumen de equipos aceptados

vmssailor/studio/wizard/step_07_topology.py
- Llama auto_assign sobre los equipos materializados
- Muestra tabla de tarjetas con uso por canal (DO/DI/AI/RPM)
- Lista warnings de capacidad

vmssailor/studio/editors/equipment_editor.py
- CRUD de Equipment del proyecto activo
- Tabla editable inline (tag_prefix, name, model_ref, system_id, coords, deck)
- Dialog modal para agregar equipos
- Senal projectMutated para refrescar canvas + sidebar

vmssailor/studio/main_window.py
- Layout actualizado: splitter vertical en panel derecho
  (canvas arriba + equipment editor abajo)
- _on_project_mutated() re-distribuye al sidebar y canvas

Biblioteca expandida (Sprint 2 brief: 5-7 yates, 10+ motores, gensets, bombas):
- vessels: + azimut_grande_32m, princess_y85, trawler_32m_offshore, patrol_coastal_30m (total: 6)
- engines: + cat_c32_acert, mtu_16v_2000_m96, yanmar_8lv_370 (total: 5)
- gensets: + kohler_28efkozd, onan_qd13500 (total: 3)
- pumps: + jabsco_36800, grundfos_cm10 (NUEVO categoria pumps)

Tests (tests/studio/test_designer.py, 10 nuevos, total 120/120):
- Rule engine: load default, propose engines, candidate picking por LOA
- auto_assign builds topology compatible with Project (Pydantic validation)
- Equipment editor smoke

VesselWizard.build_project() ahora materializa equipment + topology + tags
desde las propuestas y la asignacion automatica del paso 7.

Criterios Sprint 2:
- uv run vms-studio crea proyecto completo desde wizard con equipos + tags + topologia
- vms-validate-library: OK 6 vessels, 10 equipment, 1 rules
- 120/120 pytest verde, ruff clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 09:50:33 -04:00
parent 813476c8db
commit 6ad76a89fa
22 changed files with 1787 additions and 32 deletions
+197
View File
@@ -0,0 +1,197 @@
"""Tests del motor de reglas y del auto-asignador (Sprint 2)."""
from __future__ import annotations
from vmssailor.core.enums import SystemId, VesselSubtype, VesselType
from vmssailor.studio.designer.rule_engine import RuleContext, RuleEngine
def test_rule_engine_loads_default_library():
eng = RuleEngine()
# La biblioteca seed tiene yacht_motor_planeo.yaml
ctx = RuleContext(
vessel_type=VesselType.YACHT_MOTOR,
vessel_subtype=VesselSubtype.PLANING,
length_overall_m=23.5,
beam_max_m=5.65,
draft_m=1.85,
systems_enabled=[SystemId.MAIN_ENGINE, SystemId.GENSET],
)
applicable = eng.applicable_rules(ctx)
assert "yacht_motor_planeo" in applicable
def test_rule_engine_proposes_two_main_engines():
eng = RuleEngine()
ctx = RuleContext(
vessel_type=VesselType.YACHT_MOTOR,
vessel_subtype=VesselSubtype.PLANING,
length_overall_m=23.5,
beam_max_m=5.65,
draft_m=1.85,
systems_enabled=[SystemId.MAIN_ENGINE],
)
proposals = eng.propose(ctx)
me = [p for p in proposals if p.system_id == SystemId.MAIN_ENGINE]
assert len(me) == 2
prefixes = {p.tag_prefix for p in me}
assert prefixes == {"ME_PORT", "ME_STBD"}
def test_rule_engine_picks_volvo_for_smaller_vessel():
eng = RuleEngine()
ctx = RuleContext(
vessel_type=VesselType.YACHT_MOTOR,
vessel_subtype=VesselSubtype.PLANING,
length_overall_m=20.0, # rango Volvo 18-26
beam_max_m=5.0,
draft_m=1.5,
systems_enabled=[SystemId.MAIN_ENGINE],
)
proposals = eng.propose(ctx)
me = [p for p in proposals if p.system_id == SystemId.MAIN_ENGINE]
# 20 m está en el rango Volvo (18-26) y NO en MTU (22-32) → primero match es Volvo
assert all(p.model_ref == "volvo_d13_900hp" for p in me)
def test_rule_engine_picks_mtu_for_larger():
eng = RuleEngine()
ctx = RuleContext(
vessel_type=VesselType.YACHT_MOTOR,
vessel_subtype=VesselSubtype.PLANING,
length_overall_m=27.0, # fuera del rango Volvo, en el de MTU
beam_max_m=6.0,
draft_m=2.0,
systems_enabled=[SystemId.MAIN_ENGINE],
)
proposals = eng.propose(ctx)
me = [p for p in proposals if p.system_id == SystemId.MAIN_ENGINE]
assert all(p.model_ref == "mtu_12v_2000_m96" for p in me)
def test_rule_engine_does_not_propose_disabled_systems():
eng = RuleEngine()
ctx = RuleContext(
vessel_type=VesselType.YACHT_MOTOR,
vessel_subtype=VesselSubtype.PLANING,
length_overall_m=23.5,
beam_max_m=5.65,
draft_m=1.85,
systems_enabled=[SystemId.GENSET], # main_engine no habilitado
)
proposals = eng.propose(ctx)
assert all(p.system_id != SystemId.MAIN_ENGINE for p in proposals)
def test_rule_engine_filters_by_loa_range():
eng = RuleEngine()
ctx = RuleContext(
vessel_type=VesselType.YACHT_MOTOR,
vessel_subtype=VesselSubtype.PLANING,
length_overall_m=50.0, # fuera del rango 18-32 de yacht_motor_planeo
beam_max_m=9.0,
draft_m=2.5,
systems_enabled=[SystemId.MAIN_ENGINE],
)
assert "yacht_motor_planeo" not in eng.applicable_rules(ctx)
def test_port_auto_assigner_builds_topology():
from vmssailor.core.coords import ShipCoord
from vmssailor.core.equipment import Equipment
from vmssailor.library import load_library
from vmssailor.studio.designer.port_auto_assigner import auto_assign
lib = load_library()
model_lookup = {m.id: m for m in lib.equipment_models}
me_port = 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),
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 estribor",
location=ShipCoord(x_pp=6.0, y_cl=0.9, z_bl=1.2),
system_id=SystemId.MAIN_ENGINE,
)
report = auto_assign([me_port, me_stbd], model_lookup)
assert report.bus is not None
assert len(report.cards) >= 2 # una por motor + posiblemente aux
assert len(report.tags) > 10 # cada motor trae ~12 sensores
# Verifica que los bindings caen en canales válidos
for t in report.tags:
if t.physical_binding:
assert t.physical_binding.channel_number >= 1
assert t.physical_binding.card_id in {c.id for c in report.cards}
def test_port_auto_assigner_creates_topology_compatible_with_project():
"""El topology generado debe pasar validación de Pydantic."""
from vmssailor.core.coords import ShipCoord
from vmssailor.core.equipment import Equipment
from vmssailor.core.project import Project
from vmssailor.core.vessel import Deck, Vessel
from vmssailor.library import load_library
from vmssailor.studio.designer.port_auto_assigner import auto_assign
lib = load_library()
model_lookup = {m.id: m for m in lib.equipment_models}
eq = Equipment(
id="eq_test",
model_ref="volvo_d13_900hp",
tag_prefix="ME",
display_name="Motor",
location=ShipCoord(x_pp=6, y_cl=0, z_bl=1.2),
system_id=SystemId.MAIN_ENGINE,
)
report = auto_assign([eq], model_lookup)
vessel = Vessel(
id="v",
name="V",
type=VesselType.YACHT_MOTOR,
subtype=VesselSubtype.PLANING,
length_overall_m=24,
beam_max_m=5.5,
draft_m=1.8,
decks=[Deck(id="lower", name="Lower", z_bl_bottom=0.5, z_bl_top=2.5)],
)
project = Project(
id="test",
name="test",
vessel=vessel,
systems_enabled=[SystemId.MAIN_ENGINE],
equipment=[eq],
tags=report.tags,
topology=report.topology(),
)
assert project.stats()["cards"] >= 1
assert project.stats()["tags"] > 5
def test_equipment_editor_smoke(qtbot, sample_project):
from vmssailor.studio.editors.equipment_editor import EquipmentEditor
ed = EquipmentEditor()
qtbot.addWidget(ed)
ed.set_project(sample_project)
assert ed._table.rowCount() == len(sample_project.equipment)
def test_wizard_step_05_initializes(qtbot):
from vmssailor.studio.wizard.step_05_equipment import Step05Equipment
step = Step05Equipment()
qtbot.addWidget(step)
# Sin un QWizard padre los fields no funcionan, pero el widget debe construir
assert step._table.columnCount() == 7