STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_series.py
Go to the documentation of this file.
1"""Unit tests for the data-shaping logic (no elodin-db / real run needed).
2
3Run: cd webviewer && .venv/bin/python -m pytest backend/test_series.py -q
4"""
5
6import numpy as np
7import pandas as pd
8import pytest
9
10from . import config, export_cache, series
11from .naming import classify
12
13
15 t = np.arange(10000, dtype=float)
16 v = np.zeros(10000)
17 v[1234] = 999.0 # spike up
18 v[8765] = -999.0 # spike down
19 dt, dv = series.decimate_minmax(t, v, 200)
20 assert len(dt) <= 200
21 # Extremes survive decimation (stride sampling would drop them).
22 assert dv.max() == 999.0
23 assert dv.min() == -999.0
24 # Time order preserved.
25 assert np.all(np.diff(dt) >= 0)
26
27
29 # 100k samples of a state that changes 3 times → compress to the transitions.
30 t = np.arange(100000, dtype=float)
31 v = np.zeros(100000)
32 v[25000:] = 1
33 v[50000:] = 2
34 v[75000:] = 1
35 dt, dv = series.decimate_discrete(t, v, 4000)
36 # Endpoints + 3 change points → 5 kept, exact step reconstruction.
37 assert dt.tolist() == [0.0, 25000.0, 50000.0, 75000.0, 99999.0]
38 assert dv.tolist() == [0.0, 1.0, 2.0, 1.0, 1.0]
39
40
42 # Value changes every sample (worst case) → must still cap to max_points.
43 t = np.arange(20000, dtype=float)
44 v = (t % 2).astype(float)
45 dt, _ = series.decimate_discrete(t, v, 1000)
46 assert len(dt) <= 1000
47
48
50 t = np.arange(50, dtype=float)
51 v = t * 2
52 dt, dv = series.decimate_minmax(t, v, 4000)
53 assert np.array_equal(dt, t) and np.array_equal(dv, v)
54
55
57 t = np.arange(0, 100, dtype=float)
58 v = t.copy()
59 st, sv = series._slice(t, v, 10.0, 20.0)
60 assert st[0] == 10.0 and st[-1] == 20.0 # both ends included
61
62
64 c = classify("PT_Cal.Ox_Upstream.pressure_psi")
65 assert c.family == "PT_Cal" and c.field == "pressure_psi"
66 assert c.unit == "psi" and not c.discrete and c.primary
67
68 d = classify("ACT.Fuel_Fill_Press.actuator_state")
69 assert d.discrete and d.family == "ACT"
70
71 h = classify("1399096562196883797")
72 assert h.family == "Other" and not h.primary
73
74
76 # "T71" is the T7 on slot 1, not family "T" with board 71.
77 raw = classify("T71.AIN1.volts")
78 assert raw.family == "T7" and raw.unit == "V" and raw.primary
79 cal = classify("T71_Cal.AIN1.pressure_psi")
80 assert cal.family == "T7_Cal" and cal.unit == "psi"
81 code = classify("T71_Cal.AIN1.raw_uv")
82 assert code.unit == "µV" and not code.primary # plumbing, like raw_adc
83 assert classify("T71.CJC.temperature_c").unit == "°C"
84 # The ESP32 families are unchanged.
85 assert classify("PT1.CH1.raw_adc_counts").family == "PT"
86
87
89 # Two channels on different timebases: continuous (NaN gaps) + discrete (ffill).
90 def fake_load(_run, name, _src="sensor"):
91 if name == "cont":
92 return np.array([0.0, 2.0]), np.array([10.0, 20.0])
93 return np.array([1.0]), np.array([1.0]) # discrete, single sample at t=1
94
95 monkeypatch.setattr(series, "load_series", fake_load)
96 monkeypatch.setattr(
97 series, "classify",
98 lambda n: type("C", (), {"discrete": n == "disc", "entity": n})(),
99 )
100 text = "".join(series.wide_csv_rows("r", ["cont", "disc"], None, None))
101 lines = text.strip().split("\n")
102 assert lines[0] == "time,cont,disc"
103 assert lines[1] == ",," # role row: this fake run has no config snapshot
104 body = [ln.split(",") for ln in lines[2:]]
105 times = [float(r[0]) for r in body]
106 assert times == [0.0, 1.0, 2.0] # union of {0,2} and {1}
107 # discrete forward-fills from t=1 onward; empty before it.
108 disc_col = [r[2] for r in body]
109 assert disc_col[0] == "" and disc_col[1] == "1" and disc_col[2] == "1"
110
111
113 """The second header row names what each column IS. The component name stays the
114 column key (every analysis script keys on it); the role rides
115 underneath, blank where the run's config names nothing."""
116 monkeypatch.setattr(config, "ELODIN_DIR", tmp_path)
117 (tmp_path / "daq_20260902_120000.toml").write_text(
118 '[boards.pt_board]\ntype = "PT"\nboard_id = 21\n'
119 '\n[sensor_roles_pt_board]\n"Ox Upstream" = 5\n'
120 )
121 monkeypatch.setattr(
122 series, "load_series", lambda _r, _n, _s="sensor": (np.array([0.0]), np.array([1.0]))
123 )
124 text = "".join(
125 series.wide_csv_rows(
126 "daq_20260902_120000",
127 ["PT1.CH5.pressure_psi", "PT1.CH9.pressure_psi"],
128 None,
129 None,
130 )
131 )
132 header, roles = text.split("\n")[:2]
133 assert header == "time,PT1.CH5.pressure_psi,PT1.CH9.pressure_psi"
134 assert roles == ",Ox Upstream," # CH9 has no role; the column still holds its place
135 assert header.count(",") == roles.count(",")
136
137
138# ── x-axis clock selection ───────────────────────────────────────────────────
139
140
141@pytest.fixture
142def clock_run(tmp_path, monkeypatch):
143 """A one-entity run on disk, with both clocks. Caches cleared so each test's fixture
144 data is actually read (load_series/sensor_clock memoise on run id)."""
145 monkeypatch.setattr(export_cache, "cache_dir", lambda run_id: tmp_path)
146 series.load_series.cache_clear()
147 series.sensor_clock.cache_clear()
148 return tmp_path
149
150
151def _write(dir_, name, db_times, values):
152 """One flattened-export parquet: the DB's write time plus one value column."""
153 pd.DataFrame(
154 {
155 "time": pd.to_datetime(np.asarray(db_times) * 1e9, unit="ns"),
156 name.rsplit(".", 1)[-1]: values,
157 }
158 ).to_parquet(dir_ / f"{name}.parquet")
159
160
161# The DB stamps a whole UDP packet of samples microseconds apart, then nothing for
162# ~100 ms; the rows' own timestamp_ns says they were 10 ms apart, which they were.
163BURSTY_DB = [1000.0, 1000.00004, 1000.00008, 1000.1, 1000.10004, 1000.10008]
164EVEN_SENSOR = [1000.0, 1000.01, 1000.02, 1000.1, 1000.11, 1000.12]
165
166
168 _write(clock_run, "PT1.CH1.raw_adc_counts", BURSTY_DB, [1, 2, 3, 4, 5, 6])
169 _write(clock_run, "PT1.CH1.timestamp_ns", BURSTY_DB, [t * 1e9 for t in EVEN_SENSOR])
170
171 t, v = series.load_series("r", "PT1.CH1.raw_adc_counts", "sensor")
172 assert t.tolist() == pytest.approx(EVEN_SENSOR)
173 assert v.tolist() == [1, 2, 3, 4, 5, 6] # values stay paired with their own rows
174
175 t_db, _ = series.load_series("r", "PT1.CH1.raw_adc_counts", "db")
176 assert t_db.tolist() == pytest.approx(BURSTY_DB)
177
178
180 """The sequencer and heartbeat router stamp steady_clock — nanoseconds since boot,
181 not since 1970. Taken literally that lands them 56 years off; but the clock itself is
182 good, so it is shifted onto the epoch by the median write latency. That keeps their
183 precise relative timing instead of falling back to the jittery DB write time."""
184 boot = (12.0, 12.01, 12.02, 12.1, 12.11, 12.12)
185 _write(clock_run, "ACT_CMD.B2.CH1.actuator_state", BURSTY_DB, [0, 1, 0, 1, 0, 1])
186 _write(clock_run, "ACT_CMD.B2.CH1.timestamp_ns", BURSTY_DB, [t * 1e9 for t in boot])
187
188 arr, kind = series.sensor_clock("r", "ACT_CMD.B2.CH1")
189 assert kind == series.SOURCE_MONOTONIC
190
191 t, _ = series.load_series("r", "ACT_CMD.B2.CH1.actuator_state", "sensor")
192 # Lands in the run's wall clock, not 1970 …
193 assert t[0] == pytest.approx(BURSTY_DB[0], abs=0.2)
194 # … keeping the even 10 ms spacing its own clock recorded, rather than inheriting the
195 # DB's 40 µs-then-100 ms burst pattern.
196 assert np.diff(t).tolist() == pytest.approx(np.diff(boot).tolist())
197
198
200 """Write delay is one-sided — the DB cannot write a row before it was stamped — so the
201 offset estimate is the floor of the observed delays, and every re-anchored row must
202 end up at or before its own write time. A median estimate breaks this for about half
203 the rows, which then read as though the DB wrote them before they happened."""
204 boot = (12.0, 12.01, 12.02, 12.03, 12.04)
205 # Writes land 5, 40, 6, 30 and 5 ms after their stamp: floor is 5 ms.
206 db = [1000.005, 1000.050, 1000.026, 1000.060, 1000.045]
207 _write(clock_run, "ACT_CMD.B2.CH9.actuator_state", db, [0, 1, 0, 1, 0])
208 _write(clock_run, "ACT_CMD.B2.CH9.timestamp_ns", db, [t * 1e9 for t in boot])
209
210 t, _ = series.load_series("r", "ACT_CMD.B2.CH9.actuator_state", "sensor")
211 assert np.all(t <= np.sort(db) + 1e-9)
212 assert np.diff(t).tolist() == pytest.approx(np.diff(boot).tolist())
213
214
216 # With one row the median offset IS that row's write latency, so the result is the DB
217 # time exactly — no worse than the fallback it replaces.
218 _write(clock_run, "SEQUENCER.state.current_state", [1000.0], [1])
219 _write(clock_run, "SEQUENCER.state.timestamp_ns", [1000.0], [8.0 * 1e9])
220 t, _ = series.load_series("r", "SEQUENCER.state.current_state", "sensor")
221 assert t.tolist() == pytest.approx([1000.0])
222
223
225 # Re-anchoring an already-epoch clock would erase the genuine lead a sample time has
226 # over its write — which is real information, not error.
227 _write(clock_run, "PT1.CH9.raw_adc_counts", BURSTY_DB, [1, 2, 3, 4, 5, 6])
228 _write(clock_run, "PT1.CH9.timestamp_ns", BURSTY_DB, [t * 1e9 for t in EVEN_SENSOR])
229 arr, kind = series.sensor_clock("r", "PT1.CH9")
230 assert kind == series.SOURCE_SENSOR
231 assert arr.tolist() == pytest.approx(EVEN_SENSOR)
232
233
235 _write(clock_run, "PT_Cal.Ox_Upstream.pressure_psi", BURSTY_DB, [1, 2, 3, 4, 5, 6])
236 t, _ = series.load_series("r", "PT_Cal.Ox_Upstream.pressure_psi", "sensor")
237 assert t.tolist() == pytest.approx(BURSTY_DB)
238
239
241 # The swap is positional, so different row counts mean the two files are not the same
242 # rows — taking it anyway would pair each sample with somebody else's timestamp.
243 _write(clock_run, "PT1.CH2.raw_adc_counts", BURSTY_DB, [1, 2, 3, 4, 5, 6])
244 _write(clock_run, "PT1.CH2.timestamp_ns", BURSTY_DB[:4], [t * 1e9 for t in EVEN_SENSOR[:4]])
245 t, _ = series.load_series("r", "PT1.CH2.raw_adc_counts", "sensor")
246 assert t.tolist() == pytest.approx(BURSTY_DB)
247
248
250 # Rows arrive in DB order; the sensor clock may disagree, and the output must be
251 # monotonic on the axis actually being drawn.
252 _write(clock_run, "PT1.CH3.raw_adc_counts", [1000.0, 1000.1, 1000.2], [10, 20, 30])
253 _write(clock_run, "PT1.CH3.timestamp_ns", [1000.0, 1000.1, 1000.2],
254 [1000.2e9, 1000.0e9, 1000.1e9])
255 t, v = series.load_series("r", "PT1.CH3.raw_adc_counts", "sensor")
256 assert np.all(np.diff(t) >= 0)
257 assert v.tolist() == [20, 30, 10] # values follow their own timestamps
258
259
261 """The run extent is a union across entities, each on its own clock. Command and
262 heartbeat channels start at boot, ~3.7 s before the first sensor packet on a real
263 run; measuring only the sensor-clocked ones clipped the start of every one of them."""
264 monkeypatch.setattr(export_cache, "_db_size_bytes", lambda _r: None)
265 # A sensor channel covering 1010..1012, and a boot-relative one covering 1000..1002.
266 _write(clock_run, "PT1.CH1.raw_adc_counts", [1010.0, 1012.0], [1, 2])
267 _write(clock_run, "PT1.CH1.timestamp_ns", [1010.0, 1012.0], [1010.0e9, 1012.0e9])
268 _write(clock_run, "ACT_CMD.B2.CH1.actuator_state", [1000.0, 1002.0], [0, 1])
269 _write(clock_run, "ACT_CMD.B2.CH1.timestamp_ns", [1000.0, 1002.0], [5.0e9, 7.0e9])
270
271 idx = export_cache.build_index("r")
272 kinds = {c["entity"]: c["time_source"] for c in idx["components"]}
273 assert kinds["PT1.CH1"] == "sensor" and kinds["ACT_CMD.B2.CH1"] == "monotonic"
274 assert idx["n_reanchored"] == 2 and idx["n_db_only"] == 0
275 # The window must reach back to the command channel's start, not begin at the sensor's.
276 assert idx["sensor_t_min"] == pytest.approx(1000.0)
277 assert idx["sensor_t_max"] == pytest.approx(1012.0)
278
279
281 import time as _t
282
283 chunks = ["x" * 100_000 for _ in range(5)] # 500 KB total
284 t0 = _t.monotonic()
285 got = "".join(series.throttle(chunks, 1_000_000)) # 1 MB/s → ~0.5 s
286 dt = _t.monotonic() - t0
287 assert len(got) == 500_000
288 assert dt >= 0.35 # paced (unthrottled would be ~0 s)
289
290
292 chunks = ["a", "b", "c"]
293 assert list(series.throttle(chunks, 0)) == chunks
static std::vector< string_t > split(const string_t &reference_string)
split the string input to reference tokens
Definition json.hpp:14471
test_reanchored_rows_never_land_after_their_own_db_write(clock_run)
test_wide_csv_union_and_ffill(monkeypatch)
test_missing_sibling_falls_back_to_db_time(clock_run)
test_index_extent_covers_channels_on_both_clocks(clock_run, monkeypatch)
test_throttle_disabled_is_passthrough()
test_decimate_discrete_keeps_only_transitions()
clock_run(tmp_path, monkeypatch)
test_wide_csv_role_row_aligns_with_the_columns(monkeypatch, tmp_path)
_write(dir_, name, db_times, values)
test_reanchoring_a_single_row_degrades_to_the_db_time(clock_run)
test_an_epoch_clock_is_not_shifted(clock_run)
test_decimate_preserves_spikes_and_bounds()
test_row_count_mismatch_falls_back_rather_than_mispairing(clock_run)
test_decimate_discrete_caps_pathological()
test_sensor_clock_replaces_the_bursty_db_write_time(clock_run)
test_boot_relative_clock_is_reanchored_not_discarded(clock_run)
test_series_are_sorted_by_whichever_clock_is_in_use(clock_run)