diff options
Diffstat (limited to 'ui/windows/signals_window.py')
| -rw-r--r-- | ui/windows/signals_window.py | 659 |
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() |
