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,197 @@
|
|||||||
|
"""Tests del motor de reglas y del auto-asignador (Sprint 2)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from vmssailor.core.enums import SystemId, VesselSubtype, VesselType
|
||||||
|
from vmssailor.studio.designer.rule_engine import RuleContext, RuleEngine
|
||||||
|
|
||||||
|
|
||||||
|
def test_rule_engine_loads_default_library():
|
||||||
|
eng = RuleEngine()
|
||||||
|
# La biblioteca seed tiene yacht_motor_planeo.yaml
|
||||||
|
ctx = RuleContext(
|
||||||
|
vessel_type=VesselType.YACHT_MOTOR,
|
||||||
|
vessel_subtype=VesselSubtype.PLANING,
|
||||||
|
length_overall_m=23.5,
|
||||||
|
beam_max_m=5.65,
|
||||||
|
draft_m=1.85,
|
||||||
|
systems_enabled=[SystemId.MAIN_ENGINE, SystemId.GENSET],
|
||||||
|
)
|
||||||
|
applicable = eng.applicable_rules(ctx)
|
||||||
|
assert "yacht_motor_planeo" in applicable
|
||||||
|
|
||||||
|
|
||||||
|
def test_rule_engine_proposes_two_main_engines():
|
||||||
|
eng = RuleEngine()
|
||||||
|
ctx = RuleContext(
|
||||||
|
vessel_type=VesselType.YACHT_MOTOR,
|
||||||
|
vessel_subtype=VesselSubtype.PLANING,
|
||||||
|
length_overall_m=23.5,
|
||||||
|
beam_max_m=5.65,
|
||||||
|
draft_m=1.85,
|
||||||
|
systems_enabled=[SystemId.MAIN_ENGINE],
|
||||||
|
)
|
||||||
|
proposals = eng.propose(ctx)
|
||||||
|
me = [p for p in proposals if p.system_id == SystemId.MAIN_ENGINE]
|
||||||
|
assert len(me) == 2
|
||||||
|
prefixes = {p.tag_prefix for p in me}
|
||||||
|
assert prefixes == {"ME_PORT", "ME_STBD"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_rule_engine_picks_volvo_for_smaller_vessel():
|
||||||
|
eng = RuleEngine()
|
||||||
|
ctx = RuleContext(
|
||||||
|
vessel_type=VesselType.YACHT_MOTOR,
|
||||||
|
vessel_subtype=VesselSubtype.PLANING,
|
||||||
|
length_overall_m=20.0, # rango Volvo 18-26
|
||||||
|
beam_max_m=5.0,
|
||||||
|
draft_m=1.5,
|
||||||
|
systems_enabled=[SystemId.MAIN_ENGINE],
|
||||||
|
)
|
||||||
|
proposals = eng.propose(ctx)
|
||||||
|
me = [p for p in proposals if p.system_id == SystemId.MAIN_ENGINE]
|
||||||
|
# 20 m está en el rango Volvo (18-26) y NO en MTU (22-32) → primero match es Volvo
|
||||||
|
assert all(p.model_ref == "volvo_d13_900hp" for p in me)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rule_engine_picks_mtu_for_larger():
|
||||||
|
eng = RuleEngine()
|
||||||
|
ctx = RuleContext(
|
||||||
|
vessel_type=VesselType.YACHT_MOTOR,
|
||||||
|
vessel_subtype=VesselSubtype.PLANING,
|
||||||
|
length_overall_m=27.0, # fuera del rango Volvo, en el de MTU
|
||||||
|
beam_max_m=6.0,
|
||||||
|
draft_m=2.0,
|
||||||
|
systems_enabled=[SystemId.MAIN_ENGINE],
|
||||||
|
)
|
||||||
|
proposals = eng.propose(ctx)
|
||||||
|
me = [p for p in proposals if p.system_id == SystemId.MAIN_ENGINE]
|
||||||
|
assert all(p.model_ref == "mtu_12v_2000_m96" for p in me)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rule_engine_does_not_propose_disabled_systems():
|
||||||
|
eng = RuleEngine()
|
||||||
|
ctx = RuleContext(
|
||||||
|
vessel_type=VesselType.YACHT_MOTOR,
|
||||||
|
vessel_subtype=VesselSubtype.PLANING,
|
||||||
|
length_overall_m=23.5,
|
||||||
|
beam_max_m=5.65,
|
||||||
|
draft_m=1.85,
|
||||||
|
systems_enabled=[SystemId.GENSET], # main_engine no habilitado
|
||||||
|
)
|
||||||
|
proposals = eng.propose(ctx)
|
||||||
|
assert all(p.system_id != SystemId.MAIN_ENGINE for p in proposals)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rule_engine_filters_by_loa_range():
|
||||||
|
eng = RuleEngine()
|
||||||
|
ctx = RuleContext(
|
||||||
|
vessel_type=VesselType.YACHT_MOTOR,
|
||||||
|
vessel_subtype=VesselSubtype.PLANING,
|
||||||
|
length_overall_m=50.0, # fuera del rango 18-32 de yacht_motor_planeo
|
||||||
|
beam_max_m=9.0,
|
||||||
|
draft_m=2.5,
|
||||||
|
systems_enabled=[SystemId.MAIN_ENGINE],
|
||||||
|
)
|
||||||
|
assert "yacht_motor_planeo" not in eng.applicable_rules(ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def test_port_auto_assigner_builds_topology():
|
||||||
|
from vmssailor.core.coords import ShipCoord
|
||||||
|
from vmssailor.core.equipment import Equipment
|
||||||
|
from vmssailor.library import load_library
|
||||||
|
from vmssailor.studio.designer.port_auto_assigner import auto_assign
|
||||||
|
|
||||||
|
lib = load_library()
|
||||||
|
model_lookup = {m.id: m for m in lib.equipment_models}
|
||||||
|
|
||||||
|
me_port = Equipment(
|
||||||
|
id="eq_me_port",
|
||||||
|
model_ref="mtu_12v_2000_m96",
|
||||||
|
tag_prefix="ME_PORT",
|
||||||
|
display_name="Motor babor",
|
||||||
|
location=ShipCoord(x_pp=6.0, y_cl=-0.9, z_bl=1.2),
|
||||||
|
system_id=SystemId.MAIN_ENGINE,
|
||||||
|
)
|
||||||
|
me_stbd = Equipment(
|
||||||
|
id="eq_me_stbd",
|
||||||
|
model_ref="mtu_12v_2000_m96",
|
||||||
|
tag_prefix="ME_STBD",
|
||||||
|
display_name="Motor estribor",
|
||||||
|
location=ShipCoord(x_pp=6.0, y_cl=0.9, z_bl=1.2),
|
||||||
|
system_id=SystemId.MAIN_ENGINE,
|
||||||
|
)
|
||||||
|
|
||||||
|
report = auto_assign([me_port, me_stbd], model_lookup)
|
||||||
|
assert report.bus is not None
|
||||||
|
assert len(report.cards) >= 2 # una por motor + posiblemente aux
|
||||||
|
assert len(report.tags) > 10 # cada motor trae ~12 sensores
|
||||||
|
# Verifica que los bindings caen en canales válidos
|
||||||
|
for t in report.tags:
|
||||||
|
if t.physical_binding:
|
||||||
|
assert t.physical_binding.channel_number >= 1
|
||||||
|
assert t.physical_binding.card_id in {c.id for c in report.cards}
|
||||||
|
|
||||||
|
|
||||||
|
def test_port_auto_assigner_creates_topology_compatible_with_project():
|
||||||
|
"""El topology generado debe pasar validación de Pydantic."""
|
||||||
|
from vmssailor.core.coords import ShipCoord
|
||||||
|
from vmssailor.core.equipment import Equipment
|
||||||
|
from vmssailor.core.project import Project
|
||||||
|
from vmssailor.core.vessel import Deck, Vessel
|
||||||
|
from vmssailor.library import load_library
|
||||||
|
from vmssailor.studio.designer.port_auto_assigner import auto_assign
|
||||||
|
|
||||||
|
lib = load_library()
|
||||||
|
model_lookup = {m.id: m for m in lib.equipment_models}
|
||||||
|
|
||||||
|
eq = Equipment(
|
||||||
|
id="eq_test",
|
||||||
|
model_ref="volvo_d13_900hp",
|
||||||
|
tag_prefix="ME",
|
||||||
|
display_name="Motor",
|
||||||
|
location=ShipCoord(x_pp=6, y_cl=0, z_bl=1.2),
|
||||||
|
system_id=SystemId.MAIN_ENGINE,
|
||||||
|
)
|
||||||
|
report = auto_assign([eq], model_lookup)
|
||||||
|
|
||||||
|
vessel = Vessel(
|
||||||
|
id="v",
|
||||||
|
name="V",
|
||||||
|
type=VesselType.YACHT_MOTOR,
|
||||||
|
subtype=VesselSubtype.PLANING,
|
||||||
|
length_overall_m=24,
|
||||||
|
beam_max_m=5.5,
|
||||||
|
draft_m=1.8,
|
||||||
|
decks=[Deck(id="lower", name="Lower", z_bl_bottom=0.5, z_bl_top=2.5)],
|
||||||
|
)
|
||||||
|
|
||||||
|
project = Project(
|
||||||
|
id="test",
|
||||||
|
name="test",
|
||||||
|
vessel=vessel,
|
||||||
|
systems_enabled=[SystemId.MAIN_ENGINE],
|
||||||
|
equipment=[eq],
|
||||||
|
tags=report.tags,
|
||||||
|
topology=report.topology(),
|
||||||
|
)
|
||||||
|
assert project.stats()["cards"] >= 1
|
||||||
|
assert project.stats()["tags"] > 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_equipment_editor_smoke(qtbot, sample_project):
|
||||||
|
from vmssailor.studio.editors.equipment_editor import EquipmentEditor
|
||||||
|
|
||||||
|
ed = EquipmentEditor()
|
||||||
|
qtbot.addWidget(ed)
|
||||||
|
ed.set_project(sample_project)
|
||||||
|
assert ed._table.rowCount() == len(sample_project.equipment)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wizard_step_05_initializes(qtbot):
|
||||||
|
from vmssailor.studio.wizard.step_05_equipment import Step05Equipment
|
||||||
|
|
||||||
|
step = Step05Equipment()
|
||||||
|
qtbot.addWidget(step)
|
||||||
|
# Sin un QWizard padre los fields no funcionan, pero el widget debe construir
|
||||||
|
assert step._table.columnCount() == 7
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"id": "cat_c32_acert",
|
||||||
|
"manufacturer": "Caterpillar",
|
||||||
|
"model_name": "C32 ACERT",
|
||||||
|
"category": "engine_main",
|
||||||
|
"typical_systems": ["main_engine"],
|
||||||
|
"specs": {
|
||||||
|
"power_kw": 1081,
|
||||||
|
"rpm_nominal": 2300,
|
||||||
|
"weight_kg": 2440,
|
||||||
|
"length_m": 2.04,
|
||||||
|
"width_m": 1.30,
|
||||||
|
"height_m": 1.30,
|
||||||
|
"fuel_consumption_lph": 245
|
||||||
|
},
|
||||||
|
"description": "Caterpillar C32 ACERT, V12, 32.1 L. Aplicación yates rápidos 70-100 pies / patrulleros / pilot boats. Twin turbo intercooled.",
|
||||||
|
"data_source": "seed_estimate",
|
||||||
|
"default_sensors": [
|
||||||
|
{ "id": "rpm", "name": "RPM", "unit_si": "rpm", "range_normal_min": 0, "range_normal_max": 2400, "alarm_high_value": 2350, "alarm_high_priority": "high", "default_signal_type": "pulse_magnetic_pickup" },
|
||||||
|
{ "id": "oil_press", "name": "Presión aceite", "unit_si": "bar", "range_normal_min": 3.0, "range_normal_max": 5.5, "alarm_low_value": 1.5, "alarm_low_priority": "emergency", "default_signal_type": "4-20ma" },
|
||||||
|
{ "id": "oil_temp", "name": "Temperatura aceite", "unit_si": "C", "range_normal_min": 60, "range_normal_max": 110, "alarm_high_value": 120, "alarm_high_priority": "high", "default_signal_type": "rtd_pt100" },
|
||||||
|
{ "id": "coolant_temp", "name": "Temperatura refrigerante", "unit_si": "C", "range_normal_min": 65, "range_normal_max": 95, "alarm_high_value": 100, "alarm_high_priority": "emergency", "default_signal_type": "rtd_pt100" },
|
||||||
|
{ "id": "boost_press", "name": "Presión sobrealimentación", "unit_si": "bar", "range_normal_min": 0.0, "range_normal_max": 2.5, "default_signal_type": "4-20ma" },
|
||||||
|
{ "id": "alternator_v", "name": "Voltaje alternador", "unit_si": "V", "range_normal_min": 27.0, "range_normal_max": 29.0, "alarm_low_value": 24.0, "alarm_low_priority": "high", "default_signal_type": "voltage_divider" },
|
||||||
|
{ "id": "load_pct", "name": "Carga del motor", "unit_si": "%", "range_normal_min": 0, "range_normal_max": 100, "default_signal_type": "4-20ma" },
|
||||||
|
{ "id": "running_hours", "name": "Horas totales", "unit_si": "h", "range_normal_min": 0, "range_normal_max": 80000, "default_signal_type": "4-20ma" },
|
||||||
|
{ "id": "start_cmd", "name": "Comando arranque", "unit_si": "bool", "default_signal_type": "relay_no" },
|
||||||
|
{ "id": "stop_cmd", "name": "Comando parada", "unit_si": "bool", "default_signal_type": "relay_no" },
|
||||||
|
{ "id": "running_state", "name": "Estado motor en marcha", "unit_si": "bool", "default_signal_type": "dry_contact" },
|
||||||
|
{ "id": "estop_active", "name": "E-stop activado", "unit_si": "bool", "default_signal_type": "dry_contact" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"id": "mtu_16v_2000_m96",
|
||||||
|
"manufacturer": "MTU",
|
||||||
|
"model_name": "16V 2000 M96",
|
||||||
|
"category": "engine_main",
|
||||||
|
"typical_systems": ["main_engine"],
|
||||||
|
"specs": {
|
||||||
|
"power_kw": 1939,
|
||||||
|
"rpm_nominal": 2450,
|
||||||
|
"weight_kg": 3290,
|
||||||
|
"length_m": 2.45,
|
||||||
|
"width_m": 1.20,
|
||||||
|
"height_m": 1.27,
|
||||||
|
"fuel_consumption_lph": 432
|
||||||
|
},
|
||||||
|
"description": "MTU Series 2000, V16, 32.0 L, 2-stage turbo. Aplicación fast ferries / patrulleros offshore / yates 30-40 m. Common Rail. Habla J1939 nativo.",
|
||||||
|
"data_source": "seed_estimate",
|
||||||
|
"default_sensors": [
|
||||||
|
{ "id": "rpm", "name": "RPM", "unit_si": "rpm", "range_normal_min": 0, "range_normal_max": 2600, "alarm_high_value": 2550, "alarm_high_priority": "high", "default_signal_type": "pulse_magnetic_pickup" },
|
||||||
|
{ "id": "oil_press", "name": "Presión aceite", "unit_si": "bar", "range_normal_min": 3.5, "range_normal_max": 6.5, "alarm_low_value": 1.5, "alarm_low_priority": "emergency", "default_signal_type": "4-20ma" },
|
||||||
|
{ "id": "oil_temp", "name": "Temperatura aceite", "unit_si": "C", "range_normal_min": 60, "range_normal_max": 110, "alarm_high_value": 120, "alarm_high_priority": "high", "default_signal_type": "rtd_pt100" },
|
||||||
|
{ "id": "coolant_temp", "name": "Temperatura refrigerante", "unit_si": "C", "range_normal_min": 65, "range_normal_max": 95, "alarm_high_value": 100, "alarm_high_priority": "emergency", "default_signal_type": "rtd_pt100" },
|
||||||
|
{ "id": "boost_press", "name": "Presión sobrealimentación", "unit_si": "bar", "range_normal_min": 0.0, "range_normal_max": 2.8, "default_signal_type": "4-20ma" },
|
||||||
|
{ "id": "load_pct", "name": "Carga del motor", "unit_si": "%", "range_normal_min": 0, "range_normal_max": 100, "default_signal_type": "4-20ma" },
|
||||||
|
{ "id": "running_hours", "name": "Horas totales", "unit_si": "h", "range_normal_min": 0, "range_normal_max": 80000, "default_signal_type": "4-20ma" },
|
||||||
|
{ "id": "alternator_v", "name": "Voltaje alternador", "unit_si": "V", "range_normal_min": 27.0, "range_normal_max": 29.0, "alarm_low_value": 24.0, "alarm_low_priority": "high", "default_signal_type": "voltage_divider" },
|
||||||
|
{ "id": "start_cmd", "name": "Comando arranque", "unit_si": "bool", "default_signal_type": "relay_no" },
|
||||||
|
{ "id": "stop_cmd", "name": "Comando parada", "unit_si": "bool", "default_signal_type": "relay_no" },
|
||||||
|
{ "id": "running_state", "name": "Estado en marcha", "unit_si": "bool", "default_signal_type": "dry_contact" },
|
||||||
|
{ "id": "estop_active", "name": "E-stop activado", "unit_si": "bool", "default_signal_type": "dry_contact" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"id": "yanmar_8lv_370",
|
||||||
|
"manufacturer": "Yanmar",
|
||||||
|
"model_name": "8LV-370",
|
||||||
|
"category": "engine_main",
|
||||||
|
"typical_systems": ["main_engine"],
|
||||||
|
"specs": {
|
||||||
|
"power_kw": 272,
|
||||||
|
"rpm_nominal": 3800,
|
||||||
|
"weight_kg": 430,
|
||||||
|
"length_m": 1.04,
|
||||||
|
"width_m": 0.83,
|
||||||
|
"height_m": 0.78,
|
||||||
|
"fuel_consumption_lph": 67
|
||||||
|
},
|
||||||
|
"description": "Yanmar 8LV-370 V8, 4.46 L, common rail. Aplicación yates motor 40-60 pies y embarcaciones rápidas con stern drive.",
|
||||||
|
"data_source": "seed_estimate",
|
||||||
|
"default_sensors": [
|
||||||
|
{ "id": "rpm", "name": "RPM", "unit_si": "rpm", "range_normal_min": 0, "range_normal_max": 4000, "alarm_high_value": 3900, "alarm_high_priority": "high", "default_signal_type": "pulse_magnetic_pickup" },
|
||||||
|
{ "id": "oil_press", "name": "Presión aceite", "unit_si": "bar", "range_normal_min": 2.5, "range_normal_max": 5.0, "alarm_low_value": 1.0, "alarm_low_priority": "emergency", "default_signal_type": "4-20ma" },
|
||||||
|
{ "id": "coolant_temp", "name": "Temperatura refrigerante", "unit_si": "C", "range_normal_min": 70, "range_normal_max": 92, "alarm_high_value": 100, "alarm_high_priority": "emergency", "default_signal_type": "rtd_pt100" },
|
||||||
|
{ "id": "alternator_v", "name": "Voltaje alternador", "unit_si": "V", "range_normal_min": 13.5, "range_normal_max": 14.5, "default_signal_type": "voltage_divider" },
|
||||||
|
{ "id": "load_pct", "name": "Carga", "unit_si": "%", "range_normal_min": 0, "range_normal_max": 100, "default_signal_type": "4-20ma" },
|
||||||
|
{ "id": "running_hours", "name": "Horas", "unit_si": "h", "default_signal_type": "4-20ma" },
|
||||||
|
{ "id": "start_cmd", "name": "Arranque", "unit_si": "bool", "default_signal_type": "relay_no" },
|
||||||
|
{ "id": "stop_cmd", "name": "Parada", "unit_si": "bool", "default_signal_type": "relay_no" },
|
||||||
|
{ "id": "running_state", "name": "En marcha", "unit_si": "bool", "default_signal_type": "dry_contact" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"id": "kohler_28efkozd",
|
||||||
|
"manufacturer": "Kohler",
|
||||||
|
"model_name": "28EFKOZD",
|
||||||
|
"category": "genset",
|
||||||
|
"typical_systems": ["genset"],
|
||||||
|
"specs": {
|
||||||
|
"power_kw": 22.4,
|
||||||
|
"rpm_nominal": 1500,
|
||||||
|
"weight_kg": 450,
|
||||||
|
"length_m": 1.30,
|
||||||
|
"width_m": 0.70,
|
||||||
|
"height_m": 0.85,
|
||||||
|
"voltage_v": 230,
|
||||||
|
"current_a": 97,
|
||||||
|
"fuel_consumption_lph": 7.0
|
||||||
|
},
|
||||||
|
"description": "Kohler 28EFKOZD genset marino diésel, 28 kVA / 22.4 kW @ 1500 rpm 50 Hz. Aplicación yates motor 50-70 pies. Cabina silenciosa estándar.",
|
||||||
|
"data_source": "seed_estimate",
|
||||||
|
"default_sensors": [
|
||||||
|
{ "id": "rpm", "name": "RPM", "unit_si": "rpm", "range_normal_min": 0, "range_normal_max": 1550, "default_signal_type": "pulse_magnetic_pickup" },
|
||||||
|
{ "id": "oil_press", "name": "Presión aceite", "unit_si": "bar", "range_normal_min": 2.0, "range_normal_max": 4.5, "alarm_low_value": 1.0, "alarm_low_priority": "emergency", "default_signal_type": "4-20ma" },
|
||||||
|
{ "id": "coolant_temp", "name": "Temperatura refrigerante", "unit_si": "C", "range_normal_min": 70, "range_normal_max": 95, "alarm_high_value": 102, "alarm_high_priority": "emergency", "default_signal_type": "rtd_pt100" },
|
||||||
|
{ "id": "voltage_l1", "name": "Tensión L1", "unit_si": "V", "range_normal_min": 220, "range_normal_max": 240, "default_signal_type": "voltage_divider" },
|
||||||
|
{ "id": "current_l1", "name": "Corriente L1", "unit_si": "A", "range_normal_min": 0, "range_normal_max": 100, "alarm_high_value": 110, "alarm_high_priority": "high", "default_signal_type": "4-20ma" },
|
||||||
|
{ "id": "freq", "name": "Frecuencia", "unit_si": "Hz", "range_normal_min": 49.5, "range_normal_max": 50.5, "default_signal_type": "pulse_inductive" },
|
||||||
|
{ "id": "running_hours", "name": "Horas", "unit_si": "h", "default_signal_type": "4-20ma" },
|
||||||
|
{ "id": "start_cmd", "name": "Arranque", "unit_si": "bool", "default_signal_type": "relay_no" },
|
||||||
|
{ "id": "stop_cmd", "name": "Parada", "unit_si": "bool", "default_signal_type": "relay_no" },
|
||||||
|
{ "id": "breaker_status", "name": "Breaker principal", "unit_si": "bool", "default_signal_type": "dry_contact" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"id": "onan_qd13500",
|
||||||
|
"manufacturer": "Cummins Onan",
|
||||||
|
"model_name": "QD 13500",
|
||||||
|
"category": "genset",
|
||||||
|
"typical_systems": ["genset"],
|
||||||
|
"specs": {
|
||||||
|
"power_kw": 10.8,
|
||||||
|
"rpm_nominal": 1500,
|
||||||
|
"weight_kg": 295,
|
||||||
|
"length_m": 1.07,
|
||||||
|
"width_m": 0.55,
|
||||||
|
"height_m": 0.62,
|
||||||
|
"voltage_v": 230,
|
||||||
|
"current_a": 47,
|
||||||
|
"fuel_consumption_lph": 3.6
|
||||||
|
},
|
||||||
|
"description": "Cummins Onan QD 13500 marino diésel, 13.5 kVA / 10.8 kW @ 1500 rpm 50 Hz. Aplicación yates 35-50 pies y pesqueros pequeños.",
|
||||||
|
"data_source": "seed_estimate",
|
||||||
|
"default_sensors": [
|
||||||
|
{ "id": "rpm", "name": "RPM", "unit_si": "rpm", "range_normal_min": 0, "range_normal_max": 1550, "default_signal_type": "pulse_magnetic_pickup" },
|
||||||
|
{ "id": "oil_press", "name": "Presión aceite", "unit_si": "bar", "range_normal_min": 2.0, "range_normal_max": 4.5, "alarm_low_value": 0.9, "alarm_low_priority": "emergency", "default_signal_type": "4-20ma" },
|
||||||
|
{ "id": "coolant_temp", "name": "Temperatura refrigerante", "unit_si": "C", "range_normal_min": 70, "range_normal_max": 95, "alarm_high_value": 100, "alarm_high_priority": "emergency", "default_signal_type": "rtd_pt100" },
|
||||||
|
{ "id": "voltage_l1", "name": "Tensión L1", "unit_si": "V", "range_normal_min": 220, "range_normal_max": 240, "default_signal_type": "voltage_divider" },
|
||||||
|
{ "id": "freq", "name": "Frecuencia", "unit_si": "Hz", "range_normal_min": 49.5, "range_normal_max": 50.5, "default_signal_type": "pulse_inductive" },
|
||||||
|
{ "id": "running_hours", "name": "Horas", "unit_si": "h", "default_signal_type": "4-20ma" },
|
||||||
|
{ "id": "start_cmd", "name": "Arranque", "unit_si": "bool", "default_signal_type": "relay_no" },
|
||||||
|
{ "id": "stop_cmd", "name": "Parada", "unit_si": "bool", "default_signal_type": "relay_no" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"id": "grundfos_cm10",
|
||||||
|
"manufacturer": "Grundfos",
|
||||||
|
"model_name": "CM10",
|
||||||
|
"category": "pump",
|
||||||
|
"typical_systems": ["potable_water", "sw_cooling", "fw_cooling"],
|
||||||
|
"specs": {
|
||||||
|
"power_kw": 1.5,
|
||||||
|
"rpm_nominal": 2900,
|
||||||
|
"weight_kg": 14,
|
||||||
|
"length_m": 0.40,
|
||||||
|
"width_m": 0.20,
|
||||||
|
"height_m": 0.27,
|
||||||
|
"voltage_v": 230,
|
||||||
|
"current_a": 7,
|
||||||
|
"capacity_l": 6000
|
||||||
|
},
|
||||||
|
"description": "Grundfos CM10 centrífuga horizontal multicelular. Aplicación agua potable, refrigeración FW, transferencia. 230 V monofásico.",
|
||||||
|
"data_source": "seed_estimate",
|
||||||
|
"default_sensors": [
|
||||||
|
{ "id": "running_state", "name": "Estado en marcha", "unit_si": "bool", "default_signal_type": "dry_contact" },
|
||||||
|
{ "id": "start_cmd", "name": "Arranque", "unit_si": "bool", "default_signal_type": "relay_no" },
|
||||||
|
{ "id": "stop_cmd", "name": "Parada", "unit_si": "bool", "default_signal_type": "relay_no" },
|
||||||
|
{ "id": "current", "name": "Corriente", "unit_si": "A", "range_normal_min": 0, "range_normal_max": 8, "alarm_high_value": 10, "alarm_high_priority": "high", "default_signal_type": "4-20ma" },
|
||||||
|
{ "id": "pressure_out", "name": "Presión descarga", "unit_si": "bar", "range_normal_min": 0, "range_normal_max": 6, "default_signal_type": "4-20ma" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"id": "jabsco_36800",
|
||||||
|
"manufacturer": "Jabsco",
|
||||||
|
"model_name": "36800-0001",
|
||||||
|
"category": "pump",
|
||||||
|
"typical_systems": ["bilge", "sw_service"],
|
||||||
|
"specs": {
|
||||||
|
"power_kw": 0.10,
|
||||||
|
"rpm_nominal": 0,
|
||||||
|
"weight_kg": 1.5,
|
||||||
|
"length_m": 0.15,
|
||||||
|
"width_m": 0.10,
|
||||||
|
"height_m": 0.10,
|
||||||
|
"voltage_v": 12,
|
||||||
|
"current_a": 8.0,
|
||||||
|
"capacity_l": 60
|
||||||
|
},
|
||||||
|
"description": "Bomba sumergible Jabsco 36800 (Rule 1100 GPH equivalente). Aplicación sentinas, achique auxiliar. 12 VDC.",
|
||||||
|
"data_source": "seed_estimate",
|
||||||
|
"default_sensors": [
|
||||||
|
{ "id": "running_state", "name": "Estado en marcha", "unit_si": "bool", "default_signal_type": "dry_contact" },
|
||||||
|
{ "id": "start_cmd", "name": "Arranque", "unit_si": "bool", "default_signal_type": "relay_no" },
|
||||||
|
{ "id": "current", "name": "Corriente", "unit_si": "A", "range_normal_min": 0, "range_normal_max": 10, "alarm_high_value": 12, "alarm_high_priority": "high", "default_signal_type": "4-20ma" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"id": "azimut_grande_32m",
|
||||||
|
"name": "Azimut Grande 32M",
|
||||||
|
"type": "yacht_motor",
|
||||||
|
"subtype": "semi_planing",
|
||||||
|
"length_overall_m": 32.4,
|
||||||
|
"beam_max_m": 7.30,
|
||||||
|
"draft_m": 2.20,
|
||||||
|
"displacement_kg": 130000,
|
||||||
|
"description": "Yate motor semi-planeo italiano de 32 m, casco IPS con 3 ejes Volvo, 4 cubiertas, 5 cabinas. Sala de máquinas amplia con 3 motores principales + 2 gensets + sistemas redundantes. Tope típico ~24 nudos.",
|
||||||
|
"data_source": "seed_estimate",
|
||||||
|
"decks": [
|
||||||
|
{ "id": "tank_deck", "name": "Cubierta de tanques", "z_bl_bottom": 0.4, "z_bl_top": 2.2, "polygon_xy": [] },
|
||||||
|
{ "id": "lower", "name": "Cubierta inferior", "z_bl_bottom": 2.2, "z_bl_top": 4.6, "polygon_xy": [] },
|
||||||
|
{ "id": "main", "name": "Cubierta principal", "z_bl_bottom": 4.6, "z_bl_top": 6.9, "polygon_xy": [] },
|
||||||
|
{ "id": "flybridge", "name": "Flybridge", "z_bl_bottom": 6.9, "z_bl_top": 8.8, "polygon_xy": [] }
|
||||||
|
],
|
||||||
|
"bulkheads": [
|
||||||
|
{ "id": "collision", "name": "Mamparo de colisión", "x_pp": 29.5, "description": "" },
|
||||||
|
{ "id": "er_fwd", "name": "Mamparo proa SM", "x_pp": 9.5, "description": "" },
|
||||||
|
{ "id": "er_aft", "name": "Mamparo popa SM", "x_pp": 5.0, "description": "" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"id": "patrol_coastal_30m",
|
||||||
|
"name": "Patrullero costero 30 m",
|
||||||
|
"type": "patrol",
|
||||||
|
"subtype": "coastal",
|
||||||
|
"length_overall_m": 30.0,
|
||||||
|
"beam_max_m": 6.5,
|
||||||
|
"draft_m": 1.6,
|
||||||
|
"displacement_kg": 95000,
|
||||||
|
"description": "Patrullero costero genérico 30 m. Casco semi-planeo aluminio, 2-3 motores planos rápidos, autonomía 800 nm. Equipos típicos: armamento ligero opcional, RHIB pop-up, búsqueda y rescate.",
|
||||||
|
"data_source": "seed_estimate",
|
||||||
|
"decks": [
|
||||||
|
{ "id": "lower", "name": "Cubierta inferior", "z_bl_bottom": 0.4, "z_bl_top": 2.4, "polygon_xy": [] },
|
||||||
|
{ "id": "main", "name": "Cubierta principal", "z_bl_bottom": 2.4, "z_bl_top": 4.6, "polygon_xy": [] },
|
||||||
|
{ "id": "wheelhouse", "name": "Caseta de gobierno", "z_bl_bottom": 4.6, "z_bl_top": 6.5, "polygon_xy": [] }
|
||||||
|
],
|
||||||
|
"bulkheads": [
|
||||||
|
{ "id": "collision", "name": "Mamparo de colisión", "x_pp": 27.5, "description": "" },
|
||||||
|
{ "id": "er_fwd", "name": "Mamparo proa SM", "x_pp": 9.0, "description": "" },
|
||||||
|
{ "id": "er_aft", "name": "Mamparo popa SM", "x_pp": 4.0, "description": "" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"id": "princess_y85",
|
||||||
|
"name": "Princess Y85",
|
||||||
|
"type": "yacht_motor",
|
||||||
|
"subtype": "planing",
|
||||||
|
"length_overall_m": 25.94,
|
||||||
|
"beam_max_m": 5.92,
|
||||||
|
"draft_m": 1.92,
|
||||||
|
"displacement_kg": 64500,
|
||||||
|
"description": "Yate motor planeo británico, casco V profundo. Propulsión convencional 2 motores diésel directos a hélices. Tope ~32 nudos. 4 cabinas.",
|
||||||
|
"data_source": "seed_estimate",
|
||||||
|
"decks": [
|
||||||
|
{ "id": "lower", "name": "Cubierta inferior", "z_bl_bottom": 0.5, "z_bl_top": 2.7, "polygon_xy": [] },
|
||||||
|
{ "id": "main", "name": "Cubierta principal", "z_bl_bottom": 2.7, "z_bl_top": 4.9, "polygon_xy": [] },
|
||||||
|
{ "id": "flybridge", "name": "Flybridge", "z_bl_bottom": 4.9, "z_bl_top": 6.5, "polygon_xy": [] }
|
||||||
|
],
|
||||||
|
"bulkheads": [
|
||||||
|
{ "id": "collision", "name": "Mamparo colisión", "x_pp": 23.4, "description": "" },
|
||||||
|
{ "id": "er_fwd", "name": "Mamparo proa SM", "x_pp": 7.8, "description": "" },
|
||||||
|
{ "id": "er_aft", "name": "Mamparo popa SM", "x_pp": 3.8, "description": "" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"id": "trawler_32m_offshore",
|
||||||
|
"name": "Arrastrero offshore 32 m",
|
||||||
|
"type": "fishing",
|
||||||
|
"subtype": "trawler",
|
||||||
|
"length_overall_m": 32.0,
|
||||||
|
"beam_max_m": 9.0,
|
||||||
|
"draft_m": 3.8,
|
||||||
|
"displacement_kg": 250000,
|
||||||
|
"description": "Pesquero arrastrero offshore genérico de 32 m, casco desplazamiento, maquinaria de pesca + bodega refrigerada grande + tanques RSW (refrigerated sea water).",
|
||||||
|
"data_source": "seed_estimate",
|
||||||
|
"decks": [
|
||||||
|
{ "id": "lower", "name": "Bodega y pañoles", "z_bl_bottom": 0.5, "z_bl_top": 3.5, "polygon_xy": [] },
|
||||||
|
{ "id": "main", "name": "Cubierta principal", "z_bl_bottom": 3.5, "z_bl_top": 6.0, "polygon_xy": [] },
|
||||||
|
{ "id": "wheelhouse", "name": "Caseta de gobierno", "z_bl_bottom": 6.0, "z_bl_top": 8.5, "polygon_xy": [] }
|
||||||
|
],
|
||||||
|
"bulkheads": [
|
||||||
|
{ "id": "collision", "name": "Mamparo de colisión", "x_pp": 29.0, "description": "" },
|
||||||
|
{ "id": "er_fwd", "name": "Mamparo proa SM", "x_pp": 10.0, "description": "" },
|
||||||
|
{ "id": "er_aft", "name": "Mamparo popa SM", "x_pp": 4.0, "description": "" },
|
||||||
|
{ "id": "fish_hold_fwd", "name": "Mamparo bodega proa", "x_pp": 18.0, "description": "" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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.persistence import load_project, save_project
|
||||||
from vmssailor.core.project import Project
|
from vmssailor.core.project import Project
|
||||||
|
from vmssailor.studio.editors.equipment_editor import EquipmentEditor
|
||||||
from vmssailor.studio.theme import (
|
from vmssailor.studio.theme import (
|
||||||
|
C_CYAN,
|
||||||
C_FOAM,
|
C_FOAM,
|
||||||
C_FOG,
|
C_FOG,
|
||||||
mono_font,
|
mono_font,
|
||||||
@@ -123,10 +125,19 @@ class MainWindow(QMainWindow):
|
|||||||
self._sidebar.setMinimumWidth(260)
|
self._sidebar.setMinimumWidth(260)
|
||||||
self._sidebar.setMaximumWidth(380)
|
self._sidebar.setMaximumWidth(380)
|
||||||
|
|
||||||
|
# Right pane: vertical splitter with canvas on top + equipment editor below
|
||||||
self._canvas = VesselCanvas()
|
self._canvas = VesselCanvas()
|
||||||
|
self._equipment_editor = EquipmentEditor()
|
||||||
|
|
||||||
|
right_splitter = QSplitter(Qt.Vertical)
|
||||||
|
right_splitter.setChildrenCollapsible(False)
|
||||||
|
right_splitter.addWidget(self._canvas)
|
||||||
|
right_splitter.addWidget(self._equipment_editor)
|
||||||
|
right_splitter.setSizes([520, 380])
|
||||||
|
self._right_splitter = right_splitter
|
||||||
|
|
||||||
self._splitter.addWidget(self._sidebar)
|
self._splitter.addWidget(self._sidebar)
|
||||||
self._splitter.addWidget(self._canvas)
|
self._splitter.addWidget(right_splitter)
|
||||||
self._splitter.setSizes([280, 1160])
|
self._splitter.setSizes([280, 1160])
|
||||||
|
|
||||||
# Compose central widget: topbar (top) + splitter (rest)
|
# Compose central widget: topbar (top) + splitter (rest)
|
||||||
@@ -223,6 +234,8 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
self.projectChanged.connect(self._sidebar.set_project)
|
self.projectChanged.connect(self._sidebar.set_project)
|
||||||
self.projectChanged.connect(self._canvas.set_project)
|
self.projectChanged.connect(self._canvas.set_project)
|
||||||
|
self.projectChanged.connect(self._equipment_editor.set_project)
|
||||||
|
self._equipment_editor.projectMutated.connect(self._on_project_mutated)
|
||||||
|
|
||||||
# ----- Slots --------------------------------------------------------
|
# ----- Slots --------------------------------------------------------
|
||||||
|
|
||||||
@@ -366,3 +379,13 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def current_project(self) -> Project | None:
|
def current_project(self) -> Project | None:
|
||||||
return self._project
|
return self._project
|
||||||
|
|
||||||
|
def _on_project_mutated(self, project: Project) -> None:
|
||||||
|
"""El editor reemplazó el Project. Re-emit a sidebar + canvas."""
|
||||||
|
self._project = project
|
||||||
|
self._update_stats()
|
||||||
|
# Re-emit a sidebar + canvas (que ignoran al editor para evitar loop)
|
||||||
|
self._sidebar.set_project(project)
|
||||||
|
self._canvas.set_project(project)
|
||||||
|
self._dirty_badge.setText("● Cambios pendientes — guarda con Ctrl+S")
|
||||||
|
self._dirty_badge.setStyleSheet(f"color: {C_CYAN};")
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""Paso 5: equipos sugeridos por el motor de reglas heurísticas."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import Property, Qt, Signal
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QAbstractItemView,
|
||||||
|
QHeaderView,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QTableWidget,
|
||||||
|
QTableWidgetItem,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
QWizardPage,
|
||||||
|
)
|
||||||
|
|
||||||
|
from vmssailor.core.enums import SystemId, VesselSubtype, VesselType
|
||||||
|
from vmssailor.studio.designer.rule_engine import (
|
||||||
|
EquipmentProposal,
|
||||||
|
RuleContext,
|
||||||
|
RuleEngine,
|
||||||
|
)
|
||||||
|
from vmssailor.studio.theme import C_CYAN, C_FOG, mono_font, ui_font
|
||||||
|
|
||||||
|
|
||||||
|
class Step05Equipment(QWizardPage):
|
||||||
|
"""Muestra propuestas del rule engine. Integrador acepta/rechaza."""
|
||||||
|
|
||||||
|
proposalsChanged = Signal()
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setTitle("Paso 5 · Equipos sugeridos")
|
||||||
|
self.setSubTitle(
|
||||||
|
"El motor de reglas propuso estos equipos basado en el tipo de buque y "
|
||||||
|
"los sistemas habilitados. Desmarca los que no apliquen."
|
||||||
|
)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setSpacing(12)
|
||||||
|
|
||||||
|
intro = QLabel(
|
||||||
|
"Doble-click en una celda para editar. La columna ✓ permite excluir propuestas."
|
||||||
|
)
|
||||||
|
intro.setStyleSheet(f"color: {C_FOG};")
|
||||||
|
intro.setFont(ui_font(10))
|
||||||
|
layout.addWidget(intro)
|
||||||
|
|
||||||
|
self._table = QTableWidget(0, 7)
|
||||||
|
self._table.setHorizontalHeaderLabels(
|
||||||
|
["✓", "Sistema", "Modelo", "Tag prefix", "x_pp", "y_cl", "Rationale"]
|
||||||
|
)
|
||||||
|
self._table.horizontalHeader().setSectionResizeMode(
|
||||||
|
6, QHeaderView.ResizeMode.Stretch
|
||||||
|
)
|
||||||
|
self._table.horizontalHeader().setSectionResizeMode(
|
||||||
|
0, QHeaderView.ResizeMode.ResizeToContents
|
||||||
|
)
|
||||||
|
self._table.setAlternatingRowColors(False)
|
||||||
|
self._table.verticalHeader().setVisible(False)
|
||||||
|
self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||||
|
self._table.setEditTriggers(
|
||||||
|
QAbstractItemView.EditTrigger.DoubleClicked
|
||||||
|
| QAbstractItemView.EditTrigger.EditKeyPressed
|
||||||
|
)
|
||||||
|
self._table.itemChanged.connect(self._on_item_changed)
|
||||||
|
layout.addWidget(self._table, 1)
|
||||||
|
|
||||||
|
# Counter label
|
||||||
|
self._counter = QLabel("0 propuestas")
|
||||||
|
self._counter.setFont(mono_font(10))
|
||||||
|
self._counter.setStyleSheet(f"color: {C_CYAN};")
|
||||||
|
layout.addWidget(self._counter)
|
||||||
|
|
||||||
|
btn_row = QPushButton("Regenerar desde reglas")
|
||||||
|
btn_row.clicked.connect(self._regenerate)
|
||||||
|
layout.addWidget(btn_row)
|
||||||
|
|
||||||
|
self._proposals: list[EquipmentProposal] = []
|
||||||
|
|
||||||
|
from vmssailor.studio.wizard.wizard import F_PROPOSALS
|
||||||
|
|
||||||
|
self.registerField(F_PROPOSALS, self, "proposals", "proposalsChanged")
|
||||||
|
|
||||||
|
# ----- Property API ------------------------------------------------
|
||||||
|
|
||||||
|
def get_proposals(self) -> list[EquipmentProposal]:
|
||||||
|
return [p for p in self._proposals if p.accepted]
|
||||||
|
|
||||||
|
def set_proposals(self, value: list[EquipmentProposal]) -> None:
|
||||||
|
self._proposals = value
|
||||||
|
self._refresh_table()
|
||||||
|
self.proposalsChanged.emit()
|
||||||
|
|
||||||
|
proposals = Property(
|
||||||
|
list, fget=get_proposals, fset=set_proposals, notify=proposalsChanged
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----- Lifecycle ---------------------------------------------------
|
||||||
|
|
||||||
|
def initializePage(self) -> None:
|
||||||
|
if not self._proposals:
|
||||||
|
self._regenerate()
|
||||||
|
|
||||||
|
def _regenerate(self) -> None:
|
||||||
|
from vmssailor.studio.wizard.wizard import (
|
||||||
|
F_BEAM,
|
||||||
|
F_DRAFT,
|
||||||
|
F_LOA,
|
||||||
|
F_SYSTEMS,
|
||||||
|
F_VESSEL_SUBTYPE,
|
||||||
|
F_VESSEL_TYPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
v_type = VesselType(self.field(F_VESSEL_TYPE) or "yacht_motor")
|
||||||
|
except ValueError:
|
||||||
|
v_type = VesselType.YACHT_MOTOR
|
||||||
|
try:
|
||||||
|
v_sub = VesselSubtype(self.field(F_VESSEL_SUBTYPE) or "planing")
|
||||||
|
except ValueError:
|
||||||
|
v_sub = VesselSubtype.PLANING
|
||||||
|
loa = float(self.field(F_LOA) or 24.0)
|
||||||
|
beam = float(self.field(F_BEAM) or 5.5)
|
||||||
|
draft = float(self.field(F_DRAFT) or 1.8)
|
||||||
|
|
||||||
|
systems_raw = self.field(F_SYSTEMS) or []
|
||||||
|
systems: list[SystemId] = []
|
||||||
|
for s in systems_raw:
|
||||||
|
try:
|
||||||
|
systems.append(SystemId(s))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ctx = RuleContext(
|
||||||
|
vessel_type=v_type,
|
||||||
|
vessel_subtype=v_sub,
|
||||||
|
length_overall_m=loa,
|
||||||
|
beam_max_m=beam,
|
||||||
|
draft_m=draft,
|
||||||
|
systems_enabled=systems,
|
||||||
|
)
|
||||||
|
|
||||||
|
engine = RuleEngine()
|
||||||
|
self._proposals = engine.propose(ctx)
|
||||||
|
self._refresh_table()
|
||||||
|
self.proposalsChanged.emit()
|
||||||
|
|
||||||
|
# ----- Table rendering --------------------------------------------
|
||||||
|
|
||||||
|
def _refresh_table(self) -> None:
|
||||||
|
self._table.blockSignals(True)
|
||||||
|
self._table.setRowCount(len(self._proposals))
|
||||||
|
for row, p in enumerate(self._proposals):
|
||||||
|
check = QTableWidgetItem()
|
||||||
|
check.setFlags(check.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||||
|
check.setCheckState(Qt.CheckState.Checked if p.accepted else Qt.CheckState.Unchecked)
|
||||||
|
self._table.setItem(row, 0, check)
|
||||||
|
self._table.setItem(row, 1, QTableWidgetItem(p.system_id.value))
|
||||||
|
self._table.setItem(row, 2, QTableWidgetItem(p.model_ref))
|
||||||
|
self._table.setItem(row, 3, QTableWidgetItem(p.tag_prefix))
|
||||||
|
self._table.setItem(row, 4, QTableWidgetItem(f"{p.location_x_pp:.2f}"))
|
||||||
|
self._table.setItem(row, 5, QTableWidgetItem(f"{p.location_y_cl:.2f}"))
|
||||||
|
rationale = QTableWidgetItem(p.rationale)
|
||||||
|
rationale.setForeground(Qt.GlobalColor.gray)
|
||||||
|
self._table.setItem(row, 6, rationale)
|
||||||
|
self._table.resizeColumnsToContents()
|
||||||
|
self._table.blockSignals(False)
|
||||||
|
accepted = sum(1 for p in self._proposals if p.accepted)
|
||||||
|
self._counter.setText(
|
||||||
|
f"{accepted} aceptadas · {len(self._proposals) - accepted} rechazadas · "
|
||||||
|
f"{len(self._proposals)} totales"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_item_changed(self, item: QTableWidgetItem) -> None:
|
||||||
|
row = item.row()
|
||||||
|
if row >= len(self._proposals):
|
||||||
|
return
|
||||||
|
col = item.column()
|
||||||
|
p = self._proposals[row]
|
||||||
|
try:
|
||||||
|
if col == 0:
|
||||||
|
p.accepted = item.checkState() == Qt.CheckState.Checked
|
||||||
|
elif col == 2:
|
||||||
|
p.model_ref = item.text()
|
||||||
|
elif col == 3:
|
||||||
|
p.tag_prefix = item.text().upper()
|
||||||
|
item.setText(p.tag_prefix)
|
||||||
|
elif col == 4:
|
||||||
|
p.location_x_pp = float(item.text())
|
||||||
|
elif col == 5:
|
||||||
|
p.location_y_cl = float(item.text())
|
||||||
|
except ValueError:
|
||||||
|
# Reset celda al valor previo si la conversión falla
|
||||||
|
self._refresh_table()
|
||||||
|
return
|
||||||
|
accepted = sum(1 for pr in self._proposals if pr.accepted)
|
||||||
|
self._counter.setText(
|
||||||
|
f"{accepted} aceptadas · {len(self._proposals) - accepted} rechazadas · "
|
||||||
|
f"{len(self._proposals)} totales"
|
||||||
|
)
|
||||||
|
self.proposalsChanged.emit()
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Paso 6: refinamiento manual (Sprint 2).
|
||||||
|
|
||||||
|
En Sprint 1 era placeholder. En Sprint 2 permite reasignar ubicaciones de
|
||||||
|
equipos y editar tag_prefix con una vista preview de la silueta.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QLabel,
|
||||||
|
QListWidget,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
QWizardPage,
|
||||||
|
)
|
||||||
|
|
||||||
|
from vmssailor.studio.theme import C_FOG, mono_font, ui_font
|
||||||
|
|
||||||
|
|
||||||
|
class Step06Refinement(QWizardPage):
|
||||||
|
"""Muestra resumen de equipos aceptados con preview textual."""
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setTitle("Paso 6 · Refinamiento")
|
||||||
|
self.setSubTitle(
|
||||||
|
"Revisa los equipos finales antes de generar topología. "
|
||||||
|
"El editor visual completo viene en Sprint 3."
|
||||||
|
)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setSpacing(12)
|
||||||
|
|
||||||
|
self._list = QListWidget()
|
||||||
|
self._list.setFont(mono_font(10))
|
||||||
|
layout.addWidget(self._list, 1)
|
||||||
|
|
||||||
|
# Stats
|
||||||
|
self._stats = QLabel("Cargando…")
|
||||||
|
self._stats.setStyleSheet(f"color: {C_FOG};")
|
||||||
|
self._stats.setFont(ui_font(10))
|
||||||
|
layout.addWidget(self._stats)
|
||||||
|
|
||||||
|
def initializePage(self) -> None:
|
||||||
|
from vmssailor.studio.wizard.wizard import F_PROPOSALS
|
||||||
|
|
||||||
|
proposals = self.field(F_PROPOSALS) or []
|
||||||
|
self._list.clear()
|
||||||
|
by_system: dict[str, int] = {}
|
||||||
|
for p in proposals:
|
||||||
|
self._list.addItem(
|
||||||
|
f"{p.system_id.value:20s} {p.tag_prefix:14s} "
|
||||||
|
f"{p.model_ref:30s} @ x_pp={p.location_x_pp:.2f}m y_cl={p.location_y_cl:+.2f}m"
|
||||||
|
)
|
||||||
|
by_system[p.system_id.value] = by_system.get(p.system_id.value, 0) + 1
|
||||||
|
n = len(proposals)
|
||||||
|
groups = ", ".join(f"{k}: {v}" for k, v in sorted(by_system.items()))
|
||||||
|
self._stats.setText(
|
||||||
|
f"{n} equipos aceptados · {groups}"
|
||||||
|
if n
|
||||||
|
else "Sin equipos aceptados. Vuelve atrás y selecciona al menos uno."
|
||||||
|
)
|
||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -7,6 +11,7 @@ import logging
|
|||||||
from PySide6.QtWidgets import QWidget, QWizard
|
from PySide6.QtWidgets import QWidget, QWizard
|
||||||
|
|
||||||
from vmssailor.core import (
|
from vmssailor.core import (
|
||||||
|
Equipment,
|
||||||
Project,
|
Project,
|
||||||
SystemId,
|
SystemId,
|
||||||
Vessel,
|
Vessel,
|
||||||
@@ -15,16 +20,15 @@ from vmssailor.core import (
|
|||||||
)
|
)
|
||||||
from vmssailor.core.vessel import Bulkhead, Deck
|
from vmssailor.core.vessel import Bulkhead, Deck
|
||||||
from vmssailor.shared.ids import make_project_id
|
from vmssailor.shared.ids import make_project_id
|
||||||
from vmssailor.studio.theme import (
|
from vmssailor.studio.theme import C_ABYSS, C_SAND
|
||||||
C_ABYSS,
|
|
||||||
C_SAND,
|
|
||||||
)
|
|
||||||
from vmssailor.studio.wizard.step_01_vessel_type import Step01VesselType
|
from vmssailor.studio.wizard.step_01_vessel_type import Step01VesselType
|
||||||
from vmssailor.studio.wizard.step_02_template import Step02Template
|
from vmssailor.studio.wizard.step_02_template import Step02Template
|
||||||
from vmssailor.studio.wizard.step_03_dimensions import Step03Dimensions
|
from vmssailor.studio.wizard.step_03_dimensions import Step03Dimensions
|
||||||
from vmssailor.studio.wizard.step_04_systems import Step04Systems
|
from vmssailor.studio.wizard.step_04_systems import Step04Systems
|
||||||
|
from vmssailor.studio.wizard.step_05_equipment import Step05Equipment
|
||||||
|
from vmssailor.studio.wizard.step_06_refinement import Step06Refinement
|
||||||
|
from vmssailor.studio.wizard.step_07_topology import Step07Topology
|
||||||
from vmssailor.studio.wizard.step_08_confirm import Step08Confirm
|
from vmssailor.studio.wizard.step_08_confirm import Step08Confirm
|
||||||
from vmssailor.studio.wizard.step_57_placeholder import Step57Placeholder
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -42,6 +46,8 @@ F_DRAFT = "vessel.draft_m"
|
|||||||
F_BULKHEAD_FWD = "vessel.bulkhead_fwd_m"
|
F_BULKHEAD_FWD = "vessel.bulkhead_fwd_m"
|
||||||
F_BULKHEAD_AFT = "vessel.bulkhead_aft_m"
|
F_BULKHEAD_AFT = "vessel.bulkhead_aft_m"
|
||||||
F_SYSTEMS = "systems.enabled"
|
F_SYSTEMS = "systems.enabled"
|
||||||
|
F_PROPOSALS = "equipment.proposals"
|
||||||
|
F_ASSIGNMENT = "topology.assignment"
|
||||||
|
|
||||||
|
|
||||||
class VesselWizard(QWizard):
|
class VesselWizard(QWizard):
|
||||||
@@ -61,9 +67,9 @@ class VesselWizard(QWizard):
|
|||||||
self.addPage(Step02Template())
|
self.addPage(Step02Template())
|
||||||
self.addPage(Step03Dimensions())
|
self.addPage(Step03Dimensions())
|
||||||
self.addPage(Step04Systems())
|
self.addPage(Step04Systems())
|
||||||
self.addPage(Step57Placeholder(index=5, title="Equipos sugeridos (Sprint 2)"))
|
self.addPage(Step05Equipment())
|
||||||
self.addPage(Step57Placeholder(index=6, title="Refinamiento manual (Sprint 2)"))
|
self.addPage(Step06Refinement())
|
||||||
self.addPage(Step57Placeholder(index=7, title="Topología I/O AR-NMEA-IO (Sprint 2)"))
|
self.addPage(Step07Topology())
|
||||||
self.addPage(Step08Confirm())
|
self.addPage(Step08Confirm())
|
||||||
|
|
||||||
# Style
|
# Style
|
||||||
@@ -75,7 +81,6 @@ class VesselWizard(QWizard):
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
# Localización de botones (QWizard usa textos del sistema)
|
|
||||||
self.setButtonText(QWizard.NextButton, "Siguiente >")
|
self.setButtonText(QWizard.NextButton, "Siguiente >")
|
||||||
self.setButtonText(QWizard.BackButton, "< Atrás")
|
self.setButtonText(QWizard.BackButton, "< Atrás")
|
||||||
self.setButtonText(QWizard.FinishButton, "Crear proyecto")
|
self.setButtonText(QWizard.FinishButton, "Crear proyecto")
|
||||||
@@ -97,7 +102,6 @@ class VesselWizard(QWizard):
|
|||||||
bh_aft = float(self.field(F_BULKHEAD_AFT) or (loa * 0.15))
|
bh_aft = float(self.field(F_BULKHEAD_AFT) or (loa * 0.15))
|
||||||
systems_raw = self.field(F_SYSTEMS) or [SystemId.MAIN_ENGINE.value]
|
systems_raw = self.field(F_SYSTEMS) or [SystemId.MAIN_ENGINE.value]
|
||||||
|
|
||||||
# Build vessel
|
|
||||||
decks = [
|
decks = [
|
||||||
Deck(id="lower", name="Cubierta inferior", z_bl_bottom=0.4, z_bl_top=draft + 1.0),
|
Deck(id="lower", name="Cubierta inferior", z_bl_bottom=0.4, z_bl_top=draft + 1.0),
|
||||||
Deck(
|
Deck(
|
||||||
@@ -111,7 +115,6 @@ class VesselWizard(QWizard):
|
|||||||
Bulkhead(id="er_aft", name="Mamparo popa SM", x_pp=bh_aft),
|
Bulkhead(id="er_aft", name="Mamparo popa SM", x_pp=bh_aft),
|
||||||
Bulkhead(id="er_fwd", name="Mamparo proa SM", x_pp=bh_fwd),
|
Bulkhead(id="er_fwd", name="Mamparo proa SM", x_pp=bh_fwd),
|
||||||
]
|
]
|
||||||
try:
|
|
||||||
vessel = Vessel(
|
vessel = Vessel(
|
||||||
id=f"wizard_{int(loa * 10)}m",
|
id=f"wizard_{int(loa * 10)}m",
|
||||||
name=vessel_name,
|
name=vessel_name,
|
||||||
@@ -124,24 +127,53 @@ class VesselWizard(QWizard):
|
|||||||
bulkheads=bulkheads,
|
bulkheads=bulkheads,
|
||||||
data_source="user_input",
|
data_source="user_input",
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Vessel build failed: %s", exc)
|
|
||||||
raise
|
|
||||||
|
|
||||||
# Equipment + tags llegan en Sprint 2 (rule engine). Por ahora, vacío.
|
|
||||||
try:
|
try:
|
||||||
systems_enabled = [SystemId(s) for s in systems_raw]
|
systems_enabled = [SystemId(s) for s in systems_raw]
|
||||||
except ValueError:
|
except ValueError:
|
||||||
systems_enabled = [SystemId.MAIN_ENGINE]
|
systems_enabled = [SystemId.MAIN_ENGINE]
|
||||||
|
|
||||||
|
# Sprint 2: materializar Equipment desde las propuestas aceptadas
|
||||||
|
proposals = self.field(F_PROPOSALS) or []
|
||||||
|
equipment_list: list[Equipment] = []
|
||||||
|
for idx, p in enumerate(proposals):
|
||||||
|
equipment_list.append(
|
||||||
|
Equipment(
|
||||||
|
id=f"eq_{idx:03d}_{p.tag_prefix.lower()}",
|
||||||
|
model_ref=p.model_ref,
|
||||||
|
tag_prefix=p.tag_prefix,
|
||||||
|
display_name=p.display_name,
|
||||||
|
location=p.to_ship_coord(),
|
||||||
|
system_id=p.system_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Topología + tags del auto-assigner
|
||||||
|
assignment = self.field(F_ASSIGNMENT)
|
||||||
|
topology = assignment.topology() if assignment is not None else None
|
||||||
|
tags = list(assignment.tags) if assignment is not None else []
|
||||||
|
|
||||||
project_id = make_project_id(customer or "cliente", vessel.id)
|
project_id = make_project_id(customer or "cliente", vessel.id)
|
||||||
|
|
||||||
|
if topology is not None:
|
||||||
return Project(
|
return Project(
|
||||||
id=project_id,
|
id=project_id,
|
||||||
name=project_name,
|
name=project_name,
|
||||||
customer=customer,
|
customer=customer,
|
||||||
vessel=vessel,
|
vessel=vessel,
|
||||||
systems_enabled=systems_enabled,
|
systems_enabled=systems_enabled,
|
||||||
equipment=[],
|
equipment=equipment_list,
|
||||||
tags=[],
|
tags=tags,
|
||||||
notes="Creado desde wizard Sprint 1. Sprint 2 agregará equipos sugeridos.",
|
topology=topology,
|
||||||
|
notes="Creado desde wizard (Sprint 2 — rule engine + auto-assigner).",
|
||||||
|
)
|
||||||
|
return Project(
|
||||||
|
id=project_id,
|
||||||
|
name=project_name,
|
||||||
|
customer=customer,
|
||||||
|
vessel=vessel,
|
||||||
|
systems_enabled=systems_enabled,
|
||||||
|
equipment=equipment_list,
|
||||||
|
tags=tags,
|
||||||
|
notes="Creado desde wizard (Sprint 2 — rule engine + auto-assigner).",
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user