"""Time-series store: per-channel ring buffers + min/max, with an optional recorder for full-session capture/playback. The acquisition thread pushes samples here; the GUI (or terminal) only reads. This decoupling is what keeps the UI smooth while the ELM327 plods along at ~7-15 reads/sec. Nothing here touches serial or Qt -- pure data. """ import threading from collections import deque class Channel: """One PID's rolling history plus session min/max. Lock-guarded so the acquisition thread can push while the GUI thread reads series()/snapshots without a 'deque mutated during iteration' race. """ def __init__(self, key, maxlen=3600): self.key = key self.buf = deque(maxlen=maxlen) # (t, value); value may be None self.lo = None self.hi = None self.last_t = None self.last_v = None self._lock = threading.Lock() def push(self, t, v): with self._lock: self.buf.append((t, v)) self.last_t, self.last_v = t, v if v is not None: self.lo = v if self.lo is None else min(self.lo, v) self.hi = v if self.hi is None else max(self.hi, v) def reset_minmax(self): with self._lock: self.lo = self.hi = self.last_v def series(self, since=None): """Return [(t, v), ...]; if since given, only samples with t >= since.""" with self._lock: items = list(self.buf) # snapshot under lock if since is None: return items return [(t, v) for (t, v) in items if t >= since] class TimeSeriesStore: """Thread-safe collection of Channels keyed by PID key.""" def __init__(self, maxlen=3600): self._ch = {} self._maxlen = maxlen self._lock = threading.Lock() self.recorder = None # set to a recorder with .write(key, t, v) def channel(self, key): with self._lock: c = self._ch.get(key) if c is None: c = Channel(key, self._maxlen) self._ch[key] = c return c def push(self, key, t, v): self.channel(key).push(t, v) rec = self.recorder if rec is not None: rec.write(key, t, v) def latest(self, key): c = self._ch.get(key) return None if c is None else c.last_v def minmax(self, key): c = self._ch.get(key) return (None, None) if c is None else (c.lo, c.hi) def reset_minmax(self, keys=None): with self._lock: for k, c in self._ch.items(): if keys is None or k in keys: c.reset_minmax() def keys(self): with self._lock: return list(self._ch.keys()) def clear(self): """Empty every channel's history + min/max (start a fresh capture).""" with self._lock: chans = list(self._ch.values()) for c in chans: with c._lock: c.buf.clear() c.lo = c.hi = c.last_v = c.last_t = None def snapshot(self): """Return {key: [(t, v), ...]} of all current channel history.""" with self._lock: chans = dict(self._ch) return {k: c.series() for k, c in chans.items()} def export_csv(store, path): """Write a store's current buffers to a long-format CSV (t,key,value).""" rows = [] for key, series in store.snapshot().items(): for t, v in series: rows.append((t, key, v)) rows.sort(key=lambda r: r[0]) with open(path, "w") as f: f.write("t,key,value\n") for t, key, v in rows: f.write(f"{t:.3f},{key},{'' if v is None else v}\n") return path class CsvRecorder: """Long-format session recorder: one row per sample (t,key,value). Long format (vs wide) tolerates per-PID poll rates and PIDs appearing mid-session. Replay re-pushes rows into a fresh store in t order. """ def __init__(self, path): self._f = open(path, "w") self._f.write("t,key,value\n") self._lock = threading.Lock() self._closed = False def write(self, key, t, v): with self._lock: if self._closed: # a poll-thread write racing close() is a no-op return self._f.write(f"{t:.3f},{key},{'' if v is None else v}\n") def close(self): with self._lock: if self._closed: return self._closed = True self._f.close() def replay_csv(path, store): """Load a CsvRecorder file back into a store (for playback).""" with open(path) as f: next(f, None) # header for line in f: parts = line.rstrip("\n").split(",", 2) if len(parts) != 3: continue t, key, v = parts try: t = float(t) except ValueError: continue val = None if v == "" else float(v) if _is_num(v) else v store.push(key, t, val) return store def _is_num(s): try: float(s) return True except ValueError: return False