Files
AR-VMS-Seaman/vmssailor/studio/editors/alarm_editor.py
T
alro65 fbce1ecb42 sprint-3: editores de mimicos + tags + alarmas
3 editores nuevos integrados como tabs en MainWindow.

vmssailor/studio/editors/symbols.py
- 8 simbolos navales: motor, pump, valve, tank, sensor, indicator, line, label
- _BaseSymbol QGraphicsItemGroup arrastrable + seleccionable
- TankSymbol con fill animable por porcentaje
- SymbolSpec dataclass serializable a JSON

vmssailor/studio/editors/mimic_editor.py
- Paleta lateral con tipos de simbolos
- Doble-click crea simbolo en canvas
- Selector de sistema (de los habilitados en el proyecto)
- Boton 'Cargar demo' inserta P&ID demo (tanque-valvula-bomba-motor-sensor)
- serialize_to_project() para guardar en .vmsproj

vmssailor/studio/editors/tag_editor.py
- Tabla CRUD con 13 columnas
- Edicion inline de description, unit_si, range, control_mode
- Validacion via Pydantic en cada edit
- Filtro por texto en vivo
- Contador de tags

vmssailor/studio/editors/alarm_editor.py
- Tabla aplanada de todas las alarmas configuradas
- Counts por prioridad coloreados
- Dialog modal para agregar AlarmConfig nueva
- Edicion inline de threshold, operator, priority, hysteresis, delay, message
- Eliminacion individual

vmssailor/studio/main_window.py
- Tabs 'Equipos', 'Mimicos', 'Tags', 'Alarmas' en panel derecho
- Todos los editores reciben set_project y emiten projectMutated

Tests (tests/studio/test_editors.py, 6 nuevos, total 126/126):
- Symbol factory para los 8 tipos
- MimicEditor con demo
- TagEditor render
- AlarmEditor priority counts + empty state
- MainWindow tabs presentes

126/126 verde, ruff clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:34:30 -04:00

347 lines
12 KiB
Python

