1"""Load cached parquet series: time-slice, min/max decimate, and wide CSV export.
3Times are normalized to epoch seconds (float64) — uPlot's native x unit.
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.
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.
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.
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.
38So each entity's clock is classified once:
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")
44 no stamp column at all → use the DB write time ("db")
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.
50from __future__
import annotations
53from functools
import lru_cache
54from pathlib
import Path
55from typing
import Iterable, Iterator
60from .
import export_cache, lc_tare, run_config
61from .naming
import classify
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:
73 start = time.monotonic()
77 expected = sent / bytes_per_sec
78 elapsed = time.monotonic() - start
79 if expected > elapsed:
80 time.sleep(expected - elapsed)
87SENSOR_CLOCK_TOLERANCE_S = 60.0
92TIME_SOURCES = (
"sensor",
"db")
95SOURCE_SENSOR =
"sensor"
96SOURCE_MONOTONIC =
"monotonic"
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
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"
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:
118 v = pd.to_numeric(df[col], errors=
"coerce").to_numpy(dtype=float) / 1e9
119 if not np.isfinite(v).all():
127 offset = float(np.min(delay))
128 if abs(offset) <= SENSOR_CLOCK_TOLERANCE_S:
129 return v, SOURCE_SENSOR
137 return v + offset, SOURCE_MONOTONIC
141 p = export_cache.cache_dir(run_id) / f
"{component}.parquet"
143 raise FileNotFoundError(f
"component not in run: {component}")
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).
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."""
159 tared = component.endswith(lc_tare.TARED_SUFFIX)
161 entity = component[: -len(lc_tare.TARED_SUFFIX)]
162 if not lc_tare.load(run_id).get(entity):
164 raise FileNotFoundError(f
"component not in run: {component}")
165 component = f
"{entity}.{lc_tare.GROSS_FIELD}"
168 value_col = next(c
for c
in df.columns
if c !=
"time")
170 v = pd.to_numeric(df[value_col], errors=
"coerce").to_numpy(dtype=float)
171 if time_source ==
"sensor":
176 if own
is not None and len(own[0]) == len(t):
178 order = np.argsort(t, kind=
"stable")
179 t, v = t[order], v[order]
184 v = lc_tare.apply(run_id, entity, t, v)
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]
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."""
201 if n <= max_points
or max_points < 4:
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)
208 for b
in range(n_buckets):
209 a, z = edges[b], edges[b + 1]
213 finite = np.isfinite(seg)
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]
224 out_t[k], out_v[k] = t[a + i1], v[a + i1]
226 return out_t[:k], out_v[:k]
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."""
240 change = np.empty(n, dtype=bool)
242 change[1:] = v[1:] != v[:-1]
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]
252 run_id: str, components: list[str], start, end, max_points: int,
253 time_source: str =
"sensor",
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."""
260 for name
in components:
262 t, v =
_slice(t, v, start, end)
263 discrete = classify(name).discrete
271 "discrete": discrete,
274 "v": [
None if not np.isfinite(x)
else x
for x
in v.tolist()],
277 return {
"run_id": run_id,
"series": out}
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.
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:
290 except FileNotFoundError:
292 t, v =
_slice(t, v, start, end)
294 for i
in range(len(t)):
297 buf.append(f
"{t[i]:.6f},{name},{val:.9g}\n")
298 if len(buf) >= 10000:
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(
'"',
'""') +
'"'
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).
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."""
325 for name
in components:
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))
338 union = frames[0][2].index
339 for _, _, s
in frames[1:]:
340 union = union.union(s.index)
341 union = union.sort_values()
344 for name, discrete, s
in frames:
345 aligned = s.reindex(union)
347 aligned = aligned.ffill()
349 df = pd.DataFrame(cols, index=union)
350 df.index.name =
"time"
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
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:
long_csv_rows(str run_id, list[str] components, start, end, str time_source="sensor")
tuple[np.ndarray, np.ndarray] _slice(np.ndarray t, np.ndarray v, start, end)
tuple[np.ndarray, str]|None sensor_clock(str run_id, str entity)
wide_csv_rows(str run_id, list[str] components, start, end, str time_source="sensor")
np.ndarray _epoch_seconds(col)
tuple[np.ndarray, np.ndarray] decimate_minmax(np.ndarray t, np.ndarray v, int max_points)
Iterator[str] throttle(Iterable[str] chunks, int bytes_per_sec)
tuple[np.ndarray, np.ndarray] decimate_discrete(np.ndarray t, np.ndarray v, int max_points)
dict series_json(str run_id, list[str] components, start, end, int max_points, str time_source="sensor")
Path _parquet_path(str run_id, str component)
tuple[np.ndarray, np.ndarray] load_series(str run_id, str component, str time_source="sensor")