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
@@ -0,0 +1,32 @@
{
"id": "cat_c32_acert",
"manufacturer": "Caterpillar",
"model_name": "C32 ACERT",
"category": "engine_main",
"typical_systems": ["main_engine"],
"specs": {
"power_kw": 1081,
"rpm_nominal": 2300,
"weight_kg": 2440,
"length_m": 2.04,
"width_m": 1.30,
"height_m": 1.30,
"fuel_consumption_lph": 245
},
"description": "Caterpillar C32 ACERT, V12, 32.1 L. Aplicación yates rápidos 70-100 pies / patrulleros / pilot boats. Twin turbo intercooled.",
"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 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 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 sobrealimentación", "unit_si": "bar", "range_normal_min": 0.0, "range_normal_max": 2.5, "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" }
]
}
@@ -0,0 +1,32 @@
{
"id": "mtu_16v_2000_m96",
"manufacturer": "MTU",
"model_name": "16V 2000 M96",
"category": "engine_main",
"typical_systems": ["main_engine"],
"specs": {
"power_kw": 1939,
"rpm_nominal": 2450,
"weight_kg": 3290,
"length_m": 2.45,
"width_m": 1.20,
"height_m": 1.27,
"fuel_consumption_lph": 432
},
"description": "MTU Series 2000, V16, 32.0 L, 2-stage turbo. Aplicación fast ferries / patrulleros offshore / yates 30-40 m. Common Rail. Habla J1939 nativo.",
"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" },
{ "id": "oil_press", "name": "Presión 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" },
{ "id": "oil_temp", "name": "Temperatura 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 sobrealimentación", "unit_si": "bar", "range_normal_min": 0.0, "range_normal_max": 2.8, "default_signal_type": "4-20ma" },
{ "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": "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": "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 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" }
]
}
@@ -0,0 +1,29 @@
{
"id": "yanmar_8lv_370",
"manufacturer": "Yanmar",
"model_name": "8LV-370",
"category": "engine_main",
"typical_systems": ["main_engine"],
"specs": {
"power_kw": 272,
"rpm_nominal": 3800,
"weight_kg": 430,
"length_m": 1.04,
"width_m": 0.83,
"height_m": 0.78,
"fuel_consumption_lph": 67
},
"description": "Yanmar 8LV-370 V8, 4.46 L, common rail. Aplicación yates motor 40-60 pies y embarcaciones rápidas con stern drive.",
"data_source": "seed_estimate",
"default_sensors": [
{ "id": "rpm", "name": "RPM", "unit_si": "rpm", "range_normal_min": 0, "range_normal_max": 4000, "alarm_high_value": 3900, "alarm_high_priority": "high", "default_signal_type": "pulse_magnetic_pickup" },
{ "id": "oil_press", "name": "Presión aceite", "unit_si": "bar", "range_normal_min": 2.5, "range_normal_max": 5.0, "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": 92, "alarm_high_value": 100, "alarm_high_priority": "emergency", "default_signal_type": "rtd_pt100" },
{ "id": "alternator_v", "name": "Voltaje alternador", "unit_si": "V", "range_normal_min": 13.5, "range_normal_max": 14.5, "default_signal_type": "voltage_divider" },
{ "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", "unit_si": "h", "default_signal_type": "4-20ma" },
{ "id": "start_cmd", "name": "Arranque", "unit_si": "bool", "default_signal_type": "relay_no" },
{ "id": "stop_cmd", "name": "Parada", "unit_si": "bool", "default_signal_type": "relay_no" },
{ "id": "running_state", "name": "En marcha", "unit_si": "bool", "default_signal_type": "dry_contact" }
]
}
@@ -0,0 +1,32 @@
{
"id": "kohler_28efkozd",
"manufacturer": "Kohler",
"model_name": "28EFKOZD",
"category": "genset",
"typical_systems": ["genset"],
"specs": {
"power_kw": 22.4,
"rpm_nominal": 1500,
"weight_kg": 450,
"length_m": 1.30,
"width_m": 0.70,
"height_m": 0.85,
"voltage_v": 230,
"current_a": 97,
"fuel_consumption_lph": 7.0
},
"description": "Kohler 28EFKOZD genset marino diésel, 28 kVA / 22.4 kW @ 1500 rpm 50 Hz. Aplicación yates motor 50-70 pies. Cabina silenciosa estándar.",
"data_source": "seed_estimate",
"default_sensors": [
{ "id": "rpm", "name": "RPM", "unit_si": "rpm", "range_normal_min": 0, "range_normal_max": 1550, "default_signal_type": "pulse_magnetic_pickup" },
{ "id": "oil_press", "name": "Presión aceite", "unit_si": "bar", "range_normal_min": 2.0, "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, "default_signal_type": "voltage_divider" },
{ "id": "current_l1", "name": "Corriente L1", "unit_si": "A", "range_normal_min": 0, "range_normal_max": 100, "alarm_high_value": 110, "alarm_high_priority": "high", "default_signal_type": "4-20ma" },
{ "id": "freq", "name": "Frecuencia", "unit_si": "Hz", "range_normal_min": 49.5, "range_normal_max": 50.5, "default_signal_type": "pulse_inductive" },
{ "id": "running_hours", "name": "Horas", "unit_si": "h", "default_signal_type": "4-20ma" },
{ "id": "start_cmd", "name": "Arranque", "unit_si": "bool", "default_signal_type": "relay_no" },
{ "id": "stop_cmd", "name": "Parada", "unit_si": "bool", "default_signal_type": "relay_no" },
{ "id": "breaker_status", "name": "Breaker principal", "unit_si": "bool", "default_signal_type": "dry_contact" }
]
}
@@ -0,0 +1,30 @@
{
"id": "onan_qd13500",
"manufacturer": "Cummins Onan",
"model_name": "QD 13500",
"category": "genset",
"typical_systems": ["genset"],
"specs": {
"power_kw": 10.8,
"rpm_nominal": 1500,
"weight_kg": 295,
"length_m": 1.07,
"width_m": 0.55,
"height_m": 0.62,
"voltage_v": 230,
"current_a": 47,
"fuel_consumption_lph": 3.6
},
"description": "Cummins Onan QD 13500 marino diésel, 13.5 kVA / 10.8 kW @ 1500 rpm 50 Hz. Aplicación yates 35-50 pies y pesqueros pequeños.",
"data_source": "seed_estimate",
"default_sensors": [
{ "id": "rpm", "name": "RPM", "unit_si": "rpm", "range_normal_min": 0, "range_normal_max": 1550, "default_signal_type": "pulse_magnetic_pickup" },
{ "id": "oil_press", "name": "Presión aceite", "unit_si": "bar", "range_normal_min": 2.0, "range_normal_max": 4.5, "alarm_low_value": 0.9, "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": 100, "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, "default_signal_type": "voltage_divider" },
{ "id": "freq", "name": "Frecuencia", "unit_si": "Hz", "range_normal_min": 49.5, "range_normal_max": 50.5, "default_signal_type": "pulse_inductive" },
{ "id": "running_hours", "name": "Horas", "unit_si": "h", "default_signal_type": "4-20ma" },
{ "id": "start_cmd", "name": "Arranque", "unit_si": "bool", "default_signal_type": "relay_no" },
{ "id": "stop_cmd", "name": "Parada", "unit_si": "bool", "default_signal_type": "relay_no" }
]
}
@@ -0,0 +1,27 @@
{
"id": "grundfos_cm10",
"manufacturer": "Grundfos",
"model_name": "CM10",
"category": "pump",
"typical_systems": ["potable_water", "sw_cooling", "fw_cooling"],
"specs": {
"power_kw": 1.5,
"rpm_nominal": 2900,
"weight_kg": 14,
"length_m": 0.40,
"width_m": 0.20,
"height_m": 0.27,
"voltage_v": 230,
"current_a": 7,
"capacity_l": 6000
},
"description": "Grundfos CM10 centrífuga horizontal multicelular. Aplicación agua potable, refrigeración FW, transferencia. 230 V monofásico.",
"data_source": "seed_estimate",
"default_sensors": [
{ "id": "running_state", "name": "Estado en marcha", "unit_si": "bool", "default_signal_type": "dry_contact" },
{ "id": "start_cmd", "name": "Arranque", "unit_si": "bool", "default_signal_type": "relay_no" },
{ "id": "stop_cmd", "name": "Parada", "unit_si": "bool", "default_signal_type": "relay_no" },
{ "id": "current", "name": "Corriente", "unit_si": "A", "range_normal_min": 0, "range_normal_max": 8, "alarm_high_value": 10, "alarm_high_priority": "high", "default_signal_type": "4-20ma" },
{ "id": "pressure_out", "name": "Presión descarga", "unit_si": "bar", "range_normal_min": 0, "range_normal_max": 6, "default_signal_type": "4-20ma" }
]
}
@@ -0,0 +1,25 @@
{
"id": "jabsco_36800",
"manufacturer": "Jabsco",
"model_name": "36800-0001",
"category": "pump",
"typical_systems": ["bilge", "sw_service"],
"specs": {
"power_kw": 0.10,
"rpm_nominal": 0,
"weight_kg": 1.5,
"length_m": 0.15,
"width_m": 0.10,
"height_m": 0.10,
"voltage_v": 12,
"current_a": 8.0,
"capacity_l": 60
},
"description": "Bomba sumergible Jabsco 36800 (Rule 1100 GPH equivalente). Aplicación sentinas, achique auxiliar. 12 VDC.",
"data_source": "seed_estimate",
"default_sensors": [
{ "id": "running_state", "name": "Estado en marcha", "unit_si": "bool", "default_signal_type": "dry_contact" },
{ "id": "start_cmd", "name": "Arranque", "unit_si": "bool", "default_signal_type": "relay_no" },
{ "id": "current", "name": "Corriente", "unit_si": "A", "range_normal_min": 0, "range_normal_max": 10, "alarm_high_value": 12, "alarm_high_priority": "high", "default_signal_type": "4-20ma" }
]
}
@@ -0,0 +1,23 @@
{
"id": "azimut_grande_32m",
"name": "Azimut Grande 32M",
"type": "yacht_motor",
"subtype": "semi_planing",
"length_overall_m": 32.4,
"beam_max_m": 7.30,
"draft_m": 2.20,
"displacement_kg": 130000,
"description": "Yate motor semi-planeo italiano de 32 m, casco IPS con 3 ejes Volvo, 4 cubiertas, 5 cabinas. Sala de máquinas amplia con 3 motores principales + 2 gensets + sistemas redundantes. Tope típico ~24 nudos.",
"data_source": "seed_estimate",
"decks": [
{ "id": "tank_deck", "name": "Cubierta de tanques", "z_bl_bottom": 0.4, "z_bl_top": 2.2, "polygon_xy": [] },
{ "id": "lower", "name": "Cubierta inferior", "z_bl_bottom": 2.2, "z_bl_top": 4.6, "polygon_xy": [] },
{ "id": "main", "name": "Cubierta principal", "z_bl_bottom": 4.6, "z_bl_top": 6.9, "polygon_xy": [] },
{ "id": "flybridge", "name": "Flybridge", "z_bl_bottom": 6.9, "z_bl_top": 8.8, "polygon_xy": [] }
],
"bulkheads": [
{ "id": "collision", "name": "Mamparo de colisión", "x_pp": 29.5, "description": "" },
{ "id": "er_fwd", "name": "Mamparo proa SM", "x_pp": 9.5, "description": "" },
{ "id": "er_aft", "name": "Mamparo popa SM", "x_pp": 5.0, "description": "" }
]
}
@@ -0,0 +1,22 @@
{
"id": "patrol_coastal_30m",
"name": "Patrullero costero 30 m",
"type": "patrol",
"subtype": "coastal",
"length_overall_m": 30.0,
"beam_max_m": 6.5,
"draft_m": 1.6,
"displacement_kg": 95000,
"description": "Patrullero costero genérico 30 m. Casco semi-planeo aluminio, 2-3 motores planos rápidos, autonomía 800 nm. Equipos típicos: armamento ligero opcional, RHIB pop-up, búsqueda y rescate.",
"data_source": "seed_estimate",
"decks": [
{ "id": "lower", "name": "Cubierta inferior", "z_bl_bottom": 0.4, "z_bl_top": 2.4, "polygon_xy": [] },
{ "id": "main", "name": "Cubierta principal", "z_bl_bottom": 2.4, "z_bl_top": 4.6, "polygon_xy": [] },
{ "id": "wheelhouse", "name": "Caseta de gobierno", "z_bl_bottom": 4.6, "z_bl_top": 6.5, "polygon_xy": [] }
],
"bulkheads": [
{ "id": "collision", "name": "Mamparo de colisión", "x_pp": 27.5, "description": "" },
{ "id": "er_fwd", "name": "Mamparo proa SM", "x_pp": 9.0, "description": "" },
{ "id": "er_aft", "name": "Mamparo popa SM", "x_pp": 4.0, "description": "" }
]
}
@@ -0,0 +1,22 @@
{
"id": "princess_y85",
"name": "Princess Y85",
"type": "yacht_motor",
"subtype": "planing",
"length_overall_m": 25.94,
"beam_max_m": 5.92,
"draft_m": 1.92,
"displacement_kg": 64500,
"description": "Yate motor planeo británico, casco V profundo. Propulsión convencional 2 motores diésel directos a hélices. Tope ~32 nudos. 4 cabinas.",
"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.5, "polygon_xy": [] }
],
"bulkheads": [
{ "id": "collision", "name": "Mamparo colisión", "x_pp": 23.4, "description": "" },
{ "id": "er_fwd", "name": "Mamparo proa SM", "x_pp": 7.8, "description": "" },
{ "id": "er_aft", "name": "Mamparo popa SM", "x_pp": 3.8, "description": "" }
]
}
@@ -0,0 +1,23 @@
{
"id": "trawler_32m_offshore",
"name": "Arrastrero offshore 32 m",
"type": "fishing",
"subtype": "trawler",
"length_overall_m": 32.0,
"beam_max_m": 9.0,
"draft_m": 3.8,
"displacement_kg": 250000,
"description": "Pesquero arrastrero offshore genérico de 32 m, casco desplazamiento, maquinaria de pesca + bodega refrigerada grande + tanques RSW (refrigerated sea water).",
"data_source": "seed_estimate",
"decks": [
{ "id": "lower", "name": "Bodega y pañoles", "z_bl_bottom": 0.5, "z_bl_top": 3.5, "polygon_xy": [] },
{ "id": "main", "name": "Cubierta principal", "z_bl_bottom": 3.5, "z_bl_top": 6.0, "polygon_xy": [] },
{ "id": "wheelhouse", "name": "Caseta de gobierno", "z_bl_bottom": 6.0, "z_bl_top": 8.5, "polygon_xy": [] }
],
"bulkheads": [
{ "id": "collision", "name": "Mamparo de colisión", "x_pp": 29.0, "description": "" },
{ "id": "er_fwd", "name": "Mamparo proa SM", "x_pp": 10.0, "description": "" },
{ "id": "er_aft", "name": "Mamparo popa SM", "x_pp": 4.0, "description": "" },
{ "id": "fish_hold_fwd", "name": "Mamparo bodega proa", "x_pp": 18.0, "description": "" }
]
}
+15
View File
@@ -0,0 +1,15 @@
"""Motor de pre-diseño del Studio.
Aplica las reglas heurísticas de la biblioteca curada al input del wizard
para proponer equipos típicos y topología sugerida.
Sprint 2: rule_engine + port_auto_assigner. Sprint 3+: parametric_silhouette.
"""
from vmssailor.studio.designer.rule_engine import (
EquipmentProposal,
RuleEngine,
apply_rules,
)
__all__ = ["EquipmentProposal", "RuleEngine", "apply_rules"]
@@ -0,0 +1,256 @@
"""Asignador automático de puertos físicos AR-NMEA-IO.
Dado un Project con equipos (cada uno con sus default_sensors según
EquipmentModel), genera:
- Tarjetas necesarias (estimación por sistema → cantidad)
- TagBindings que mapean cada sensor a un canal concreto
- Conflictos detectados (capacidad excedida)
Sprint 2: implementación greedy basada en proximidad.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from vmssailor.core.card import Bus, CardInstance, Topology
from vmssailor.core.coords import ShipCoord
from vmssailor.core.enums import (
BusRole,
ChannelType,
FilterType,
Protocol,
SignalType,
)
from vmssailor.core.equipment import Equipment, EquipmentModel, Sensor
from vmssailor.core.tag import Scaling, Tag, TagBinding
# Mapeo SignalType -> ChannelType (para auto-asignar)
_SIGNAL_TO_CHANNEL: dict[SignalType, ChannelType] = {
# Analógicas → AI
SignalType.SIG_4_20_MA: ChannelType.AI,
SignalType.SIG_0_10_V: ChannelType.AI,
SignalType.SIG_0_5_V: ChannelType.AI,
SignalType.RTD_PT100: ChannelType.AI,
SignalType.RTD_PT1000: ChannelType.AI,
SignalType.THERMOCOUPLE_K: ChannelType.AI,
SignalType.THERMOCOUPLE_J: ChannelType.AI,
SignalType.RESISTIVE_SENDER: ChannelType.AI,
SignalType.VOLTAGE_DIVIDER: ChannelType.AI,
# Pulso → RPM
SignalType.PULSE_MAGNETIC_PICKUP: ChannelType.RPM,
SignalType.PULSE_INDUCTIVE: ChannelType.RPM,
SignalType.PULSE_TACHO: ChannelType.RPM,
# Digitales: relés son DO, contactos son DI
SignalType.RELAY_NO: ChannelType.DO,
SignalType.RELAY_NC: ChannelType.DO,
SignalType.DRY_CONTACT: ChannelType.DI,
SignalType.CONTACT_24VDC: ChannelType.DI,
}
# Capacidades fijas del hardware AR-NMEA-IO-v1.0
_CAPACITY: dict[ChannelType, int] = {
ChannelType.AI: 4,
ChannelType.DI: 5,
ChannelType.DO: 10,
ChannelType.RPM: 1,
}
# Escalado por defecto por tipo de señal
def _default_scaling(signal: SignalType, sensor: Sensor) -> Scaling | None:
rng_min = sensor.range_normal_min if sensor.range_normal_min is not None else 0.0
rng_max = sensor.range_normal_max if sensor.range_normal_max is not None else 100.0
if signal == SignalType.SIG_4_20_MA:
return Scaling(raw_min=4.0, raw_max=20.0, eng_min=rng_min, eng_max=rng_max)
if signal == SignalType.SIG_0_10_V:
return Scaling(raw_min=0.0, raw_max=10.0, eng_min=rng_min, eng_max=rng_max)
if signal == SignalType.SIG_0_5_V:
return Scaling(raw_min=0.0, raw_max=5.0, eng_min=rng_min, eng_max=rng_max)
if signal == SignalType.RTD_PT100:
return Scaling(raw_min=0.0, raw_max=4095.0, eng_min=-50.0, eng_max=200.0)
if signal == SignalType.VOLTAGE_DIVIDER:
# rango razonable para baterías + alternadores
return Scaling(raw_min=0.0, raw_max=4095.0, eng_min=0.0, eng_max=32.0)
return None
@dataclass(slots=True)
class CardSlot:
"""Slot de uso interno del asignador. Mantiene contadores por canal."""
card: CardInstance
used: dict[ChannelType, int] = field(default_factory=lambda: dict.fromkeys(ChannelType, 0))
def has_space(self, channel: ChannelType) -> bool:
return self.used[channel] < _CAPACITY[channel]
def take(self, channel: ChannelType) -> int:
"""Asigna el siguiente canal libre y devuelve su número (1-based)."""
self.used[channel] += 1
return self.used[channel]
@dataclass(slots=True)
class AssignmentReport:
cards: list[CardInstance] = field(default_factory=list)
tags: list[Tag] = field(default_factory=list)
bus: Bus | None = None
n_skipped: int = 0
warnings: list[str] = field(default_factory=list)
def topology(self) -> Topology:
return Topology(buses=[self.bus] if self.bus else [], cards=self.cards)
def auto_assign(
equipment_list: list[Equipment],
model_lookup: dict[str, EquipmentModel],
) -> AssignmentReport:
"""Asignación greedy de tags a tarjetas distribuidas.
Estrategia:
- 1 bus Modbus RTU principal
- 1 tarjeta por equipo del sistema main_engine / genset (pegada al equipo)
- 1 tarjeta compartida para equipos auxiliares
"""
report = AssignmentReport()
if not equipment_list:
return report
bus = Bus(
id="bus_main",
name="Bus principal Modbus RTU",
protocol=Protocol.MODBUS_RTU,
physical_port="COM3",
baud_rate=115200,
)
report.bus = bus
cards: list[CardSlot] = []
# Tarjetas dedicadas para motor principal + genset
next_slot = 1
next_addr = 1
card_by_eq: dict[str, CardSlot] = {}
for eq in equipment_list:
if eq.system_id.value in ("main_engine", "genset"):
card = CardInstance(
id=f"card_{next_slot:03d}",
slot_number=next_slot,
bus_id=bus.id,
bus_role=BusRole.MODBUS_SLAVE,
modbus_address=next_addr,
physical_location=f"Junto a {eq.display_name}",
location=ShipCoord(
x_pp=eq.location.x_pp,
y_cl=eq.location.y_cl,
z_bl=eq.location.z_bl + 0.2,
),
firmware_version="1.0.0",
)
slot = CardSlot(card=card)
cards.append(slot)
card_by_eq[eq.id] = slot
next_slot += 1
next_addr += 1
# Tarjeta auxiliar compartida para el resto
aux_slot: CardSlot | None = None
if any(
eq.system_id.value not in ("main_engine", "genset") for eq in equipment_list
):
aux_card = CardInstance(
id=f"card_{next_slot:03d}",
slot_number=next_slot,
bus_id=bus.id,
bus_role=BusRole.MODBUS_SLAVE,
modbus_address=next_addr,
physical_location="Sala máquinas — panel auxiliar",
firmware_version="1.0.0",
)
aux_slot = CardSlot(card=aux_card)
cards.append(aux_slot)
# Construir tags
for eq in equipment_list:
model = model_lookup.get(eq.model_ref)
if model is None:
report.warnings.append(
f"Equipo '{eq.id}' referencia model_ref='{eq.model_ref}' "
"que no existe en biblioteca cargada."
)
continue
target_slot = card_by_eq.get(eq.id) or aux_slot
if target_slot is None:
continue
for sensor in model.default_sensors:
if sensor.default_signal_type is None:
# Sin tipo de señal, no podemos hacer binding físico
report.n_skipped += 1
continue
signal = sensor.default_signal_type
channel = _SIGNAL_TO_CHANNEL.get(signal)
if channel is None:
report.n_skipped += 1
continue
slot = target_slot
if not slot.has_space(channel):
# Si la tarjeta dedicada se llenó, intentar la auxiliar
if aux_slot and aux_slot is not slot and aux_slot.has_space(channel):
slot = aux_slot
else:
report.warnings.append(
f"Sensor {eq.tag_prefix}.{sensor.id} no asignado: "
f"sin capacidad {channel.value} en tarjetas dedicadas ni aux."
)
continue
ch_num = slot.take(channel)
scaling = _default_scaling(signal, sensor)
binding = TagBinding(
card_id=slot.card.id,
channel_type=channel,
channel_number=ch_num,
signal_type=signal,
scaling=scaling,
filter=FilterType.MOVING_AVG if channel == ChannelType.AI else FilterType.NONE,
filter_param=4.0 if channel == ChannelType.AI else None,
update_rate_ms=200 if channel == ChannelType.AI else 100,
)
tag_id = f"{eq.tag_prefix}.{sensor.id.upper()}"
# Tags controllable (relés DO) marcados como tales
controllable = channel == ChannelType.DO
from vmssailor.core.enums import (
AuthorityRequired,
ControlMode,
UnitSI,
)
try:
unit_si = UnitSI(sensor.unit_si.value)
except Exception:
unit_si = UnitSI.NONE
tag = Tag(
id=tag_id,
equipment_id=eq.id,
description=f"{sensor.name} ({eq.display_name})",
unit_si=unit_si,
range_normal_min=sensor.range_normal_min,
range_normal_max=sensor.range_normal_max,
controllable=controllable,
control_mode=ControlMode.MANUAL if controllable else ControlMode.MONITOR,
authority_required=(
AuthorityRequired.BRIDGE
if controllable
else AuthorityRequired.EITHER
),
protocol=Protocol.MODBUS_RTU,
physical_binding=binding,
)
report.tags.append(tag)
report.cards = [s.card for s in cards]
return report
+196
View File
@@ -0,0 +1,196 @@
"""Motor de reglas heurísticas — Studio Sprint 2.
Lee `vmssailor/library/rules/*.yaml` y, dado el contexto del wizard
(tipo de buque, subtipo, eslora, sistemas habilitados), produce una lista
de `EquipmentProposal` que el wizard muestra al integrador.
El integrador acepta/edita/rechaza cada propuesta antes de pasar al paso 6
(refinamiento manual).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from vmssailor.core.coords import ShipCoord
from vmssailor.core.enums import SystemId, VesselSubtype, VesselType
from vmssailor.library import load_library
@dataclass(slots=True)
class EquipmentProposal:
"""Una propuesta concreta de equipo para el integrador.
El integrador puede:
- aceptarla tal cual → se materializa en un Equipment del proyecto
- editarla (modelo distinto, ubicación distinta, prefix diferente)
- rechazarla
"""
system_id: SystemId
model_ref: str
"""ID de EquipmentModel de la biblioteca."""
tag_prefix: str
"""Prefijo de tags sugerido (ME_PORT, GEN_1, BILGE_FWD, etc.)."""
display_name: str
"""Nombre humano para el integrador."""
location_x_pp: float
location_y_cl: float
location_z_bl: float
rationale: str = ""
"""Por qué la regla propone este equipo."""
accepted: bool = True
"""El integrador puede desmarcar para rechazar."""
def to_ship_coord(self) -> ShipCoord:
return ShipCoord(
x_pp=self.location_x_pp, y_cl=self.location_y_cl, z_bl=self.location_z_bl
)
@dataclass(slots=True)
class RuleContext:
"""Input que el wizard pasa al rule engine."""
vessel_type: VesselType
vessel_subtype: VesselSubtype
length_overall_m: float
beam_max_m: float
draft_m: float
systems_enabled: list[SystemId] = field(default_factory=list)
class RuleEngine:
"""Carga reglas YAML y produce propuestas para un contexto dado."""
def __init__(self, rules: dict[str, dict[str, Any]] | None = None) -> None:
if rules is None:
try:
rules = load_library().rules
except Exception:
rules = {}
self._rules = rules
def applicable_rules(self, ctx: RuleContext) -> list[str]:
"""Lista de rule_ids cuyo `meta.applies_to` matchea el contexto."""
out: list[str] = []
for rid, data in self._rules.items():
applies = (data.get("meta") or {}).get("applies_to") or {}
v_types = applies.get("vessel_types") or []
v_subs = applies.get("vessel_subtypes") or []
loa_range = applies.get("length_overall_m") or {}
if v_types and ctx.vessel_type.value not in v_types:
continue
if v_subs and ctx.vessel_subtype.value not in v_subs:
continue
lo = loa_range.get("min")
hi = loa_range.get("max")
if lo is not None and ctx.length_overall_m < float(lo):
continue
if hi is not None and ctx.length_overall_m > float(hi):
continue
out.append(rid)
return out
def propose(self, ctx: RuleContext) -> list[EquipmentProposal]:
"""Genera la lista completa de propuestas para un contexto."""
proposals: list[EquipmentProposal] = []
for rid in self.applicable_rules(ctx):
proposals.extend(self._propose_from_rule(rid, ctx))
return proposals
# ----- internals --------------------------------------------------
def _propose_from_rule(
self, rule_id: str, ctx: RuleContext
) -> list[EquipmentProposal]:
rule = self._rules.get(rule_id) or {}
sys_proposals = rule.get("equipment_proposals") or {}
out: list[EquipmentProposal] = []
for sys_str, block in sys_proposals.items():
if not isinstance(block, dict):
continue
try:
sys_id = SystemId(sys_str)
except ValueError:
continue
if sys_id not in ctx.systems_enabled:
continue
count = int(block.get("count", 1))
candidates = block.get("candidates") or []
# Pick the first candidate whose `when` matches ctx
chosen = self._pick_candidate(candidates, ctx)
if not chosen:
continue
model_ref = chosen.get("model_ref", "")
rationale = chosen.get("rationale", "")
tag_template = block.get("tag_prefix_template", sys_str.upper())
sides = block.get("sides") or []
location_tpl = block.get("location_template") or {}
for idx in range(count):
if sides and idx < len(sides):
side = sides[idx]
prefix = tag_template.replace("{side}", side)
side_key = side.lower()
loc = location_tpl.get(side_key) or location_tpl.get("default") or {}
else:
prefix = tag_template.replace("{idx}", str(idx + 1))
loc = location_tpl.get("default") or {}
x_pp = self._resolve_x(loc, ctx)
y_cl = float(loc.get("y_cl", 0.0))
z_bl = float(loc.get("z_bl", 1.0))
out.append(
EquipmentProposal(
system_id=sys_id,
model_ref=model_ref,
tag_prefix=prefix,
display_name=f"{prefix} · {model_ref}",
location_x_pp=x_pp,
location_y_cl=y_cl,
location_z_bl=z_bl,
rationale=rationale,
)
)
return out
def _pick_candidate(
self, candidates: list[dict[str, Any]], ctx: RuleContext
) -> dict[str, Any] | None:
for c in candidates:
when = c.get("when") or {}
loa = when.get("length_overall_m") or {}
lo = loa.get("min")
hi = loa.get("max")
if lo is not None and ctx.length_overall_m < float(lo):
continue
if hi is not None and ctx.length_overall_m > float(hi):
continue
return c
# Si no matchea ninguno con `when`, devolver el primero sin condiciones
for c in candidates:
if not c.get("when"):
return c
return candidates[0] if candidates else None
def _resolve_x(self, loc: dict[str, Any], ctx: RuleContext) -> float:
if "x_pp" in loc:
return float(loc["x_pp"])
if "x_pp_pct" in loc:
return float(loc["x_pp_pct"]) * ctx.length_overall_m
return ctx.length_overall_m * 0.30
def apply_rules(ctx: RuleContext) -> list[EquipmentProposal]:
"""Conveniencia: aplica todas las reglas globales al contexto."""
return RuleEngine().propose(ctx)
+5
View File
@@ -0,0 +1,5 @@
"""Editores del Studio (post-wizard). Sprint 2: equipos. Sprint 3: mímicos, tags, alarmas."""
from vmssailor.studio.editors.equipment_editor import EquipmentEditor
__all__ = ["EquipmentEditor"]
@@ -0,0 +1,316 @@
"""Editor de equipos del Studio (post-wizard).
Tabla con todos los equipos del Project. CRUD básico:
- Agregar: dialog modal con campos
- Editar: doble-click en celda (inline)
- Eliminar: tecla Delete o botón
"""
from __future__ import annotations
import uuid
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QAbstractItemView,
QComboBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from vmssailor.core.coords import ShipCoord
from vmssailor.core.enums import SystemId
from vmssailor.core.equipment import Equipment
from vmssailor.core.project import Project
from vmssailor.library import load_library
from vmssailor.studio.theme import C_CYAN, C_FOG, mono_font, ui_font
_COLS = ["ID", "Tag prefix", "Nombre", "Modelo", "Sistema", "x_pp", "y_cl", "z_bl", "Deck"]
class EquipmentEditor(QWidget):
"""Editor CRUD de equipos del proyecto activo."""
projectMutated = Signal(Project)
"""Emitido cuando se agrega, edita o elimina un equipo."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._project: Project | None = None
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(12)
# Header
header = QLabel("Editor de equipos")
header.setObjectName("title")
header.setFont(ui_font(14))
layout.addWidget(header)
intro = QLabel(
"CRUD de equipos del proyecto. Doble-click en celda para editar inline. "
"El editor de mímicos y tags llega en Sprint 3."
)
intro.setStyleSheet(f"color: {C_FOG};")
intro.setFont(ui_font(10))
intro.setWordWrap(True)
layout.addWidget(intro)
# Buttons row
btn_row = QHBoxLayout()
self._btn_add = QPushButton("+ Agregar equipo")
self._btn_add.setObjectName("primary")
self._btn_remove = QPushButton("Eliminar seleccionado")
self._counter = QLabel("0 equipos")
self._counter.setFont(mono_font(10))
self._counter.setStyleSheet(f"color: {C_CYAN};")
btn_row.addWidget(self._btn_add)
btn_row.addWidget(self._btn_remove)
btn_row.addStretch(1)
btn_row.addWidget(self._counter)
layout.addLayout(btn_row)
# Table
self._table = QTableWidget(0, len(_COLS))
self._table.setHorizontalHeaderLabels(_COLS)
self._table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
self._table.verticalHeader().setVisible(False)
self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self._table.setEditTriggers(
QAbstractItemView.EditTrigger.DoubleClicked
| QAbstractItemView.EditTrigger.EditKeyPressed
)
self._table.itemChanged.connect(self._on_item_changed)
layout.addWidget(self._table, 1)
# Signals
self._btn_add.clicked.connect(self._on_add)
self._btn_remove.clicked.connect(self._on_remove)
# ----- Public API ---------------------------------------------------
def set_project(self, project: Project | None) -> None:
self._project = project
self._refresh_table()
# ----- Internals ----------------------------------------------------
def _refresh_table(self) -> None:
self._table.blockSignals(True)
self._table.setRowCount(0)
if self._project is None:
self._counter.setText("0 equipos")
self._table.blockSignals(False)
return
for eq in self._project.equipment:
self._append_row(eq)
self._counter.setText(f"{len(self._project.equipment)} equipos")
self._table.blockSignals(False)
def _append_row(self, eq: Equipment) -> None:
row = self._table.rowCount()
self._table.insertRow(row)
id_item = QTableWidgetItem(eq.id)
id_item.setFlags(id_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
id_item.setForeground(Qt.GlobalColor.gray)
self._table.setItem(row, 0, id_item)
self._table.setItem(row, 1, QTableWidgetItem(eq.tag_prefix))
self._table.setItem(row, 2, QTableWidgetItem(eq.display_name))
self._table.setItem(row, 3, QTableWidgetItem(eq.model_ref))
sys_item = QTableWidgetItem(eq.system_id.value)
sys_item.setForeground(Qt.GlobalColor.cyan)
self._table.setItem(row, 4, sys_item)
self._table.setItem(row, 5, QTableWidgetItem(f"{eq.location.x_pp:.2f}"))
self._table.setItem(row, 6, QTableWidgetItem(f"{eq.location.y_cl:.2f}"))
self._table.setItem(row, 7, QTableWidgetItem(f"{eq.location.z_bl:.2f}"))
self._table.setItem(row, 8, QTableWidgetItem(eq.deck_id or ""))
def _on_item_changed(self, item: QTableWidgetItem) -> None:
if self._project is None:
return
row = item.row()
if row >= len(self._project.equipment):
return
eq = self._project.equipment[row]
col = item.column()
try:
if col == 1:
# tag_prefix
new_val = item.text().upper()
self._project.equipment[row] = eq.model_copy(update={"tag_prefix": new_val})
item.setText(new_val)
elif col == 2:
self._project.equipment[row] = eq.model_copy(update={"display_name": item.text()})
elif col == 3:
self._project.equipment[row] = eq.model_copy(update={"model_ref": item.text()})
elif col == 4:
self._project.equipment[row] = eq.model_copy(
update={"system_id": SystemId(item.text())}
)
elif col in (5, 6, 7):
x = float(self._table.item(row, 5).text())
y = float(self._table.item(row, 6).text())
z = float(self._table.item(row, 7).text())
self._project.equipment[row] = eq.model_copy(
update={"location": ShipCoord(x_pp=x, y_cl=y, z_bl=z)}
)
elif col == 8:
deck = item.text().strip() or None
self._project.equipment[row] = eq.model_copy(update={"deck_id": deck})
except Exception as exc:
QMessageBox.warning(self, "Edición inválida", str(exc))
self._refresh_table()
return
self._project.touch()
self.projectMutated.emit(self._project)
def _on_add(self) -> None:
if self._project is None:
return
dlg = _AddEquipmentDialog(self._project, self)
if dlg.exec():
eq = dlg.build()
if eq is None:
return
try:
new_list = [*self._project.equipment, eq]
updated = self._project.model_copy(update={"equipment": new_list})
# Re-construir para revalidar via Pydantic
updated = updated.__class__.model_validate(updated.model_dump())
self._project = updated
except Exception as exc:
QMessageBox.critical(self, "Inválido", str(exc))
return
self._refresh_table()
self.projectMutated.emit(self._project)
def _on_remove(self) -> None:
if self._project is None:
return
row = self._table.currentRow()
if row < 0 or row >= len(self._project.equipment):
return
eq = self._project.equipment[row]
confirm = QMessageBox.question(
self,
"Eliminar equipo",
f"¿Eliminar '{eq.tag_prefix} · {eq.display_name}'?",
)
if confirm != QMessageBox.StandardButton.Yes:
return
new_list = [e for i, e in enumerate(self._project.equipment) if i != row]
try:
updated = self._project.model_copy(update={"equipment": new_list})
updated = updated.__class__.model_validate(updated.model_dump())
self._project = updated
except Exception as exc:
QMessageBox.critical(self, "Error", str(exc))
return
self._refresh_table()
self.projectMutated.emit(self._project)
class _AddEquipmentDialog(QDialog):
"""Diálogo modal para agregar un Equipment."""
def __init__(self, project: Project, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._project = project
self.setWindowTitle("Agregar equipo")
self.setMinimumWidth(420)
layout = QVBoxLayout(self)
form = QFormLayout()
self._tag_prefix = QLineEdit()
self._tag_prefix.setPlaceholderText("ME_PORT, GEN_2, BILGE_FWD...")
form.addRow("Tag prefix:", self._tag_prefix)
self._display = QLineEdit()
self._display.setPlaceholderText("Motor principal babor")
form.addRow("Nombre:", self._display)
self._model_ref = QComboBox()
try:
lib = load_library()
for em in lib.equipment_models:
self._model_ref.addItem(f"{em.id} · {em.manufacturer} {em.model_name}", em.id)
except Exception:
pass
form.addRow("Modelo (biblioteca):", self._model_ref)
self._system = QComboBox()
for sys_id in project.systems_enabled:
self._system.addItem(sys_id.value, sys_id.value)
if self._system.count() == 0:
for sys_id in SystemId:
self._system.addItem(sys_id.value, sys_id.value)
form.addRow("Sistema:", self._system)
# Location
self._x = QDoubleSpinBox()
self._x.setRange(-5.0, 200.0)
self._x.setDecimals(2)
self._x.setSuffix(" m")
self._x.setValue(project.vessel.length_overall_m * 0.3)
form.addRow("x_pp:", self._x)
self._y = QDoubleSpinBox()
self._y.setRange(-30.0, 30.0)
self._y.setDecimals(2)
self._y.setSuffix(" m")
form.addRow("y_cl:", self._y)
self._z = QDoubleSpinBox()
self._z.setRange(-10.0, 50.0)
self._z.setDecimals(2)
self._z.setSuffix(" m")
self._z.setValue(1.0)
form.addRow("z_bl:", self._z)
self._deck = QComboBox()
self._deck.addItem("(sin asignar)", None)
for d in project.vessel.decks:
self._deck.addItem(f"{d.id} · {d.name}", d.id)
form.addRow("Deck:", self._deck)
layout.addLayout(form)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def build(self) -> Equipment | None:
prefix = self._tag_prefix.text().strip().upper()
if not prefix:
return None
try:
return Equipment(
id=f"eq_{uuid.uuid4().hex[:8]}_{prefix.lower()}",
model_ref=self._model_ref.currentData() or "",
tag_prefix=prefix,
display_name=self._display.text() or prefix,
location=ShipCoord(
x_pp=self._x.value(), y_cl=self._y.value(), z_bl=self._z.value()
),
deck_id=self._deck.currentData(),
system_id=SystemId(self._system.currentData() or "main_engine"),
)
except Exception:
return None
+24 -1
View File
@@ -28,7 +28,9 @@ from PySide6.QtWidgets import (
from vmssailor.core.persistence import load_project, save_project from vmssailor.core.persistence import load_project, save_project
from vmssailor.core.project import Project from vmssailor.core.project import Project
from vmssailor.studio.editors.equipment_editor import EquipmentEditor
from vmssailor.studio.theme import ( from vmssailor.studio.theme import (
C_CYAN,
C_FOAM, C_FOAM,
C_FOG, C_FOG,
mono_font, mono_font,
@@ -123,10 +125,19 @@ class MainWindow(QMainWindow):
self._sidebar.setMinimumWidth(260) self._sidebar.setMinimumWidth(260)
self._sidebar.setMaximumWidth(380) self._sidebar.setMaximumWidth(380)
# Right pane: vertical splitter with canvas on top + equipment editor below
self._canvas = VesselCanvas() self._canvas = VesselCanvas()
self._equipment_editor = EquipmentEditor()
right_splitter = QSplitter(Qt.Vertical)
right_splitter.setChildrenCollapsible(False)
right_splitter.addWidget(self._canvas)
right_splitter.addWidget(self._equipment_editor)
right_splitter.setSizes([520, 380])
self._right_splitter = right_splitter
self._splitter.addWidget(self._sidebar) self._splitter.addWidget(self._sidebar)
self._splitter.addWidget(self._canvas) self._splitter.addWidget(right_splitter)
self._splitter.setSizes([280, 1160]) self._splitter.setSizes([280, 1160])
# Compose central widget: topbar (top) + splitter (rest) # Compose central widget: topbar (top) + splitter (rest)
@@ -223,6 +234,8 @@ class MainWindow(QMainWindow):
self.projectChanged.connect(self._sidebar.set_project) self.projectChanged.connect(self._sidebar.set_project)
self.projectChanged.connect(self._canvas.set_project) self.projectChanged.connect(self._canvas.set_project)
self.projectChanged.connect(self._equipment_editor.set_project)
self._equipment_editor.projectMutated.connect(self._on_project_mutated)
# ----- Slots -------------------------------------------------------- # ----- Slots --------------------------------------------------------
@@ -366,3 +379,13 @@ class MainWindow(QMainWindow):
def current_project(self) -> Project | None: def current_project(self) -> Project | None:
return self._project return self._project
def _on_project_mutated(self, project: Project) -> None:
"""El editor reemplazó el Project. Re-emit a sidebar + canvas."""
self._project = project
self._update_stats()
# Re-emit a sidebar + canvas (que ignoran al editor para evitar loop)
self._sidebar.set_project(project)
self._canvas.set_project(project)
self._dirty_badge.setText("● Cambios pendientes — guarda con Ctrl+S")
self._dirty_badge.setStyleSheet(f"color: {C_CYAN};")
@@ -0,0 +1,203 @@
"""Paso 5: equipos sugeridos por el motor de reglas heurísticas."""
from __future__ import annotations
from PySide6.QtCore import Property, Qt, Signal
from PySide6.QtWidgets import (
QAbstractItemView,
QHeaderView,
QLabel,
QPushButton,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
QWizardPage,
)
from vmssailor.core.enums import SystemId, VesselSubtype, VesselType
from vmssailor.studio.designer.rule_engine import (
EquipmentProposal,
RuleContext,
RuleEngine,
)
from vmssailor.studio.theme import C_CYAN, C_FOG, mono_font, ui_font
class Step05Equipment(QWizardPage):
"""Muestra propuestas del rule engine. Integrador acepta/rechaza."""
proposalsChanged = Signal()
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setTitle("Paso 5 · Equipos sugeridos")
self.setSubTitle(
"El motor de reglas propuso estos equipos basado en el tipo de buque y "
"los sistemas habilitados. Desmarca los que no apliquen."
)
layout = QVBoxLayout(self)
layout.setSpacing(12)
intro = QLabel(
"Doble-click en una celda para editar. La columna ✓ permite excluir propuestas."
)
intro.setStyleSheet(f"color: {C_FOG};")
intro.setFont(ui_font(10))
layout.addWidget(intro)
self._table = QTableWidget(0, 7)
self._table.setHorizontalHeaderLabels(
["", "Sistema", "Modelo", "Tag prefix", "x_pp", "y_cl", "Rationale"]
)
self._table.horizontalHeader().setSectionResizeMode(
6, QHeaderView.ResizeMode.Stretch
)
self._table.horizontalHeader().setSectionResizeMode(
0, QHeaderView.ResizeMode.ResizeToContents
)
self._table.setAlternatingRowColors(False)
self._table.verticalHeader().setVisible(False)
self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self._table.setEditTriggers(
QAbstractItemView.EditTrigger.DoubleClicked
| QAbstractItemView.EditTrigger.EditKeyPressed
)
self._table.itemChanged.connect(self._on_item_changed)
layout.addWidget(self._table, 1)
# Counter label
self._counter = QLabel("0 propuestas")
self._counter.setFont(mono_font(10))
self._counter.setStyleSheet(f"color: {C_CYAN};")
layout.addWidget(self._counter)
btn_row = QPushButton("Regenerar desde reglas")
btn_row.clicked.connect(self._regenerate)
layout.addWidget(btn_row)
self._proposals: list[EquipmentProposal] = []
from vmssailor.studio.wizard.wizard import F_PROPOSALS
self.registerField(F_PROPOSALS, self, "proposals", "proposalsChanged")
# ----- Property API ------------------------------------------------
def get_proposals(self) -> list[EquipmentProposal]:
return [p for p in self._proposals if p.accepted]
def set_proposals(self, value: list[EquipmentProposal]) -> None:
self._proposals = value
self._refresh_table()
self.proposalsChanged.emit()
proposals = Property(
list, fget=get_proposals, fset=set_proposals, notify=proposalsChanged
)
# ----- Lifecycle ---------------------------------------------------
def initializePage(self) -> None:
if not self._proposals:
self._regenerate()
def _regenerate(self) -> None:
from vmssailor.studio.wizard.wizard import (
F_BEAM,
F_DRAFT,
F_LOA,
F_SYSTEMS,
F_VESSEL_SUBTYPE,
F_VESSEL_TYPE,
)
try:
v_type = VesselType(self.field(F_VESSEL_TYPE) or "yacht_motor")
except ValueError:
v_type = VesselType.YACHT_MOTOR
try:
v_sub = VesselSubtype(self.field(F_VESSEL_SUBTYPE) or "planing")
except ValueError:
v_sub = VesselSubtype.PLANING
loa = float(self.field(F_LOA) or 24.0)
beam = float(self.field(F_BEAM) or 5.5)
draft = float(self.field(F_DRAFT) or 1.8)
systems_raw = self.field(F_SYSTEMS) or []
systems: list[SystemId] = []
for s in systems_raw:
try:
systems.append(SystemId(s))
except ValueError:
continue
ctx = RuleContext(
vessel_type=v_type,
vessel_subtype=v_sub,
length_overall_m=loa,
beam_max_m=beam,
draft_m=draft,
systems_enabled=systems,
)
engine = RuleEngine()
self._proposals = engine.propose(ctx)
self._refresh_table()
self.proposalsChanged.emit()
# ----- Table rendering --------------------------------------------
def _refresh_table(self) -> None:
self._table.blockSignals(True)
self._table.setRowCount(len(self._proposals))
for row, p in enumerate(self._proposals):
check = QTableWidgetItem()
check.setFlags(check.flags() | Qt.ItemFlag.ItemIsUserCheckable)
check.setCheckState(Qt.CheckState.Checked if p.accepted else Qt.CheckState.Unchecked)
self._table.setItem(row, 0, check)
self._table.setItem(row, 1, QTableWidgetItem(p.system_id.value))
self._table.setItem(row, 2, QTableWidgetItem(p.model_ref))
self._table.setItem(row, 3, QTableWidgetItem(p.tag_prefix))
self._table.setItem(row, 4, QTableWidgetItem(f"{p.location_x_pp:.2f}"))
self._table.setItem(row, 5, QTableWidgetItem(f"{p.location_y_cl:.2f}"))
rationale = QTableWidgetItem(p.rationale)
rationale.setForeground(Qt.GlobalColor.gray)
self._table.setItem(row, 6, rationale)
self._table.resizeColumnsToContents()
self._table.blockSignals(False)
accepted = sum(1 for p in self._proposals if p.accepted)
self._counter.setText(
f"{accepted} aceptadas · {len(self._proposals) - accepted} rechazadas · "
f"{len(self._proposals)} totales"
)
def _on_item_changed(self, item: QTableWidgetItem) -> None:
row = item.row()
if row >= len(self._proposals):
return
col = item.column()
p = self._proposals[row]
try:
if col == 0:
p.accepted = item.checkState() == Qt.CheckState.Checked
elif col == 2:
p.model_ref = item.text()
elif col == 3:
p.tag_prefix = item.text().upper()
item.setText(p.tag_prefix)
elif col == 4:
p.location_x_pp = float(item.text())
elif col == 5:
p.location_y_cl = float(item.text())
except ValueError:
# Reset celda al valor previo si la conversión falla
self._refresh_table()
return
accepted = sum(1 for pr in self._proposals if pr.accepted)
self._counter.setText(
f"{accepted} aceptadas · {len(self._proposals) - accepted} rechazadas · "
f"{len(self._proposals)} totales"
)
self.proposalsChanged.emit()
@@ -0,0 +1,62 @@
"""Paso 6: refinamiento manual (Sprint 2).
En Sprint 1 era placeholder. En Sprint 2 permite reasignar ubicaciones de
equipos y editar tag_prefix con una vista preview de la silueta.
"""
from __future__ import annotations
from PySide6.QtWidgets import (
QLabel,
QListWidget,
QVBoxLayout,
QWidget,
QWizardPage,
)
from vmssailor.studio.theme import C_FOG, mono_font, ui_font
class Step06Refinement(QWizardPage):
"""Muestra resumen de equipos aceptados con preview textual."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setTitle("Paso 6 · Refinamiento")
self.setSubTitle(
"Revisa los equipos finales antes de generar topología. "
"El editor visual completo viene en Sprint 3."
)
layout = QVBoxLayout(self)
layout.setSpacing(12)
self._list = QListWidget()
self._list.setFont(mono_font(10))
layout.addWidget(self._list, 1)
# Stats
self._stats = QLabel("Cargando…")
self._stats.setStyleSheet(f"color: {C_FOG};")
self._stats.setFont(ui_font(10))
layout.addWidget(self._stats)
def initializePage(self) -> None:
from vmssailor.studio.wizard.wizard import F_PROPOSALS
proposals = self.field(F_PROPOSALS) or []
self._list.clear()
by_system: dict[str, int] = {}
for p in proposals:
self._list.addItem(
f"{p.system_id.value:20s} {p.tag_prefix:14s} "
f"{p.model_ref:30s} @ x_pp={p.location_x_pp:.2f}m y_cl={p.location_y_cl:+.2f}m"
)
by_system[p.system_id.value] = by_system.get(p.system_id.value, 0) + 1
n = len(proposals)
groups = ", ".join(f"{k}: {v}" for k, v in sorted(by_system.items()))
self._stats.setText(
f"{n} equipos aceptados · {groups}"
if n
else "Sin equipos aceptados. Vuelve atrás y selecciona al menos uno."
)
+153
View File
@@ -0,0 +1,153 @@
"""Paso 7: topología AR-NMEA-IO + asignación automática de I/O.
Genera tarjetas + bindings físicos de cada sensor declarado en
EquipmentModel.default_sensors. El integrador puede ajustar después en el
editor de topología (Sprint 3+).
"""
from __future__ import annotations
from PySide6.QtCore import Property, Signal
from PySide6.QtWidgets import (
QHeaderView,
QLabel,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
QWizardPage,
)
from vmssailor.core.equipment import Equipment
from vmssailor.library import load_library
from vmssailor.studio.designer.port_auto_assigner import (
AssignmentReport,
auto_assign,
)
from vmssailor.studio.designer.rule_engine import EquipmentProposal
from vmssailor.studio.theme import C_FOG, mono_font, ui_font
class Step07Topology(QWizardPage):
"""Vista previa de la topología auto-asignada."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setTitle("Paso 7 · Topología AR-NMEA-IO")
self.setSubTitle(
"Asignación automática de tarjetas y canales físicos. "
"Capacidad por tarjeta: 10 DO · 5 DI · 1 RPM · 4 AI."
)
layout = QVBoxLayout(self)
layout.setSpacing(10)
self._summary = QLabel("")
self._summary.setFont(ui_font(11))
layout.addWidget(self._summary)
self._table = QTableWidget(0, 4)
self._table.setHorizontalHeaderLabels(
["Tarjeta", "Slot · Addr", "DO/DI/AI/RPM usados", "Ubicación"]
)
self._table.horizontalHeader().setSectionResizeMode(
3, QHeaderView.ResizeMode.Stretch
)
self._table.verticalHeader().setVisible(False)
layout.addWidget(self._table, 1)
self._warnings = QLabel("")
self._warnings.setWordWrap(True)
self._warnings.setFont(mono_font(9))
self._warnings.setStyleSheet(f"color: {C_FOG};")
layout.addWidget(self._warnings)
self._assignment: AssignmentReport | None = None
from vmssailor.studio.wizard.wizard import F_ASSIGNMENT
self.registerField(F_ASSIGNMENT, self, "assignment", "assignmentChanged")
# Property API
assignmentChanged = Signal()
def get_assignment(self) -> AssignmentReport | None:
return self._assignment
def set_assignment(self, value: AssignmentReport) -> None:
self._assignment = value
self.assignmentChanged.emit()
assignment = Property(
object, fget=get_assignment, fset=set_assignment, notify=assignmentChanged
)
def initializePage(self) -> None:
from vmssailor.studio.wizard.wizard import F_PROPOSALS
proposals: list[EquipmentProposal] = self.field(F_PROPOSALS) or []
if not proposals:
self._summary.setText("Sin equipos. Vuelve al paso 5.")
self._table.setRowCount(0)
return
# Materialize Equipment objects so we can run the assigner
equipment_list: list[Equipment] = []
for idx, p in enumerate(proposals):
from vmssailor.core.coords import ShipCoord
equipment_list.append(
Equipment(
id=f"eq_{idx:03d}_{p.tag_prefix.lower()}",
model_ref=p.model_ref,
tag_prefix=p.tag_prefix,
display_name=p.display_name,
location=ShipCoord(
x_pp=p.location_x_pp,
y_cl=p.location_y_cl,
z_bl=p.location_z_bl,
),
system_id=p.system_id,
)
)
# Cargar modelos de biblioteca
try:
lib = load_library()
model_lookup = {m.id: m for m in lib.equipment_models}
except Exception:
model_lookup = {}
report = auto_assign(equipment_list, model_lookup)
self._assignment = report
self._summary.setText(
f"<b>{len(report.cards)}</b> tarjetas · "
f"<b>{len(report.tags)}</b> tags asignados · "
f"<b>{report.n_skipped}</b> sin tipo de señal · "
f"bus: <b>{report.bus.id if report.bus else ''}</b>"
)
self._table.setRowCount(len(report.cards))
capacities: dict[str, dict[str, int]] = {}
for t in report.tags:
if t.physical_binding:
cid = t.physical_binding.card_id
ch = t.physical_binding.channel_type.value.upper()
capacities.setdefault(cid, {"DO": 0, "DI": 0, "AI": 0, "RPM": 0})
capacities[cid][ch] = capacities[cid].get(ch, 0) + 1
for row, c in enumerate(report.cards):
self._table.setItem(row, 0, QTableWidgetItem(c.id))
self._table.setItem(row, 1, QTableWidgetItem(f"slot {c.slot_number} · addr {c.modbus_address}"))
cap = capacities.get(c.id, {})
usage = f"DO {cap.get('DO', 0)}/10 · DI {cap.get('DI', 0)}/5 · AI {cap.get('AI', 0)}/4 · RPM {cap.get('RPM', 0)}/1"
self._table.setItem(row, 2, QTableWidgetItem(usage))
self._table.setItem(row, 3, QTableWidgetItem(c.physical_location))
self._table.resizeColumnsToContents()
if report.warnings:
self._warnings.setText("\n".join(report.warnings[:6]))
else:
self._warnings.setText("✓ Sin conflictos de capacidad detectados.")
self.assignmentChanged.emit()
+51 -19
View File
@@ -1,4 +1,8 @@
"""QWizard contenedor + builder de Project a partir del estado del wizard.""" """QWizard contenedor + builder de Project a partir del estado del wizard.
Sprint 1: pasos 1-4 funcionales, 5-7 placeholders, 8 confirm.
Sprint 2: pasos 5-7 funcionales con rule engine + auto-assigner.
"""
from __future__ import annotations from __future__ import annotations
@@ -7,6 +11,7 @@ import logging
from PySide6.QtWidgets import QWidget, QWizard from PySide6.QtWidgets import QWidget, QWizard
from vmssailor.core import ( from vmssailor.core import (
Equipment,
Project, Project,
SystemId, SystemId,
Vessel, Vessel,
@@ -15,16 +20,15 @@ from vmssailor.core import (
) )
from vmssailor.core.vessel import Bulkhead, Deck from vmssailor.core.vessel import Bulkhead, Deck
from vmssailor.shared.ids import make_project_id from vmssailor.shared.ids import make_project_id
from vmssailor.studio.theme import ( from vmssailor.studio.theme import C_ABYSS, C_SAND
C_ABYSS,
C_SAND,
)
from vmssailor.studio.wizard.step_01_vessel_type import Step01VesselType from vmssailor.studio.wizard.step_01_vessel_type import Step01VesselType
from vmssailor.studio.wizard.step_02_template import Step02Template from vmssailor.studio.wizard.step_02_template import Step02Template
from vmssailor.studio.wizard.step_03_dimensions import Step03Dimensions from vmssailor.studio.wizard.step_03_dimensions import Step03Dimensions
from vmssailor.studio.wizard.step_04_systems import Step04Systems from vmssailor.studio.wizard.step_04_systems import Step04Systems
from vmssailor.studio.wizard.step_05_equipment import Step05Equipment
from vmssailor.studio.wizard.step_06_refinement import Step06Refinement
from vmssailor.studio.wizard.step_07_topology import Step07Topology
from vmssailor.studio.wizard.step_08_confirm import Step08Confirm from vmssailor.studio.wizard.step_08_confirm import Step08Confirm
from vmssailor.studio.wizard.step_57_placeholder import Step57Placeholder
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -42,6 +46,8 @@ F_DRAFT = "vessel.draft_m"
F_BULKHEAD_FWD = "vessel.bulkhead_fwd_m" F_BULKHEAD_FWD = "vessel.bulkhead_fwd_m"
F_BULKHEAD_AFT = "vessel.bulkhead_aft_m" F_BULKHEAD_AFT = "vessel.bulkhead_aft_m"
F_SYSTEMS = "systems.enabled" F_SYSTEMS = "systems.enabled"
F_PROPOSALS = "equipment.proposals"
F_ASSIGNMENT = "topology.assignment"
class VesselWizard(QWizard): class VesselWizard(QWizard):
@@ -61,9 +67,9 @@ class VesselWizard(QWizard):
self.addPage(Step02Template()) self.addPage(Step02Template())
self.addPage(Step03Dimensions()) self.addPage(Step03Dimensions())
self.addPage(Step04Systems()) self.addPage(Step04Systems())
self.addPage(Step57Placeholder(index=5, title="Equipos sugeridos (Sprint 2)")) self.addPage(Step05Equipment())
self.addPage(Step57Placeholder(index=6, title="Refinamiento manual (Sprint 2)")) self.addPage(Step06Refinement())
self.addPage(Step57Placeholder(index=7, title="Topología I/O AR-NMEA-IO (Sprint 2)")) self.addPage(Step07Topology())
self.addPage(Step08Confirm()) self.addPage(Step08Confirm())
# Style # Style
@@ -75,7 +81,6 @@ class VesselWizard(QWizard):
""" """
) )
# Localización de botones (QWizard usa textos del sistema)
self.setButtonText(QWizard.NextButton, "Siguiente >") self.setButtonText(QWizard.NextButton, "Siguiente >")
self.setButtonText(QWizard.BackButton, "< Atrás") self.setButtonText(QWizard.BackButton, "< Atrás")
self.setButtonText(QWizard.FinishButton, "Crear proyecto") self.setButtonText(QWizard.FinishButton, "Crear proyecto")
@@ -97,7 +102,6 @@ class VesselWizard(QWizard):
bh_aft = float(self.field(F_BULKHEAD_AFT) or (loa * 0.15)) bh_aft = float(self.field(F_BULKHEAD_AFT) or (loa * 0.15))
systems_raw = self.field(F_SYSTEMS) or [SystemId.MAIN_ENGINE.value] systems_raw = self.field(F_SYSTEMS) or [SystemId.MAIN_ENGINE.value]
# Build vessel
decks = [ decks = [
Deck(id="lower", name="Cubierta inferior", z_bl_bottom=0.4, z_bl_top=draft + 1.0), Deck(id="lower", name="Cubierta inferior", z_bl_bottom=0.4, z_bl_top=draft + 1.0),
Deck( Deck(
@@ -111,7 +115,6 @@ class VesselWizard(QWizard):
Bulkhead(id="er_aft", name="Mamparo popa SM", x_pp=bh_aft), Bulkhead(id="er_aft", name="Mamparo popa SM", x_pp=bh_aft),
Bulkhead(id="er_fwd", name="Mamparo proa SM", x_pp=bh_fwd), Bulkhead(id="er_fwd", name="Mamparo proa SM", x_pp=bh_fwd),
] ]
try:
vessel = Vessel( vessel = Vessel(
id=f"wizard_{int(loa * 10)}m", id=f"wizard_{int(loa * 10)}m",
name=vessel_name, name=vessel_name,
@@ -124,24 +127,53 @@ class VesselWizard(QWizard):
bulkheads=bulkheads, bulkheads=bulkheads,
data_source="user_input", data_source="user_input",
) )
except Exception as exc:
logger.error("Vessel build failed: %s", exc)
raise
# Equipment + tags llegan en Sprint 2 (rule engine). Por ahora, vacío.
try: try:
systems_enabled = [SystemId(s) for s in systems_raw] systems_enabled = [SystemId(s) for s in systems_raw]
except ValueError: except ValueError:
systems_enabled = [SystemId.MAIN_ENGINE] systems_enabled = [SystemId.MAIN_ENGINE]
# Sprint 2: materializar Equipment desde las propuestas aceptadas
proposals = self.field(F_PROPOSALS) or []
equipment_list: list[Equipment] = []
for idx, p in enumerate(proposals):
equipment_list.append(
Equipment(
id=f"eq_{idx:03d}_{p.tag_prefix.lower()}",
model_ref=p.model_ref,
tag_prefix=p.tag_prefix,
display_name=p.display_name,
location=p.to_ship_coord(),
system_id=p.system_id,
)
)
# Topología + tags del auto-assigner
assignment = self.field(F_ASSIGNMENT)
topology = assignment.topology() if assignment is not None else None
tags = list(assignment.tags) if assignment is not None else []
project_id = make_project_id(customer or "cliente", vessel.id) project_id = make_project_id(customer or "cliente", vessel.id)
if topology is not None:
return Project( return Project(
id=project_id, id=project_id,
name=project_name, name=project_name,
customer=customer, customer=customer,
vessel=vessel, vessel=vessel,
systems_enabled=systems_enabled, systems_enabled=systems_enabled,
equipment=[], equipment=equipment_list,
tags=[], tags=tags,
notes="Creado desde wizard Sprint 1. Sprint 2 agregará equipos sugeridos.", topology=topology,
notes="Creado desde wizard (Sprint 2 — rule engine + auto-assigner).",
)
return Project(
id=project_id,
name=project_name,
customer=customer,
vessel=vessel,
systems_enabled=systems_enabled,
equipment=equipment_list,
tags=tags,
notes="Creado desde wizard (Sprint 2 — rule engine + auto-assigner).",
) )