"""Editor de configuración de alarmas (Sprint 3).
Cada tag puede tener múltiples AlarmConfig. Este editor permite:
- Listar todas las alarmas configuradas (todos los tags)
- Editar threshold, operator, priority, hysteresis, delay, message
- Agregar / eliminar alarmas
La lógica del alarm engine (evaluación en tiempo real) llega en Sprint 4
con el Runtime servidor.
"""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QAbstractItemView,
QComboBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from vmssailor.core.enums import AlarmPriority
from vmssailor.core.project import Project
from vmssailor.core.tag import AlarmConfig
from vmssailor.shared.ids import make_alarm_config_id
from vmssailor.studio.theme import (
C_CYAN,
C_EMERGENCY,
C_FOG,
C_HIGH,
C_INFO,
C_WARN,
mono_font,
ui_font,
)
_PRIORITY_COLORS = {
AlarmPriority.EMERGENCY: C_EMERGENCY,
AlarmPriority.HIGH: C_HIGH,
AlarmPriority.LOW: C_WARN,
AlarmPriority.INFO: C_INFO,
}
_COLS = [
"Tag",
"Alarm ID",
"Operador",
"Threshold",
"Prioridad",
"Histéresis",
"Delay (s)",
"Mensaje",
]
class AlarmEditor(QWidget):
projectMutated = Signal(Project)
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._project: Project | None = None
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(10)
header = QLabel("Editor de alarmas")
header.setObjectName("title")
header.setFont(ui_font(14))
layout.addWidget(header)
intro = QLabel(
"Configuración declarativa de alarmas. Cuatro prioridades: "
"EMERGENCY (rojo, accion inmediata), HIGH (naranja), LOW (amber, watch), "
"INFO (cyan, registro)."
)
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 alarma")
self._btn_add.setObjectName("primary")
self._btn_remove = QPushButton("Eliminar seleccionada")
self._counts_by_priority = QLabel("")
self._counts_by_priority.setFont(mono_font(10))
btn_row.addWidget(self._btn_add)
btn_row.addWidget(self._btn_remove)
btn_row.addStretch(1)
btn_row.addWidget(self._counts_by_priority)
layout.addLayout(btn_row)
# Table
self._table = QTableWidget(0, len(_COLS))
self._table.setHorizontalHeaderLabels(_COLS)
self._table.horizontalHeader().setSectionResizeMode(7, 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)
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._counts_by_priority.setText("")
self._table.blockSignals(False)
return
# Aplanar todas las alarmas
flat: list[tuple[int, str, AlarmConfig]] = []
for tag_idx, t in enumerate(self._project.tags):
for a in t.alarms:
flat.append((tag_idx, t.id, a))
self._flat = flat
for _tag_idx, tag_id, a in flat:
self._append_row(tag_id, a)
self._update_priority_counts()
self._table.blockSignals(False)
def _append_row(self, tag_id: str, a: AlarmConfig) -> None:
row = self._table.rowCount()
self._table.insertRow(row)
tag_item = QTableWidgetItem(tag_id)
tag_item.setFlags(tag_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
tag_item.setForeground(QColor(C_CYAN))
self._table.setItem(row, 0, tag_item)
id_item = QTableWidgetItem(a.id)
id_item.setFlags(id_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
self._table.setItem(row, 1, id_item)
self._table.setItem(row, 2, QTableWidgetItem(a.operator))
self._table.setItem(row, 3, QTableWidgetItem(f"{a.threshold:.3f}"))
prio = QTableWidgetItem(a.priority.value)
prio.setForeground(QColor(_PRIORITY_COLORS[a.priority]))
self._table.setItem(row, 4, prio)
self._table.setItem(row, 5, QTableWidgetItem(f"{a.hysteresis:.3f}"))
self._table.setItem(row, 6, QTableWidgetItem(f"{a.delay_seconds:.1f}"))
self._table.setItem(row, 7, QTableWidgetItem(a.message))
def _update_priority_counts(self) -> None:
if not self._project:
return
counts: dict[AlarmPriority, int] = dict.fromkeys(AlarmPriority, 0)
for t in self._project.tags:
for a in t.alarms:
counts[a.priority] += 1
parts = []
for p in (AlarmPriority.EMERGENCY, AlarmPriority.HIGH, AlarmPriority.LOW, AlarmPriority.INFO):
color = _PRIORITY_COLORS[p]
parts.append(f'<span style="color:{color};">{p.value}:{counts[p]}</span>')
self._counts_by_priority.setText(" ".join(parts))
def _on_item_changed(self, item: QTableWidgetItem) -> None:
if self._project is None:
return
row = item.row()
if row >= len(self._flat):
return
tag_idx, _tag_id, a = self._flat[row]
col = item.column()
new_alarm: AlarmConfig | None = None
try:
if col == 2:
new_alarm = a.model_copy(update={"operator": item.text()})
elif col == 3:
new_alarm = a.model_copy(update={"threshold": float(item.text())})
elif col == 4:
new_alarm = a.model_copy(update={"priority": AlarmPriority(item.text())})
elif col == 5:
new_alarm = a.model_copy(update={"hysteresis": float(item.text())})
elif col == 6:
new_alarm = a.model_copy(update={"delay_seconds": float(item.text())})
elif col == 7:
new_alarm = a.model_copy(update={"message": item.text()})
except Exception as exc:
QMessageBox.warning(self, "Edición inválida", str(exc))
self._refresh_table()
return
if new_alarm is None:
return
tag = self._project.tags[tag_idx]
# Reemplazo del alarmConfig dentro de la lista
new_alarms = [new_alarm if x.id == a.id else x for x in tag.alarms]
new_tag = tag.model_copy(update={"alarms": new_alarms})
new_tags = list(self._project.tags)
new_tags[tag_idx] = new_tag
try:
self._project = self._project.model_copy(update={"tags": new_tags})
self._project = self._project.__class__.model_validate(self._project.model_dump())
except Exception as exc:
QMessageBox.critical(self, "Validación falló", str(exc))
self._refresh_table()
return
self._project.touch()
self.projectMutated.emit(self._project)
self._update_priority_counts()
def _on_add(self) -> None:
if self._project is None or not self._project.tags:
QMessageBox.information(self, "Agregar alarma", "No hay tags en el proyecto.")
return
dlg = _AddAlarmDialog(self._project, self)
if dlg.exec():
tag_idx, alarm = dlg.build()
if alarm is None:
return
tag = self._project.tags[tag_idx]
new_tag = tag.model_copy(update={"alarms": [*tag.alarms, alarm]})
new_tags = list(self._project.tags)
new_tags[tag_idx] = new_tag
try:
self._project = self._project.model_copy(update={"tags": new_tags})
self._project = self._project.__class__.model_validate(
self._project.model_dump()
)
except Exception as exc:
QMessageBox.critical(self, "Validación falló", str(exc))
return
self._project.touch()
self.projectMutated.emit(self._project)
self._refresh_table()
def _on_remove(self) -> None:
if self._project is None:
return
row = self._table.currentRow()
if row < 0 or row >= len(self._flat):
return
tag_idx, _tag_id, a = self._flat[row]
tag = self._project.tags[tag_idx]
new_tag = tag.model_copy(update={"alarms": [x for x in tag.alarms if x.id != a.id]})
new_tags = list(self._project.tags)
new_tags[tag_idx] = new_tag
try:
self._project = self._project.model_copy(update={"tags": new_tags})
self._project = self._project.__class__.model_validate(self._project.model_dump())
except Exception as exc:
QMessageBox.critical(self, "Validación falló", str(exc))
return
self._project.touch()
self.projectMutated.emit(self._project)
self._refresh_table()
class _AddAlarmDialog(QDialog):
def __init__(self, project: Project, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._project = project
self.setWindowTitle("Agregar configuración de alarma")
self.setMinimumWidth(420)
layout = QVBoxLayout(self)
form = QFormLayout()
self._tag_combo = QComboBox()
for idx, t in enumerate(project.tags):
self._tag_combo.addItem(t.id, idx)
form.addRow("Tag:", self._tag_combo)
self._level = QLineEdit("low")
form.addRow("Nivel (low/high/hihi/etc):", self._level)
self._op = QComboBox()
for op in (">", ">=", "<", "<=", "==", "!="):
self._op.addItem(op, op)
form.addRow("Operador:", self._op)
self._threshold = QDoubleSpinBox()
self._threshold.setRange(-1e9, 1e9)
self._threshold.setDecimals(3)
self._threshold.setValue(0.0)
form.addRow("Threshold:", self._threshold)
self._priority = QComboBox()
for p in AlarmPriority:
self._priority.addItem(p.value, p.value)
form.addRow("Prioridad:", self._priority)
self._hyst = QDoubleSpinBox()
self._hyst.setRange(0.0, 1e6)
self._hyst.setDecimals(3)
form.addRow("Histéresis:", self._hyst)
self._delay = QDoubleSpinBox()
self._delay.setRange(0.0, 300.0)
self._delay.setDecimals(1)
form.addRow("Delay (s):", self._delay)
self._message = QLineEdit()
form.addRow("Mensaje:", self._message)
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) -> tuple[int, AlarmConfig | None]:
tag_idx = self._tag_combo.currentData()
if tag_idx is None:
return -1, None
tag = self._project.tags[tag_idx]
try:
alarm = AlarmConfig(
id=make_alarm_config_id(tag.id, self._level.text() or "low"),
threshold=self._threshold.value(),
operator=self._op.currentData(),
priority=AlarmPriority(self._priority.currentData()),
hysteresis=self._hyst.value(),
delay_seconds=self._delay.value(),
message=self._message.text(),
)
return tag_idx, alarm
except Exception:
return -1, None