STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
backend.series Namespace Reference

Functions

Iterator[str] throttle (Iterable[str] chunks, int bytes_per_sec)
np.ndarray _epoch_seconds (col)
tuple[np.ndarray, str]|None sensor_clock (str run_id, str entity)
Path _parquet_path (str run_id, str component)
tuple[np.ndarray, np.ndarray] load_series (str run_id, str component, str time_source="sensor")
tuple[np.ndarray, np.ndarray] _slice (np.ndarray t, np.ndarray v, start, end)
tuple[np.ndarray, np.ndarray] decimate_minmax (np.ndarray t, np.ndarray v, int max_points)
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")
 long_csv_rows (str run_id, list[str] components, start, end, str time_source="sensor")
str _csv_cell (str text)
 wide_csv_rows (str run_id, list[str] components, start, end, str time_source="sensor")

Variables

float SENSOR_CLOCK_TOLERANCE_S = 60.0
tuple TIME_SOURCES = ("sensor", "db")
str SOURCE_SENSOR = "sensor"
str SOURCE_MONOTONIC = "monotonic"
str SOURCE_DB = "db"

Detailed Description

Load cached parquet series: time-slice, min/max decimate, and wide CSV export.

Times are normalized to epoch seconds (float64) — uPlot's native x unit.

Which clock is the x-axis
-------------------------
Every row carries two. The parquet `time` column is elodin-db's own write time, stamped
as the bridge's TCP flush lands — and the boards ship ~9 samples per UDP packet, so a
whole packet's worth of samples gets written microseconds apart and then nothing for
~100 ms. Plotted on that, a steady 90 Hz channel becomes bursts of 9 points separated by
gaps. The row's `timestamp_ns` field is the real sample time, reconstructed by
BoardClockSync, and it is evenly spaced. `elodin-db` never learns this because
DatabaseConfig.cpp declares that field as an ordinary data column rather than wrapping it
in db.hpp's `builder::timestamp(...)` op, so the DB indexes on arrival.

So the default x-axis is the sensor clock, taken positionally from the sibling
`<entity>.timestamp_ns` parquet — the flattened export writes one file per field from the
same rows, so row i is row i in both.

Reconciling the two, and the publishers that stamp neither
----------------------------------------------------------
Both clocks are the same wall clock: the sample time is anchored to daq_bridge's
`system_clock` receive, and the DB time is elodin-db writing those same rows. They differ
only by write latency — measured at ~1 ms for raw channels, and growing to ~29 ms over a
run for calibrated ones, where the calibration service adds a republish hop. So mixing
them on one axis is coherent; it is the DB clock that drifts, which is the case for the
sensor clock being the default rather than an argument against it.

The sequencer (ACT_CMD, SEQUENCER.state) and the heartbeat router (BOARD.HB_*) stamp
`steady_clock` instead — monotonic since boot, ~0 rather than ~1.79e9. Taken literally
that lands them in 1970. But it is still a good clock, and CLOCK_MONOTONIC is
system-wide, so it differs from CLOCK_REALTIME by a constant over a run: recover that
constant as the median of (db time − stamp) and the channel gets its true emission time.
That is worth doing rather than falling back, because it is exactly the comparison people
care about — when a valve was commanded versus when chamber pressure moved — and the DB
write time it would otherwise use carries 12 ms of jitter (p99 55 ms) on ACT_CMD.

So each entity's clock is classified once:

    offset = median(db_time − stamp)
    |offset| small  → already epoch          → use the stamp as-is        ("sensor")
    |offset| large  → monotonic, or a board  → use stamp + offset         ("monotonic")
                      whose clock is unset
    no stamp column at all                   → use the DB write time      ("db")

A re-anchored channel's *relative* timing is exact; its *absolute* position inherits the
median write latency as a few-ms bias. Worth knowing, not worth refusing to draw.

Function Documentation

◆ _csv_cell()

str backend.series._csv_cell ( str text)
protected
Quote a header cell only if it needs it. Role names are plain words today, but
they are operator-typed config strings, so a comma must not shift the columns.

Definition at line 305 of file series.py.

Referenced by wide_csv_rows().

◆ _epoch_seconds()

np.ndarray backend.series._epoch_seconds ( col)
protected
Parquet arrow timestamps → epoch seconds. Forced through ns so the result is
right whatever resolution the column was stored at.

Definition at line 100 of file series.py.

Referenced by load_series(), and sensor_clock().

◆ _parquet_path()

Path backend.series._parquet_path ( str run_id,
str component )
protected

Definition at line 140 of file series.py.

Referenced by load_series().

◆ _slice()

tuple[np.ndarray, np.ndarray] backend.series._slice ( np.ndarray t,
np.ndarray v,
start,
end )
protected

Definition at line 188 of file series.py.

