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).", )