summaryrefslogtreecommitdiff
path: root/ui/windows
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-04-22 14:50:13 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-04-22 14:50:13 -0600
commit99445c6fd2fd4f15df828c79600b96b80fa1cfa6 (patch)
tree22c9933fc6bb942172a1f456bef7a35ec4be1726 /ui/windows
parent445403fdc61f4e65736dc65a959f119fc096f4c0 (diff)
Added derived signal to be sources of other derived signals.
Fixed derived signal saving profile. Renamed Signals>Pipeline to Signas>Channels and adding/removing channels feature.
Diffstat (limited to 'ui/windows')
-rw-r--r--ui/windows/__pycache__/plot_window.cpython-312.pycbin44551 -> 44550 bytes
-rw-r--r--ui/windows/__pycache__/signals_window.cpython-312.pycbin42907 -> 53973 bytes
-rw-r--r--ui/windows/plot_window.py2
-rw-r--r--ui/windows/signals_window.py415
4 files changed, 294 insertions, 123 deletions
diff --git a/ui/windows/__pycache__/plot_window.cpython-312.pyc b/ui/windows/__pycache__/plot_window.cpython-312.pyc
index 288e026..c72d74b 100644
--- a/ui/windows/__pycache__/plot_window.cpython-312.pyc
+++ b/ui/windows/__pycache__/plot_window.cpython-312.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/signals_window.cpython-312.pyc b/ui/windows/__pycache__/signals_window.cpython-312.pyc
index f057963..8b0db88 100644
--- a/ui/windows/__pycache__/signals_window.cpython-312.pyc
+++ b/ui/windows/__pycache__/signals_window.cpython-312.pyc
Binary files differ
diff --git a/ui/windows/plot_window.py b/ui/windows/plot_window.py
index 1be590a..ea1ba1c 100644
--- a/ui/windows/plot_window.py
+++ b/ui/windows/plot_window.py
@@ -262,7 +262,7 @@ class PaneBlock(QFrame):
pick_row = QHBoxLayout()
self._picker = self._make_picker(); pick_row.addWidget(self._picker,1)
- ab = QPushButton("+ Add Channel"); ab.setObjectName("addTraceBtn")
+ ab = QPushButton("+ Add Signal"); ab.setObjectName("addTraceBtn")
ab.clicked.connect(self._on_add); pick_row.addWidget(ab)
self._tlay.addLayout(pick_row)
outer.addWidget(tr_w)
diff --git a/ui/windows/signals_window.py b/ui/windows/signals_window.py
index 68cceda..ff863af 100644
--- a/ui/windows/signals_window.py
+++ b/ui/windows/signals_window.py
@@ -4,37 +4,27 @@ ui/windows/signals_window.py
SIGNALS window — toolbar section 3.
Tabs:
- Pipeline — per-channel filter stack (filters, scale/offset)
- Derived — create virtual channels from physical ones
- Channels — visibility & color management for all channels
-
-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
+ Channels — per-channel filter stack; user-curated list with add/remove
+ Derived — create virtual channels from physical or derived ones
+ Signals — flat combined list of all physical + derived signals
"""
from __future__ import annotations
-from typing import List, Optional
+from typing import List, Optional, Set, Tuple
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QScrollArea, QFrame, QTabWidget, QComboBox, QLineEdit,
QDoubleSpinBox, QSpinBox, QCheckBox, QTextEdit, QGroupBox,
- QToolButton, QSizePolicy, QFormLayout, QMessageBox,
+ QToolButton,
)
from PyQt6.QtCore import Qt, pyqtSignal
-from PyQt6.QtGui import QColor, QFont, QCloseEvent
+from PyQt6.QtGui import QCloseEvent
from devices.device_registry import DeviceRegistry
from core.signal_processor import (
SignalProcessor, ChannelPipeline, DerivedChannel,
- FilterBase, MovingAverageFilter, MedianFilter, LowPassFilter,
- HighPassFilter, DerivativeFilter, IntegralFilter,
- FILTER_CLASSES,
+ FilterBase, FILTER_CLASSES,
)
_DERIVED_COLORS = [
@@ -42,18 +32,24 @@ _DERIVED_COLORS = [
"#f77f00","#d62828","#588157","#e9c46a",
]
+
def _channel_combo(registry: DeviceRegistry,
processor: Optional[SignalProcessor] = None,
- include_derived: bool = False) -> QComboBox:
+ include_derived: bool = False,
+ show_unit: 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))
+ label = f"{dev.info.device_id} / {ch.channel_id} ({ch.name})"
+ if show_unit and ch.unit:
+ label += f" [{ch.unit}]"
+ cb.addItem(label, 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))
+ label = f"[derived] {dc.channel_id} ({dc.name})"
+ if show_unit and dc.unit:
+ label += f" [{dc.unit}]"
+ cb.addItem(label, userData=("derived", dc.channel_id))
return cb
@@ -90,7 +86,8 @@ class FilterRow(QFrame):
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")))
+ pl = QLabel(f"{pname}:"); pl.setObjectName("pwmLabel")
+ lay.addWidget(pl)
if ptype == "int":
w = QSpinBox(); w.setRange(int(lo), int(hi))
w.setValue(int(self.filt.params.get(pname, default)))
@@ -116,61 +113,71 @@ class FilterRow(QFrame):
self.changed.emit()
-# monkey-patch QLabel.also so we can set objectName inline above
+# monkey-patch QLabel.also for inline objectName
from PyQt6.QtWidgets import QLabel as _QLabel
def _also(self, fn): fn(self); return self
_QLabel.also = _also
# ══════════════════════════════════════════════════════════════════════════════
-# Pipeline Tab — filter stack + calibration for one channel
+# Channel Pipeline Block — collapsible filter stack for one channel
# ══════════════════════════════════════════════════════════════════════════════
-class PipelineTab(QWidget):
+class ChannelPipelineBlock(QFrame):
changed = pyqtSignal()
+ removed = pyqtSignal(object) # emits self
- def __init__(self, registry: DeviceRegistry, processor: SignalProcessor):
+ def __init__(self, dev_id: str, ch_id: str, ch_name: str, unit: str,
+ processor: SignalProcessor):
super().__init__()
- self.registry = registry
+ self.dev_id = dev_id
+ self.ch_id = ch_id
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)
+ self._expanded = False
+ self.setObjectName("plotBlock")
+ self._build(ch_name, unit)
+ self._load()
- # 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)
+ def _build(self, ch_name: str, unit: str):
+ outer = QVBoxLayout(self); outer.setContentsMargins(0,0,0,0); outer.setSpacing(0)
- div = QFrame(); div.setFrameShape(QFrame.Shape.HLine)
- div.setObjectName("devWindowDivider"); lay.addWidget(div)
+ # Header
+ hdr = QWidget(); hdr.setObjectName("plotBlockHeader"); hdr.setFixedHeight(32)
+ hl = QHBoxLayout(hdr); hl.setContentsMargins(8,0,6,0)
+ title = f"{self.dev_id} / {self.ch_id} ({ch_name})"
+ if unit:
+ title += f" [{unit}]"
+ lbl = QLabel(title); lbl.setObjectName("traceSource")
+ hl.addWidget(lbl, 1)
+
+ rm_btn = QToolButton(); rm_btn.setText("✕")
+ rm_btn.setObjectName("traceRemoveBtn"); rm_btn.setFixedSize(22, 22)
+ rm_btn.clicked.connect(lambda: self.removed.emit(self))
+ hl.addWidget(rm_btn)
+
+ self._toggle_btn = QToolButton(); self._toggle_btn.setText("▶")
+ self._toggle_btn.setObjectName("traceRemoveBtn"); self._toggle_btn.setFixedSize(22, 22)
+ self._toggle_btn.clicked.connect(self._toggle)
+ hl.addWidget(self._toggle_btn)
+ outer.addWidget(hdr)
+ hdr.mousePressEvent = lambda e: self._toggle()
- 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)
+ # Body
+ self._body = QWidget(); self._body.setObjectName("plotBlockSettings")
+ bl = QVBoxLayout(self._body)
+ bl.setContentsMargins(10,8,10,10); bl.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)
+ bl.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)
+ bl.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])
@@ -178,34 +185,26 @@ class PipelineTab(QWidget):
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)
+ bl.addLayout(add_bar)
- # Apply button
- ap_row = QHBoxLayout()
- ap_row.addStretch()
- ap_btn = QPushButton("✓ Apply Pipeline"); ap_btn.setObjectName("applyButton")
+ ap_row = QHBoxLayout(); ap_row.addStretch()
+ ap_btn = QPushButton("✓ Apply"); ap_btn.setObjectName("applyButton")
ap_btn.clicked.connect(self._push)
ap_row.addWidget(ap_btn)
- self._inner.addLayout(ap_row)
- self._inner.addStretch()
+ bl.addLayout(ap_row)
- scroll.setWidget(self._cont)
- lay.addWidget(scroll, 1)
- self._load_pipeline(0)
+ outer.addWidget(self._body)
+ self._body.setVisible(False)
- def _load_pipeline(self, _=None):
- data = self._ch_cb.currentData()
- if not data: return
- dev_id, ch_id = data
+ def _toggle(self):
+ self._expanded = not self._expanded
+ self._body.setVisible(self._expanded)
+ self._toggle_btn.setText("▼" if self._expanded else "▶")
- # 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)
+ def _load(self):
+ pipeline = self.processor.get_pipeline(self.dev_id, self.ch_id)
if pipeline is None:
- pipeline = ChannelPipeline(device_id=dev_id, channel_id=ch_id)
+ pipeline = ChannelPipeline(device_id=self.dev_id, channel_id=self.ch_id)
self.processor.set_pipeline(pipeline)
self._cur_pipeline = pipeline
self._en_chk.setChecked(pipeline.enabled)
@@ -236,7 +235,8 @@ class PipelineTab(QWidget):
def _on_enable(self, v: bool):
if self._cur_pipeline:
- self._cur_pipeline.enabled = v; self._push()
+ self._cur_pipeline.enabled = v
+ self._push()
def _push(self):
if self._cur_pipeline:
@@ -245,16 +245,110 @@ class PipelineTab(QWidget):
# ══════════════════════════════════════════════════════════════════════════════
+# Channels Tab (formerly Pipeline Tab) — user-curated channel list
+# ══════════════════════════════════════════════════════════════════════════════
+
+class PipelineTab(QWidget):
+ changed = pyqtSignal()
+
+ def __init__(self, registry: DeviceRegistry, processor: SignalProcessor):
+ super().__init__()
+ self.registry = registry
+ self.processor = processor
+ self._shown: Set[Tuple[str,str]] = set() # (dev_id, ch_id)
+ self._build()
+
+ def _build(self):
+ lay = QVBoxLayout(self); lay.setContentsMargins(0,0,0,0); lay.setSpacing(0)
+
+ # Add-channel bar at top
+ add_bar = QWidget(); add_bar.setObjectName("cfgGlobalBar")
+ ab_lay = QHBoxLayout(add_bar); ab_lay.setContentsMargins(10,7,10,7); ab_lay.setSpacing(6)
+ ab_lay.addWidget(QLabel("Add:"))
+ self._avail_cb = QComboBox(); self._avail_cb.setObjectName("channelPickerCb")
+ ab_lay.addWidget(self._avail_cb, 1)
+ add_btn = QPushButton("+ Add Channel"); add_btn.setObjectName("addTraceBtn")
+ add_btn.clicked.connect(self._on_add_channel)
+ ab_lay.addWidget(add_btn)
+ lay.addWidget(add_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)
+ self._inner.addStretch()
+ scroll.setWidget(self._cont)
+ lay.addWidget(scroll, 1)
+
+ # Populate with all device channels on startup
+ for dev in self.registry.all_instances():
+ for ch in dev.info.channels:
+ self._add_block(dev.info.device_id, ch.channel_id, ch.name, ch.unit)
+
+ self._refresh_avail_cb()
+
+ # ── Channel block management ──────────────────────────────────────────────
+
+ def _add_block(self, dev_id: str, ch_id: str, ch_name: str, unit: str):
+ if (dev_id, ch_id) in self._shown:
+ return
+ self._shown.add((dev_id, ch_id))
+ block = ChannelPipelineBlock(dev_id, ch_id, ch_name, unit, self.processor)
+ block.changed.connect(self.changed)
+ block.removed.connect(self._on_remove_block)
+ # Insert before the trailing stretch
+ self._inner.insertWidget(self._inner.count() - 1, block)
+
+ def _on_add_channel(self):
+ data = self._avail_cb.currentData()
+ if not data:
+ return
+ dev_id, ch_id = data
+ dev = self.registry.get_instance(dev_id)
+ if not dev:
+ return
+ ch = dev.get_channel(ch_id)
+ if not ch:
+ return
+ self._add_block(dev_id, ch_id, ch.name, ch.unit)
+ self._refresh_avail_cb()
+
+ def _on_remove_block(self, block: ChannelPipelineBlock):
+ self._shown.discard((block.dev_id, block.ch_id))
+ self._inner.removeWidget(block)
+ block.deleteLater()
+ self._refresh_avail_cb()
+
+ def _refresh_avail_cb(self):
+ self._avail_cb.clear()
+ for dev in self.registry.all_instances():
+ for ch in dev.info.channels:
+ if (dev.info.device_id, ch.channel_id) not in self._shown:
+ label = f"{dev.info.device_id} / {ch.channel_id} ({ch.name})"
+ if ch.unit:
+ label += f" [{ch.unit}]"
+ self._avail_cb.addItem(label,
+ userData=(dev.info.device_id, ch.channel_id))
+
+
+# ══════════════════════════════════════════════════════════════════════════════
# Derived editor for one DerivedChannel
# ══════════════════════════════════════════════════════════════════════════════
-_BUILTIN = ["velocity","acceleration","power","rms","difference","sum"]
-_SCRIPT = ["expression","function","custom_script"]
+_BUILTIN = ["derivative", "second_derivative", "power", "rms", "difference", "sum"]
+_SCRIPT = ["expression", "function", "custom_script"]
_ALL_KINDS = _BUILTIN + _SCRIPT
+_KIND_DISPLAY = {k: k.replace("_", " ").title() for k in _ALL_KINDS}
+
_KIND_HELP = {
- "velocity": "Backward difference dy/dt from 1 displacement source",
- "acceleration": "Second derivative d²y/dt² from 1 displacement source",
+ "derivative": "Backward difference dy/dt from 1 source",
+ "second_derivative": "Second derivative d²y/dt² from 1 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",
@@ -264,6 +358,7 @@ _KIND_HELP = {
"custom_script":"Full Python. Must define: def compute(x, t): ...",
}
+
class DerivedBlock(QFrame):
removed = pyqtSignal(object)
applied = pyqtSignal(object) # DerivedChannel
@@ -286,7 +381,8 @@ class DerivedBlock(QFrame):
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))
+ 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))
@@ -297,29 +393,34 @@ class DerivedBlock(QFrame):
body = QWidget(); body.setObjectName("plotBlockSettings")
bl = QVBoxLayout(body); bl.setContentsMargins(10,8,10,10); bl.setSpacing(6)
- # ID / unit / color row
+ # ID / unit row
meta = QHBoxLayout()
- meta.addWidget(QLabel("ID:")); self._id = QLineEdit(self.dc.channel_id)
+ 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)
+ 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
+ # Kind combo + 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._kind_cb.addItems([_KIND_DISPLAY[k] for k in _ALL_KINDS])
+ cur_idx = _ALL_KINDS.index(self.dc.kind) if self.dc.kind in _ALL_KINDS else 0
+ self._kind_cb.setCurrentIndex(cur_idx)
+ self._kind_cb.currentIndexChanged.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)
+ # RMS window
self._win_row = QHBoxLayout()
self._win_row.addWidget(QLabel("Window samples:"))
self._win_sp = QSpinBox(); self._win_sp.setRange(2,10000)
@@ -330,12 +431,13 @@ class DerivedBlock(QFrame):
self._win_widget = QWidget(); self._win_widget.setLayout(self._win_row)
bl.addWidget(self._win_widget)
- # Sources
+ # Sources — include derived channels as valid 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)
+ 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)
@@ -356,7 +458,8 @@ class DerivedBlock(QFrame):
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)
+ ap.clicked.connect(self._apply)
+ ap_row.addWidget(ap)
bl.addLayout(ap_row)
outer.addWidget(body)
@@ -364,11 +467,14 @@ class DerivedBlock(QFrame):
def _add_src(self, src=None):
row = QHBoxLayout()
- cb = _channel_combo(self.registry, include_derived=False)
+ # Include derived channels as valid sources (enables chaining)
+ cb = _channel_combo(self.registry, self.processor,
+ include_derived=True, show_unit=True)
cb.setObjectName("channelPickerCb")
if src:
for i in range(cb.count()):
- if cb.itemData(i) == src: cb.setCurrentIndex(i); break
+ 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)
@@ -376,24 +482,25 @@ class DerivedBlock(QFrame):
self._src_combos.append(cb)
def _rm_src(self, row, cb):
- if cb in self._src_combos: self._src_combos.remove(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):
+ def _on_kind(self, idx: int):
+ k = _ALL_KINDS[idx]
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)
+ self._code_grp.setVisible(self.dc.kind in _SCRIPT)
+ self._win_widget.setVisible(self.dc.kind == "rms")
def _apply(self):
- self.dc.sources = [cb.currentData() for cb in self._src_combos if cb.currentData()]
+ 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"):
@@ -409,10 +516,10 @@ class DerivedBlock(QFrame):
# ══════════════════════════════════════════════════════════════════════════════
-# Channel Visibility Tab
+# Signals Tab — flat unified list of all signals
# ══════════════════════════════════════════════════════════════════════════════
-class ChannelVisTab(QWidget):
+class SignalsTab(QWidget):
changed = pyqtSignal()
def __init__(self, registry: DeviceRegistry, processor: SignalProcessor):
@@ -426,7 +533,7 @@ class ChannelVisTab(QWidget):
scroll.setObjectName("deviceScroll")
self._cont = QWidget()
self._lay = QVBoxLayout(self._cont)
- self._lay.setContentsMargins(10,10,10,10); self._lay.setSpacing(8)
+ self._lay.setContentsMargins(8,8,8,8); self._lay.setSpacing(2)
scroll.setWidget(self._cont)
root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0)
root.addWidget(scroll)
@@ -436,21 +543,63 @@ class ChannelVisTab(QWidget):
while self._lay.count():
it = self._lay.takeAt(0)
if it.widget(): it.widget().deleteLater()
+
+ # Physical channels — flat list across all devices
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.addWidget(self._make_row(
+ color = ch.color,
+ name = f"{ch.channel_id} — {ch.name}",
+ unit = ch.unit,
+ source = dev.info.device_id,
+ enabled = ch.enabled,
+ on_toggle = lambda v, c=ch: (setattr(c,"enabled",v), self.changed.emit()),
+ ))
+
+ # Thin divider before derived (only if both sections have entries)
+ if self.registry.all_instances() and self.processor.get_derived():
+ div = QFrame(); div.setFrameShape(QFrame.Shape.HLine)
+ div.setObjectName("devWindowDivider")
+ self._lay.addWidget(div)
+
+ # Derived signals
+ for dc in self.processor.get_derived():
+ self._lay.addWidget(self._make_row(
+ color = dc.color,
+ name = f"{dc.channel_id} — {dc.name}",
+ unit = dc.unit,
+ source = "derived",
+ enabled = dc.enabled,
+ on_toggle = lambda v, d=dc: (setattr(d,"enabled",v), self.changed.emit()),
+ ))
+
self._lay.addStretch()
+ def _make_row(self, color: str, name: str, unit: str, source: str,
+ enabled: bool, on_toggle) -> QFrame:
+ row = QFrame(); row.setObjectName("traceRow")
+ lay = QHBoxLayout(row); lay.setContentsMargins(8,4,8,4); lay.setSpacing(8)
+
+ dot = QLabel("●"); dot.setStyleSheet(f"color:{color};"); dot.setFixedWidth(14)
+ lay.addWidget(dot)
+
+ chk = QCheckBox(name); chk.setChecked(enabled)
+ chk.toggled.connect(on_toggle)
+ lay.addWidget(chk, 1)
+
+ if unit:
+ ul = QLabel(unit); ul.setObjectName("traceSource")
+ ul.setFixedWidth(52)
+ ul.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
+ lay.addWidget(ul)
+
+ sl = QLabel(source); sl.setObjectName("traceSource")
+ sl.setFixedWidth(72)
+ sl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
+ lay.addWidget(sl)
+
+ return row
+
# ══════════════════════════════════════════════════════════════════════════════
# SignalsWindow — main
@@ -489,29 +638,35 @@ class SignalsWindow(QWidget):
tabs = QTabWidget(); tabs.setObjectName("signalBuilderTabs")
root.addWidget(tabs, 1)
- # Tab 1: Pipeline / Filters / Calibration
+ # Tab 1: Channels (filter pipelines per channel)
self._pip_tab = PipelineTab(self.registry, self.processor)
self._pip_tab.changed.connect(self.pipeline_changed)
- tabs.addTab(self._pip_tab, " Pipeline ")
+ tabs.addTab(self._pip_tab, " Channels ")
# Tab 2: Derived channels
tabs.addTab(self._build_derived_tab(), " Derived ")
- # Tab 3: Channel visibility
- self._vis_tab = ChannelVisTab(self.registry, self.processor)
+ # Tab 3: Flat signals list
+ self._vis_tab = SignalsTab(self.registry, self.processor)
self._vis_tab.changed.connect(self.visibility_changed)
- tabs.addTab(self._vis_tab, " Channels ")
+ tabs.addTab(self._vis_tab, " Signals ")
+
+ # Keep Signals tab in sync when derived channels are added/removed
+ self.derived_changed.connect(self._vis_tab.refresh)
+
+ # Populate from processor state (handles opening window after profile load)
+ self.refresh_derived()
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)
+ self._new_kind.addItems([_KIND_DISPLAY[k] for k in _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()
@@ -531,7 +686,7 @@ class SignalsWindow(QWidget):
return w
def _add_derived(self):
- kind = self._new_kind.currentText()
+ kind = _ALL_KINDS[self._new_kind.currentIndex()]
color = _DERIVED_COLORS[self._color_idx % len(_DERIVED_COLORS)]
self._color_idx += 1
n = len(self._derived_blocks) + 1
@@ -549,5 +704,21 @@ class SignalsWindow(QWidget):
self._d_lay.removeWidget(blk); blk.deleteLater()
self.derived_changed.emit()
+ def refresh_derived(self):
+ """Rebuild DerivedBlock widgets from processor state (called after profile load)."""
+ for blk in self._derived_blocks:
+ self._d_lay.removeWidget(blk); blk.deleteLater()
+ self._derived_blocks.clear()
+
+ for dc in self.processor.get_derived():
+ 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)
+
+ self._color_idx = len(self._derived_blocks)
+ self._vis_tab.refresh()
+
def closeEvent(self, e: QCloseEvent):
self.closed.emit(); e.accept()