diff --git a/tests/studio/test_designer.py b/tests/studio/test_designer.py
new file mode 100644
index 0000000..a3a7ab3
--- /dev/null
+++ b/tests/studio/test_designer.py
@@ -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
diff --git a/vmssailor/library/equipment/engines/cat_c32_acert.json b/vmssailor/library/equipment/engines/cat_c32_acert.json
new file mode 100644
index 0000000..7072c2c
--- /dev/null
+++ b/vmssailor/library/equipment/engines/cat_c32_acert.json
@@ -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" }
+ ]
+}
diff --git a/vmssailor/library/equipment/engines/mtu_16v_2000_m96.json b/vmssailor/library/equipment/engines/mtu_16v_2000_m96.json
new file mode 100644
index 0000000..a02e68b
--- /dev/null
+++ b/vmssailor/library/equipment/engines/mtu_16v_2000_m96.json
@@ -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" }
+ ]
+}
diff --git a/vmssailor/library/equipment/engines/yanmar_8lv_370.json b/vmssailor/library/equipment/engines/yanmar_8lv_370.json
new file mode 100644
index 0000000..e2673de
--- /dev/null
+++ b/vmssailor/library/equipment/engines/yanmar_8lv_370.json
@@ -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" }
+ ]
+}
diff --git a/vmssailor/library/equipment/gensets/kohler_28efkozd.json b/vmssailor/library/equipment/gensets/kohler_28efkozd.json
new file mode 100644
index 0000000..843a8ba
--- /dev/null
+++ b/vmssailor/library/equipment/gensets/kohler_28efkozd.json
@@ -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" }
+ ]
+}
diff --git a/vmssailor/library/equipment/gensets/onan_qd13500.json b/vmssailor/library/equipment/gensets/onan_qd13500.json
new file mode 100644
index 0000000..9cdf4ec
--- /dev/null
+++ b/vmssailor/library/equipment/gensets/onan_qd13500.json
@@ -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" }
+ ]
+}
diff --git a/vmssailor/library/equipment/pumps/grundfos_cm10.json b/vmssailor/library/equipment/pumps/grundfos_cm10.json
new file mode 100644
index 0000000..06c2599
--- /dev/null
+++ b/vmssailor/library/equipment/pumps/grundfos_cm10.json
@@ -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" }
+ ]
+}
diff --git a/vmssailor/library/equipment/pumps/jabsco_36800.json b/vmssailor/library/equipment/pumps/jabsco_36800.json
new file mode 100644
index 0000000..7e532bb
--- /dev/null
+++ b/vmssailor/library/equipment/pumps/jabsco_36800.json
@@ -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" }
+ ]
+}
diff --git a/vmssailor/library/vessels/azimut_grande_32m.json b/vmssailor/library/vessels/azimut_grande_32m.json
new file mode 100644
index 0000000..556206e
--- /dev/null
+++ b/vmssailor/library/vessels/azimut_grande_32m.json
@@ -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": "" }
+ ]
+}
diff --git a/vmssailor/library/vessels/patrol_coastal_30m.json b/vmssailor/library/vessels/patrol_coastal_30m.json
new file mode 100644
index 0000000..17b6a70
--- /dev/null
+++ b/vmssailor/library/vessels/patrol_coastal_30m.json
@@ -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": "" }
+ ]
+}
diff --git a/vmssailor/library/vessels/princess_y85.json b/vmssailor/library/vessels/princess_y85.json
new file mode 100644
index 0000000..f3b1118
--- /dev/null
+++ b/vmssailor/library/vessels/princess_y85.json
@@ -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": "" }
+ ]
+}
diff --git a/vmssailor/library/vessels/trawler_32m_offshore.json b/vmssailor/library/vessels/trawler_32m_offshore.json
new file mode 100644
index 0000000..6266932
--- /dev/null
+++ b/vmssailor/library/vessels/trawler_32m_offshore.json
@@ -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": "" }
+ ]
+}
diff --git a/vmssailor/studio/designer/__init__.py b/vmssailor/studio/designer/__init__.py
new file mode 100644
index 0000000..1b6fd61
--- /dev/null
+++ b/vmssailor/studio/designer/__init__.py
@@ -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"]
diff --git a/vmssailor/studio/designer/port_auto_assigner.py b/vmssailor/studio/designer/port_auto_assigner.py
new file mode 100644
index 0000000..3612478
--- /dev/null
+++ b/vmssailor/studio/designer/port_auto_assigner.py
@@ -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
diff --git a/vmssailor/studio/designer/rule_engine.py b/vmssailor/studio/designer/rule_engine.py
new file mode 100644
index 0000000..7f6946b
--- /dev/null
+++ b/vmssailor/studio/designer/rule_engine.py
@@ -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)
diff --git a/vmssailor/studio/editors/__init__.py b/vmssailor/studio/editors/__init__.py
new file mode 100644
index 0000000..0571a45
--- /dev/null
+++ b/vmssailor/studio/editors/__init__.py
@@ -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"]
diff --git a/vmssailor/studio/editors/equipment_editor.py b/vmssailor/studio/editors/equipment_editor.py
new file mode 100644
index 0000000..c1e38fd
--- /dev/null
+++ b/vmssailor/studio/editors/equipment_editor.py
@@ -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
diff --git a/vmssailor/studio/main_window.py b/vmssailor/studio/main_window.py
index 4034c38..38f8a5a 100644
--- a/vmssailor/studio/main_window.py
+++ b/vmssailor/studio/main_window.py
@@ -28,7 +28,9 @@ from PySide6.QtWidgets import (
from vmssailor.core.persistence import load_project, save_project
from vmssailor.core.project import Project
+from vmssailor.studio.editors.equipment_editor import EquipmentEditor
from vmssailor.studio.theme import (
+ C_CYAN,
C_FOAM,
C_FOG,
mono_font,
@@ -123,10 +125,19 @@ class MainWindow(QMainWindow):
self._sidebar.setMinimumWidth(260)
self._sidebar.setMaximumWidth(380)
+ # Right pane: vertical splitter with canvas on top + equipment editor below
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._canvas)
+ self._splitter.addWidget(right_splitter)
self._splitter.setSizes([280, 1160])
# 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._canvas.set_project)
+ self.projectChanged.connect(self._equipment_editor.set_project)
+ self._equipment_editor.projectMutated.connect(self._on_project_mutated)
# ----- Slots --------------------------------------------------------
@@ -366,3 +379,13 @@ class MainWindow(QMainWindow):
def current_project(self) -> Project | None:
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};")
diff --git a/vmssailor/studio/wizard/step_05_equipment.py b/vmssailor/studio/wizard/step_05_equipment.py
new file mode 100644
index 0000000..cadef6a
--- /dev/null
+++ b/vmssailor/studio/wizard/step_05_equipment.py
@@ -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()
diff --git a/vmssailor/studio/wizard/step_06_refinement.py b/vmssailor/studio/wizard/step_06_refinement.py
new file mode 100644
index 0000000..131c8b5
--- /dev/null
+++ b/vmssailor/studio/wizard/step_06_refinement.py
@@ -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."
+ )
diff --git a/vmssailor/studio/wizard/step_07_topology.py b/vmssailor/studio/wizard/step_07_topology.py
new file mode 100644
index 0000000..3001d43
--- /dev/null
+++ b/vmssailor/studio/wizard/step_07_topology.py
@@ -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"{len(report.cards)} tarjetas · "
+ f"{len(report.tags)} tags asignados · "
+ f"{report.n_skipped} sin tipo de señal · "
+ f"bus: {report.bus.id if report.bus else '—'}"
+ )
+
+ 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()
diff --git a/vmssailor/studio/wizard/wizard.py b/vmssailor/studio/wizard/wizard.py
index 7ae6242..c31b35e 100644
--- a/vmssailor/studio/wizard/wizard.py
+++ b/vmssailor/studio/wizard/wizard.py
@@ -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
@@ -7,6 +11,7 @@ import logging
from PySide6.QtWidgets import QWidget, QWizard
from vmssailor.core import (
+ Equipment,
Project,
SystemId,
Vessel,
@@ -15,16 +20,15 @@ from vmssailor.core import (
)
from vmssailor.core.vessel import Bulkhead, Deck
from vmssailor.shared.ids import make_project_id
-from vmssailor.studio.theme import (
- C_ABYSS,
- C_SAND,
-)
+from vmssailor.studio.theme import C_ABYSS, C_SAND
from vmssailor.studio.wizard.step_01_vessel_type import Step01VesselType
from vmssailor.studio.wizard.step_02_template import Step02Template
from vmssailor.studio.wizard.step_03_dimensions import Step03Dimensions
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_57_placeholder import Step57Placeholder
logger = logging.getLogger(__name__)
@@ -42,6 +46,8 @@ F_DRAFT = "vessel.draft_m"
F_BULKHEAD_FWD = "vessel.bulkhead_fwd_m"
F_BULKHEAD_AFT = "vessel.bulkhead_aft_m"
F_SYSTEMS = "systems.enabled"
+F_PROPOSALS = "equipment.proposals"
+F_ASSIGNMENT = "topology.assignment"
class VesselWizard(QWizard):
@@ -61,9 +67,9 @@ class VesselWizard(QWizard):
self.addPage(Step02Template())
self.addPage(Step03Dimensions())
self.addPage(Step04Systems())
- self.addPage(Step57Placeholder(index=5, title="Equipos sugeridos (Sprint 2)"))
- self.addPage(Step57Placeholder(index=6, title="Refinamiento manual (Sprint 2)"))
- self.addPage(Step57Placeholder(index=7, title="Topología I/O AR-NMEA-IO (Sprint 2)"))
+ self.addPage(Step05Equipment())
+ self.addPage(Step06Refinement())
+ self.addPage(Step07Topology())
self.addPage(Step08Confirm())
# 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.BackButton, "< Atrás")
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))
systems_raw = self.field(F_SYSTEMS) or [SystemId.MAIN_ENGINE.value]
- # Build vessel
decks = [
Deck(id="lower", name="Cubierta inferior", z_bl_bottom=0.4, z_bl_top=draft + 1.0),
Deck(
@@ -111,37 +115,65 @@ class VesselWizard(QWizard):
Bulkhead(id="er_aft", name="Mamparo popa SM", x_pp=bh_aft),
Bulkhead(id="er_fwd", name="Mamparo proa SM", x_pp=bh_fwd),
]
- try:
- vessel = Vessel(
- id=f"wizard_{int(loa * 10)}m",
- name=vessel_name,
- type=VesselType(v_type),
- subtype=VesselSubtype(v_sub),
- length_overall_m=loa,
- beam_max_m=beam,
- draft_m=draft,
- decks=decks,
- bulkheads=bulkheads,
- data_source="user_input",
- )
- except Exception as exc:
- logger.error("Vessel build failed: %s", exc)
- raise
+ vessel = Vessel(
+ id=f"wizard_{int(loa * 10)}m",
+ name=vessel_name,
+ type=VesselType(v_type),
+ subtype=VesselSubtype(v_sub),
+ length_overall_m=loa,
+ beam_max_m=beam,
+ draft_m=draft,
+ decks=decks,
+ bulkheads=bulkheads,
+ data_source="user_input",
+ )
- # Equipment + tags llegan en Sprint 2 (rule engine). Por ahora, vacío.
try:
systems_enabled = [SystemId(s) for s in systems_raw]
except ValueError:
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)
+
+ if topology is not None:
+ return Project(
+ id=project_id,
+ name=project_name,
+ customer=customer,
+ vessel=vessel,
+ systems_enabled=systems_enabled,
+ equipment=equipment_list,
+ tags=tags,
+ 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=[],
- tags=[],
- notes="Creado desde wizard Sprint 1. Sprint 2 agregará equipos sugeridos.",
+ equipment=equipment_list,
+ tags=tags,
+ notes="Creado desde wizard (Sprint 2 — rule engine + auto-assigner).",
)