6ad76a89fa
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>
198 lines
6.5 KiB
Python
198 lines
6.5 KiB
Python
"""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
|