Referenced by long_csv_rows(), series_json(), and wide_csv_rows().

◆ decimate_discrete()

tuple[np.ndarray, np.ndarray] backend.series.decimate_discrete ( np.ndarray t,
np.ndarray v,
int max_points )
Run-length compress a step series: keep only value-change points plus the
endpoints. Lossless for step rendering (repeated values are redundant when
step-drawn), and collapses a state/actuator channel from 100k+ samples to
the handful of transitions. If a channel is pathologically noisy and still
exceeds max_points, stride down as a last resort.

Definition at line 229 of file series.py.

Referenced by series_json().

◆ decimate_minmax()

tuple[np.ndarray, np.ndarray] backend.series.decimate_minmax ( np.ndarray t,
np.ndarray v,
int max_points )
Bucket into ~max_points/2 windows, keep each window's min & max in time
order. Preserves spikes (unlike stride sampling), which matters for pressure
transients. Returns <= max_points points.

Definition at line 194 of file series.py.

References max.

Referenced by series_json().

◆ load_series()

tuple[np.ndarray, np.ndarray] backend.series.load_series ( str run_id,
str component,
str time_source = "sensor" )
Return (t_seconds, values) sorted by time. Cached in-process (immutable runs).

With time_source="sensor" the x-axis is the row's own sample time where the publisher
stamps a real clock, falling back to the DB's write time where it does not.

Definition at line 148 of file series.py.

References _epoch_seconds(), _parquet_path(), and sensor_clock().

Referenced by long_csv_rows(), series_json(), and wide_csv_rows().

◆ long_csv_rows()

backend.series.long_csv_rows ( str run_id,
list[str] components,
start,
end,
str time_source = "sensor" )
Yield a tidy/long CSV: `time,component,value`, one component at a time.

Streams at constant memory — the right shape for a whole-run dump over many
channels, where a wide aligned frame would explode to millions of rows ×
hundreds of mostly-NaN columns.

Definition at line 280 of file series.py.

References _slice(), and load_series().

◆ sensor_clock()

tuple[np.ndarray, str] | None backend.series.sensor_clock ( str run_id,
str entity )
The entity's own per-row time in epoch seconds and which kind of clock it was, or
None when it has no timestamp column at all. Cached — immutable runs.

Definition at line 108 of file series.py.

References _epoch_seconds().

Referenced by load_series().

◆ series_json()

dict backend.series.series_json ( str run_id,
list[str] components,
start,
end,
int max_points,
str time_source = "sensor" )
Per-component sliced + decimated arrays for plotting. Every series is
capped to ~max_points: continuous via min/max (keeps spikes), discrete via
run-length compression (keeps transitions). Without this cap, a long run's
uncapped discrete channels return millions of points and crash the browser.

Definition at line 251 of file series.py.

References _slice(), decimate_discrete(), decimate_minmax(), and load_series().

◆ throttle()

Iterator[str] backend.series.throttle ( Iterable[str] chunks,
int bytes_per_sec )
Pace an iterable of text chunks to at most `bytes_per_sec` cumulative
throughput. Sleeps between chunks so total bytes / elapsed ≈ the target rate;
with coarse chunks (~100 KB) the sleeps are few and cheap. This runs inside
Starlette's threadpool (sync generator), so the sleep never blocks the event
loop. <=0 disables throttling.

Definition at line 64 of file series.py.

◆ wide_csv_rows()

backend.series.wide_csv_rows ( str run_id,
list[str] components,
start,
end,
str time_source = "sensor" )
Yield CSV text rows: a 'time' column (epoch seconds) plus one column per
component, aligned on the union of timestamps. Discrete series are
forward-filled; continuous series are left as sampled (NaN between samples).

Two header rows: the component name, then the role it played in this run
(from the run's config snapshot; blank where config names nothing). The
component name stays the column KEY — analysis scripts
key on it — and the role rides underneath as the legend, so a spreadsheet
says "Ox Upstream" without the file losing its stable identifiers.

Definition at line 313 of file series.py.

References _csv_cell(), _slice(), and load_series().

Variable Documentation

◆ SENSOR_CLOCK_TOLERANCE_S

float backend.series.SENSOR_CLOCK_TOLERANCE_S = 60.0

Definition at line 87 of file series.py.

◆ SOURCE_DB

str backend.series.SOURCE_DB = "db"

Definition at line 97 of file series.py.

◆ SOURCE_MONOTONIC

str backend.series.SOURCE_MONOTONIC = "monotonic"

Definition at line 96 of file series.py.

◆ SOURCE_SENSOR

str backend.series.SOURCE_SENSOR = "sensor"

Definition at line 95 of file series.py.

◆ TIME_SOURCES

tuple backend.series.TIME_SOURCES = ("sensor", "db")

Definition at line 92 of file series.py.