STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
series.py
Go to the documentation of this file.
1"""Load cached parquet series: time-slice, min/max decimate, and wide CSV export.
2
3Times are normalized to epoch seconds (float64) — uPlot's native x unit.
4
5Which clock is the x-axis
6-------------------------
7Every row carries two. The parquet `time` column is elodin-db's own write time, stamped
8as the bridge's TCP flush lands — and the boards ship ~9 samples per UDP packet, so a
9whole packet's worth of samples gets written microseconds apart and then nothing for
10~100 ms. Plotted on that, a steady 90 Hz channel becomes bursts of 9 points separated by
11gaps. The row's `timestamp_ns` field is the real sample time, reconstructed by
12BoardClockSync, and it is evenly spaced. `elodin-db` never learns this because
13DatabaseConfig.cpp declares that field as an ordinary data column rather than wrapping it
14in db.hpp's `builder::timestamp(...)` op, so the DB indexes on arrival.
15
16So the default x-axis is the sensor clock, taken positionally from the sibling
17`<entity>.timestamp_ns` parquet — the flattened export writes one file per field from the
18same rows, so row i is row i in both.
19
20Reconciling the two, and the publishers that stamp neither
21----------------------------------------------------------
22Both clocks are the same wall clock: the sample time is anchored to daq_bridge's
23`system_clock` receive, and the DB time is elodin-db writing those same rows. They differ
24only by write latency — measured at ~1 ms for raw channels, and growing to ~29 ms over a
25run for calibrated ones, where the calibration service adds a republish hop. So mixing
26them on one axis is coherent; it is the DB clock that drifts, which is the case for the
27sensor clock being the default rather than an argument against it.
28
29The sequencer (ACT_CMD, SEQUENCER.state) and the heartbeat router (BOARD.HB_*) stamp
30`steady_clock` instead — monotonic since boot, ~0 rather than ~1.79e9. Taken literally
31that lands them in 1970. But it is still a good clock, and CLOCK_MONOTONIC is
32system-wide, so it differs from CLOCK_REALTIME by a constant over a run: recover that
33constant as the median of (db time − stamp) and the channel gets its true emission time.
34That is worth doing rather than falling back, because it is exactly the comparison people
35care about — when a valve was commanded versus when chamber pressure moved — and the DB
36write time it would otherwise use carries 12 ms of jitter (p99 55 ms) on ACT_CMD.
37
38So each entity's clock is classified once:
39
40 offset = median(db_time − stamp)
41 |offset| small → already epoch → use the stamp as-is ("sensor")
42 |offset| large → monotonic, or a board → use stamp + offset ("monotonic")
43 whose clock is unset
44 no stamp column at all → use the DB write time ("db")
45
46A re-anchored channel's *relative* timing is exact; its *absolute* position inherits the
47median write latency as a few-ms bias. Worth knowing, not worth refusing to draw.
48"""
49
50from __future__ import annotations
51
52import time
53from functools import lru_cache
54from pathlib import Path
55from typing import Iterable, Iterator
56
57import numpy as np
58import pandas as pd
59
60from . import export_cache, lc_tare, run_config
61from .naming import classify
62
63
64def throttle(chunks: Iterable[str], bytes_per_sec: int) -> Iterator[str]:
65 """Pace an iterable of text chunks to at most `bytes_per_sec` cumulative
66 throughput. Sleeps between chunks so total bytes / elapsed ≈ the target rate;
67 with coarse chunks (~100 KB) the sleeps are few and cheap. This runs inside
68 Starlette's threadpool (sync generator), so the sleep never blocks the event
69 loop. <=0 disables throttling."""
70 if bytes_per_sec <= 0:
71 yield from chunks
72 return
73 start = time.monotonic()
74 sent = 0
75 for ch in chunks:
76 sent += len(ch) # CSV is ASCII → len == byte count
77 expected = sent / bytes_per_sec
78 elapsed = time.monotonic() - start
79 if expected > elapsed:
80 time.sleep(expected - elapsed)
81 yield ch
82
83
84# Below this, a row's own timestamp is taken to be a wall clock already; above it, the
85# clock is sound but its origin is not the epoch, so it gets re-anchored. Generous on
86# purpose: real write latency is milliseconds, and what this separates is off by decades.
87SENSOR_CLOCK_TOLERANCE_S = 60.0
88
89# x-axis choices, as the API takes them. "sensor" means "each row's own time", including
90# the re-anchored ones; "db" is elodin-db's write order, kept because it is what every run
91# so far was read on and it is the only way to see the raw arrival pattern.
92TIME_SOURCES = ("sensor", "db")
93
94# What a single channel ended up plotted on. Reported per component in the index.
95SOURCE_SENSOR = "sensor" # its own stamp, already epoch
96SOURCE_MONOTONIC = "monotonic" # its own stamp, re-anchored to the DB epoch
97SOURCE_DB = "db" # no stamp column — elodin-db's write time
98
99
100def _epoch_seconds(col) -> np.ndarray:
101 """Parquet arrow timestamps → epoch seconds. Forced through ns so the result is
102 right whatever resolution the column was stored at."""
103 ts = pd.to_datetime(col).to_numpy(dtype="datetime64[ns]")
104 return ts.astype("int64").astype(float) / 1e9
105
106
107@lru_cache(maxsize=256)
108def sensor_clock(run_id: str, entity: str) -> tuple[np.ndarray, str] | None:
109 """The entity's own per-row time in epoch seconds and which kind of clock it was, or
110 None when it has no timestamp column at all. Cached — immutable runs."""
111 p = export_cache.cache_dir(run_id) / f"{entity}.timestamp_ns.parquet"
112 if not p.exists():
113 return None
114 df = pd.read_parquet(p)
115 col = next((c for c in df.columns if c != "time"), None)
116 if col is None or df.empty:
117 return None
118 v = pd.to_numeric(df[col], errors="coerce").to_numpy(dtype=float) / 1e9
119 if not np.isfinite(v).all():
120 return None
121 delay = _epoch_seconds(df["time"]) - v
122 # The MINIMUM, not the mean or median — the same argument BoardClockSync.hpp makes for
123 # its own offset estimate. Write delay is one-sided noise: the DB cannot write a row
124 # before it was stamped, so the observed delays scatter strictly ABOVE the true offset
125 # and the floor is the best estimate. A median sits ~0.3 ms above it, which puts half
126 # the rows *after* their own write — a nonsense ordering to then reason about.
127 offset = float(np.min(delay))
128 if abs(offset) <= SENSOR_CLOCK_TOLERANCE_S:
129 return v, SOURCE_SENSOR
130 # Monotonic (or a board whose clock was never set). Adding the floor of the write
131 # delay puts it on the epoch. This assumes CLOCK_MONOTONIC and CLOCK_REALTIME stay a
132 # fixed distance apart for the run — they share the oscillator, so they only diverge
133 # if NTP steps or slews REALTIME. (BoardClockSync needs a *sliding* window because it
134 # compares two different oscillators, a board crystal against the server, which drift
135 # continuously. Same estimator, weaker assumption here.) Measured drift of this floor
136 # across a run: 0.13 ms.
137 return v + offset, SOURCE_MONOTONIC
138
139
140def _parquet_path(run_id: str, component: str) -> Path:
141 p = export_cache.cache_dir(run_id) / f"{component}.parquet"
142 if not p.exists():
143 raise FileNotFoundError(f"component not in run: {component}")
144 return p
145
146
147@lru_cache(maxsize=512)
149 run_id: str, component: str, time_source: str = "sensor"
150) -> tuple[np.ndarray, np.ndarray]:
151 """Return (t_seconds, values) sorted by time. Cached in-process (immutable runs).
152
153 With time_source="sensor" the x-axis is the row's own sample time where the publisher
154 stamps a real clock, falling back to the DB's write time where it does not."""
155 # A tared load-cell channel is derived, not exported: read its absolute twin and replay the
156 # run's tare record over it. Done here because this is the one place a component name becomes
157 # data, so series, long CSV, wide CSV and the whole-run download all get it with no signature
158 # change — and cannot disagree with each other, which a per-request toggle would allow.
159 tared = component.endswith(lc_tare.TARED_SUFFIX)
160 if tared:
161 entity = component[: -len(lc_tare.TARED_SUFFIX)]
162 if not lc_tare.load(run_id).get(entity):
163 # No recorded tare for this channel: the component does not exist for this run.
164 raise FileNotFoundError(f"component not in run: {component}")
165 component = f"{entity}.{lc_tare.GROSS_FIELD}"
166
167 df = pd.read_parquet(_parquet_path(run_id, component))
168 value_col = next(c for c in df.columns if c != "time")
169 t = _epoch_seconds(df["time"])
170 v = pd.to_numeric(df[value_col], errors="coerce").to_numpy(dtype=float)
171 if time_source == "sensor":
172 own = sensor_clock(run_id, classify(component).entity)
173 # Positional swap, so it must be the same rows. A length mismatch would mean the
174 # export wrote the fields of one entity from different row sets — take the DB
175 # clock rather than silently pairing the wrong times to the wrong samples.
176 if own is not None and len(own[0]) == len(t):
177 t = own[0]
178 order = np.argsort(t, kind="stable")
179 t, v = t[order], v[order]
180 if tared:
181 # After the sort and after the clock substitution: the sidecar stamps wall-clock, which is
182 # the same clock the sensor axis uses. On the DB axis the same instant sits a few ms later
183 # (write latency), which shifts where the step lands by less than one sample.
184 v = lc_tare.apply(run_id, entity, t, v)
185 return t, v
186
187
188def _slice(t: np.ndarray, v: np.ndarray, start, end) -> tuple[np.ndarray, np.ndarray]:
189 lo = 0 if start is None else int(np.searchsorted(t, start, "left"))
190 hi = len(t) if end is None else int(np.searchsorted(t, end, "right"))
191 return t[lo:hi], v[lo:hi]
192
193
195 t: np.ndarray, v: np.ndarray, max_points: int
196) -> tuple[np.ndarray, np.ndarray]:
197 """Bucket into ~max_points/2 windows, keep each window's min & max in time
198 order. Preserves spikes (unlike stride sampling), which matters for pressure
199 transients. Returns <= max_points points."""
200 n = len(t)
201 if n <= max_points or max_points < 4:
202 return t, v
203 n_buckets = max(1, max_points // 2)
204 edges = np.linspace(0, n, n_buckets + 1).astype(int)
205 out_t = np.empty(n_buckets * 2, dtype=float)
206 out_v = np.empty(n_buckets * 2, dtype=float)
207 k = 0
208 for b in range(n_buckets):
209 a, z = edges[b], edges[b + 1]
210 if z <= a:
211 continue
212 seg = v[a:z]
213 finite = np.isfinite(seg)
214 if not finite.any():
215 imin = imax = 0
216 else:
217 idx = np.where(finite)[0]
218 imin = idx[np.argmin(seg[idx])]
219 imax = idx[np.argmax(seg[idx])]
220 i0, i1 = (imin, imax) if imin <= imax else (imax, imin)
221 out_t[k], out_v[k] = t[a + i0], v[a + i0]
222 k += 1
223 if i1 != i0:
224 out_t[k], out_v[k] = t[a + i1], v[a + i1]
225 k += 1
226 return out_t[:k], out_v[:k]
227
228
230 t: np.ndarray, v: np.ndarray, max_points: int
231) -> tuple[np.ndarray, np.ndarray]:
232 """Run-length compress a step series: keep only value-change points plus the
233 endpoints. Lossless for step rendering (repeated values are redundant when
234 step-drawn), and collapses a state/actuator channel from 100k+ samples to
235 the handful of transitions. If a channel is pathologically noisy and still
236 exceeds max_points, stride down as a last resort."""
237 n = len(t)
238 if n <= 2:
239 return t, v
240 change = np.empty(n, dtype=bool)
241 change[0] = True
242 change[1:] = v[1:] != v[:-1] # NaN vs NaN compares True → kept (fine, rare)
243 change[-1] = True
244 idx = np.flatnonzero(change)
245 if len(idx) > max_points >= 2:
246 sel = np.linspace(0, len(idx) - 1, max_points).astype(int)
247 idx = np.unique(idx[sel])
248 return t[idx], v[idx]
249
250
252 run_id: str, components: list[str], start, end, max_points: int,
253 time_source: str = "sensor",
254) -> dict:
255 """Per-component sliced + decimated arrays for plotting. Every series is
256 capped to ~max_points: continuous via min/max (keeps spikes), discrete via
257 run-length compression (keeps transitions). Without this cap, a long run's
258 uncapped discrete channels return millions of points and crash the browser."""
259 out = []
260 for name in components:
261 t, v = load_series(run_id, name, time_source)
262 t, v = _slice(t, v, start, end)
263 discrete = classify(name).discrete
264 if discrete:
265 t, v = decimate_discrete(t, v, max_points)
266 else:
267 t, v = decimate_minmax(t, v, max_points)
268 out.append(
269 {
270 "name": name,
271 "discrete": discrete,
272 "n": int(len(t)),
273 "t": t.tolist(),
274 "v": [None if not np.isfinite(x) else x for x in v.tolist()],
275 }
276 )
277 return {"run_id": run_id, "series": out}
278
279
280def long_csv_rows(run_id: str, components: list[str], start, end, time_source: str = "sensor"):
281 """Yield a tidy/long CSV: `time,component,value`, one component at a time.
282
283 Streams at constant memory — the right shape for a whole-run dump over many
284 channels, where a wide aligned frame would explode to millions of rows ×
285 hundreds of mostly-NaN columns."""
286 yield "time,component,value\n"
287 for name in components:
288 try:
289 t, v = load_series(run_id, name, time_source)
290 except FileNotFoundError:
291 continue
292 t, v = _slice(t, v, start, end)
293 buf = []
294 for i in range(len(t)):
295 val = v[i]
296 if val == val: # skip NaN
297 buf.append(f"{t[i]:.6f},{name},{val:.9g}\n")
298 if len(buf) >= 10000:
299 yield "".join(buf)
300 buf = []
301 if buf:
302 yield "".join(buf)
303
304
305def _csv_cell(text: str) -> str:
306 """Quote a header cell only if it needs it. Role names are plain words today, but
307 they are operator-typed config strings, so a comma must not shift the columns."""
308 if any(c in text for c in ',"\n'):
309 return '"' + text.replace('"', '""') + '"'
310 return text
311
312
313def wide_csv_rows(run_id: str, components: list[str], start, end, time_source: str = "sensor"):
314 """Yield CSV text rows: a 'time' column (epoch seconds) plus one column per
315 component, aligned on the union of timestamps. Discrete series are
316 forward-filled; continuous series are left as sampled (NaN between samples).
317
318 Two header rows: the component name, then the role it played in this run
319 (from the run's config snapshot; blank where config names nothing). The
320 component name stays the column KEY — analysis scripts
321 key on it — and the role rides underneath as the legend, so a spreadsheet
322 says "Ox Upstream" without the file losing its stable identifiers."""
323 frames = []
324 entities = []
325 for name in components:
326 t, v = load_series(run_id, name, time_source)
327 t, v = _slice(t, v, start, end)
328 s = pd.Series(v, index=pd.Index(t, name="time"), name=name)
329 s = s[~s.index.duplicated(keep="last")]
330 comp = classify(name)
331 entities.append(comp.entity)
332 frames.append((name, comp.discrete, s))
333
334 if not frames:
335 yield "time\n"
336 return
337
338 union = frames[0][2].index
339 for _, _, s in frames[1:]:
340 union = union.union(s.index)
341 union = union.sort_values()
342
343 cols = {}
344 for name, discrete, s in frames:
345 aligned = s.reindex(union)
346 if discrete:
347 aligned = aligned.ffill()
348 cols[name] = aligned
349 df = pd.DataFrame(cols, index=union)
350 df.index.name = "time"
351
352 labels = run_config.entity_labels(run_config.load(run_id))
353 yield "time," + ",".join(_csv_cell(c) for c in components) + "\n"
354 yield "," + ",".join(
355 _csv_cell(run_config.label_for(e, labels)) for e in entities
356 ) + "\n"
357 buf = []
358 for ts, row in zip(df.index.to_numpy(), df.to_numpy()):
359 cells = ["" if (x != x) else f"{float(x):.9g}" for x in row]
360 buf.append(f"{float(ts):.6f}," + ",".join(cells) + "\n")
361 if len(buf) >= 10000:
362 yield "".join(buf)
363 buf = []
364 if buf:
365 yield "".join(buf)
#define max(a, b)
Definition Arduino.h:43
long_csv_rows(str run_id, list[str] components, start, end, str time_source="sensor")
Definition series.py:280
tuple[np.ndarray, np.ndarray] _slice(np.ndarray t, np.ndarray v, start, end)
Definition series.py:188
tuple[np.ndarray, str]|None sensor_clock(str run_id, str entity)
Definition series.py:108
str _csv_cell(str text)
Definition series.py:305
wide_csv_rows(str run_id, list[str] components, start, end, str time_source="sensor")
Definition series.py:313
np.ndarray _epoch_seconds(col)
Definition series.py:100
tuple[np.ndarray, np.ndarray] decimate_minmax(np.ndarray t, np.ndarray v, int max_points)
Definition series.py:196
Iterator[str] throttle(Iterable[str] chunks, int bytes_per_sec)
Definition series.py:64
tuple[np.ndarray, np.ndarray] decimate_discrete(np.ndarray t, np.ndarray v, int max_points)
Definition series.py:231
dict series_json(str run_id, list[str] components, start, end, int max_points, str time_source="sensor")
Definition series.py:254
Path _parquet_path(str run_id, str component)
Definition series.py:140
tuple[np.ndarray, np.ndarray] load_series(str run_id, str component, str time_source="sensor")
Definition series.py:150