sprint-2: rule engine + auto-assigner + equipment editor + biblioteca
Wizard pasos 5-7 ahora funcionales (1-4 ya estaban en Sprint 1).
vmssailor/studio/designer/rule_engine.py
- RuleContext, RuleEngine, EquipmentProposal
- Lee library/rules/*.yaml y aplica reglas heuristicas
- Filtra por vessel_type, vessel_subtype, length_overall_m range
- Selecciona candidato segun condiciones 'when' (loa min/max)
- Genera tag_prefix con sustitucion {side}/{idx}
vmssailor/studio/designer/port_auto_assigner.py
- auto_assign() greedy: 1 bus Modbus RTU + tarjetas dedicadas para motores/gensets
- Tarjeta auxiliar compartida para resto de equipos
- Mapea SignalType -> ChannelType (AI/DI/DO/RPM)
- Genera TagBindings con scaling apropiado por tipo de senal
- Respeta capacidades 10/5/4/1 de AR-NMEA-IO-v1.0
- AssignmentReport con cards + tags + warnings
vmssailor/studio/wizard/step_05_equipment.py
- Tabla con propuestas del rule engine
- Checkboxes accept/reject + edicion inline de columnas
- Boton 'Regenerar' para re-aplicar reglas
vmssailor/studio/wizard/step_06_refinement.py
- Vista resumen de equipos aceptados
vmssailor/studio/wizard/step_07_topology.py
- Llama auto_assign sobre los equipos materializados
- Muestra tabla de tarjetas con uso por canal (DO/DI/AI/RPM)
- Lista warnings de capacidad
vmssailor/studio/editors/equipment_editor.py
- CRUD de Equipment del proyecto activo
- Tabla editable inline (tag_prefix, name, model_ref, system_id, coords, deck)
- Dialog modal para agregar equipos
- Senal projectMutated para refrescar canvas + sidebar
vmssailor/studio/main_window.py
- Layout actualizado: splitter vertical en panel derecho
(canvas arriba + equipment editor abajo)
- _on_project_mutated() re-distribuye al sidebar y canvas
Biblioteca expandida (Sprint 2 brief: 5-7 yates, 10+ motores, gensets, bombas):
- vessels: + azimut_grande_32m, princess_y85, trawler_32m_offshore, patrol_coastal_30m (total: 6)
- engines: + cat_c32_acert, mtu_16v_2000_m96, yanmar_8lv_370 (total: 5)
- gensets: + kohler_28efkozd, onan_qd13500 (total: 3)
- pumps: + jabsco_36800, grundfos_cm10 (NUEVO categoria pumps)
Tests (tests/studio/test_designer.py, 10 nuevos, total 120/120):
- Rule engine: load default, propose engines, candidate picking por LOA
- auto_assign builds topology compatible with Project (Pydantic validation)
- Equipment editor smoke
VesselWizard.build_project() ahora materializa equipment + topology + tags
desde las propuestas y la asignacion automatica del paso 7.
Criterios Sprint 2:
- uv run vms-studio crea proyecto completo desde wizard con equipos + tags + topologia
- vms-validate-library: OK 6 vessels, 10 equipment, 1 rules
- 120/120 pytest verde, ruff clean
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"id": "cat_c32_acert",
|
||||
"manufacturer": "Caterpillar",
|
||||
"model_name": "C32 ACERT",
|
||||
"category": "engine_main",
|
||||
"typical_systems": ["main_engine"],
|
||||
"specs": {
|
||||
"power_kw": 1081,
|
||||
"rpm_nominal": 2300,
|
||||
"weight_kg": 2440,
|
||||
"length_m": 2.04,
|
||||
"width_m": 1.30,
|
||||
"height_m": 1.30,
|
||||
"fuel_consumption_lph": 245
|
||||
},
|
||||
"description": "Caterpillar C32 ACERT, V12, 32.1 L. Aplicación yates rápidos 70-100 pies / patrulleros / pilot boats. Twin turbo intercooled.",
|
||||
"data_source": "seed_estimate",
|
||||
"default_sensors": [
|
||||
{ "id": "rpm", "name": "RPM", "unit_si": "rpm", "range_normal_min": 0, "range_normal_max": 2400, "alarm_high_value": 2350, "alarm_high_priority": "high", "default_signal_type": "pulse_magnetic_pickup" },
|
||||
{ "id": "oil_press", "name": "Presión aceite", "unit_si": "bar", "range_normal_min": 3.0, "range_normal_max": 5.5, "alarm_low_value": 1.5, "alarm_low_priority": "emergency", "default_signal_type": "4-20ma" },
|
||||
{ "id": "oil_temp", "name": "Temperatura aceite", "unit_si": "C", "range_normal_min": 60, "range_normal_max": 110, "alarm_high_value": 120, "alarm_high_priority": "high", "default_signal_type": "rtd_pt100" },
|
||||
{ "id": "coolant_temp", "name": "Temperatura refrigerante", "unit_si": "C", "range_normal_min": 65, "range_normal_max": 95, "alarm_high_value": 100, "alarm_high_priority": "emergency", "default_signal_type": "rtd_pt100" },
|
||||
{ "id": "boost_press", "name": "Presión sobrealimentación", "unit_si": "bar", "range_normal_min": 0.0, "range_normal_max": 2.5, "default_signal_type": "4-20ma" },
|
||||
{ "id": "alternator_v", "name": "Voltaje alternador", "unit_si": "V", "range_normal_min": 27.0, "range_normal_max": 29.0, "alarm_low_value": 24.0, "alarm_low_priority": "high", "default_signal_type": "voltage_divider" },
|
||||
{ "id": "load_pct", "name": "Carga del motor", "unit_si": "%", "range_normal_min": 0, "range_normal_max": 100, "default_signal_type": "4-20ma" },
|
||||
{ "id": "running_hours", "name": "Horas totales", "unit_si": "h", "range_normal_min": 0, "range_normal_max": 80000, "default_signal_type": "4-20ma" },
|
||||
{ "id": "start_cmd", "name": "Comando arranque", "unit_si": "bool", "default_signal_type": "relay_no" },
|
||||
{ "id": "stop_cmd", "name": "Comando parada", "unit_si": "bool", "default_signal_type": "relay_no" },
|
||||
{ "id": "running_state", "name": "Estado motor en marcha", "unit_si": "bool", "default_signal_type": "dry_contact" },
|
||||
{ "id": "estop_active", "name": "E-stop activado", "unit_si": "bool", "default_signal_type": "dry_contact" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"id": "mtu_16v_2000_m96",
|
||||
"manufacturer": "MTU",
|
||||
"model_name": "16V 2000 M96",
|
||||
"category": "engine_main",
|
||||
"typical_systems": ["main_engine"],
|
||||
"specs": {
|
||||
"power_kw": 1939,
|
||||
"rpm_nominal": 2450,
|
||||
"weight_kg": 3290,
|
||||
"length_m": 2.45,
|
||||
"width_m": 1.20,
|
||||
"height_m": 1.27,
|
||||
"fuel_consumption_lph": 432
|
||||
},
|
||||
"description": "MTU Series 2000, V16, 32.0 L, 2-stage turbo. Aplicación fast ferries / patrulleros offshore / yates 30-40 m. Common Rail. Habla J1939 nativo.",
|
||||
"data_source": "seed_estimate",
|
||||
"default_sensors": [
|
||||
{ "id": "rpm", "name": "RPM", "unit_si": "rpm", "range_normal_min": 0, "range_normal_max": 2600, "alarm_high_value": 2550, "alarm_high_priority": "high", "default_signal_type": "pulse_magnetic_pickup" },
|
||||
{ "id": "oil_press", "name": "Presión aceite", "unit_si": "bar", "range_normal_min": 3.5, "range_normal_max": 6.5, "alarm_low_value": 1.5, "alarm_low_priority": "emergency", "default_signal_type": "4-20ma" },
|
||||
{ "id": "oil_temp", "name": "Temperatura aceite", "unit_si": "C", "range_normal_min": 60, "range_normal_max": 110, "alarm_high_value": 120, "alarm_high_priority": "high", "default_signal_type": "rtd_pt100" },
|
||||
{ "id": "coolant_temp", "name": "Temperatura refrigerante", "unit_si": "C", "range_normal_min": 65, "range_normal_max": 95, "alarm_high_value": 100, "alarm_high_priority": "emergency", "default_signal_type": "rtd_pt100" },
|
||||
{ "id": "boost_press", "name": "Presión sobrealimentación", "unit_si": "bar", "range_normal_min": 0.0, "range_normal_max": 2.8, "default_signal_type": "4-20ma" },
|
||||
{ "id": "load_pct", "name": "Carga del motor", "unit_si": "%", "range_normal_min": 0, "range_normal_max": 100, "default_signal_type": "4-20ma" },
|
||||
{ "id": "running_hours", "name": "Horas totales", "unit_si": "h", "range_normal_min": 0, "range_normal_max": 80000, "default_signal_type": "4-20ma" },
|
||||
{ "id": "alternator_v", "name": "Voltaje alternador", "unit_si": "V", "range_normal_min": 27.0, "range_normal_max": 29.0, "alarm_low_value": 24.0, "alarm_low_priority": "high", "default_signal_type": "voltage_divider" },
|
||||
{ "id": "start_cmd", "name": "Comando arranque", "unit_si": "bool", "default_signal_type": "relay_no" },
|
||||
{ "id": "stop_cmd", "name": "Comando parada", "unit_si": "bool", "default_signal_type": "relay_no" },
|
||||
{ "id": "running_state", "name": "Estado en marcha", "unit_si": "bool", "default_signal_type": "dry_contact" },
|
||||
{ "id": "estop_active", "name": "E-stop activado", "unit_si": "bool", "default_signal_type": "dry_contact" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"id": "yanmar_8lv_370",
|
||||
"manufacturer": "Yanmar",
|
||||
"model_name": "8LV-370",
|
||||
"category": "engine_main",
|
||||
"typical_systems": ["main_engine"],
|
||||
"specs": {
|
||||
"power_kw": 272,
|
||||
"rpm_nominal": 3800,
|
||||
"weight_kg": 430,
|
||||
"length_m": 1.04,
|
||||
"width_m": 0.83,
|
||||
"height_m": 0.78,
|
||||
"fuel_consumption_lph": 67
|
||||
},
|
||||
"description": "Yanmar 8LV-370 V8, 4.46 L, common rail. Aplicación yates motor 40-60 pies y embarcaciones rápidas con stern drive.",
|
||||
"data_source": "seed_estimate",
|
||||
"default_sensors": [
|
||||
{ "id": "rpm", "name": "RPM", "unit_si": "rpm", "range_normal_min": 0, "range_normal_max": 4000, "alarm_high_value": 3900, "alarm_high_priority": "high", "default_signal_type": "pulse_magnetic_pickup" },
|
||||
{ "id": "oil_press", "name": "Presión aceite", "unit_si": "bar", "range_normal_min": 2.5, "range_normal_max": 5.0, "alarm_low_value": 1.0, "alarm_low_priority": "emergency", "default_signal_type": "4-20ma" },
|
||||
{ "id": "coolant_temp", "name": "Temperatura refrigerante", "unit_si": "C", "range_normal_min": 70, "range_normal_max": 92, "alarm_high_value": 100, "alarm_high_priority": "emergency", "default_signal_type": "rtd_pt100" },
|
||||
{ "id": "alternator_v", "name": "Voltaje alternador", "unit_si": "V", "range_normal_min": 13.5, "range_normal_max": 14.5, "default_signal_type": "voltage_divider" },
|
||||
{ "id": "load_pct", "name": "Carga", "unit_si": "%", "range_normal_min": 0, "range_normal_max": 100, "default_signal_type": "4-20ma" },
|
||||
{ "id": "running_hours", "name": "Horas", "unit_si": "h", "default_signal_type": "4-20ma" },
|
||||
{ "id": "start_cmd", "name": "Arranque", "unit_si": "bool", "default_signal_type": "relay_no" },
|
||||
{ "id": "stop_cmd", "name": "Parada", "unit_si": "bool", "default_signal_type": "relay_no" },
|
||||
{ "id": "running_state", "name": "En marcha", "unit_si": "bool", "default_signal_type": "dry_contact" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"id": "kohler_28efkozd",
|
||||
"manufacturer": "Kohler",
|
||||
"model_name": "28EFKOZD",
|
||||
"category": "genset",
|
||||
"typical_systems": ["genset"],
|
||||
"specs": {
|
||||
"power_kw": 22.4,
|
||||
"rpm_nominal": 1500,
|
||||
"weight_kg": 450,
|
||||
"length_m": 1.30,
|
||||
"width_m": 0.70,
|
||||
"height_m": 0.85,
|
||||
"voltage_v": 230,
|
||||
"current_a": 97,
|
||||
"fuel_consumption_lph": 7.0
|
||||
},
|
||||
"description": "Kohler 28EFKOZD genset marino diésel, 28 kVA / 22.4 kW @ 1500 rpm 50 Hz. Aplicación yates motor 50-70 pies. Cabina silenciosa estándar.",
|
||||
"data_source": "seed_estimate",
|
||||
"default_sensors": [
|
||||
{ "id": "rpm", "name": "RPM", "unit_si": "rpm", "range_normal_min": 0, "range_normal_max": 1550, "default_signal_type": "pulse_magnetic_pickup" },
|
||||
{ "id": "oil_press", "name": "Presión aceite", "unit_si": "bar", "range_normal_min": 2.0, "range_normal_max": 4.5, "alarm_low_value": 1.0, "alarm_low_priority": "emergency", "default_signal_type": "4-20ma" },
|
||||
{ "id": "coolant_temp", "name": "Temperatura refrigerante", "unit_si": "C", "range_normal_min": 70, "range_normal_max": 95, "alarm_high_value": 102, "alarm_high_priority": "emergency", "default_signal_type": "rtd_pt100" },
|
||||
{ "id": "voltage_l1", "name": "Tensión L1", "unit_si": "V", "range_normal_min": 220, "range_normal_max": 240, "default_signal_type": "voltage_divider" },
|
||||
{ "id": "current_l1", "name": "Corriente L1", "unit_si": "A", "range_normal_min": 0, "range_normal_max": 100, "alarm_high_value": 110, "alarm_high_priority": "high", "default_signal_type": "4-20ma" },
|
||||
{ "id": "freq", "name": "Frecuencia", "unit_si": "Hz", "range_normal_min": 49.5, "range_normal_max": 50.5, "default_signal_type": "pulse_inductive" },
|
||||
{ "id": "running_hours", "name": "Horas", "unit_si": "h", "default_signal_type": "4-20ma" },
|
||||
{ "id": "start_cmd", "name": "Arranque", "unit_si": "bool", "default_signal_type": "relay_no" },
|
||||
{ "id": "stop_cmd", "name": "Parada", "unit_si": "bool", "default_signal_type": "relay_no" },
|
||||
{ "id": "breaker_status", "name": "Breaker principal", "unit_si": "bool", "default_signal_type": "dry_contact" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"id": "onan_qd13500",
|
||||
"manufacturer": "Cummins Onan",
|
||||
"model_name": "QD 13500",
|
||||
"category": "genset",
|
||||
"typical_systems": ["genset"],
|
||||
"specs": {
|
||||
"power_kw": 10.8,
|
||||
"rpm_nominal": 1500,
|
||||
"weight_kg": 295,
|
||||
"length_m": 1.07,
|
||||
"width_m": 0.55,
|
||||
"height_m": 0.62,
|
||||
"voltage_v": 230,
|
||||
"current_a": 47,
|
||||
"fuel_consumption_lph": 3.6
|
||||
},
|
||||
"description": "Cummins Onan QD 13500 marino diésel, 13.5 kVA / 10.8 kW @ 1500 rpm 50 Hz. Aplicación yates 35-50 pies y pesqueros pequeños.",
|
||||
"data_source": "seed_estimate",
|
||||
"default_sensors": [
|
||||
{ "id": "rpm", "name": "RPM", "unit_si": "rpm", "range_normal_min": 0, "range_normal_max": 1550, "default_signal_type": "pulse_magnetic_pickup" },
|
||||
{ "id": "oil_press", "name": "Presión aceite", "unit_si": "bar", "range_normal_min": 2.0, "range_normal_max": 4.5, "alarm_low_value": 0.9, "alarm_low_priority": "emergency", "default_signal_type": "4-20ma" },
|
||||
{ "id": "coolant_temp", "name": "Temperatura refrigerante", "unit_si": "C", "range_normal_min": 70, "range_normal_max": 95, "alarm_high_value": 100, "alarm_high_priority": "emergency", "default_signal_type": "rtd_pt100" },
|
||||
{ "id": "voltage_l1", "name": "Tensión L1", "unit_si": "V", "range_normal_min": 220, "range_normal_max": 240, "default_signal_type": "voltage_divider" },
|
||||
{ "id": "freq", "name": "Frecuencia", "unit_si": "Hz", "range_normal_min": 49.5, "range_normal_max": 50.5, "default_signal_type": "pulse_inductive" },
|
||||
{ "id": "running_hours", "name": "Horas", "unit_si": "h", "default_signal_type": "4-20ma" },
|
||||
{ "id": "start_cmd", "name": "Arranque", "unit_si": "bool", "default_signal_type": "relay_no" },
|
||||
{ "id": "stop_cmd", "name": "Parada", "unit_si": "bool", "default_signal_type": "relay_no" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"id": "grundfos_cm10",
|
||||
"manufacturer": "Grundfos",
|
||||
"model_name": "CM10",
|
||||
"category": "pump",
|
||||
"typical_systems": ["potable_water", "sw_cooling", "fw_cooling"],
|
||||
"specs": {
|
||||
"power_kw": 1.5,
|
||||
"rpm_nominal": 2900,
|
||||
"weight_kg": 14,
|
||||
"length_m": 0.40,
|
||||
"width_m": 0.20,
|
||||
"height_m": 0.27,
|
||||
"voltage_v": 230,
|
||||
"current_a": 7,
|
||||
"capacity_l": 6000
|
||||
},
|
||||
"description": "Grundfos CM10 centrífuga horizontal multicelular. Aplicación agua potable, refrigeración FW, transferencia. 230 V monofásico.",
|
||||
"data_source": "seed_estimate",
|
||||
"default_sensors": [
|
||||
{ "id": "running_state", "name": "Estado en marcha", "unit_si": "bool", "default_signal_type": "dry_contact" },
|
||||
{ "id": "start_cmd", "name": "Arranque", "unit_si": "bool", "default_signal_type": "relay_no" },
|
||||
{ "id": "stop_cmd", "name": "Parada", "unit_si": "bool", "default_signal_type": "relay_no" },
|
||||
{ "id": "current", "name": "Corriente", "unit_si": "A", "range_normal_min": 0, "range_normal_max": 8, "alarm_high_value": 10, "alarm_high_priority": "high", "default_signal_type": "4-20ma" },
|
||||
{ "id": "pressure_out", "name": "Presión descarga", "unit_si": "bar", "range_normal_min": 0, "range_normal_max": 6, "default_signal_type": "4-20ma" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"id": "jabsco_36800",
|
||||
"manufacturer": "Jabsco",
|
||||
"model_name": "36800-0001",
|
||||
"category": "pump",
|
||||
"typical_systems": ["bilge", "sw_service"],
|
||||
"specs": {
|
||||
"power_kw": 0.10,
|
||||
"rpm_nominal": 0,
|
||||
"weight_kg": 1.5,
|
||||
"length_m": 0.15,
|
||||
"width_m": 0.10,
|
||||
"height_m": 0.10,
|
||||
"voltage_v": 12,
|
||||
"current_a": 8.0,
|
||||
"capacity_l": 60
|
||||
},
|
||||
"description": "Bomba sumergible Jabsco 36800 (Rule 1100 GPH equivalente). Aplicación sentinas, achique auxiliar. 12 VDC.",
|
||||
"data_source": "seed_estimate",
|
||||
"default_sensors": [
|
||||
{ "id": "running_state", "name": "Estado en marcha", "unit_si": "bool", "default_signal_type": "dry_contact" },
|
||||
{ "id": "start_cmd", "name": "Arranque", "unit_si": "bool", "default_signal_type": "relay_no" },
|
||||
{ "id": "current", "name": "Corriente", "unit_si": "A", "range_normal_min": 0, "range_normal_max": 10, "alarm_high_value": 12, "alarm_high_priority": "high", "default_signal_type": "4-20ma" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"id": "azimut_grande_32m",
|
||||
"name": "Azimut Grande 32M",
|
||||
"type": "yacht_motor",
|
||||
"subtype": "semi_planing",
|
||||
"length_overall_m": 32.4,
|
||||
"beam_max_m": 7.30,
|
||||
"draft_m": 2.20,
|
||||
"displacement_kg": 130000,
|
||||
"description": "Yate motor semi-planeo italiano de 32 m, casco IPS con 3 ejes Volvo, 4 cubiertas, 5 cabinas. Sala de máquinas amplia con 3 motores principales + 2 gensets + sistemas redundantes. Tope típico ~24 nudos.",
|
||||
"data_source": "seed_estimate",
|
||||
"decks": [
|
||||
{ "id": "tank_deck", "name": "Cubierta de tanques", "z_bl_bottom": 0.4, "z_bl_top": 2.2, "polygon_xy": [] },
|
||||
{ "id": "lower", "name": "Cubierta inferior", "z_bl_bottom": 2.2, "z_bl_top": 4.6, "polygon_xy": [] },
|
||||
{ "id": "main", "name": "Cubierta principal", "z_bl_bottom": 4.6, "z_bl_top": 6.9, "polygon_xy": [] },
|
||||
{ "id": "flybridge", "name": "Flybridge", "z_bl_bottom": 6.9, "z_bl_top": 8.8, "polygon_xy": [] }
|
||||
],
|
||||
"bulkheads": [
|
||||
{ "id": "collision", "name": "Mamparo de colisión", "x_pp": 29.5, "description": "" },
|
||||
{ "id": "er_fwd", "name": "Mamparo proa SM", "x_pp": 9.5, "description": "" },
|
||||
{ "id": "er_aft", "name": "Mamparo popa SM", "x_pp": 5.0, "description": "" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"id": "patrol_coastal_30m",
|
||||
"name": "Patrullero costero 30 m",
|
||||
"type": "patrol",
|
||||
"subtype": "coastal",
|
||||
"length_overall_m": 30.0,
|
||||
"beam_max_m": 6.5,
|
||||
"draft_m": 1.6,
|
||||
"displacement_kg": 95000,
|
||||
"description": "Patrullero costero genérico 30 m. Casco semi-planeo aluminio, 2-3 motores planos rápidos, autonomía 800 nm. Equipos típicos: armamento ligero opcional, RHIB pop-up, búsqueda y rescate.",
|
||||
"data_source": "seed_estimate",
|
||||
"decks": [
|
||||
{ "id": "lower", "name": "Cubierta inferior", "z_bl_bottom": 0.4, "z_bl_top": 2.4, "polygon_xy": [] },
|
||||
{ "id": "main", "name": "Cubierta principal", "z_bl_bottom": 2.4, "z_bl_top": 4.6, "polygon_xy": [] },
|
||||
{ "id": "wheelhouse", "name": "Caseta de gobierno", "z_bl_bottom": 4.6, "z_bl_top": 6.5, "polygon_xy": [] }
|
||||
],
|
||||
"bulkheads": [
|
||||
{ "id": "collision", "name": "Mamparo de colisión", "x_pp": 27.5, "description": "" },
|
||||
{ "id": "er_fwd", "name": "Mamparo proa SM", "x_pp": 9.0, "description": "" },
|
||||
{ "id": "er_aft", "name": "Mamparo popa SM", "x_pp": 4.0, "description": "" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"id": "princess_y85",
|
||||
"name": "Princess Y85",
|
||||
"type": "yacht_motor",
|
||||
"subtype": "planing",
|
||||
"length_overall_m": 25.94,
|
||||
"beam_max_m": 5.92,
|
||||
"draft_m": 1.92,
|
||||
"displacement_kg": 64500,
|
||||
"description": "Yate motor planeo británico, casco V profundo. Propulsión convencional 2 motores diésel directos a hélices. Tope ~32 nudos. 4 cabinas.",
|
||||
"data_source": "seed_estimate",
|
||||
"decks": [
|
||||
{ "id": "lower", "name": "Cubierta inferior", "z_bl_bottom": 0.5, "z_bl_top": 2.7, "polygon_xy": [] },
|
||||
{ "id": "main", "name": "Cubierta principal", "z_bl_bottom": 2.7, "z_bl_top": 4.9, "polygon_xy": [] },
|
||||
{ "id": "flybridge", "name": "Flybridge", "z_bl_bottom": 4.9, "z_bl_top": 6.5, "polygon_xy": [] }
|
||||
],
|
||||
"bulkheads": [
|
||||
{ "id": "collision", "name": "Mamparo colisión", "x_pp": 23.4, "description": "" },
|
||||
{ "id": "er_fwd", "name": "Mamparo proa SM", "x_pp": 7.8, "description": "" },
|
||||
{ "id": "er_aft", "name": "Mamparo popa SM", "x_pp": 3.8, "description": "" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"id": "trawler_32m_offshore",
|
||||
"name": "Arrastrero offshore 32 m",
|
||||
"type": "fishing",
|
||||
"subtype": "trawler",
|
||||
"length_overall_m": 32.0,
|
||||
"beam_max_m": 9.0,
|
||||
"draft_m": 3.8,
|
||||
"displacement_kg": 250000,
|
||||
"description": "Pesquero arrastrero offshore genérico de 32 m, casco desplazamiento, maquinaria de pesca + bodega refrigerada grande + tanques RSW (refrigerated sea water).",
|
||||
"data_source": "seed_estimate",
|
||||
"decks": [
|
||||
{ "id": "lower", "name": "Bodega y pañoles", "z_bl_bottom": 0.5, "z_bl_top": 3.5, "polygon_xy": [] },
|
||||
{ "id": "main", "name": "Cubierta principal", "z_bl_bottom": 3.5, "z_bl_top": 6.0, "polygon_xy": [] },
|
||||
{ "id": "wheelhouse", "name": "Caseta de gobierno", "z_bl_bottom": 6.0, "z_bl_top": 8.5, "polygon_xy": [] }
|
||||
],
|
||||
"bulkheads": [
|
||||
{ "id": "collision", "name": "Mamparo de colisión", "x_pp": 29.0, "description": "" },
|
||||
{ "id": "er_fwd", "name": "Mamparo proa SM", "x_pp": 10.0, "description": "" },
|
||||
{ "id": "er_aft", "name": "Mamparo popa SM", "x_pp": 4.0, "description": "" },
|
||||
{ "id": "fish_hold_fwd", "name": "Mamparo bodega proa", "x_pp": 18.0, "description": "" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Motor de pre-diseño del Studio.
|
||||
|
||||
Aplica las reglas heurísticas de la biblioteca curada al input del wizard
|
||||
para proponer equipos típicos y topología sugerida.
|
||||
|
||||
Sprint 2: rule_engine + port_auto_assigner. Sprint 3+: parametric_silhouette.
|
||||
"""
|
||||
|
||||
from vmssailor.studio.designer.rule_engine import (
|
||||
EquipmentProposal,
|
||||
RuleEngine,
|
||||
apply_rules,
|
||||
)
|
||||
|
||||
__all__ = ["EquipmentProposal", "RuleEngine", "apply_rules"]
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Asignador automático de puertos físicos AR-NMEA-IO.
|
||||
|
||||
Dado un Project con equipos (cada uno con sus default_sensors según
|
||||
EquipmentModel), genera:
|
||||
- Tarjetas necesarias (estimación por sistema → cantidad)
|
||||
- TagBindings que mapean cada sensor a un canal concreto
|
||||
- Conflictos detectados (capacidad excedida)
|
||||
|
||||
Sprint 2: implementación greedy basada en proximidad.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from vmssailor.core.card import Bus, CardInstance, Topology
|
||||
from vmssailor.core.coords import ShipCoord
|
||||
from vmssailor.core.enums import (
|
||||
BusRole,
|
||||
ChannelType,
|
||||
FilterType,
|
||||
Protocol,
|
||||
SignalType,
|
||||
)
|
||||
from vmssailor.core.equipment import Equipment, EquipmentModel, Sensor
|
||||
from vmssailor.core.tag import Scaling, Tag, TagBinding
|
||||
|
||||
# Mapeo SignalType -> ChannelType (para auto-asignar)
|
||||
_SIGNAL_TO_CHANNEL: dict[SignalType, ChannelType] = {
|
||||
# Analógicas → AI
|
||||
SignalType.SIG_4_20_MA: ChannelType.AI,
|
||||
SignalType.SIG_0_10_V: ChannelType.AI,
|
||||
SignalType.SIG_0_5_V: ChannelType.AI,
|
||||
SignalType.RTD_PT100: ChannelType.AI,
|
||||
SignalType.RTD_PT1000: ChannelType.AI,
|
||||
SignalType.THERMOCOUPLE_K: ChannelType.AI,
|
||||
SignalType.THERMOCOUPLE_J: ChannelType.AI,
|
||||
SignalType.RESISTIVE_SENDER: ChannelType.AI,
|
||||
SignalType.VOLTAGE_DIVIDER: ChannelType.AI,
|
||||
# Pulso → RPM
|
||||
SignalType.PULSE_MAGNETIC_PICKUP: ChannelType.RPM,
|
||||
SignalType.PULSE_INDUCTIVE: ChannelType.RPM,
|
||||
SignalType.PULSE_TACHO: ChannelType.RPM,
|
||||
# Digitales: relés son DO, contactos son DI
|
||||
SignalType.RELAY_NO: ChannelType.DO,
|
||||
SignalType.RELAY_NC: ChannelType.DO,
|
||||
SignalType.DRY_CONTACT: ChannelType.DI,
|
||||
SignalType.CONTACT_24VDC: ChannelType.DI,
|
||||
}
|
||||
|
||||
|
||||
# Capacidades fijas del hardware AR-NMEA-IO-v1.0
|
||||
_CAPACITY: dict[ChannelType, int] = {
|
||||
ChannelType.AI: 4,
|
||||
ChannelType.DI: 5,
|
||||
ChannelType.DO: 10,
|
||||
ChannelType.RPM: 1,
|
||||
}
|
||||
|
||||
|
||||
# Escalado por defecto por tipo de señal
|
||||
def _default_scaling(signal: SignalType, sensor: Sensor) -> Scaling | None:
|
||||
rng_min = sensor.range_normal_min if sensor.range_normal_min is not None else 0.0
|
||||
rng_max = sensor.range_normal_max if sensor.range_normal_max is not None else 100.0
|
||||
if signal == SignalType.SIG_4_20_MA:
|
||||
return Scaling(raw_min=4.0, raw_max=20.0, eng_min=rng_min, eng_max=rng_max)
|
||||
if signal == SignalType.SIG_0_10_V:
|
||||
return Scaling(raw_min=0.0, raw_max=10.0, eng_min=rng_min, eng_max=rng_max)
|
||||
if signal == SignalType.SIG_0_5_V:
|
||||
return Scaling(raw_min=0.0, raw_max=5.0, eng_min=rng_min, eng_max=rng_max)
|
||||
if signal == SignalType.RTD_PT100:
|
||||
return Scaling(raw_min=0.0, raw_max=4095.0, eng_min=-50.0, eng_max=200.0)
|
||||
if signal == SignalType.VOLTAGE_DIVIDER:
|
||||
# rango razonable para baterías + alternadores
|
||||
return Scaling(raw_min=0.0, raw_max=4095.0, eng_min=0.0, eng_max=32.0)
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CardSlot:
|
||||
"""Slot de uso interno del asignador. Mantiene contadores por canal."""
|
||||
|
||||
card: CardInstance
|
||||
used: dict[ChannelType, int] = field(default_factory=lambda: dict.fromkeys(ChannelType, 0))
|
||||
|
||||
def has_space(self, channel: ChannelType) -> bool:
|
||||
return self.used[channel] < _CAPACITY[channel]
|
||||
|
||||
def take(self, channel: ChannelType) -> int:
|
||||
"""Asigna el siguiente canal libre y devuelve su número (1-based)."""
|
||||
self.used[channel] += 1
|
||||
return self.used[channel]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AssignmentReport:
|
||||
cards: list[CardInstance] = field(default_factory=list)
|
||||
tags: list[Tag] = field(default_factory=list)
|
||||
bus: Bus | None = None
|
||||
n_skipped: int = 0
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
def topology(self) -> Topology:
|
||||
return Topology(buses=[self.bus] if self.bus else [], cards=self.cards)
|
||||
|
||||
|
||||
def auto_assign(
|
||||
equipment_list: list[Equipment],
|
||||
model_lookup: dict[str, EquipmentModel],
|
||||
) -> AssignmentReport:
|
||||
"""Asignación greedy de tags a tarjetas distribuidas.
|
||||
|
||||
Estrategia:
|
||||
- 1 bus Modbus RTU principal
|
||||
- 1 tarjeta por equipo del sistema main_engine / genset (pegada al equipo)
|
||||
- 1 tarjeta compartida para equipos auxiliares
|
||||
"""
|
||||
report = AssignmentReport()
|
||||
if not equipment_list:
|
||||
return report
|
||||
|
||||
bus = Bus(
|
||||
id="bus_main",
|
||||
name="Bus principal Modbus RTU",
|
||||
protocol=Protocol.MODBUS_RTU,
|
||||
physical_port="COM3",
|
||||
baud_rate=115200,
|
||||
)
|
||||
report.bus = bus
|
||||
|
||||
cards: list[CardSlot] = []
|
||||
# Tarjetas dedicadas para motor principal + genset
|
||||
next_slot = 1
|
||||
next_addr = 1
|
||||
card_by_eq: dict[str, CardSlot] = {}
|
||||
|
||||
for eq in equipment_list:
|
||||
if eq.system_id.value in ("main_engine", "genset"):
|
||||
card = CardInstance(
|
||||
id=f"card_{next_slot:03d}",
|
||||
slot_number=next_slot,
|
||||
bus_id=bus.id,
|
||||
bus_role=BusRole.MODBUS_SLAVE,
|
||||
modbus_address=next_addr,
|
||||
physical_location=f"Junto a {eq.display_name}",
|
||||
location=ShipCoord(
|
||||
x_pp=eq.location.x_pp,
|
||||
y_cl=eq.location.y_cl,
|
||||
z_bl=eq.location.z_bl + 0.2,
|
||||
),
|
||||
firmware_version="1.0.0",
|
||||
)
|
||||
slot = CardSlot(card=card)
|
||||
cards.append(slot)
|
||||
card_by_eq[eq.id] = slot
|
||||
next_slot += 1
|
||||
next_addr += 1
|
||||
|
||||
# Tarjeta auxiliar compartida para el resto
|
||||
aux_slot: CardSlot | None = None
|
||||
if any(
|
||||
eq.system_id.value not in ("main_engine", "genset") for eq in equipment_list
|
||||
):
|
||||
aux_card = CardInstance(
|
||||
id=f"card_{next_slot:03d}",
|
||||
slot_number=next_slot,
|
||||
bus_id=bus.id,
|
||||
bus_role=BusRole.MODBUS_SLAVE,
|
||||
modbus_address=next_addr,
|
||||
physical_location="Sala máquinas — panel auxiliar",
|
||||
firmware_version="1.0.0",
|
||||
)
|
||||
aux_slot = CardSlot(card=aux_card)
|
||||
cards.append(aux_slot)
|
||||
|
||||
# Construir tags
|
||||
for eq in equipment_list:
|
||||
model = model_lookup.get(eq.model_ref)
|
||||
if model is None:
|
||||
report.warnings.append(
|
||||
f"Equipo '{eq.id}' referencia model_ref='{eq.model_ref}' "
|
||||
"que no existe en biblioteca cargada."
|
||||
)
|
||||
continue
|
||||
target_slot = card_by_eq.get(eq.id) or aux_slot
|
||||
if target_slot is None:
|
||||
continue
|
||||
|
||||
for sensor in model.default_sensors:
|
||||
if sensor.default_signal_type is None:
|
||||
# Sin tipo de señal, no podemos hacer binding físico
|
||||
report.n_skipped += 1
|
||||
continue
|
||||
signal = sensor.default_signal_type
|
||||
channel = _SIGNAL_TO_CHANNEL.get(signal)
|
||||
if channel is None:
|
||||
report.n_skipped += 1
|
||||
continue
|
||||
slot = target_slot
|
||||
if not slot.has_space(channel):
|
||||
# Si la tarjeta dedicada se llenó, intentar la auxiliar
|
||||
if aux_slot and aux_slot is not slot and aux_slot.has_space(channel):
|
||||
slot = aux_slot
|
||||
else:
|
||||
report.warnings.append(
|
||||
f"Sensor {eq.tag_prefix}.{sensor.id} no asignado: "
|
||||
f"sin capacidad {channel.value} en tarjetas dedicadas ni aux."
|
||||
)
|
||||
continue
|
||||
ch_num = slot.take(channel)
|
||||
scaling = _default_scaling(signal, sensor)
|
||||
binding = TagBinding(
|
||||
card_id=slot.card.id,
|
||||
channel_type=channel,
|
||||
channel_number=ch_num,
|
||||
signal_type=signal,
|
||||
scaling=scaling,
|
||||
filter=FilterType.MOVING_AVG if channel == ChannelType.AI else FilterType.NONE,
|
||||
filter_param=4.0 if channel == ChannelType.AI else None,
|
||||
update_rate_ms=200 if channel == ChannelType.AI else 100,
|
||||
)
|
||||
tag_id = f"{eq.tag_prefix}.{sensor.id.upper()}"
|
||||
# Tags controllable (relés DO) marcados como tales
|
||||
controllable = channel == ChannelType.DO
|
||||
from vmssailor.core.enums import (
|
||||
AuthorityRequired,
|
||||
ControlMode,
|
||||
UnitSI,
|
||||
)
|
||||
|
||||
try:
|
||||
unit_si = UnitSI(sensor.unit_si.value)
|
||||
except Exception:
|
||||
unit_si = UnitSI.NONE
|
||||
|
||||
tag = Tag(
|
||||
id=tag_id,
|
||||
equipment_id=eq.id,
|
||||
description=f"{sensor.name} ({eq.display_name})",
|
||||
unit_si=unit_si,
|
||||
range_normal_min=sensor.range_normal_min,
|
||||
range_normal_max=sensor.range_normal_max,
|
||||
controllable=controllable,
|
||||
control_mode=ControlMode.MANUAL if controllable else ControlMode.MONITOR,
|
||||
authority_required=(
|
||||
AuthorityRequired.BRIDGE
|
||||
if controllable
|
||||
else AuthorityRequired.EITHER
|
||||
),
|
||||
protocol=Protocol.MODBUS_RTU,
|
||||
physical_binding=binding,
|
||||
)
|
||||
report.tags.append(tag)
|
||||
|
||||
report.cards = [s.card for s in cards]
|
||||
return report
|
||||
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Editores del Studio (post-wizard). Sprint 2: equipos. Sprint 3: mímicos, tags, alarmas."""
|
||||
|
||||
from vmssailor.studio.editors.equipment_editor import EquipmentEditor
|
||||
|
||||
__all__ = ["EquipmentEditor"]
|
||||
@@ -0,0 +1,316 @@
|
||||
"""Editor de equipos del Studio (post-wizard).
|
||||
|
||||
Tabla con todos los equipos del Project. CRUD básico:
|
||||
- Agregar: dialog modal con campos
|
||||
- Editar: doble-click en celda (inline)
|
||||
- Eliminar: tecla Delete o botón
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from vmssailor.core.coords import ShipCoord
|
||||
from vmssailor.core.enums import SystemId
|
||||
from vmssailor.core.equipment import Equipment
|
||||
from vmssailor.core.project import Project
|
||||
from vmssailor.library import load_library
|
||||
from vmssailor.studio.theme import C_CYAN, C_FOG, mono_font, ui_font
|
||||
|
||||
_COLS = ["ID", "Tag prefix", "Nombre", "Modelo", "Sistema", "x_pp", "y_cl", "z_bl", "Deck"]
|
||||
|
||||
|
||||
class EquipmentEditor(QWidget):
|
||||
"""Editor CRUD de equipos del proyecto activo."""
|
||||
|
||||
projectMutated = Signal(Project)
|
||||
"""Emitido cuando se agrega, edita o elimina un equipo."""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._project: Project | None = None
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(16, 16, 16, 16)
|
||||
layout.setSpacing(12)
|
||||
|
||||
# Header
|
||||
header = QLabel("Editor de equipos")
|
||||
header.setObjectName("title")
|
||||
header.setFont(ui_font(14))
|
||||
layout.addWidget(header)
|
||||
|
||||
intro = QLabel(
|
||||
"CRUD de equipos del proyecto. Doble-click en celda para editar inline. "
|
||||
"El editor de mímicos y tags llega en Sprint 3."
|
||||
)
|
||||
intro.setStyleSheet(f"color: {C_FOG};")
|
||||
intro.setFont(ui_font(10))
|
||||
intro.setWordWrap(True)
|
||||
layout.addWidget(intro)
|
||||
|
||||
# Buttons row
|
||||
btn_row = QHBoxLayout()
|
||||
self._btn_add = QPushButton("+ Agregar equipo")
|
||||
self._btn_add.setObjectName("primary")
|
||||
self._btn_remove = QPushButton("Eliminar seleccionado")
|
||||
self._counter = QLabel("0 equipos")
|
||||
self._counter.setFont(mono_font(10))
|
||||
self._counter.setStyleSheet(f"color: {C_CYAN};")
|
||||
btn_row.addWidget(self._btn_add)
|
||||
btn_row.addWidget(self._btn_remove)
|
||||
btn_row.addStretch(1)
|
||||
btn_row.addWidget(self._counter)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
# Table
|
||||
self._table = QTableWidget(0, len(_COLS))
|
||||
self._table.setHorizontalHeaderLabels(_COLS)
|
||||
self._table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
||||
self._table.verticalHeader().setVisible(False)
|
||||
self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self._table.setEditTriggers(
|
||||
QAbstractItemView.EditTrigger.DoubleClicked
|
||||
| QAbstractItemView.EditTrigger.EditKeyPressed
|
||||
)
|
||||
self._table.itemChanged.connect(self._on_item_changed)
|
||||
layout.addWidget(self._table, 1)
|
||||
|
||||
# Signals
|
||||
self._btn_add.clicked.connect(self._on_add)
|
||||
self._btn_remove.clicked.connect(self._on_remove)
|
||||
|
||||
# ----- Public API ---------------------------------------------------
|
||||
|
||||
def set_project(self, project: Project | None) -> None:
|
||||
self._project = project
|
||||
self._refresh_table()
|
||||
|
||||
# ----- Internals ----------------------------------------------------
|
||||
|
||||
def _refresh_table(self) -> None:
|
||||
self._table.blockSignals(True)
|
||||
self._table.setRowCount(0)
|
||||
if self._project is None:
|
||||
self._counter.setText("0 equipos")
|
||||
self._table.blockSignals(False)
|
||||
return
|
||||
for eq in self._project.equipment:
|
||||
self._append_row(eq)
|
||||
self._counter.setText(f"{len(self._project.equipment)} equipos")
|
||||
self._table.blockSignals(False)
|
||||
|
||||
def _append_row(self, eq: Equipment) -> None:
|
||||
row = self._table.rowCount()
|
||||
self._table.insertRow(row)
|
||||
id_item = QTableWidgetItem(eq.id)
|
||||
id_item.setFlags(id_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
id_item.setForeground(Qt.GlobalColor.gray)
|
||||
self._table.setItem(row, 0, id_item)
|
||||
self._table.setItem(row, 1, QTableWidgetItem(eq.tag_prefix))
|
||||
self._table.setItem(row, 2, QTableWidgetItem(eq.display_name))
|
||||
self._table.setItem(row, 3, QTableWidgetItem(eq.model_ref))
|
||||
sys_item = QTableWidgetItem(eq.system_id.value)
|
||||
sys_item.setForeground(Qt.GlobalColor.cyan)
|
||||
self._table.setItem(row, 4, sys_item)
|
||||
self._table.setItem(row, 5, QTableWidgetItem(f"{eq.location.x_pp:.2f}"))
|
||||
self._table.setItem(row, 6, QTableWidgetItem(f"{eq.location.y_cl:.2f}"))
|
||||
self._table.setItem(row, 7, QTableWidgetItem(f"{eq.location.z_bl:.2f}"))
|
||||
self._table.setItem(row, 8, QTableWidgetItem(eq.deck_id or ""))
|
||||
|
||||
def _on_item_changed(self, item: QTableWidgetItem) -> None:
|
||||
if self._project is None:
|
||||
return
|
||||
row = item.row()
|
||||
if row >= len(self._project.equipment):
|
||||
return
|
||||
eq = self._project.equipment[row]
|
||||
col = item.column()
|
||||
try:
|
||||
if col == 1:
|
||||
# tag_prefix
|
||||
new_val = item.text().upper()
|
||||
self._project.equipment[row] = eq.model_copy(update={"tag_prefix": new_val})
|
||||
item.setText(new_val)
|
||||
elif col == 2:
|
||||
self._project.equipment[row] = eq.model_copy(update={"display_name": item.text()})
|
||||
elif col == 3:
|
||||
self._project.equipment[row] = eq.model_copy(update={"model_ref": item.text()})
|
||||
elif col == 4:
|
||||
self._project.equipment[row] = eq.model_copy(
|
||||
update={"system_id": SystemId(item.text())}
|
||||
)
|
||||
elif col in (5, 6, 7):
|
||||
x = float(self._table.item(row, 5).text())
|
||||
y = float(self._table.item(row, 6).text())
|
||||
z = float(self._table.item(row, 7).text())
|
||||
self._project.equipment[row] = eq.model_copy(
|
||||
update={"location": ShipCoord(x_pp=x, y_cl=y, z_bl=z)}
|
||||
)
|
||||
elif col == 8:
|
||||
deck = item.text().strip() or None
|
||||
self._project.equipment[row] = eq.model_copy(update={"deck_id": deck})
|
||||
except Exception as exc:
|
||||
QMessageBox.warning(self, "Edición inválida", str(exc))
|
||||
self._refresh_table()
|
||||
return
|
||||
self._project.touch()
|
||||
self.projectMutated.emit(self._project)
|
||||
|
||||
def _on_add(self) -> None:
|
||||
if self._project is None:
|
||||
return
|
||||
dlg = _AddEquipmentDialog(self._project, self)
|
||||
if dlg.exec():
|
||||
eq = dlg.build()
|
||||
if eq is None:
|
||||
return
|
||||
try:
|
||||
new_list = [*self._project.equipment, eq]
|
||||
updated = self._project.model_copy(update={"equipment": new_list})
|
||||
# Re-construir para revalidar via Pydantic
|
||||
updated = updated.__class__.model_validate(updated.model_dump())
|
||||
self._project = updated
|
||||
except Exception as exc:
|
||||
QMessageBox.critical(self, "Inválido", str(exc))
|
||||
return
|
||||
self._refresh_table()
|
||||
self.projectMutated.emit(self._project)
|
||||
|
||||
def _on_remove(self) -> None:
|
||||
if self._project is None:
|
||||
return
|
||||
row = self._table.currentRow()
|
||||
if row < 0 or row >= len(self._project.equipment):
|
||||
return
|
||||
eq = self._project.equipment[row]
|
||||
confirm = QMessageBox.question(
|
||||
self,
|
||||
"Eliminar equipo",
|
||||
f"¿Eliminar '{eq.tag_prefix} · {eq.display_name}'?",
|
||||
)
|
||||
if confirm != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
new_list = [e for i, e in enumerate(self._project.equipment) if i != row]
|
||||
try:
|
||||
updated = self._project.model_copy(update={"equipment": new_list})
|
||||
updated = updated.__class__.model_validate(updated.model_dump())
|
||||
self._project = updated
|
||||
except Exception as exc:
|
||||
QMessageBox.critical(self, "Error", str(exc))
|
||||
return
|
||||
self._refresh_table()
|
||||
self.projectMutated.emit(self._project)
|
||||
|
||||
|
||||
class _AddEquipmentDialog(QDialog):
|
||||
"""Diálogo modal para agregar un Equipment."""
|
||||
|
||||
def __init__(self, project: Project, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._project = project
|
||||
self.setWindowTitle("Agregar equipo")
|
||||
self.setMinimumWidth(420)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
form = QFormLayout()
|
||||
|
||||
self._tag_prefix = QLineEdit()
|
||||
self._tag_prefix.setPlaceholderText("ME_PORT, GEN_2, BILGE_FWD...")
|
||||
form.addRow("Tag prefix:", self._tag_prefix)
|
||||
|
||||
self._display = QLineEdit()
|
||||
self._display.setPlaceholderText("Motor principal babor")
|
||||
form.addRow("Nombre:", self._display)
|
||||
|
||||
self._model_ref = QComboBox()
|
||||
try:
|
||||
lib = load_library()
|
||||
for em in lib.equipment_models:
|
||||
self._model_ref.addItem(f"{em.id} · {em.manufacturer} {em.model_name}", em.id)
|
||||
except Exception:
|
||||
pass
|
||||
form.addRow("Modelo (biblioteca):", self._model_ref)
|
||||
|
||||
self._system = QComboBox()
|
||||
for sys_id in project.systems_enabled:
|
||||
self._system.addItem(sys_id.value, sys_id.value)
|
||||
if self._system.count() == 0:
|
||||
for sys_id in SystemId:
|
||||
self._system.addItem(sys_id.value, sys_id.value)
|
||||
form.addRow("Sistema:", self._system)
|
||||
|
||||
# Location
|
||||
self._x = QDoubleSpinBox()
|
||||
self._x.setRange(-5.0, 200.0)
|
||||
self._x.setDecimals(2)
|
||||
self._x.setSuffix(" m")
|
||||
self._x.setValue(project.vessel.length_overall_m * 0.3)
|
||||
form.addRow("x_pp:", self._x)
|
||||
|
||||
self._y = QDoubleSpinBox()
|
||||
self._y.setRange(-30.0, 30.0)
|
||||
self._y.setDecimals(2)
|
||||
self._y.setSuffix(" m")
|
||||
form.addRow("y_cl:", self._y)
|
||||
|
||||
self._z = QDoubleSpinBox()
|
||||
self._z.setRange(-10.0, 50.0)
|
||||
self._z.setDecimals(2)
|
||||
self._z.setSuffix(" m")
|
||||
self._z.setValue(1.0)
|
||||
form.addRow("z_bl:", self._z)
|
||||
|
||||
self._deck = QComboBox()
|
||||
self._deck.addItem("(sin asignar)", None)
|
||||
for d in project.vessel.decks:
|
||||
self._deck.addItem(f"{d.id} · {d.name}", d.id)
|
||||
form.addRow("Deck:", self._deck)
|
||||
|
||||
layout.addLayout(form)
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||
)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
def build(self) -> Equipment | None:
|
||||
prefix = self._tag_prefix.text().strip().upper()
|
||||
if not prefix:
|
||||
return None
|
||||
try:
|
||||
return Equipment(
|
||||
id=f"eq_{uuid.uuid4().hex[:8]}_{prefix.lower()}",
|
||||
model_ref=self._model_ref.currentData() or "",
|
||||
tag_prefix=prefix,
|
||||
display_name=self._display.text() or prefix,
|
||||
location=ShipCoord(
|
||||
x_pp=self._x.value(), y_cl=self._y.value(), z_bl=self._z.value()
|
||||
),
|
||||
deck_id=self._deck.currentData(),
|
||||
system_id=SystemId(self._system.currentData() or "main_engine"),
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
@@ -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};")
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Paso 5: equipos sugeridos por el motor de reglas heurísticas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Property, Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
QWizardPage,
|
||||
)
|
||||
|
||||
from vmssailor.core.enums import SystemId, VesselSubtype, VesselType
|
||||
from vmssailor.studio.designer.rule_engine import (
|
||||
EquipmentProposal,
|
||||
RuleContext,
|
||||
RuleEngine,
|
||||
)
|
||||
from vmssailor.studio.theme import C_CYAN, C_FOG, mono_font, ui_font
|
||||
|
||||
|
||||
class Step05Equipment(QWizardPage):
|
||||
"""Muestra propuestas del rule engine. Integrador acepta/rechaza."""
|
||||
|
||||
proposalsChanged = Signal()
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setTitle("Paso 5 · Equipos sugeridos")
|
||||
self.setSubTitle(
|
||||
"El motor de reglas propuso estos equipos basado en el tipo de buque y "
|
||||
"los sistemas habilitados. Desmarca los que no apliquen."
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(12)
|
||||
|
||||
intro = QLabel(
|
||||
"Doble-click en una celda para editar. La columna ✓ permite excluir propuestas."
|
||||
)
|
||||
intro.setStyleSheet(f"color: {C_FOG};")
|
||||
intro.setFont(ui_font(10))
|
||||
layout.addWidget(intro)
|
||||
|
||||
self._table = QTableWidget(0, 7)
|
||||
self._table.setHorizontalHeaderLabels(
|
||||
["✓", "Sistema", "Modelo", "Tag prefix", "x_pp", "y_cl", "Rationale"]
|
||||
)
|
||||
self._table.horizontalHeader().setSectionResizeMode(
|
||||
6, QHeaderView.ResizeMode.Stretch
|
||||
)
|
||||
self._table.horizontalHeader().setSectionResizeMode(
|
||||
0, QHeaderView.ResizeMode.ResizeToContents
|
||||
)
|
||||
self._table.setAlternatingRowColors(False)
|
||||
self._table.verticalHeader().setVisible(False)
|
||||
self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self._table.setEditTriggers(
|
||||
QAbstractItemView.EditTrigger.DoubleClicked
|
||||
| QAbstractItemView.EditTrigger.EditKeyPressed
|
||||
)
|
||||
self._table.itemChanged.connect(self._on_item_changed)
|
||||
layout.addWidget(self._table, 1)
|
||||
|
||||
# Counter label
|
||||
self._counter = QLabel("0 propuestas")
|
||||
self._counter.setFont(mono_font(10))
|
||||
self._counter.setStyleSheet(f"color: {C_CYAN};")
|
||||
layout.addWidget(self._counter)
|
||||
|
||||
btn_row = QPushButton("Regenerar desde reglas")
|
||||
btn_row.clicked.connect(self._regenerate)
|
||||
layout.addWidget(btn_row)
|
||||
|
||||
self._proposals: list[EquipmentProposal] = []
|
||||
|
||||
from vmssailor.studio.wizard.wizard import F_PROPOSALS
|
||||
|
||||
self.registerField(F_PROPOSALS, self, "proposals", "proposalsChanged")
|
||||
|
||||
# ----- Property API ------------------------------------------------
|
||||
|
||||
def get_proposals(self) -> list[EquipmentProposal]:
|
||||
return [p for p in self._proposals if p.accepted]
|
||||
|
||||
def set_proposals(self, value: list[EquipmentProposal]) -> None:
|
||||
self._proposals = value
|
||||
self._refresh_table()
|
||||
self.proposalsChanged.emit()
|
||||
|
||||
proposals = Property(
|
||||
list, fget=get_proposals, fset=set_proposals, notify=proposalsChanged
|
||||
)
|
||||
|
||||
# ----- Lifecycle ---------------------------------------------------
|
||||
|
||||
def initializePage(self) -> None:
|
||||
if not self._proposals:
|
||||
self._regenerate()
|
||||
|
||||
def _regenerate(self) -> None:
|
||||
from vmssailor.studio.wizard.wizard import (
|
||||
F_BEAM,
|
||||
F_DRAFT,
|
||||
F_LOA,
|
||||
F_SYSTEMS,
|
||||
F_VESSEL_SUBTYPE,
|
||||
F_VESSEL_TYPE,
|
||||
)
|
||||
|
||||
try:
|
||||
v_type = VesselType(self.field(F_VESSEL_TYPE) or "yacht_motor")
|
||||
except ValueError:
|
||||
v_type = VesselType.YACHT_MOTOR
|
||||
try:
|
||||
v_sub = VesselSubtype(self.field(F_VESSEL_SUBTYPE) or "planing")
|
||||
except ValueError:
|
||||
v_sub = VesselSubtype.PLANING
|
||||
loa = float(self.field(F_LOA) or 24.0)
|
||||
beam = float(self.field(F_BEAM) or 5.5)
|
||||
draft = float(self.field(F_DRAFT) or 1.8)
|
||||
|
||||
systems_raw = self.field(F_SYSTEMS) or []
|
||||
systems: list[SystemId] = []
|
||||
for s in systems_raw:
|
||||
try:
|
||||
systems.append(SystemId(s))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
ctx = RuleContext(
|
||||
vessel_type=v_type,
|
||||
vessel_subtype=v_sub,
|
||||
length_overall_m=loa,
|
||||
beam_max_m=beam,
|
||||
draft_m=draft,
|
||||
systems_enabled=systems,
|
||||
)
|
||||
|
||||
engine = RuleEngine()
|
||||
self._proposals = engine.propose(ctx)
|
||||
self._refresh_table()
|
||||
self.proposalsChanged.emit()
|
||||
|
||||
# ----- Table rendering --------------------------------------------
|
||||
|
||||
def _refresh_table(self) -> None:
|
||||
self._table.blockSignals(True)
|
||||
self._table.setRowCount(len(self._proposals))
|
||||
for row, p in enumerate(self._proposals):
|
||||
check = QTableWidgetItem()
|
||||
check.setFlags(check.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||
check.setCheckState(Qt.CheckState.Checked if p.accepted else Qt.CheckState.Unchecked)
|
||||
self._table.setItem(row, 0, check)
|
||||
self._table.setItem(row, 1, QTableWidgetItem(p.system_id.value))
|
||||
self._table.setItem(row, 2, QTableWidgetItem(p.model_ref))
|
||||
self._table.setItem(row, 3, QTableWidgetItem(p.tag_prefix))
|
||||
self._table.setItem(row, 4, QTableWidgetItem(f"{p.location_x_pp:.2f}"))
|
||||
self._table.setItem(row, 5, QTableWidgetItem(f"{p.location_y_cl:.2f}"))
|
||||
rationale = QTableWidgetItem(p.rationale)
|
||||
rationale.setForeground(Qt.GlobalColor.gray)
|
||||
self._table.setItem(row, 6, rationale)
|
||||
self._table.resizeColumnsToContents()
|
||||
self._table.blockSignals(False)
|
||||
accepted = sum(1 for p in self._proposals if p.accepted)
|
||||
self._counter.setText(
|
||||
f"{accepted} aceptadas · {len(self._proposals) - accepted} rechazadas · "
|
||||
f"{len(self._proposals)} totales"
|
||||
)
|
||||
|
||||
def _on_item_changed(self, item: QTableWidgetItem) -> None:
|
||||
row = item.row()
|
||||
if row >= len(self._proposals):
|
||||
return
|
||||
col = item.column()
|
||||
p = self._proposals[row]
|
||||
try:
|
||||
if col == 0:
|
||||
p.accepted = item.checkState() == Qt.CheckState.Checked
|
||||
elif col == 2:
|
||||
p.model_ref = item.text()
|
||||
elif col == 3:
|
||||
p.tag_prefix = item.text().upper()
|
||||
item.setText(p.tag_prefix)
|
||||
elif col == 4:
|
||||
p.location_x_pp = float(item.text())
|
||||
elif col == 5:
|
||||
p.location_y_cl = float(item.text())
|
||||
except ValueError:
|
||||
# Reset celda al valor previo si la conversión falla
|
||||
self._refresh_table()
|
||||
return
|
||||
accepted = sum(1 for pr in self._proposals if pr.accepted)
|
||||
self._counter.setText(
|
||||
f"{accepted} aceptadas · {len(self._proposals) - accepted} rechazadas · "
|
||||
f"{len(self._proposals)} totales"
|
||||
)
|
||||
self.proposalsChanged.emit()
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Paso 6: refinamiento manual (Sprint 2).
|
||||
|
||||
En Sprint 1 era placeholder. En Sprint 2 permite reasignar ubicaciones de
|
||||
equipos y editar tag_prefix con una vista preview de la silueta.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QLabel,
|
||||
QListWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
QWizardPage,
|
||||
)
|
||||
|
||||
from vmssailor.studio.theme import C_FOG, mono_font, ui_font
|
||||
|
||||
|
||||
class Step06Refinement(QWizardPage):
|
||||
"""Muestra resumen de equipos aceptados con preview textual."""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setTitle("Paso 6 · Refinamiento")
|
||||
self.setSubTitle(
|
||||
"Revisa los equipos finales antes de generar topología. "
|
||||
"El editor visual completo viene en Sprint 3."
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(12)
|
||||
|
||||
self._list = QListWidget()
|
||||
self._list.setFont(mono_font(10))
|
||||
layout.addWidget(self._list, 1)
|
||||
|
||||
# Stats
|
||||
self._stats = QLabel("Cargando…")
|
||||
self._stats.setStyleSheet(f"color: {C_FOG};")
|
||||
self._stats.setFont(ui_font(10))
|
||||
layout.addWidget(self._stats)
|
||||
|
||||
def initializePage(self) -> None:
|
||||
from vmssailor.studio.wizard.wizard import F_PROPOSALS
|
||||
|
||||
proposals = self.field(F_PROPOSALS) or []
|
||||
self._list.clear()
|
||||
by_system: dict[str, int] = {}
|
||||
for p in proposals:
|
||||
self._list.addItem(
|
||||
f"{p.system_id.value:20s} {p.tag_prefix:14s} "
|
||||
f"{p.model_ref:30s} @ x_pp={p.location_x_pp:.2f}m y_cl={p.location_y_cl:+.2f}m"
|
||||
)
|
||||
by_system[p.system_id.value] = by_system.get(p.system_id.value, 0) + 1
|
||||
n = len(proposals)
|
||||
groups = ", ".join(f"{k}: {v}" for k, v in sorted(by_system.items()))
|
||||
self._stats.setText(
|
||||
f"{n} equipos aceptados · {groups}"
|
||||
if n
|
||||
else "Sin equipos aceptados. Vuelve atrás y selecciona al menos uno."
|
||||
)
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Paso 7: topología AR-NMEA-IO + asignación automática de I/O.
|
||||
|
||||
Genera tarjetas + bindings físicos de cada sensor declarado en
|
||||
EquipmentModel.default_sensors. El integrador puede ajustar después en el
|
||||
editor de topología (Sprint 3+).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Property, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
QWizardPage,
|
||||
)
|
||||
|
||||
from vmssailor.core.equipment import Equipment
|
||||
from vmssailor.library import load_library
|
||||
from vmssailor.studio.designer.port_auto_assigner import (
|
||||
AssignmentReport,
|
||||
auto_assign,
|
||||
)
|
||||
from vmssailor.studio.designer.rule_engine import EquipmentProposal
|
||||
from vmssailor.studio.theme import C_FOG, mono_font, ui_font
|
||||
|
||||
|
||||
class Step07Topology(QWizardPage):
|
||||
"""Vista previa de la topología auto-asignada."""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setTitle("Paso 7 · Topología AR-NMEA-IO")
|
||||
self.setSubTitle(
|
||||
"Asignación automática de tarjetas y canales físicos. "
|
||||
"Capacidad por tarjeta: 10 DO · 5 DI · 1 RPM · 4 AI."
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(10)
|
||||
|
||||
self._summary = QLabel("")
|
||||
self._summary.setFont(ui_font(11))
|
||||
layout.addWidget(self._summary)
|
||||
|
||||
self._table = QTableWidget(0, 4)
|
||||
self._table.setHorizontalHeaderLabels(
|
||||
["Tarjeta", "Slot · Addr", "DO/DI/AI/RPM usados", "Ubicación"]
|
||||
)
|
||||
self._table.horizontalHeader().setSectionResizeMode(
|
||||
3, QHeaderView.ResizeMode.Stretch
|
||||
)
|
||||
self._table.verticalHeader().setVisible(False)
|
||||
layout.addWidget(self._table, 1)
|
||||
|
||||
self._warnings = QLabel("")
|
||||
self._warnings.setWordWrap(True)
|
||||
self._warnings.setFont(mono_font(9))
|
||||
self._warnings.setStyleSheet(f"color: {C_FOG};")
|
||||
layout.addWidget(self._warnings)
|
||||
|
||||
self._assignment: AssignmentReport | None = None
|
||||
|
||||
from vmssailor.studio.wizard.wizard import F_ASSIGNMENT
|
||||
|
||||
self.registerField(F_ASSIGNMENT, self, "assignment", "assignmentChanged")
|
||||
|
||||
# Property API
|
||||
assignmentChanged = Signal()
|
||||
|
||||
def get_assignment(self) -> AssignmentReport | None:
|
||||
return self._assignment
|
||||
|
||||
def set_assignment(self, value: AssignmentReport) -> None:
|
||||
self._assignment = value
|
||||
self.assignmentChanged.emit()
|
||||
|
||||
assignment = Property(
|
||||
object, fget=get_assignment, fset=set_assignment, notify=assignmentChanged
|
||||
)
|
||||
|
||||
def initializePage(self) -> None:
|
||||
from vmssailor.studio.wizard.wizard import F_PROPOSALS
|
||||
|
||||
proposals: list[EquipmentProposal] = self.field(F_PROPOSALS) or []
|
||||
if not proposals:
|
||||
self._summary.setText("Sin equipos. Vuelve al paso 5.")
|
||||
self._table.setRowCount(0)
|
||||
return
|
||||
|
||||
# Materialize Equipment objects so we can run the assigner
|
||||
equipment_list: list[Equipment] = []
|
||||
for idx, p in enumerate(proposals):
|
||||
from vmssailor.core.coords import ShipCoord
|
||||
|
||||
equipment_list.append(
|
||||
Equipment(
|
||||
id=f"eq_{idx:03d}_{p.tag_prefix.lower()}",
|
||||
model_ref=p.model_ref,
|
||||
tag_prefix=p.tag_prefix,
|
||||
display_name=p.display_name,
|
||||
location=ShipCoord(
|
||||
x_pp=p.location_x_pp,
|
||||
y_cl=p.location_y_cl,
|
||||
z_bl=p.location_z_bl,
|
||||
),
|
||||
system_id=p.system_id,
|
||||
)
|
||||
)
|
||||
|
||||
# Cargar modelos de biblioteca
|
||||
try:
|
||||
lib = load_library()
|
||||
model_lookup = {m.id: m for m in lib.equipment_models}
|
||||
except Exception:
|
||||
model_lookup = {}
|
||||
|
||||
report = auto_assign(equipment_list, model_lookup)
|
||||
self._assignment = report
|
||||
|
||||
self._summary.setText(
|
||||
f"<b>{len(report.cards)}</b> tarjetas · "
|
||||
f"<b>{len(report.tags)}</b> tags asignados · "
|
||||
f"<b>{report.n_skipped}</b> sin tipo de señal · "
|
||||
f"bus: <b>{report.bus.id if report.bus else '—'}</b>"
|
||||
)
|
||||
|
||||
self._table.setRowCount(len(report.cards))
|
||||
capacities: dict[str, dict[str, int]] = {}
|
||||
for t in report.tags:
|
||||
if t.physical_binding:
|
||||
cid = t.physical_binding.card_id
|
||||
ch = t.physical_binding.channel_type.value.upper()
|
||||
capacities.setdefault(cid, {"DO": 0, "DI": 0, "AI": 0, "RPM": 0})
|
||||
capacities[cid][ch] = capacities[cid].get(ch, 0) + 1
|
||||
|
||||
for row, c in enumerate(report.cards):
|
||||
self._table.setItem(row, 0, QTableWidgetItem(c.id))
|
||||
self._table.setItem(row, 1, QTableWidgetItem(f"slot {c.slot_number} · addr {c.modbus_address}"))
|
||||
cap = capacities.get(c.id, {})
|
||||
usage = f"DO {cap.get('DO', 0)}/10 · DI {cap.get('DI', 0)}/5 · AI {cap.get('AI', 0)}/4 · RPM {cap.get('RPM', 0)}/1"
|
||||
self._table.setItem(row, 2, QTableWidgetItem(usage))
|
||||
self._table.setItem(row, 3, QTableWidgetItem(c.physical_location))
|
||||
self._table.resizeColumnsToContents()
|
||||
|
||||
if report.warnings:
|
||||
self._warnings.setText("\n".join(report.warnings[:6]))
|
||||
else:
|
||||
self._warnings.setText("✓ Sin conflictos de capacidad detectados.")
|
||||
self.assignmentChanged.emit()
|
||||
@@ -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).",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user