summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--core/acquisition.py41
-rw-r--r--core/signal_processor.py18
-rw-r--r--ui/main_window.py2
-rw-r--r--ui/strip_chart.py54
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)