summaryrefslogtreecommitdiff
path: root/ui/windows/signals_window.py
diff options
context:
space:
mode:
Diffstat (limited to 'ui/windows/signals_window.py')
-rw-r--r--ui/windows/signals_window.py659
1 files changed, 659 insertions, 0 deletions
diff --git a/ui/windows/signals_window.py b/ui/windows/signals_window.py
new file mode 100644
index 0000000..eb7730a
--- /dev/null
+++ b/ui/windows/signals_window.py
@@ -0,0 +1,659 @@
+"""
+ui/windows/signals_window.py
+
+SIGNALS window — toolbar section 3.
+
+Tabs:
+ Pipeline — per-channel filter stack (filters, calibration, scale/offset)
+ Derived — create virtual channels from physical ones
+ Channels — visibility & color management for all channels
+
+CALIBRATION: A dedicated calibration section inside Pipeline lets the operator
+enter known input→output pairs and fits a linear (or polynomial) cal curve,
+which is applied as a ScaleOffset filter.
+
+DERIVED includes:
+ - displacement from pot voltage (calibrated)
+ - velocity from displacement (backward difference)
+ - acceleration from velocity
+ - power (V*I)
+ - expression (x[0]*2 + 1)
+ - custom Python function/script
+"""
+
+from __future__ import annotations
+import math
+from typing import List, Optional, Tuple
+
+from PyQt6.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
+ QScrollArea, QFrame, QTabWidget, QComboBox, QLineEdit,
+ QDoubleSpinBox, QSpinBox, QCheckBox, QTextEdit, QGroupBox,
+ QToolButton, QSizePolicy, QTableWidget, QTableWidgetItem,
+ QHeaderView, QAbstractItemView, QFormLayout, QMessageBox,
+)
+from PyQt6.QtCore import Qt, pyqtSignal
+from PyQt6.QtGui import QColor, QFont, QCloseEvent
+
+from devices.device_registry import DeviceRegistry
+from core.signal_processor import (
+ SignalProcessor, ChannelPipeline, DerivedChannel,
+ FilterBase, MovingAverageFilter, MedianFilter, LowPassFilter,
+ HighPassFilter, ScaleOffsetFilter, DerivativeFilter, IntegralFilter,
+ FILTER_CLASSES,
+)
+
+_DERIVED_COLORS = [
+ "#f72585","#7209b7","#4361ee","#4cc9f0",
+ "#f77f00","#d62828","#588157","#e9c46a",
+]
+
+def _channel_combo(registry: DeviceRegistry,
+ processor: Optional[SignalProcessor] = None,
+ include_derived: bool = False) -> QComboBox:
+ cb = QComboBox(); cb.setObjectName("channelPickerCb")
+ for dev in registry.all_instances():
+ for ch in dev.info.channels:
+ cb.addItem(f"{dev.info.device_id} / {ch.channel_id} ({ch.name})",
+ userData=(dev.info.device_id, ch.channel_id))
+ if include_derived and processor:
+ for dc in processor.get_derived():
+ cb.addItem(f"[derived] {dc.channel_id} ({dc.name})",
+ userData=("derived", dc.channel_id))
+ return cb
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# Filter row
+# ══════════════════════════════════════════════════════════════════════════════
+
+_PARAM_SPECS = {
+ "moving_average": [("window", "int", 10, 1, 5000)],
+ "median": [("window", "int", 5, 1, 500)],
+ "low_pass": [("alpha", "float", 0.1, 0.001, 1.0)],
+ "high_pass": [("alpha", "float", 0.9, 0.001, 1.0)],
+ "scale_offset": [("scale", "float", 1.0, -1e6, 1e6),
+ ("offset", "float", 0.0, -1e9, 1e9)],
+ "derivative": [],
+ "integral": [],
+}
+
+class FilterRow(QFrame):
+ removed = pyqtSignal(object)
+ changed = pyqtSignal()
+
+ def __init__(self, filt: FilterBase):
+ super().__init__()
+ self.filt = filt
+ self.setObjectName("traceRow")
+ self._build()
+
+ def _build(self):
+ lay = QHBoxLayout(self); lay.setContentsMargins(6,4,6,4); lay.setSpacing(8)
+
+ lbl = QLabel(self.filt.name.replace("_"," ").title())
+ lbl.setObjectName("traceSource"); lbl.setMinimumWidth(120)
+ lay.addWidget(lbl)
+
+ for pname, ptype, default, lo, hi in _PARAM_SPECS.get(self.filt.name, []):
+ lay.addWidget(QLabel(f"{pname}:").also(lambda w: w.setObjectName("pwmLabel")))
+ if ptype == "int":
+ w = QSpinBox(); w.setRange(int(lo), int(hi))
+ w.setValue(int(self.filt.params.get(pname, default)))
+ w.setFixedWidth(70); w.setObjectName("traceWidthSpin")
+ w.valueChanged.connect(lambda v, p=pname: self._upd(p, v))
+ else:
+ w = QDoubleSpinBox(); w.setRange(lo, hi); w.setDecimals(4)
+ w.setValue(float(self.filt.params.get(pname, default)))
+ w.setFixedWidth(88); w.setObjectName("traceWidthSpin")
+ w.valueChanged.connect(lambda v, p=pname: self._upd(p, v))
+ lay.addWidget(w)
+
+ lay.addStretch()
+ rm = QToolButton(); rm.setText("✕"); rm.setObjectName("traceRemoveBtn")
+ rm.setFixedSize(22, 22); rm.clicked.connect(lambda: self.removed.emit(self))
+ lay.addWidget(rm)
+
+ def _upd(self, name, value):
+ self.filt.params[name] = value
+ cls = FILTER_CLASSES.get(self.filt.name)
+ if cls:
+ nf = cls(**self.filt.params); self.filt.__dict__.update(nf.__dict__)
+ self.changed.emit()
+
+
+# monkey-patch QLabel.also so we can set objectName inline above
+from PyQt6.QtWidgets import QLabel as _QLabel
+def _also(self, fn): fn(self); return self
+_QLabel.also = _also
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# Calibration widget
+# ══════════════════════════════════════════════════════════════════════════════
+
+class CalibrationWidget(QGroupBox):
+ """
+ Linear calibration: enter N (raw, engineering) point pairs,
+ fit y = scale*x + offset, apply as ScaleOffsetFilter.
+ """
+ cal_applied = pyqtSignal(float, float) # scale, offset
+
+ def __init__(self, parent=None):
+ super().__init__("Calibration", parent)
+ self._points: List[Tuple[float,float]] = []
+ self._build()
+
+ def _build(self):
+ lay = QVBoxLayout(self); lay.setSpacing(4)
+
+ # Table of raw→engineering pairs
+ self._tbl = QTableWidget(0, 2)
+ self._tbl.setHorizontalHeaderLabels(["Raw (sensor)", "Engineering"])
+ self._tbl.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
+ self._tbl.setMaximumHeight(130)
+ self._tbl.setObjectName("channelTable")
+ lay.addWidget(self._tbl)
+
+ btn_row = QHBoxLayout()
+ add_pt = QPushButton("+ Point"); add_pt.setObjectName("addTraceBtn")
+ add_pt.clicked.connect(self._add_row)
+ rm_pt = QPushButton("✕ Remove"); rm_pt.setObjectName("configButton")
+ rm_pt.clicked.connect(self._rm_row)
+ btn_row.addWidget(add_pt); btn_row.addWidget(rm_pt); btn_row.addStretch()
+ lay.addLayout(btn_row)
+
+ # Result
+ self._result_lbl = QLabel("")
+ self._result_lbl.setObjectName("traceSource"); lay.addWidget(self._result_lbl)
+
+ fit_btn = QPushButton("Fit & Apply"); fit_btn.setObjectName("applyButton")
+ fit_btn.clicked.connect(self._fit); lay.addWidget(fit_btn)
+
+ def _add_row(self):
+ r = self._tbl.rowCount(); self._tbl.insertRow(r)
+ self._tbl.setItem(r, 0, QTableWidgetItem("0.0"))
+ self._tbl.setItem(r, 1, QTableWidgetItem("0.0"))
+
+ def _rm_row(self):
+ rows = {i.row() for i in self._tbl.selectedItems()}
+ for r in sorted(rows, reverse=True):
+ self._tbl.removeRow(r)
+
+ def _fit(self):
+ pts = []
+ for r in range(self._tbl.rowCount()):
+ try:
+ x = float(self._tbl.item(r,0).text())
+ y = float(self._tbl.item(r,1).text())
+ pts.append((x,y))
+ except Exception:
+ pass
+ if len(pts) < 2:
+ self._result_lbl.setText("⚠ Need ≥2 points"); return
+ xs = [p[0] for p in pts]; ys = [p[1] for p in pts]
+ n = len(pts)
+ sx = sum(xs); sy = sum(ys)
+ sxx = sum(x*x for x in xs); sxy = sum(x*y for x,y in pts)
+ denom = n*sxx - sx*sx
+ if abs(denom) < 1e-12:
+ self._result_lbl.setText("⚠ Singular — all raw values identical"); return
+ scale = (n*sxy - sx*sy) / denom
+ offset = (sy - scale*sx) / n
+ self._result_lbl.setText(
+ f"y = {scale:.5g} × x + {offset:.5g} (R²≈{self._r2(xs,ys,scale,offset):.4f})"
+ )
+ self._result_lbl.setStyleSheet("color:#22c55e;")
+ self.cal_applied.emit(scale, offset)
+
+ @staticmethod
+ def _r2(xs, ys, scale, offset):
+ y_mean = sum(ys)/len(ys)
+ ss_tot = sum((y-y_mean)**2 for y in ys)
+ ss_res = sum((y - (scale*x+offset))**2 for x,y in zip(xs,ys))
+ return 1 - ss_res/ss_tot if ss_tot else 1.0
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# Pipeline Tab — filter stack + calibration for one channel
+# ══════════════════════════════════════════════════════════════════════════════
+
+class PipelineTab(QWidget):
+ changed = pyqtSignal()
+
+ def __init__(self, registry: DeviceRegistry, processor: SignalProcessor):
+ super().__init__()
+ self.registry = registry
+ self.processor = processor
+ self._rows: List[FilterRow] = []
+ self._cur_pipeline: Optional[ChannelPipeline] = None
+ self._build()
+
+ def _build(self):
+ lay = QVBoxLayout(self); lay.setContentsMargins(0,0,0,0); lay.setSpacing(0)
+
+ # Channel selector bar
+ sel_bar = QWidget(); sel_bar.setObjectName("cfgGlobalBar")
+ sb_lay = QHBoxLayout(sel_bar); sb_lay.setContentsMargins(10,7,10,7)
+ sb_lay.addWidget(QLabel("Channel:"))
+ self._ch_cb = _channel_combo(self.registry, self.processor, include_derived=True)
+ self._ch_cb.currentIndexChanged.connect(self._load_pipeline)
+ sb_lay.addWidget(self._ch_cb, 1)
+ lay.addWidget(sel_bar)
+
+ div = QFrame(); div.setFrameShape(QFrame.Shape.HLine)
+ div.setObjectName("devWindowDivider"); lay.addWidget(div)
+
+ scroll = QScrollArea(); scroll.setWidgetResizable(True)
+ scroll.setObjectName("deviceScroll")
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ self._cont = QWidget()
+ self._inner = QVBoxLayout(self._cont)
+ self._inner.setContentsMargins(10,10,10,10); self._inner.setSpacing(6)
+
+ # Enable toggle
+ self._en_chk = QCheckBox("Pipeline enabled"); self._en_chk.setChecked(True)
+ self._en_chk.toggled.connect(self._on_enable)
+ self._inner.addWidget(self._en_chk)
+
+ # Filter rows area
+ self._filter_area = QWidget()
+ self._filter_lay = QVBoxLayout(self._filter_area)
+ self._filter_lay.setContentsMargins(0,0,0,0); self._filter_lay.setSpacing(3)
+ self._inner.addWidget(self._filter_area)
+
+ # Add filter row
+ add_bar = QHBoxLayout()
+ self._filt_cb = QComboBox(); self._filt_cb.setObjectName("channelPickerCb")
+ self._filt_cb.addItems([k.replace("_"," ").title() for k in FILTER_CLASSES])
+ add_bar.addWidget(self._filt_cb, 1)
+ add_btn = QPushButton("+ Add Filter"); add_btn.setObjectName("addTraceBtn")
+ add_btn.clicked.connect(self._add_filter)
+ add_bar.addWidget(add_btn)
+ self._inner.addLayout(add_bar)
+
+ # Calibration
+ self._cal_widget = CalibrationWidget()
+ self._cal_widget.cal_applied.connect(self._apply_calibration)
+ self._inner.addWidget(self._cal_widget)
+ self._inner.addStretch()
+
+ scroll.setWidget(self._cont)
+ lay.addWidget(scroll, 1)
+ self._load_pipeline(0)
+
+ def _load_pipeline(self, _=None):
+ data = self._ch_cb.currentData()
+ if not data: return
+ dev_id, ch_id = data
+
+ # Clear filter rows
+ for r in self._rows:
+ self._filter_lay.removeWidget(r); r.deleteLater()
+ self._rows.clear()
+
+ pipeline = self.processor.get_pipeline(dev_id, ch_id)
+ if pipeline is None:
+ pipeline = ChannelPipeline(device_id=dev_id, channel_id=ch_id)
+ self.processor.set_pipeline(pipeline)
+ self._cur_pipeline = pipeline
+ self._en_chk.setChecked(pipeline.enabled)
+ for f in pipeline.filters:
+ self._add_filter_row(f)
+
+ def _add_filter(self):
+ key = list(FILTER_CLASSES.keys())[self._filt_cb.currentIndex()]
+ filt = FILTER_CLASSES[key]()
+ if self._cur_pipeline:
+ self._cur_pipeline.filters.append(filt)
+ self._add_filter_row(filt)
+ self._push()
+
+ def _add_filter_row(self, filt: FilterBase):
+ row = FilterRow(filt)
+ row.removed.connect(self._remove_filter)
+ row.changed.connect(self._push)
+ self._rows.append(row)
+ self._filter_lay.addWidget(row)
+
+ def _remove_filter(self, row: FilterRow):
+ if self._cur_pipeline and row.filt in self._cur_pipeline.filters:
+ self._cur_pipeline.filters.remove(row.filt)
+ self._rows.remove(row)
+ self._filter_lay.removeWidget(row); row.deleteLater()
+ self._push()
+
+ def _on_enable(self, v: bool):
+ if self._cur_pipeline:
+ self._cur_pipeline.enabled = v; self._push()
+
+ def _apply_calibration(self, scale: float, offset: float):
+ """Insert/replace a scale_offset filter at the END of the pipeline."""
+ if self._cur_pipeline is None: return
+ # Remove existing scale_offset
+ self._cur_pipeline.filters = [
+ f for f in self._cur_pipeline.filters if f.name != "scale_offset"
+ ]
+ cal_filt = ScaleOffsetFilter(scale=scale, offset=offset)
+ self._cur_pipeline.filters.append(cal_filt)
+ # Refresh rows
+ for r in self._rows:
+ self._filter_lay.removeWidget(r); r.deleteLater()
+ self._rows.clear()
+ for f in self._cur_pipeline.filters:
+ self._add_filter_row(f)
+ self._push()
+
+ def _push(self):
+ if self._cur_pipeline:
+ self.processor.set_pipeline(self._cur_pipeline)
+ self.changed.emit()
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# Derived editor for one DerivedChannel
+# ══════════════════════════════════════════════════════════════════════════════
+
+_BUILTIN = ["velocity","acceleration","power","rms","difference","sum"]
+_SCRIPT = ["expression","function","custom_script"]
+_ALL_KINDS = _BUILTIN + _SCRIPT
+
+_KIND_HELP = {
+ "velocity": "Backward difference dy/dt from 1 displacement source",
+ "acceleration": "Second derivative d²y/dt² from 1 displacement source",
+ "power": "V × I from 2 sources (voltage, current)",
+ "rms": "Rolling RMS from 1 source (set window below)",
+ "difference": "source[0] − source[1] from 2 sources",
+ "sum": "Σ all source values",
+ "expression": "x = list of source values, t = elapsed time (s)\nExample: x[0] * 3.14159 / 180",
+ "function": "def compute(x, t):\n # x = source values list\n return x[0] * 2.0",
+ "custom_script":"Full Python. Must define: def compute(x, t): ...",
+}
+
+class DerivedBlock(QFrame):
+ removed = pyqtSignal(object)
+ applied = pyqtSignal(object) # DerivedChannel
+
+ def __init__(self, dc: DerivedChannel, registry: DeviceRegistry,
+ processor: SignalProcessor):
+ super().__init__()
+ self.dc = dc; self.registry = registry; self.processor = processor
+ self.setObjectName("plotBlock")
+ self._src_combos: List[QComboBox] = []
+ self._build()
+
+ def _build(self):
+ outer = QVBoxLayout(self); outer.setContentsMargins(0,0,0,0); outer.setSpacing(0)
+
+ # Header
+ hdr = QWidget(); hdr.setObjectName("plotBlockHeader"); hdr.setFixedHeight(32)
+ hl = QHBoxLayout(hdr); hl.setContentsMargins(8,0,6,0)
+ self._name = QLineEdit(self.dc.name); self._name.setObjectName("plotBlockTitle")
+ self._name.textChanged.connect(lambda t: setattr(self.dc,"name",t))
+ hl.addWidget(self._name, 1)
+ self._en = QCheckBox(); self._en.setChecked(self.dc.enabled)
+ self._en.setToolTip("Enable"); self._en.toggled.connect(lambda v: setattr(self.dc,"enabled",v))
+ hl.addWidget(self._en)
+ rm = QToolButton(); rm.setText("✕"); rm.setObjectName("plotRemoveBtn")
+ rm.setFixedSize(22,22); rm.clicked.connect(lambda: self.removed.emit(self))
+ hl.addWidget(rm)
+ outer.addWidget(hdr)
+
+ # Body
+ body = QWidget(); body.setObjectName("plotBlockSettings")
+ bl = QVBoxLayout(body); bl.setContentsMargins(10,8,10,10); bl.setSpacing(6)
+
+ # ID / unit / color row
+ meta = QHBoxLayout()
+ meta.addWidget(QLabel("ID:")); self._id = QLineEdit(self.dc.channel_id)
+ self._id.setObjectName("traceLabel"); self._id.setFixedWidth(100)
+ self._id.textChanged.connect(lambda t: setattr(self.dc,"channel_id",t))
+ meta.addWidget(self._id)
+ meta.addWidget(QLabel("Unit:")); self._unit = QLineEdit(self.dc.unit)
+ self._unit.setObjectName("traceLabel"); self._unit.setFixedWidth(60)
+ self._unit.textChanged.connect(lambda t: setattr(self.dc,"unit",t))
+ meta.addWidget(self._unit); meta.addStretch()
+ bl.addLayout(meta)
+
+ # Kind + help
+ k_row = QHBoxLayout(); k_row.addWidget(QLabel("Kind:"))
+ self._kind_cb = QComboBox(); self._kind_cb.setObjectName("channelPickerCb")
+ self._kind_cb.addItems(_ALL_KINDS); self._kind_cb.setCurrentText(self.dc.kind)
+ self._kind_cb.currentTextChanged.connect(self._on_kind)
+ k_row.addWidget(self._kind_cb); k_row.addStretch(); bl.addLayout(k_row)
+ self._help = QLabel(_KIND_HELP.get(self.dc.kind,""))
+ self._help.setObjectName("traceSource"); self._help.setWordWrap(True)
+ bl.addWidget(self._help)
+
+ # RMS window (shown for rms)
+ self._win_row = QHBoxLayout()
+ self._win_row.addWidget(QLabel("Window samples:"))
+ self._win_sp = QSpinBox(); self._win_sp.setRange(2,10000)
+ self._win_sp.setValue(self.dc.params.get("window",20))
+ self._win_sp.setObjectName("traceWidthSpin"); self._win_sp.setFixedWidth(80)
+ self._win_sp.valueChanged.connect(lambda v: self.dc.params.update({"window":v}))
+ self._win_row.addWidget(self._win_sp); self._win_row.addStretch()
+ self._win_widget = QWidget(); self._win_widget.setLayout(self._win_row)
+ bl.addWidget(self._win_widget)
+
+ # Sources
+ src_grp = QGroupBox("Sources"); src_grp.setObjectName("cfgGlobalBar")
+ src_lay = QVBoxLayout(src_grp); src_lay.setContentsMargins(6,4,6,4); src_lay.setSpacing(3)
+ self._src_cont = QWidget(); self._src_vlay = QVBoxLayout(self._src_cont)
+ self._src_vlay.setContentsMargins(0,0,0,0); self._src_vlay.setSpacing(3)
+ for s in self.dc.sources: self._add_src(s)
+ add_s = QPushButton("+ Add Source"); add_s.setObjectName("addTraceBtn")
+ add_s.clicked.connect(lambda: self._add_src())
+ src_lay.addWidget(self._src_cont); src_lay.addWidget(add_s)
+ bl.addWidget(src_grp)
+
+ # Code editor
+ self._code_grp = QGroupBox("Code")
+ code_lay = QVBoxLayout(self._code_grp); code_lay.setContentsMargins(6,4,6,4)
+ self._code = QTextEdit(); self._code.setObjectName("codeEditor")
+ self._code.setMinimumHeight(90); self._code.setMaximumHeight(180)
+ txt = self.dc.expression if self.dc.kind=="expression" else self.dc.script
+ self._code.setPlainText(txt)
+ code_lay.addWidget(self._code)
+ bl.addWidget(self._code_grp)
+
+ # Status + apply
+ ap_row = QHBoxLayout()
+ self._st = QLabel(""); self._st.setObjectName("traceSource")
+ ap_row.addWidget(self._st, 1)
+ ap = QPushButton("✓ Apply"); ap.setObjectName("applyButton")
+ ap.clicked.connect(self._apply); ap_row.addWidget(ap)
+ bl.addLayout(ap_row)
+
+ outer.addWidget(body)
+ self._update_visibility()
+
+ def _add_src(self, src=None):
+ row = QHBoxLayout()
+ cb = _channel_combo(self.registry, include_derived=False)
+ cb.setObjectName("channelPickerCb")
+ if src:
+ for i in range(cb.count()):
+ if cb.itemData(i) == src: cb.setCurrentIndex(i); break
+ rm = QToolButton(); rm.setText("✕"); rm.setObjectName("traceRemoveBtn")
+ rm.setFixedSize(22,22); rm.clicked.connect(lambda: self._rm_src(row, cb))
+ row.addWidget(cb,1); row.addWidget(rm)
+ self._src_vlay.addLayout(row)
+ self._src_combos.append(cb)
+
+ def _rm_src(self, row, cb):
+ if cb in self._src_combos: self._src_combos.remove(cb)
+ while row.count():
+ it = row.takeAt(0)
+ if it.widget(): it.widget().deleteLater()
+
+ def _on_kind(self, k: str):
+ self.dc.kind = k
+ self._help.setText(_KIND_HELP.get(k,""))
+ self._update_visibility()
+
+ def _update_visibility(self):
+ is_script = self.dc.kind in _SCRIPT
+ is_rms = self.dc.kind == "rms"
+ self._code_grp.setVisible(is_script)
+ self._win_widget.setVisible(is_rms)
+
+ def _apply(self):
+ self.dc.sources = [cb.currentData() for cb in self._src_combos if cb.currentData()]
+ if self.dc.kind == "expression":
+ self.dc.expression = self._code.toPlainText().strip()
+ elif self.dc.kind in ("function","custom_script"):
+ self.dc.script = self._code.toPlainText()
+ err = self.processor.add_derived(self.dc)
+ if err:
+ self._st.setText(f"⚠ {err[:100]}")
+ self._st.setStyleSheet("color:#ef4444;")
+ else:
+ self._st.setText("✓ Applied")
+ self._st.setStyleSheet("color:#22c55e;")
+ self.applied.emit(self.dc)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# Channel Visibility Tab
+# ══════════════════════════════════════════════════════════════════════════════
+
+class ChannelVisTab(QWidget):
+ changed = pyqtSignal()
+
+ def __init__(self, registry: DeviceRegistry, processor: SignalProcessor):
+ super().__init__()
+ self.registry = registry
+ self.processor = processor
+ self._build()
+
+ def _build(self):
+ scroll = QScrollArea(); scroll.setWidgetResizable(True)
+ scroll.setObjectName("deviceScroll")
+ self._cont = QWidget()
+ self._lay = QVBoxLayout(self._cont)
+ self._lay.setContentsMargins(10,10,10,10); self._lay.setSpacing(8)
+ scroll.setWidget(self._cont)
+ root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0)
+ root.addWidget(scroll)
+ self.refresh()
+
+ def refresh(self):
+ while self._lay.count():
+ it = self._lay.takeAt(0)
+ if it.widget(): it.widget().deleteLater()
+ for dev in self.registry.all_instances():
+ grp = QGroupBox(f"{dev.info.icon} {dev.info.name} [{dev.info.device_id}]")
+ g_lay = QVBoxLayout(grp); g_lay.setSpacing(3)
+ for ch in dev.info.channels:
+ row = QHBoxLayout()
+ chk = QCheckBox(f"{ch.channel_id} — {ch.name}")
+ chk.setChecked(ch.enabled)
+ chk.setStyleSheet(f"color:{ch.color};")
+ chk.toggled.connect(lambda v, c=ch: (setattr(c,"enabled",v), self.changed.emit()))
+ unit = QLabel(ch.unit); unit.setObjectName("traceSource")
+ row.addWidget(chk,1); row.addWidget(unit)
+ g_lay.addLayout(row)
+ self._lay.addWidget(grp)
+ self._lay.addStretch()
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# SignalsWindow — main
+# ══════════════════════════════════════════════════════════════════════════════
+
+class SignalsWindow(QWidget):
+ pipeline_changed = pyqtSignal()
+ derived_changed = pyqtSignal()
+ visibility_changed = pyqtSignal()
+ closed = pyqtSignal()
+
+ def __init__(self, registry: DeviceRegistry,
+ processor: SignalProcessor, parent=None):
+ super().__init__(parent, Qt.WindowType.Window | Qt.WindowType.Tool)
+ self.registry = registry
+ self.processor = processor
+ self._derived_blocks: List[DerivedBlock] = []
+ self._color_idx = 0
+
+ self.setWindowTitle("Signals")
+ self.setMinimumSize(640, 520)
+ self.resize(740, 640)
+ self._build()
+
+ def _build(self):
+ root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0); root.setSpacing(0)
+
+ # Header
+ hdr = QWidget(); hdr.setObjectName("devWindowTitleBar"); hdr.setFixedHeight(44)
+ hl = QHBoxLayout(hdr); hl.setContentsMargins(14,0,14,0)
+ hl.addWidget(QLabel("SIGNALS").also(lambda w: w.setObjectName("devWindowTitle")), 1)
+ root.addWidget(hdr)
+ div = QFrame(); div.setFrameShape(QFrame.Shape.HLine)
+ div.setObjectName("devWindowDivider"); root.addWidget(div)
+
+ tabs = QTabWidget(); tabs.setObjectName("signalBuilderTabs")
+ root.addWidget(tabs, 1)
+
+ # Tab 1: Pipeline / Filters / Calibration
+ self._pip_tab = PipelineTab(self.registry, self.processor)
+ self._pip_tab.changed.connect(self.pipeline_changed)
+ tabs.addTab(self._pip_tab, " Pipeline ")
+
+ # Tab 2: Derived channels
+ tabs.addTab(self._build_derived_tab(), " Derived ")
+
+ # Tab 3: Channel visibility
+ self._vis_tab = ChannelVisTab(self.registry, self.processor)
+ self._vis_tab.changed.connect(self.visibility_changed)
+ tabs.addTab(self._vis_tab, " Channels ")
+
+ def _build_derived_tab(self):
+ w = QWidget()
+ lay = QVBoxLayout(w); lay.setContentsMargins(0,0,0,0); lay.setSpacing(0)
+
+ # Toolbar
+ top = QWidget(); top.setObjectName("cfgGlobalBar")
+ tl = QHBoxLayout(top); tl.setContentsMargins(10,7,10,7)
+ tl.addWidget(QLabel("New:"))
+ self._new_kind = QComboBox(); self._new_kind.setObjectName("channelPickerCb")
+ self._new_kind.addItems(_ALL_KINDS); tl.addWidget(self._new_kind)
+ cr = QPushButton("+ Create Derived Channel")
+ cr.setObjectName("addDeviceButton"); cr.clicked.connect(self._add_derived)
+ tl.addWidget(cr); tl.addStretch()
+ lay.addWidget(top)
+
+ div = QFrame(); div.setFrameShape(QFrame.Shape.HLine)
+ div.setObjectName("devWindowDivider"); lay.addWidget(div)
+
+ scroll = QScrollArea(); scroll.setWidgetResizable(True)
+ scroll.setObjectName("deviceScroll")
+ self._d_cont = QWidget()
+ self._d_lay = QVBoxLayout(self._d_cont)
+ self._d_lay.setContentsMargins(10,10,10,10); self._d_lay.setSpacing(10)
+ self._d_lay.addStretch()
+ scroll.setWidget(self._d_cont)
+ lay.addWidget(scroll, 1)
+ return w
+
+ def _add_derived(self):
+ kind = self._new_kind.currentText()
+ color = _DERIVED_COLORS[self._color_idx % len(_DERIVED_COLORS)]
+ self._color_idx += 1
+ n = len(self._derived_blocks) + 1
+ dc = DerivedChannel(channel_id=f"sig_{n}", name=f"Signal {n}",
+ kind=kind, color=color)
+ blk = DerivedBlock(dc, self.registry, self.processor)
+ blk.removed.connect(self._rm_derived)
+ blk.applied.connect(lambda _: self.derived_changed.emit())
+ self._derived_blocks.append(blk)
+ self._d_lay.insertWidget(self._d_lay.count()-1, blk)
+
+ def _rm_derived(self, blk: DerivedBlock):
+ self.processor.remove_derived(blk.dc.channel_id)
+ self._derived_blocks.remove(blk)
+ self._d_lay.removeWidget(blk); blk.deleteLater()
+ self.derived_changed.emit()
+
+ def closeEvent(self, e: QCloseEvent):
+ self.closed.emit(); e.accept()