diff options
| -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) |
