STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_lc_tare.py
Go to the documentation of this file.
1"""Load-cell tare reconstruction in the viewer.
2
3Run: cd webviewer && .venv/bin/python -m pytest backend/test_lc_tare.py -q
4
5Hermetic — no elodin-db and no real run. Elodin archives ABSOLUTE force forever; the tare is
6display state the backend recorded beside the run, and these tests pin the replay of it.
7
8Every case names the wrong number it prevents:
9 * a step applied across the whole array makes the PRE-tare part of every run read wrong;
10 * a missed 'recal' line makes everything after a re-fit read wrong;
11 * synthesising a tared twin unconditionally makes an untared run look tared;
12 * synthesising in build_index instead of at serve time means a run exported before its sidecar
13 landed never shows its tare, with the cached index quietly winning.
14"""
15
16from __future__ import annotations
17
18import json
19
20import numpy as np
21import pandas as pd
22import pytest
23
24from . import config, export_cache, lc_tare, run_config, series
25
26
27@pytest.fixture
28def run(tmp_path, monkeypatch):
29 """A scratch run: parquet cache and ELODIN_DIR both under tmp_path."""
30 cache = tmp_path / "cache"
31 cache.mkdir()
32 elodin = tmp_path / "elodin"
33 (elodin / "daq_20260914_120000").mkdir(parents=True)
34 monkeypatch.setattr(config, "ELODIN_DIR", elodin)
35 monkeypatch.setattr(export_cache, "cache_dir", lambda run_id: cache)
36 # Mandatory for anything touching load_series: both caches are keyed on (run_id, component)
37 # and would serve a previous test's arrays.
38 series.load_series.cache_clear()
39 series.sensor_clock.cache_clear()
40 return {"id": "daq_20260914_120000", "cache": cache, "elodin": elodin}
41
42
43def write_gross(run, entity="LC2_Cal.CH1", times=(100.0, 200.0, 300.0), values=(50.0, 51.0, 52.0)):
44 df = pd.DataFrame({
45 "time": pd.to_datetime(np.array(times) * 1e9, unit="ns"),
46 "value": list(values),
47 })
48 df.to_parquet(run["cache"] / f"{entity}.force_kg.parquet")
49
50
51def write_sidecar(run, lines):
52 p = run["elodin"] / run["id"] / "lc_tare.jsonl"
53 p.write_text("".join(json.dumps(l) + "\n" for l in lines))
54
55
56# ── the step function ───────────────────────────────────────────────────────
57
59 # Subtracting the final offset across the whole array would make every sample before the
60 # operator pressed Tare read 20 kg light — the pre-tare part of the run, silently wrong.
61 write_gross(run)
62 write_sidecar(run, [
63 {"entity": "LC2_Cal.CH1", "uid": 4201, "event": "set",
64 "offsetKg": 20.0, "adcAtTare": 1000, "appliedAtMs": 250_000},
65 ])
66 t, v = series.load_series(run["id"], "LC2_Cal.CH1.force_kg_tared")
67 assert list(t) == [100.0, 200.0, 300.0]
68 assert v == pytest.approx([50.0, 51.0, 32.0])
69
70
72 # A re-fit rewrites the offset with NO operator action. Reading only the first line leaves
73 # everything after the re-cal wrong by the difference.
74 write_gross(run)
75 write_sidecar(run, [
76 {"entity": "LC2_Cal.CH1", "event": "set", "offsetKg": 20.0, "appliedAtMs": 150_000},
77 {"entity": "LC2_Cal.CH1", "event": "recal", "offsetKg": 22.0, "appliedAtMs": 250_000},
78 ])
79 _, v = series.load_series(run["id"], "LC2_Cal.CH1.force_kg_tared")
80 assert v == pytest.approx([50.0, 31.0, 30.0])
81
82
84 write_gross(run)
85 write_sidecar(run, [
86 {"entity": "LC2_Cal.CH1", "event": "set", "offsetKg": 20.0, "appliedAtMs": 150_000},
87 {"entity": "LC2_Cal.CH1", "event": "clear", "offsetKg": 0.0, "appliedAtMs": 250_000},
88 ])
89 _, v = series.load_series(run["id"], "LC2_Cal.CH1.force_kg_tared")
90 assert v == pytest.approx([50.0, 31.0, 52.0])
91
92
94 # The whole premise: force_kg means the same thing in every run ever archived.
95 write_gross(run)
96 write_sidecar(run, [
97 {"entity": "LC2_Cal.CH1", "event": "set", "offsetKg": 20.0, "appliedAtMs": 150_000},
98 ])
99 _, gross = series.load_series(run["id"], "LC2_Cal.CH1.force_kg")
100 assert gross == pytest.approx([50.0, 51.0, 52.0])
101
102
104 # Both go through one lru_cache keyed on (run_id, component, time_source). Stripping the
105 # suffix before the lookup would make the tared request return the absolute arrays.
106 write_gross(run)
107 write_sidecar(run, [
108 {"entity": "LC2_Cal.CH1", "event": "set", "offsetKg": 20.0, "appliedAtMs": 150_000},
109 ])
110 _, gross = series.load_series(run["id"], "LC2_Cal.CH1.force_kg")
111 _, tared = series.load_series(run["id"], "LC2_Cal.CH1.force_kg_tared")
112 assert list(gross) != list(tared)
113
114
115# ── existence ───────────────────────────────────────────────────────────────
116
118 # Synthesising one anyway would render identically to gross and lie about whether a tare ever
119 # existed on this run.
120 write_gross(run)
121 with pytest.raises(FileNotFoundError):
122 series.load_series(run["id"], "LC2_Cal.CH1.force_kg_tared")
123 idx = run_config.annotate({"components": [
124 {"name": "LC2_Cal.CH1.force_kg", "entity": "LC2_Cal.CH1", "field": "force_kg"},
125 ]}, run["id"])
126 assert [c["name"] for c in idx["components"]] == ["LC2_Cal.CH1.force_kg"]
127
128
130 # Two load cells, one tared. The untared one must not grow a tared component.
131 write_sidecar(run, [
132 {"entity": "LC2_Cal.CH1", "event": "set", "offsetKg": 20.0, "appliedAtMs": 150_000},
133 ])
134 idx = run_config.annotate({"components": [
135 {"name": "LC2_Cal.CH1.force_kg", "entity": "LC2_Cal.CH1", "field": "force_kg"},
136 {"name": "LC2_Cal.CH2.force_kg", "entity": "LC2_Cal.CH2", "field": "force_kg"},
137 ]}, run["id"])
138 names = [c["name"] for c in idx["components"]]
139 assert "LC2_Cal.CH1.force_kg_tared" in names
140 assert "LC2_Cal.CH2.force_kg_tared" not in names
141
142
144 # The reason synthesis lives in annotate() and not build_index(): a run indexed before its
145 # tare record was written must still show its tared traces on the next open. If this moved
146 # into build_index the cached index would win and the tare would never appear.
147 comps = [{"name": "LC2_Cal.CH1.force_kg", "entity": "LC2_Cal.CH1", "field": "force_kg"}]
148 before = run_config.annotate({"components": list(comps)}, run["id"])
149 assert "LC2_Cal.CH1.force_kg_tared" not in [c["name"] for c in before["components"]]
150
151 write_sidecar(run, [
152 {"entity": "LC2_Cal.CH1", "event": "set", "offsetKg": 20.0, "appliedAtMs": 150_000},
153 ])
154 after = run_config.annotate({"components": list(comps)}, run["id"])
155 assert "LC2_Cal.CH1.force_kg_tared" in [c["name"] for c in after["components"]]
156
157
159 # Bumping it would invalidate every cached export on the postprocessing box, for a feature
160 # that is layered on at read time and needs no re-export at all.
161 assert export_cache.INDEX_VERSION == 3
162
163
164# ── degradation ─────────────────────────────────────────────────────────────
165
167 # The backend appends to this file while the run is live, so a reader can catch it mid-write.
168 p = run["elodin"] / run["id"] / "lc_tare.jsonl"
169 p.write_text(
170 json.dumps({"entity": "LC2_Cal.CH1", "event": "set",
171 "offsetKg": 20.0, "appliedAtMs": 150_000}) + "\n"
172 + '{"entity":"LC2_Cal.CH1","event":"rec'
173 )
174 assert lc_tare.load(run["id"]) == {"LC2_Cal.CH1": [(150.0, 20.0)]}
175
176
178 assert lc_tare.load(run["id"]) == {}
179
180
182 write_sidecar(run, [
183 {"entity": "LC2_Cal.CH1", "event": "set", "offsetKg": None, "appliedAtMs": 150_000},
184 ])
185 assert lc_tare.load(run["id"]) == {}
186
187
189 # Different units put the two traces on different y-axes, so the tared and absolute lines for
190 # one load cell would not be comparable on the same chart.
191 from .naming import classify
192 assert classify("LC2_Cal.CH1.force_kg_tared").unit == classify("LC2_Cal.CH1.force_kg").unit
test_a_torn_final_line_does_not_lose_the_earlier_ones(run)
test_a_sidecar_dropped_in_later_takes_effect_without_a_re_export(run)
test_offset_applies_only_after_the_tare_was_set(run)
write_sidecar(run, lines)
test_a_non_finite_offset_is_skipped(run)
test_a_clear_returns_the_trace_to_absolute(run)
test_the_absolute_series_is_never_touched(run)
test_a_missing_sidecar_is_empty_not_an_error(run)
test_a_channel_with_no_tare_gets_no_twin(run)
test_the_unit_matches_its_absolute_twin(run)
test_gross_and_tared_do_not_share_a_cache_entry(run)
write_gross(run, entity="LC2_Cal.CH1", times=(100.0, 200.0, 300.0), values=(50.0, 51.0, 52.0))
test_a_recal_line_moves_the_offset_again(run)
test_no_sidecar_means_no_tared_component(run)
Definition run.py:1