summaryrefslogtreecommitdiff
path: root/ui/windows/channels_window.py
diff options
context:
space:
mode:
Diffstat (limited to 'ui/windows/channels_window.py')
-rw-r--r--ui/windows/channels_window.py305
1 files changed, 237 insertions, 68 deletions
diff --git a/ui/windows/channels_window.py b/ui/windows/channels_window.py
index 38d477b..2388b83 100644
--- a/ui/windows/channels_window.py
+++ b/ui/windows/channels_window.py
@@ -215,7 +215,8 @@ class ChannelPipelineBlock(QFrame):
removed = pyqtSignal(object)
def __init__(self, dev_id: str, ch_id: str, ch_name: str, unit: str,
- processor: SignalProcessor):
+ processor: SignalProcessor, show_calibration: bool = True,
+ show_header: bool = True):
super().__init__()
self.dev_id = dev_id
self.ch_id = ch_id
@@ -224,6 +225,8 @@ class ChannelPipelineBlock(QFrame):
self._cal_rows: List[FilterRow] = []
self._cur_pipeline: Optional[ChannelPipeline] = None
self._expanded = False
+ self._show_calibration = show_calibration
+ self._show_header = show_header
self.setObjectName("plotBlock")
self._build(ch_name, unit)
self._load()
@@ -233,26 +236,28 @@ class ChannelPipelineBlock(QFrame):
self._unit = unit
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)
- title = f"{ch_name} ({self.dev_id}/{self.ch_id})"
- if unit:
- title += f" [{unit}]"
- self._title_lbl = QLabel(title); self._title_lbl.setObjectName("traceSource")
- hl.addWidget(self._title_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()
+ # Header (omitted when embedded in another block, e.g. DerivedBlock's
+ # Filters section — the parent already provides a header/toggle)
+ if self._show_header:
+ hdr = QWidget(); hdr.setObjectName("plotBlockHeader"); hdr.setFixedHeight(32)
+ hl = QHBoxLayout(hdr); hl.setContentsMargins(8, 0, 6, 0)
+ title = f"{ch_name} ({self.dev_id}/{self.ch_id})"
+ if unit:
+ title += f" [{unit}]"
+ self._title_lbl = QLabel(title); self._title_lbl.setObjectName("traceSource")
+ hl.addWidget(self._title_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()
# Body
self._body = QWidget(); self._body.setObjectName("plotBlockSettings")
@@ -281,21 +286,25 @@ class ChannelPipelineBlock(QFrame):
div.setObjectName("devWindowDivider")
bl.addWidget(div)
- # ── Calibration section ───────────────────────────────────────────────
- cal_lbl = QLabel("Calibration"); cal_lbl.setObjectName("pwmLabel")
- bl.addWidget(cal_lbl)
-
+ # ── Calibration section — hidden when embedded (calibration is now
+ # configured as a Channel "Scale/Offset" Operation instead), but the
+ # widgets still exist so a legacy scale_offset filter already saved
+ # on this pipeline (from an older profile) is preserved by _push()
+ # rather than silently dropped the next time a filter is edited.
self._cal_area = QWidget()
self._cal_lay = QVBoxLayout(self._cal_area)
self._cal_lay.setContentsMargins(0, 0, 0, 0); self._cal_lay.setSpacing(3)
- bl.addWidget(self._cal_area)
-
- cal_add_bar = QHBoxLayout()
- cal_add_btn = QPushButton("+ Add Calibration"); cal_add_btn.setObjectName("addTraceBtn")
- cal_add_btn.clicked.connect(self._add_calibrate)
- cal_add_bar.addWidget(cal_add_btn)
- cal_add_bar.addStretch()
- bl.addLayout(cal_add_bar)
+ if self._show_calibration:
+ cal_lbl = QLabel("Calibration"); cal_lbl.setObjectName("pwmLabel")
+ bl.addWidget(cal_lbl)
+ bl.addWidget(self._cal_area)
+
+ cal_add_bar = QHBoxLayout()
+ cal_add_btn = QPushButton("+ Add Calibration"); cal_add_btn.setObjectName("addTraceBtn")
+ cal_add_btn.clicked.connect(self._add_calibrate)
+ cal_add_bar.addWidget(cal_add_btn)
+ cal_add_bar.addStretch()
+ bl.addLayout(cal_add_bar)
ap_row = QHBoxLayout(); ap_row.addStretch()
ap_btn = QPushButton("✓ Apply"); ap_btn.setObjectName("applyButton")
@@ -304,9 +313,12 @@ class ChannelPipelineBlock(QFrame):
bl.addLayout(ap_row)
outer.addWidget(self._body)
- self._body.setVisible(False)
+ self._body.setVisible(self._show_header is False)
+ self._expanded = not self._show_header
def _refresh_title(self):
+ if not self._show_header:
+ return
title = f"{self._ch_name} ({self.dev_id}/{self.ch_id})"
if self._unit:
title += f" [{self._unit}]"
@@ -447,7 +459,7 @@ class PipelineTab(QWidget):
vb_lay.addWidget(vb_lbl)
self._src_cb = _channel_combo(self.registry, self.processor, include_derived=True)
self._src_cb.setObjectName("channelPickerCb")
- self._src_cb.insertItem(0, "Source: none (empty channel)", userData=None)
+ self._src_cb.insertItem(0, "Signal: none (empty channel)", userData=None)
self._src_cb.setCurrentIndex(0)
vb_lay.addWidget(self._src_cb, 1)
add_virt = QPushButton("+ Add Channel"); add_virt.setObjectName("addTraceBtn")
@@ -521,6 +533,7 @@ class PipelineTab(QWidget):
block = self._blocks.get(key)
if block:
self._on_remove_block(block)
+ self._refresh_src_cb()
def on_device_reconfigured(self, dev_id: str):
dev = self.registry.get_instance(dev_id)
@@ -534,9 +547,10 @@ class PipelineTab(QWidget):
block = self._blocks.get(key)
if block:
self._on_remove_block(block)
+ self._refresh_src_cb()
def on_device_added(self):
- pass
+ self._refresh_src_cb()
def on_channel_name_changed(self, dev_id: str, ch_id: str, ch_name: str):
key = (dev_id, ch_id)
@@ -548,12 +562,42 @@ class PipelineTab(QWidget):
ch = dev.get_channel(ch_id)
if ch and ch.enabled:
self._add_block(dev_id, ch_id, ch_name, ch.unit)
+ self._refresh_src_cb()
def on_channel_unit_changed(self, dev_id: str, ch_id: str, unit: str):
key = (dev_id, ch_id)
if key in self._blocks:
self._blocks[key].update_unit(unit)
+ def _refresh_src_cb(self):
+ """Rebuild the 'Source: …' picker at the top of the Channels pane —
+ it's a persistent combo (not a dialog), so it must be kept in sync
+ whenever devices/channels are added, removed, or renamed, not just
+ built once at construction time."""
+ current = self._src_cb.currentData()
+ self._src_cb.blockSignals(True)
+ self._src_cb.clear()
+ self._src_cb.insertItem(0, "Signal: none (empty channel)", userData=None)
+ for dev in self.registry.all_instances():
+ for ch in dev.info.channels:
+ if not ch.enabled:
+ continue
+ label = f"{ch.name} ({dev.info.device_id}/{ch.channel_id})"
+ self._src_cb.addItem(label, userData=(dev.info.device_id, ch.channel_id))
+ for dc in self.processor.get_derived():
+ label = f"[derived] {dc.channel_id} ({dc.name})"
+ self._src_cb.addItem(label, userData=("derived", dc.channel_id))
+ if current:
+ for i in range(self._src_cb.count()):
+ if self._src_cb.itemData(i) == current:
+ self._src_cb.setCurrentIndex(i)
+ break
+ else:
+ self._src_cb.setCurrentIndex(0)
+ else:
+ self._src_cb.setCurrentIndex(0)
+ self._src_cb.blockSignals(False)
+
def _on_add_channel(self):
dlg = ChannelPickerDialog(self.registry, self._shown, self)
if dlg.exec() != QDialog.DialogCode.Accepted:
@@ -579,14 +623,21 @@ class PipelineTab(QWidget):
def _add_virtual(self):
color = _DERIVED_COLORS[self._color_idx % len(_DERIVED_COLORS)]
self._color_idx += 1
- n = len(self._derived_blocks) + 1
- dc = DerivedChannel(channel_id=f"CH_{n}", name=f"Channel {n}",
+ existing_names = {d.name for d in self.processor.get_derived()}
+ existing_ids = {d.channel_id for d in self.processor.get_derived()}
+ # Pre-seed the source picked in the bar above, if any — otherwise the
+ # channel is created empty and signals can be added manually.
+ src = self._src_cb.currentData()
+ if src:
+ src_name = _channel_display_name(self.registry, self.processor, src[0], src[1])
+ else:
+ src_name = f"Channel {len(self._derived_blocks) + 1}"
+ name = _dedupe_name(src_name, existing_names)
+ channel_id = _slug_id(name, existing_ids)
+ dc = DerivedChannel(channel_id=channel_id, name=name,
kind="expression", color=color)
blk = self._make_derived_block(dc)
self._virt_inner.insertWidget(self._virt_inner.count() - 1, blk)
- # Pre-seed the source picked in the bar above, if any — otherwise the
- # channel is created empty and sources can be added manually.
- src = self._src_cb.currentData()
if src:
blk._add_src(src)
@@ -615,45 +666,85 @@ class PipelineTab(QWidget):
self._virt_inner.insertWidget(self._virt_inner.count() - 1, blk)
self._color_idx = len(self._derived_blocks)
+ self._refresh_src_cb()
# ══════════════════════════════════════════════════════════════════════════════
# Derived editor for one DerivedChannel
# ══════════════════════════════════════════════════════════════════════════════
-_BUILTIN = ["none", "derivative", "second_derivative", "power", "rms", "difference", "sum"]
+_BUILTIN = ["none", "scale_offset", "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_DISPLAY["none"] = "None"
+_KIND_DISPLAY["scale_offset"] = "Scale/Offset (Calibration)"
_KIND_HELP = {
- "none": "Pass source through unchanged",
- "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",
- "sum": "Σ all source values",
+ "none": "Pass signal through unchanged",
+ "scale_offset": "Linear calibration from 1 signal: result = value × scale + offset",
+ "derivative": "Backward difference dy/dt from 1 signal",
+ "second_derivative": "Second derivative d²y/dt² from 1 signal",
+ "power": "V × I from 2 signals (voltage, current)",
+ "rms": "Rolling RMS from 1 signal (set window below)",
+ "difference": "signal[0] − signal[1] from 2 signals",
+ "sum": "Σ all signal values",
"expression": (
"Single-line Python expression.\n"
"Available: x[0]… or use signal names directly (e.g. A0, encoder_0)\n"
" t = elapsed time (s), math, vars, state\n"
- "Example: A0 * 3.14159 / 180"
+ "Example: A0 * 3.14159 / 180\n"
+ "Tip: vars is shared with Control Panel 'On Action Script' — a button\n"
+ " can set vars['specimen_length'] = value; read it here as\n"
+ " vars.get('specimen_length', 1.0)."
),
"function": (
"Multi-line function body — auto-wrapped as def compute(x, t):\n"
"Available globals: x, signal names, t, math, vars, state\n"
- "Example:\n offset = vars.get('zero', 0)\n return x[0] - offset"
+ "Example:\n offset = vars.get('zero', 0)\n return x[0] - offset\n"
+ "Tip: vars is shared with Control Panel 'On Action Script' — a 'Zero'\n"
+ " button can set vars['zero'] = value while this reads it back."
),
"custom_script": (
"Multi-line Python block. Assign computed value to result.\n"
- "Available: x, signal names, t, math, vars, state"
+ "Available: x, signal names, t, math, vars, state\n"
+ "Tip: vars is shared with Control Panel 'On Action Script' — a button\n"
+ " can set vars['key'] = value; read it here as vars.get('key', default)."
),
}
+def _slug_id(name: str, existing: Set[str]) -> str:
+ """Short-hand channel_id derived from a display name, deduplicated against
+ existing derived channel IDs so a new Channel never silently overwrites
+ (goes stale on top of) an existing one."""
+ words = re.findall(r"[A-Za-z0-9]+", name)
+ if not words:
+ base = "CH"
+ elif len(words) == 1:
+ base = words[0][:4].upper()
+ else:
+ base = "".join(w[0] for w in words[:4]).upper()
+ slug = base
+ i = 2
+ while slug in existing:
+ slug = f"{base}_{i}"
+ i += 1
+ return slug
+
+
+def _dedupe_name(name: str, existing: Set[str]) -> str:
+ """Two channels must never share a display name — append ' (2)', ' (3)'...
+ until unique."""
+ if name not in existing:
+ return name
+ i = 2
+ while f"{name} ({i})" in existing:
+ i += 1
+ return f"{name} ({i})"
+
+
def _sub_xrefs(code: str, names: list) -> str:
"""Replace x[0], x[1], ... with named source vars where index is in range."""
def repl(m):
@@ -712,6 +803,22 @@ class DerivedBlock(QFrame):
meta.addWidget(self._unit); meta.addStretch()
bl.addLayout(meta)
+ # Sources — include derived channels as valid sources
+ src_grp = QGroupBox("Signals"); 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)
+ _is_deriv = self.dc.kind in ("derivative", "second_derivative")
+ if _is_deriv:
+ self._add_src(self.dc.sources[0] if self.dc.sources else None)
+ else:
+ for _s in self.dc.sources:
+ self._add_src(_s)
+ self._add_src_btn = QPushButton("+ Add Signal"); self._add_src_btn.setObjectName("addTraceBtn")
+ self._add_src_btn.clicked.connect(lambda: self._add_src())
+ src_lay.addWidget(self._src_cont); src_lay.addWidget(self._add_src_btn)
+ bl.addWidget(src_grp)
+
# Kind combo + help
k_row = QHBoxLayout(); k_row.addWidget(QLabel("Operation:"))
self._kind_cb = QComboBox(); self._kind_cb.setObjectName("channelPickerCb")
@@ -736,21 +843,30 @@ class DerivedBlock(QFrame):
self._win_widget = QWidget(); self._win_widget.setLayout(self._win_row)
bl.addWidget(self._win_widget)
- # 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)
- _is_deriv = self.dc.kind in ("derivative", "second_derivative")
- if _is_deriv:
- self._add_src(self.dc.sources[0] if self.dc.sources else None)
- else:
- for _s in self.dc.sources:
- self._add_src(_s)
- self._add_src_btn = QPushButton("+ Add Source"); self._add_src_btn.setObjectName("addTraceBtn")
- self._add_src_btn.clicked.connect(lambda: self._add_src())
- src_lay.addWidget(self._src_cont); src_lay.addWidget(self._add_src_btn)
- bl.addWidget(src_grp)
+ # Scale/Offset params
+ self._so_row = QHBoxLayout()
+ self._so_row.addWidget(QLabel("Scale:"))
+ self._scale_sp = QDoubleSpinBox(); self._scale_sp.setRange(-1e6, 1e6); self._scale_sp.setDecimals(6)
+ self._scale_sp.setValue(self.dc.params.get("scale", 1.0))
+ self._scale_sp.setObjectName("traceWidthSpin"); self._scale_sp.setFixedWidth(90)
+ self._scale_sp.valueChanged.connect(lambda v: self.dc.params.update({"scale": v}))
+ self._so_row.addWidget(self._scale_sp)
+ self._so_row.addWidget(QLabel("Offset:"))
+ self._offset_sp = QDoubleSpinBox(); self._offset_sp.setRange(-1e9, 1e9); self._offset_sp.setDecimals(6)
+ self._offset_sp.setValue(self.dc.params.get("offset", 0.0))
+ self._offset_sp.setObjectName("traceWidthSpin"); self._offset_sp.setFixedWidth(90)
+ self._offset_sp.valueChanged.connect(lambda v: self.dc.params.update({"offset": v}))
+ self._so_row.addWidget(self._offset_sp); self._so_row.addStretch()
+ self._so_widget = QWidget(); self._so_widget.setLayout(self._so_row)
+ bl.addWidget(self._so_widget)
+
+ # Filters — only for a single-physical-source passthrough channel
+ # (kind="none"); the embedded block is (re)built by _refresh_filters()
+ self._filters_grp = QGroupBox("Filters"); self._filters_grp.setObjectName("cfgGlobalBar")
+ self._filters_lay = QVBoxLayout(self._filters_grp)
+ self._filters_lay.setContentsMargins(6, 4, 6, 4)
+ self._filters_block: Optional[ChannelPipelineBlock] = None
+ bl.addWidget(self._filters_grp)
# W.r.t. — only for derivative/second_derivative
self._wrt_grp = QGroupBox("With Respect To"); self._wrt_grp.setObjectName("cfgGlobalBar")
@@ -876,8 +992,10 @@ class DerivedBlock(QFrame):
if wrt_src and wrt_src not in sources:
sources.append(wrt_src)
+ self._refresh_filters()
+
if not sources:
- self._vars_lbl.setText("No sources — add sources above to use named variables")
+ self._vars_lbl.setText("No signals — add signals above to use named variables")
return
display_names = [_channel_display_name(self.registry, self.processor, d, c)
@@ -903,6 +1021,36 @@ class DerivedBlock(QFrame):
self._code.setTextCursor(c)
self._code.blockSignals(False)
+ def _refresh_filters(self):
+ """(Re)build the embedded single-source Filters block when eligible:
+ kind is a plain passthrough ("none") with exactly one physical source."""
+ if not hasattr(self, "_filters_grp"):
+ return # called during initial Sources construction, too early
+ sources = [cb.currentData() for cb in self._src_combos if cb.currentData()]
+ eligible = (self.dc.kind == "none" and len(sources) == 1
+ and sources[0][0] != "derived")
+ if not eligible:
+ self._filters_grp.setVisible(False)
+ if self._filters_block is not None:
+ self._filters_lay.removeWidget(self._filters_block)
+ self._filters_block.deleteLater()
+ self._filters_block = None
+ return
+ dev_id, ch_id = sources[0]
+ if (self._filters_block is not None
+ and (self._filters_block.dev_id, self._filters_block.ch_id) != (dev_id, ch_id)):
+ self._filters_lay.removeWidget(self._filters_block)
+ self._filters_block.deleteLater()
+ self._filters_block = None
+ if self._filters_block is None:
+ ch_name = _channel_display_name(self.registry, self.processor, dev_id, ch_id)
+ self._filters_block = ChannelPipelineBlock(
+ dev_id, ch_id, ch_name, "", self.processor,
+ show_calibration=False, show_header=False,
+ )
+ self._filters_lay.addWidget(self._filters_block)
+ self._filters_grp.setVisible(True)
+
def _on_kind(self, idx: int):
from ui.code_templates import BUILTIN_TEMPLATES, SCRIPT_TEMPLATES
k = _ALL_KINDS[idx]
@@ -958,12 +1106,33 @@ class DerivedBlock(QFrame):
self._code.setReadOnly(is_builtin)
self._edit_as_script_btn.setVisible(is_builtin)
self._win_widget.setVisible(self.dc.kind == "rms")
+ self._so_widget.setVisible(self.dc.kind == "scale_offset")
+ self._refresh_filters()
self._wrt_grp.setVisible(is_deriv)
self._add_src_btn.setVisible(not is_deriv)
if is_deriv:
self._wrt_x_row.setVisible(self._wrt_cb.currentIndex() == 1)
def _apply(self):
+ name = self.dc.name.strip()
+ channel_id = self.dc.channel_id.strip()
+ if not name:
+ self._st.setText("⚠ Name cannot be empty")
+ self._st.setStyleSheet("color:#ef4444;")
+ return
+ if not channel_id:
+ self._st.setText("⚠ ID cannot be empty")
+ self._st.setStyleSheet("color:#ef4444;")
+ return
+ others = [d for d in self.processor.get_derived() if d is not self.dc]
+ if any(d.name == name for d in others):
+ self._st.setText(f"⚠ Name '{name}' is already used by another channel")
+ self._st.setStyleSheet("color:#ef4444;")
+ return
+ if any(d.channel_id == channel_id for d in others):
+ self._st.setText(f"⚠ ID '{channel_id}' is already used by another channel")
+ self._st.setStyleSheet("color:#ef4444;")
+ return
sources = [cb.currentData() for cb in self._src_combos if cb.currentData()]
if self.dc.kind in ("derivative", "second_derivative"):
wrt = "channel" if self._wrt_cb.currentIndex() == 1 else "time"