diff options
| author | Christian Kolset <ckolset@colostate.edu> | 2026-08-03 15:32:56 -0600 |
|---|---|---|
| committer | Christian Kolset <ckolset@colostate.edu> | 2026-08-03 15:32:56 -0600 |
| commit | 9bcad5d3af258eb72bc9c8ac87e3f1713e1b8b80 (patch) | |
| tree | bd2b07ae968e3b422c536fab21f57a5b660d4b98 | |
| parent | 68a0c5ba06f471d1d63aedfb61d708faa4b76565 (diff) | |
Fix acquisition pipeline stutter with many channels
Batches per-tick device readings into one signal emit instead of one
per channel, evaluates derived channels once per tick instead of once
per raw sample, and makes ChannelBuffer.window() cost scale with the
window size instead of total buffer history. Strip chart X-range now
scrolls on an independent ~30fps timer instead of only snapping when
new data lands, which was the main visible cause of the stutter.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
| -rw-r--r-- | core/acquisition.py | 41 | ||||
| -rw-r--r-- | core/signal_processor.py | 18 | ||||
| -rw-r--r-- | ui/main_window.py | 2 | ||||
| -rw-r--r-- | ui/strip_chart.py | 54 |
4 files changed, 89 insertions, 26 deletions
diff --git a/core/acquisition.py b/core/acquisition.py index a984c71..4fffb8b 100644 --- a/core/acquisition.py +++ b/core/acquisition.py @@ -33,13 +33,22 @@ class ChannelBuffer: self.values.append(v) def window(self, seconds: float) -> Tuple[List[float], List[float]]: + """Last `seconds` of data. Walks in from the newest sample only — + cost is proportional to the window size, not the full buffer + (which can hold far more history than is ever displayed).""" if not self.times: return [], [] cutoff = self.times[-1] - seconds - ts = list(self.times) - vs = list(self.values) - idx = next((i for i, t in enumerate(ts) if t >= cutoff), 0) - return ts[idx:], vs[idx:] + out_t: List[float] = [] + out_v: List[float] = [] + for t, v in zip(reversed(self.times), reversed(self.values)): + if t < cutoff: + break + out_t.append(t) + out_v.append(v) + out_t.reverse() + out_v.reverse() + return out_t, out_v def all(self) -> Tuple[List[float], List[float]]: return list(self.times), list(self.values) @@ -61,13 +70,14 @@ class AcquisitionEngine(QObject): Signals ------- - new_data(device_id, channel_id, timestamp, value) + new_data(timestamp, readings) # readings: list[(device_id, channel_id, value)] — + # one batched emit per poll tick, not one per channel device_status_changed(device_id, status_str) log_started(filepath) log_stopped(filepath) """ - new_data = pyqtSignal(str, str, float, float) + new_data = pyqtSignal(float, list) device_status_changed = pyqtSignal(str, str) log_started = pyqtSignal(str) log_stopped = pyqtSignal(str) @@ -109,6 +119,14 @@ class AcquisitionEngine(QObject): # ── Start / stop ───────────────────────────────────────────────────── + def is_running(self) -> bool: + return self._running + + def elapsed_now(self) -> float: + """Wall-clock elapsed since start() — lets the UI scroll the strip + chart smoothly between poll ticks instead of only on data arrival.""" + return time.time() - self._t0 if self._running else 0.0 + def start(self): if self._running: return @@ -165,6 +183,7 @@ class AcquisitionEngine(QObject): t_start = time.time() elapsed = t_start - self._t0 log_row = [f"{elapsed:.4f}"] + batch: List[Tuple[str, str, float]] = [] for dev in list(self._devices): active = dev.status in (DeviceStatus.CONNECTED, DeviceStatus.SIMULATED) @@ -179,11 +198,15 @@ class AcquisitionEngine(QObject): if val is None or not ch.enabled: log_row.append("") continue + fval = float(val) buf = self._buffers.get(dev.info.device_id, {}).get(ch.channel_id) if buf is not None: - buf.append(elapsed, float(val)) - self.new_data.emit(dev.info.device_id, ch.channel_id, elapsed, float(val)) - log_row.append(f"{val:.5f}") + buf.append(elapsed, fval) + batch.append((dev.info.device_id, ch.channel_id, fval)) + log_row.append(f"{fval:.5f}") + + if batch: + self.new_data.emit(elapsed, batch) if self._logging and self._csv_writer: try: diff --git a/core/signal_processor.py b/core/signal_processor.py index bfb7ba3..8ab3937 100644 --- a/core/signal_processor.py +++ b/core/signal_processor.py @@ -380,7 +380,7 @@ class SignalProcessor(QObject): Applies filter pipelines to raw channel data, then evaluates all derived channels and emits processed_data for everything. - Connect: engine.new_data → processor.on_raw_data + Connect: engine.new_data → processor.on_raw_batch Connect: processor.processed_data → chart.on_new_data """ @@ -468,9 +468,16 @@ class SignalProcessor(QObject): # ── Main data path ──────────────────────────────────────────────────── - def on_raw_data(self, device_id: str, channel_id: str, - timestamp: float, value: float): - """Slot: receive raw data, apply filters, emit processed, update derived.""" + def on_raw_batch(self, timestamp: float, readings: List[Tuple[str, str, float]]): + """Slot: receive one tick's worth of raw readings, apply filters and + emit processed for each, then evaluate derived channels once for the + whole batch — not once per channel.""" + for device_id, channel_id, value in readings: + self._process_one(device_id, channel_id, timestamp, value) + self._evaluate_derived(timestamp) + + def _process_one(self, device_id: str, channel_id: str, + timestamp: float, value: float): key = (device_id, channel_id) # Apply filter pipeline @@ -485,9 +492,6 @@ class SignalProcessor(QObject): # Emit processed physical channel self.processed_data.emit(device_id, channel_id, timestamp, processed) - # Evaluate all derived channels whose sources include this channel - self._evaluate_derived(timestamp) - def _evaluate_derived(self, timestamp: float): with self._lock: derived = list(self._derived) diff --git a/ui/main_window.py b/ui/main_window.py index 8f7a3be..616f16d 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -304,7 +304,7 @@ class MainWindow(QMainWindow): self._ctrl_dock.raise_() def _connect_signals(self): - self.engine.new_data.connect(self.processor.on_raw_data) + self.engine.new_data.connect(self.processor.on_raw_batch) self.processor.processed_data.connect(self._chart.on_new_data) self.engine.log_started.connect(lambda p: self._log_lbl.setText(f"● {p}")) self.engine.log_stopped.connect(lambda p: self._log_lbl.setText(f"✓ {p}")) diff --git a/ui/strip_chart.py b/ui/strip_chart.py index 5a1d1e9..a212e5c 100644 --- a/ui/strip_chart.py +++ b/ui/strip_chart.py @@ -14,7 +14,7 @@ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QPushButton, QSizePolicy, ) -from PyQt6.QtCore import Qt, pyqtSlot +from PyQt6.QtCore import Qt, pyqtSlot, QTimer from PyQt6.QtGui import QFont try: @@ -52,6 +52,22 @@ _STYLES = { } +def _windowed(times_dq, values_dq, seconds: float): + """Last `seconds` of a (times, values) deque pair, walking in from the + newest sample only — same trick as ChannelBuffer.window(), needed here + for derived-channel buffers which aren't wrapped in ChannelBuffer.""" + if not times_dq: + return [], [] + cutoff = times_dq[-1] - seconds + out_t, out_v = [], [] + for t, v in zip(reversed(times_dq), reversed(values_dq)): + if t < cutoff: + break + out_t.append(t); out_v.append(v) + out_t.reverse(); out_v.reverse() + return out_t, out_v + + class StripChartWidget(QWidget): def __init__(self, engine: AcquisitionEngine, registry: DeviceRegistry, processor: SignalProcessor): @@ -63,9 +79,18 @@ class StripChartWidget(QWidget): self._cfg: Optional[LayoutConfig] = None # (dev_id, ch_id) → [{curve, plot, spec}] self._curves: Dict[Tuple, List[dict]] = {} + # Time-axis plots, scrolled continuously by _scroll_timer rather than + # only when a new sample lands — decouples the visible scroll rate + # from the (much slower) poll rate so it doesn't look stepped. + self._time_plots: List = [] self._build() self.apply_layout(build_default_layout(registry, processor)) + self._scroll_timer = QTimer(self) + self._scroll_timer.setInterval(33) # ~30 fps + self._scroll_timer.timeout.connect(self._on_scroll_tick) + self._scroll_timer.start() + def _build(self): lay = QVBoxLayout(self); lay.setContentsMargins(6,6,6,4); lay.setSpacing(4) ctrl = QHBoxLayout() @@ -89,7 +114,7 @@ class StripChartWidget(QWidget): self._cfg = cfg self._win.setValue(cfg.time_window_s) if not _HAS_PG: return - self._gw.clear(); self._curves.clear() + self._gw.clear(); self._curves.clear(); self._time_plots.clear() if not cfg.panes: return mf = QFont("IBM Plex Mono", 8) @@ -130,6 +155,7 @@ class StripChartWidget(QWidget): plot.setXLink(ref_plot) if spec.x_source == "time": ref_plot = plot + self._time_plots.append(plot) if cfg.show_legend: t = _current_theme @@ -182,7 +208,8 @@ class StripChartWidget(QWidget): if device_id == "derived": bufs = self.processor.get_derived_buffer(channel_id) if bufs is None or len(bufs[0]) < 2: return - ts_l = list(bufs[0]); vs_l = list(bufs[1]) + ts_l, vs_l = _windowed(bufs[0], bufs[1], window) + if not ts_l: return else: buf = self.engine.get_buffer(device_id, channel_id) if buf is None or len(buf) < 2: return @@ -193,13 +220,11 @@ class StripChartWidget(QWidget): spec = e["pane"] # Determine X axis data if spec.x_source == "time": - cutoff = ts_l[-1] - window if ts_l else 0 - idx = next((i for i,t in enumerate(ts_l) if t >= cutoff), 0) - x_arr = np.array(ts_l[idx:], dtype=np.float64) - y_arr = np.array(vs_l[idx:], dtype=np.float64) + x_arr = np.array(ts_l, dtype=np.float64) + y_arr = np.array(vs_l, dtype=np.float64) e["curve"].setData(x_arr, y_arr) - if x_arr.size: - e["plot"].setXRange(x_arr[-1]-window, x_arr[-1], padding=0) + # X-range scrolling is handled by _scroll_timer, continuously — + # not here, so the window doesn't jump only on data arrival. else: # X = another channel's processed values parts = spec.x_source.split("/") @@ -227,3 +252,14 @@ class StripChartWidget(QWidget): def _on_pause(self, c: bool): self._paused = c self._pause.setText("▶ Resume" if c else "⏸ Pause") + + def _on_scroll_tick(self): + """Scroll time-axis plots at a steady UI rate, independent of the + (much slower) poll rate — keeps the window sliding smoothly instead + of jumping once per data tick.""" + if self._paused or not _HAS_PG or not self._cfg or not self.engine.is_running(): + return + now = self.engine.elapsed_now() + window = self._cfg.time_window_s + for plot in self._time_plots: + plot.setXRange(now - window, now, padding=0) |
