diff options
| author | Christian Kolset <ckolset@colostate.edu> | 2026-08-10 16:12:05 -0600 |
|---|---|---|
| committer | Christian Kolset <ckolset@colostate.edu> | 2026-08-10 16:12:05 -0600 |
| commit | 5d5bcaed8639a162fda85c8cd0d340c296a054b8 (patch) | |
| tree | a7a1a62fb607c9c85127dab4aab17ccf8e22c906 /ui/windows/channels_window.py | |
| parent | 46231c00994cd681d598961f2056dbda31c3e483 (diff) | |
Unify Signals/Channels around auto-naming, plot-by-channel, and shared vars
Devices: auto-name from OS-reported port description (Device Manager /
udev string) instead of a generic label; default Serial #Channels 1;
consistent simulate-mode checkbox across backends; inline device rename.
Channels: calibration/filters move out of Signals into a Channel
"Operation" (adds Scale/Offset alongside the existing math kinds);
single-physical-source Channels get an embedded Filters block; new
Channels auto-name/auto-ID from their source and reject duplicate
names/IDs on apply; "Create Channel" button on each Signal row; renamed
"Sources" -> "Signals" in the Channels UI; Signals' READ/WRITE dir badge
(was silently-broken IN/OUT) now reflects the real writable flag.
Plot: default layout is one pane per Channel (not per device, not raw
Signals) titled by channel name; "+ Add Channel" is a checkbox dialog
instead of an inline dropdown; traces get up/down reorder; live unit
changes (e.g. a Mark-10 gauge switching lb/kgF/N/ozF) propagate to an
open pane's axis label without clobbering a manually-typed one.
Control Panel: hardware assignment now picks from the same writable-
Signals list instead of a separate device/channel combo with a
hardcoded Arduino pin list; stale visual indicator when a control's
target device/channel disconnects or disappears; documented the `vars`
bridge (Control Panel On Action Script <-> Channel scripts) in both
directions' help text and script templates.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Diffstat (limited to 'ui/windows/channels_window.py')
| -rw-r--r-- | ui/windows/channels_window.py | 305 |
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" |
