diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-09-19 17:38:07 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-09-19 17:38:07 -0600 |
| commit | f3327d56d48d63b21ffc03a55fa5e7db753d1707 (patch) | |
| tree | e19d4c7846dd4a434f8a18f82290597731b4a12c /ui/plot_builder.py | |
| parent | 00671a90bb51bfe485d98a928a456d55adbe1faf (diff) | |
Redesign Signal/Channel naming and fix pin-remap breaking references
Arduino analog Signal ids used to be the pin name itself, so fixing a
wrong pin silently changed the id and orphaned any plot trace, control,
or filter pipeline pointing at it. Analog Signal ids are now positional
(ai0, ai1, ...) and decoupled from the underlying pin, matching how
digital I/O already worked.
More broadly, formalizes a Device -> Signal -> Channel -> Plot naming
model (SignalConfig for physical device channels, Channel for derived/
computed ones) and adds core/reference_registry.py to propagate any
Signal or Channel id rename through plot traces, controls, and filter
pipelines instead of leaving them dangling. Signal ids are now editable
in the Signals tab with the same propagation.
.labui profile schema bumped to v2.0 with a transparent migration for
old files, including remapping old pin-name Signal ids to the new
positional ones. Removed six confirmed-dead files (two legacy device
classes, four superseded top-level ui/ duplicates).
Diffstat (limited to 'ui/plot_builder.py')
| -rw-r--r-- | ui/plot_builder.py | 510 |
1 files changed, 0 insertions, 510 deletions
diff --git a/ui/plot_builder.py b/ui/plot_builder.py deleted file mode 100644 index 7445bda..0000000 --- a/ui/plot_builder.py +++ /dev/null @@ -1,510 +0,0 @@ -""" -ui/plot_builder.py - -Plot Builder — floating window for operators to: - • Create named subplot rows - • Drag channels (physical + derived) into any row - • Name each axis, set Y range, grid, color - • Reorder rows with ▲▼ arrows - • Toggle per-trace visibility - • Export / Import config as JSON - -Emits config_applied(ChartConfig) to rebuild the strip chart. -""" - -from __future__ import annotations -import json -from copy import deepcopy -from dataclasses import dataclass, field, asdict -from typing import List, Optional, Tuple - -from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, - QScrollArea, QFrame, QLineEdit, QDoubleSpinBox, - QCheckBox, QComboBox, QColorDialog, QToolButton, - QSizePolicy, QFileDialog, QMessageBox, QSpinBox, -) -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 - - -# ══════════════════════════════════════════════════════════════════════════════ -# Data model (same as before, kept self-contained for clarity) -# ══════════════════════════════════════════════════════════════════════════════ - -@dataclass -class TraceSpec: - device_id: str - channel_id: str - label: str = "" - color: str = "#00d4ff" - width: float = 1.8 - visible: bool = True - style: str = "solid" # solid | dash | dot - -@dataclass -class PlotSpec: - title: str = "Plot" - y_label: str = "" - y_auto: bool = True - y_min: float = -10.0 - y_max: float = 10.0 - grid: bool = True - height: int = 1 # relative weight in splitter - traces: List[TraceSpec] = field(default_factory=list) - -@dataclass -class LayoutConfig: - plots: List[PlotSpec] = field(default_factory=list) - time_window_s: float = 30.0 - link_x: bool = True - show_legend: bool = True - - def to_json(self) -> str: - return json.dumps(asdict(self), indent=2) - - @staticmethod - def from_json(s: str) -> "LayoutConfig": - d = json.loads(s) - plots = [] - for p in d.get("plots", []): - traces = [TraceSpec(**t) for t in p.pop("traces", [])] - plots.append(PlotSpec(**p, traces=traces)) - d.pop("plots") - return LayoutConfig(**d, plots=plots) - - -def build_default_layout(registry: DeviceRegistry, - processor: SignalProcessor) -> LayoutConfig: - cfg = LayoutConfig() - for dev in registry.all_instances(): - enabled = [ch for ch in dev.info.channels if ch.enabled] - if not enabled: continue - p = PlotSpec(title=dev.info.name, - y_label=enabled[0].unit if enabled else "") - for ch in enabled: - p.traces.append(TraceSpec( - device_id=dev.info.device_id, - channel_id=ch.channel_id, - label=ch.name, color=ch.color, - )) - if p.traces: cfg.plots.append(p) - # Add derived channels - for dc in processor.get_derived(): - p = PlotSpec(title=dc.name, y_label=dc.unit) - p.traces.append(TraceSpec( - device_id="derived", channel_id=dc.channel_id, - label=dc.name, color=dc.color, - )) - cfg.plots.append(p) - return cfg - - -# ══════════════════════════════════════════════════════════════════════════════ -# Color swatch button -# ══════════════════════════════════════════════════════════════════════════════ - -class ColorSwatch(QPushButton): - color_changed = pyqtSignal(str) - - def __init__(self, color: str = "#00d4ff"): - super().__init__() - self._color = color - self.setFixedSize(26, 20) - self._paint() - self.clicked.connect(self._pick) - - def _paint(self): - self.setStyleSheet( - f"QPushButton{{background:{self._color};border:1px solid #2a3558;" - f"border-radius:3px;}}QPushButton:hover{{border-color:#3b82f6;}}") - - def _pick(self): - c = QColorDialog.getColor(QColor(self._color), self) - if c.isValid(): - self._color = c.name(); self._paint() - self.color_changed.emit(self._color) - - @property - def color(self): return self._color - @color.setter - def color(self, v): - self._color = v; self._paint() - - -# ══════════════════════════════════════════════════════════════════════════════ -# Trace row inside a plot block -# ══════════════════════════════════════════════════════════════════════════════ - -class TraceRow(QFrame): - removed = pyqtSignal(object) - changed = pyqtSignal() - - def __init__(self, trace: TraceSpec): - super().__init__() - self.trace = trace - self.setObjectName("traceRow") - self._build() - - def _build(self): - lay = QHBoxLayout(self) - lay.setContentsMargins(6, 3, 6, 3); lay.setSpacing(6) - - self._vis = QCheckBox(); self._vis.setChecked(self.trace.visible) - self._vis.setToolTip("Visible"); self._vis.toggled.connect(self._on_vis) - lay.addWidget(self._vis) - - self._color = ColorSwatch(self.trace.color) - self._color.color_changed.connect(self._on_color) - lay.addWidget(self._color) - - src = QLabel(f"{self.trace.device_id} / {self.trace.channel_id}") - src.setObjectName("traceSource"); src.setMinimumWidth(130) - lay.addWidget(src) - - self._lbl = QLineEdit(self.trace.label) - self._lbl.setObjectName("traceLabel"); self._lbl.setPlaceholderText("Label…") - self._lbl.textChanged.connect(lambda t: setattr(self.trace, "label", t)) - lay.addWidget(self._lbl, 1) - - self._style = QComboBox(); self._style.setObjectName("traceStyleCb") - self._style.addItems(["solid", "dash", "dot"]) - self._style.setCurrentText(self.trace.style); self._style.setFixedWidth(64) - self._style.currentTextChanged.connect(lambda s: setattr(self.trace, "style", s)) - lay.addWidget(self._style) - - self._w = QDoubleSpinBox(); self._w.setObjectName("traceWidthSpin") - self._w.setRange(0.5, 6.0); self._w.setSingleStep(0.5) - self._w.setValue(self.trace.width); self._w.setFixedWidth(56) - self._w.valueChanged.connect(lambda v: setattr(self.trace, "width", v)) - lay.addWidget(self._w) - - rm = QToolButton(); rm.setText("✕"); rm.setObjectName("traceRemoveBtn") - rm.setFixedSize(22, 22); rm.clicked.connect(lambda: self.removed.emit(self)) - lay.addWidget(rm) - - def _on_vis(self, v): self.trace.visible = v; self.changed.emit() - def _on_color(self, c): self.trace.color = c; self.changed.emit() - - -# ══════════════════════════════════════════════════════════════════════════════ -# Plot block — one collapsible row per PlotSpec -# ══════════════════════════════════════════════════════════════════════════════ - -class PlotBlock(QFrame): - removed = pyqtSignal(object) - changed = pyqtSignal() - move_up = pyqtSignal(object) - move_dn = pyqtSignal(object) - - def __init__(self, spec: PlotSpec, registry: DeviceRegistry, - processor: SignalProcessor): - super().__init__() - self.spec = spec - self.registry = registry - self.processor = processor - self._rows: List[TraceRow] = [] - self.setObjectName("plotBlock") - 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); hl.setSpacing(4) - - self._title = QLineEdit(self.spec.title) - self._title.setObjectName("plotBlockTitle") - self._title.textChanged.connect(lambda t: (setattr(self.spec, "title", t), self.changed.emit())) - hl.addWidget(self._title, 1) - - for txt, sig in [("▲", self.move_up), ("▼", self.move_dn)]: - b = QToolButton(); b.setText(txt); b.setObjectName("plotMoveBtn") - b.setFixedSize(22,22); b.clicked.connect(lambda _, s=sig: s.emit(self)) - hl.addWidget(b) - - 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) - - # ── Axis / range settings ───────────────────────────────────── - sett = QWidget(); sett.setObjectName("plotBlockSettings") - sl = QHBoxLayout(sett); sl.setContentsMargins(8,6,8,6); sl.setSpacing(8) - - sl.addWidget(QLabel("Y Axis:")) - self._ylabel = QLineEdit(self.spec.y_label) - self._ylabel.setObjectName("plotYLabelEdit"); self._ylabel.setMaximumWidth(130) - self._ylabel.setPlaceholderText("e.g. Voltage (V)") - self._ylabel.textChanged.connect(lambda t: setattr(self.spec, "y_label", t)) - sl.addWidget(self._ylabel) - - self._auto = QCheckBox("Auto Y"); self._auto.setChecked(self.spec.y_auto) - self._auto.toggled.connect(self._on_auto) - sl.addWidget(self._auto) - - sl.addWidget(QLabel("Min:")) - self._ymin = QDoubleSpinBox(); self._ymin.setRange(-1e9,1e9) - self._ymin.setValue(self.spec.y_min); self._ymin.setFixedWidth(72) - self._ymin.setEnabled(not self.spec.y_auto) - self._ymin.valueChanged.connect(lambda v: setattr(self.spec, "y_min", v)) - sl.addWidget(self._ymin) - - sl.addWidget(QLabel("Max:")) - self._ymax = QDoubleSpinBox(); self._ymax.setRange(-1e9,1e9) - self._ymax.setValue(self.spec.y_max); self._ymax.setFixedWidth(72) - self._ymax.setEnabled(not self.spec.y_auto) - self._ymax.valueChanged.connect(lambda v: setattr(self.spec, "y_max", v)) - sl.addWidget(self._ymax) - - self._grid = QCheckBox("Grid"); self._grid.setChecked(self.spec.grid) - self._grid.toggled.connect(lambda v: setattr(self.spec, "grid", v)) - sl.addWidget(self._grid) - - sl.addWidget(QLabel("H:")) - self._height_spin = QSpinBox(); self._height_spin.setRange(1,10) - self._height_spin.setValue(self.spec.height); self._height_spin.setFixedWidth(44) - self._height_spin.setToolTip("Relative row height") - self._height_spin.valueChanged.connect(lambda v: setattr(self.spec, "height", v)) - sl.addWidget(self._height_spin) - sl.addStretch() - outer.addWidget(sett) - - # ── Trace rows ──────────────────────────────────────────────── - traces_w = QWidget(); traces_w.setObjectName("plotBlockTraces") - self._tlay = QVBoxLayout(traces_w) - self._tlay.setContentsMargins(4,2,4,4); self._tlay.setSpacing(2) - - for tr in self.spec.traces: - self._add_trace_row(tr) - - # Channel picker + add button - pick_row = QHBoxLayout() - self._picker = self._make_picker() - pick_row.addWidget(self._picker, 1) - add_btn = QPushButton("+ Add Channel") - add_btn.setObjectName("addTraceBtn") - add_btn.clicked.connect(self._on_add_trace) - pick_row.addWidget(add_btn) - self._tlay.addLayout(pick_row) - outer.addWidget(traces_w) - - def _make_picker(self) -> QComboBox: - cb = QComboBox(); cb.setObjectName("channelPickerCb") - cb.setPlaceholderText("Select channel…") - for dev in self.registry.all_instances(): - for ch in dev.info.channels: - if not ch.enabled: - continue - cb.addItem(f"{ch.name} ({dev.info.device_id}/{ch.channel_id})", - userData=(dev.info.device_id, ch.channel_id, ch.name, ch.color)) - for dc in self.processor.get_derived(): - if not dc.enabled: - continue - cb.addItem(f"{dc.name} ([derived]/{dc.channel_id})", - userData=("derived", dc.channel_id, dc.name, dc.color)) - return cb - - def _add_trace_row(self, trace: TraceSpec): - row = TraceRow(trace) - row.removed.connect(self._remove_trace) - row.changed.connect(self.changed) - self._rows.append(row) - self._tlay.insertWidget(self._tlay.count() - 1, row) - - def _remove_trace(self, row: TraceRow): - if row.trace in self.spec.traces: self.spec.traces.remove(row.trace) - self._rows.remove(row) - self._tlay.removeWidget(row); row.deleteLater() - self.changed.emit() - - def _on_add_trace(self): - data = self._picker.currentData() - if not data: return - dev_id, ch_id, ch_name, ch_color = data - tr = TraceSpec(device_id=dev_id, channel_id=ch_id, - label=ch_name, color=ch_color) - self.spec.traces.append(tr) - self._add_trace_row(tr) - self.changed.emit() - - def _on_auto(self, v: bool): - self.spec.y_auto = v - self._ymin.setEnabled(not v); self._ymax.setEnabled(not v) - - -# ══════════════════════════════════════════════════════════════════════════════ -# PlotBuilderWindow -# ══════════════════════════════════════════════════════════════════════════════ - -class PlotBuilderWindow(QWidget): - """ - Floating Plot Builder. Emits config_applied(LayoutConfig) on Apply. - """ - - config_applied = pyqtSignal(object) # LayoutConfig - closed = pyqtSignal() - - def __init__(self, registry: DeviceRegistry, - processor: SignalProcessor, - current: Optional[LayoutConfig] = None, - parent=None): - super().__init__(parent, Qt.WindowType.Window | Qt.WindowType.Tool) - self.registry = registry - self.processor = processor - self.cfg = deepcopy(current) if current \ - else build_default_layout(registry, processor) - - self.setWindowTitle("Plot Builder") - self.setMinimumSize(700, 780) - self.resize(800, 780) - self._blocks: List[PlotBlock] = [] - self._build() - self._populate() - - def _build(self): - root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0); root.setSpacing(0) - - # ── Top bar ────────────────────────────────────────────────── - top = QWidget(); top.setObjectName("cfgTopBar"); top.setFixedHeight(44) - tl = QHBoxLayout(top); tl.setContentsMargins(12,0,12,0) - title = QLabel("📐 PLOT BUILDER"); title.setObjectName("cfgTopBarTitle") - tl.addWidget(title, 1) - self._live = QCheckBox("Live preview"); self._live.setObjectName("cfgLiveChk") - tl.addWidget(self._live) - root.addWidget(top) - - # ── Global settings ────────────────────────────────────────── - glob = QWidget(); glob.setObjectName("cfgGlobalBar") - gl = QHBoxLayout(glob); gl.setContentsMargins(12,6,12,6); gl.setSpacing(12) - gl.addWidget(QLabel("Time window:")) - self._win = QDoubleSpinBox(); self._win.setRange(1,3600) - self._win.setSuffix(" s"); self._win.setValue(self.cfg.time_window_s) - self._win.setObjectName("cfgGlobalSpin") - self._win.valueChanged.connect(self._global_changed) - gl.addWidget(self._win) - self._linkx = QCheckBox("Link X"); self._linkx.setChecked(self.cfg.link_x) - self._linkx.setObjectName("cfgLiveChk"); self._linkx.toggled.connect(self._global_changed) - gl.addWidget(self._linkx) - self._legend = QCheckBox("Legend"); self._legend.setChecked(self.cfg.show_legend) - self._legend.setObjectName("cfgLiveChk"); self._legend.toggled.connect(self._global_changed) - gl.addWidget(self._legend) - gl.addStretch() - add_row_btn = QPushButton("+ Add Plot Row"); add_row_btn.setObjectName("addDeviceButton") - add_row_btn.clicked.connect(self._add_plot) - gl.addWidget(add_row_btn) - root.addWidget(glob) - - div = QFrame(); div.setFrameShape(QFrame.Shape.HLine); div.setObjectName("devWindowDivider") - root.addWidget(div) - - # ── Scrollable plot blocks ─────────────────────────────────── - scroll = QScrollArea(); scroll.setWidgetResizable(True); scroll.setObjectName("deviceScroll") - self._container = QWidget() - self._blay = QVBoxLayout(self._container) - self._blay.setContentsMargins(10,10,10,10); self._blay.setSpacing(12) - self._blay.addStretch() - scroll.setWidget(self._container) - root.addWidget(scroll, 1) - - # ── Bottom bar ─────────────────────────────────────────────── - btm = QWidget(); btm.setObjectName("cfgBottomBar") - bl = QHBoxLayout(btm); bl.setContentsMargins(12,8,12,8) - for lbl, fn in [("⟳ Rebuild from Devices", self._reset), - ("↓ Export JSON", self._export), - ("↑ Import JSON", self._import)]: - b = QPushButton(lbl); b.setObjectName("configButton"); b.clicked.connect(fn) - bl.addWidget(b) - bl.addStretch() - ap = QPushButton("✓ Apply"); ap.setObjectName("applyButton") - ap.clicked.connect(self._apply) - bl.addWidget(ap) - root.addWidget(btm) - - def _populate(self): - for b in self._blocks: - self._blay.removeWidget(b); b.deleteLater() - self._blocks.clear() - for spec in self.cfg.plots: - self._insert_block(spec) - - def _insert_block(self, spec: PlotSpec): - blk = PlotBlock(spec, self.registry, self.processor) - blk.removed.connect(self._remove_block) - blk.changed.connect(self._maybe_live) - blk.move_up.connect(self._move_up) - blk.move_dn.connect(self._move_dn) - self._blocks.append(blk) - self._blay.insertWidget(self._blay.count() - 1, blk) - - def _add_plot(self): - spec = PlotSpec(title=f"Plot {len(self.cfg.plots)+1}") - self.cfg.plots.append(spec) - self._insert_block(spec) - self._maybe_live() - - def _remove_block(self, blk: PlotBlock): - if blk.spec in self.cfg.plots: self.cfg.plots.remove(blk.spec) - self._blocks.remove(blk) - self._blay.removeWidget(blk); blk.deleteLater() - self._maybe_live() - - def _move_up(self, blk: PlotBlock): - i = self._blocks.index(blk) - if i == 0: return - self.cfg.plots.insert(i-1, self.cfg.plots.pop(i)) - self._blocks.insert(i-1, self._blocks.pop(i)) - self._blay.removeWidget(blk); self._blay.insertWidget(i-1, blk) - self._maybe_live() - - def _move_dn(self, blk: PlotBlock): - i = self._blocks.index(blk) - if i >= len(self._blocks)-1: return - self.cfg.plots.insert(i+1, self.cfg.plots.pop(i)) - self._blocks.insert(i+1, self._blocks.pop(i)) - self._blay.removeWidget(blk); self._blay.insertWidget(i+1, blk) - self._maybe_live() - - def _global_changed(self): - self.cfg.time_window_s = self._win.value() - self.cfg.link_x = self._linkx.isChecked() - self.cfg.show_legend = self._legend.isChecked() - self._maybe_live() - - def _maybe_live(self): - if self._live.isChecked(): - self.config_applied.emit(deepcopy(self.cfg)) - - def _apply(self): - self.config_applied.emit(deepcopy(self.cfg)) - - def _reset(self): - self.cfg = build_default_layout(self.registry, self.processor) - self._win.setValue(self.cfg.time_window_s) - self._populate(); self._maybe_live() - - def _export(self): - path, _ = QFileDialog.getSaveFileName(self, "Export Layout", "layout.json", "JSON (*.json)") - if path: - open(path, "w").write(self.cfg.to_json()) - - def _import(self): - path, _ = QFileDialog.getOpenFileName(self, "Import Layout", "", "JSON (*.json)") - if path: - try: - self.cfg = LayoutConfig.from_json(open(path).read()) - self._win.setValue(self.cfg.time_window_s) - self._populate(); self._maybe_live() - except Exception as e: - QMessageBox.critical(self, "Import failed", str(e)) - - def refresh_channels(self): - """Call after devices/derived channels change.""" - self._populate() - - def closeEvent(self, event: QCloseEvent): - self.closed.emit(); event.accept() |
