STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_lc_tare.cpp
Go to the documentation of this file.
1/**
2 * LcTareStore — the guards that keep a load-cell tare from becoming a confident wrong number.
3 *
4 * The bug this whole store exists to prevent, in the operator's words: a 20 kg tank sits on a
5 * scale whose calibration is a bad two-point fit reading it as 18. The operator tares and the
6 * display reads 0. Later they add points, the fit improves, and the same tank evaluates to 20.
7 * A tare persisted as "18 kg" then displays 2 kg for a tank that never moved — plausible enough
8 * that nobody questions it. Persisting the ADC CODE instead and re-deriving through the current
9 * curve gives 20 - 20 = 0. Case 1 pins exactly that.
10 *
11 * 1. recompute_carries_tare_across_a_better_fit — the 2 kg bug
12 * 2. untrusted_curves_keep_the_last_good_offset — a failed cubic load must not zero a tare
13 * 3. non_finite_offset_is_never_recorded — a degenerate fit kills the series downstream
14 * 4. entity_matches_the_publish_path — Node keys on this string; slot != board_id
15 * 5. loaded_tare_is_corrected_by_recompute — startup ordering: stale offset must self-heal
16 * 6. fingerprint_tracks_what_the_curve_does — staleness is detectable, not assumed
17 * 7. stale_audit_finds_a_missed_recompute — a hook nobody added still gets caught
18 */
19
20#include <cmath>
21#include <cstdio>
22#include <filesystem>
23#include <fstream>
24#include <limits>
25#include <nlohmann/json.hpp>
26#include <string>
27
29
33using json = nlohmann::json;
34
35static int g_failures = 0;
36
37#define CHECK(cond, ...) \
38 do { \
39 if (!(cond)) { \
40 g_failures++; \
41 std::printf(" ❌ [%s:%d] ", __func__, __LINE__); \
42 std::printf(__VA_ARGS__); \
43 std::printf("\n"); \
44 } \
45 } while (0)
46
47namespace {
48
49/** A unique scratch path per test — hermetic, no committed fixtures, parallel-safe. */
50std::string scratch(const char* name) {
51 static int seq = 0;
52 auto p = std::filesystem::temp_directory_path() /
53 ("lc_tare_" + std::string(name) + "_" + std::to_string(++seq) + ".json");
54 std::filesystem::remove(p);
55 return p.string();
56}
57
58std::string read_text(const std::string& path) {
59 std::ifstream f(path);
60 return std::string((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
61}
62
63/** A linear adc->kg curve, standing in for select_lc_kg. */
64LcTareStore::Evaluator linear(double kg_per_count) {
65 return [kg_per_count](double adc) {
66 return adc * kg_per_count;
67 };
68}
69
70// ── 1. the 2 kg bug ─────────────────────────────────────────────────────────
71
73 LcTareStore s(scratch("recal"));
74
75 // Bad fit: the tank's true 20 kg reads as 18. adc 1000 -> 18 kg.
76 const auto bad = linear(0.018);
77 CHECK(s.set(4201, lc_tare_entity(42, 1), 1000.0, bad), "set should succeed");
78 const LcTare* t = s.tare_for(4201);
79 CHECK(t != nullptr, "tare should exist");
80 CHECK(std::fabs(t->offset_kg - 18.0) < 1e-9, "offset under the bad fit should be 18, got %f",
81 t->offset_kg);
82
83 // The operator clears the scale, hangs a known mass, types its TRUE weight; the fit improves
84 // so that the same code now evaluates to the tank's real 20 kg.
85 const auto good = linear(0.020);
86 s.recompute(4201, good);
87
88 t = s.tare_for(4201);
89 CHECK(std::fabs(t->offset_kg - 20.0) < 1e-9,
90 "offset must re-derive to 20 under the better fit, got %f", t->offset_kg);
91 CHECK(std::fabs(t->adc_at_tare - 1000.0) < 1e-9, "adc_at_tare is the truth and must not move");
92
93 // What the operator sees with the tank back on the scale: good(1000) - offset == 0.
94 const double displayed = good(1000.0) - t->offset_kg;
95 CHECK(std::fabs(displayed) < 1e-9, "the tank must still read 0 after the re-cal, got %f kg",
96 displayed);
97}
98
99// ── 2. an untrusted curve must not overwrite a good offset ──────────────────
100
102 LcTareStore s(scratch("untrusted"));
103 CHECK(s.set(4201, lc_tare_entity(42, 1), 1000.0, linear(0.020)), "set should succeed");
104 const double good_offset = s.tare_for(4201)->offset_kg;
105 CHECK(std::fabs(good_offset - 20.0) < 1e-9, "baseline offset should be 20, got %f",
106 good_offset);
107
108 // The cubic store failed to load, so every curve currently evaluates to a flat zero. A
109 // recompute through it would replace 20 kg with 0 and the tank would suddenly read its full
110 // weight, with nothing on screen to say why.
111 s.set_curves_trusted(false);
112 s.recompute(4201, linear(0.0));
113
114 CHECK(std::fabs(s.tare_for(4201)->offset_kg - good_offset) < 1e-9,
115 "an untrusted recompute must keep the last good offset, got %f",
116 s.tare_for(4201)->offset_kg);
117
118 // And once the curves are trustworthy again it does update.
119 s.set_curves_trusted(true);
120 s.recompute(4201, linear(0.030));
121 CHECK(std::fabs(s.tare_for(4201)->offset_kg - 30.0) < 1e-9,
122 "a trusted recompute must update, got %f", s.tare_for(4201)->offset_kg);
123}
124
125// ── 3. non-finite offsets are never recorded ────────────────────────────────
126
128 LcTareStore s(scratch("nonfinite"));
129
130 // A degenerate one-point fit can evaluate to inf/NaN. Node subtracts offset_kg from every
131 // sample; a NaN there is dropped by the finite guard downstream and the whole series simply
132 // vanishes from the plot with no error anywhere.
133 const auto blown_up = [](double) {
134 return std::numeric_limits<double>::quiet_NaN();
135 };
136 CHECK(!s.set(4201, lc_tare_entity(42, 1), 1000.0, blown_up), "a NaN offset must be refused");
137 CHECK(s.tare_for(4201) == nullptr, "nothing may be recorded for a refused tare");
138
139 const auto inf_curve = [](double) {
140 return std::numeric_limits<double>::infinity();
141 };
142 CHECK(!s.set(4202, lc_tare_entity(42, 2), 1000.0, inf_curve), "an inf offset must be refused");
143 CHECK(s.size() == 0, "store must still be empty, has %zu", s.size());
144
145 // A good tare that later sees a blown-up curve keeps its offset rather than adopting NaN.
146 CHECK(s.set(4203, lc_tare_entity(42, 3), 500.0, linear(0.02)), "good set should succeed");
147 s.recompute(4203, blown_up);
148 CHECK(std::isfinite(s.tare_for(4203)->offset_kg), "offset must stay finite after a bad curve");
149 CHECK(std::fabs(s.tare_for(4203)->offset_kg - 10.0) < 1e-9, "offset must be unchanged, got %f",
150 s.tare_for(4203)->offset_kg);
151}
152
153// ── 4. the entity string Node keys on ───────────────────────────────────────
154
156 // DatabaseConfig names the calibrated LC VTable LC<board_number>_Cal.CH<ch>, where
157 // board_number is board_id % 10 with 0 -> 10. Getting this wrong means C++ writes a key the
158 // backend never looks up, and the tare silently never applies.
159 CHECK(lc_tare_entity(42, 1) == "LC2_Cal.CH1", "board 42 ch 1 -> LC2_Cal.CH1, got %s",
160 lc_tare_entity(42, 1).c_str());
161 CHECK(lc_tare_entity(41, 6) == "LC1_Cal.CH6", "board 41 ch 6 -> LC1_Cal.CH6, got %s",
162 lc_tare_entity(41, 6).c_str());
163 // slot is NOT board_id: board 42 is slot 2, not 42.
164 CHECK(lc_tare_entity(42, 1) != "LC42_Cal.CH1", "must use the slot, not the raw board id");
165 // 0 -> 10, the documented edge (id 10, id 20).
166 CHECK(lc_tare_entity(10, 3) == "LC10_Cal.CH3", "board 10 ch 3 -> LC10_Cal.CH3, got %s",
167 lc_tare_entity(10, 3).c_str());
168}
169
170// ── 5. a stale offset from disk self-heals on recompute ─────────────────────
171
173 const std::string path = scratch("startup");
174
175 // Hand-write a record whose offset_kg belongs to a curve that is no longer live — exactly
176 // what a profile swapped while the service was down leaves behind.
177 json root;
178 root["version"] = 1;
179 json t;
180 t["uid"] = 4201;
181 t["entity"] = "LC2_Cal.CH1";
182 t["adc_at_tare"] = 1000.0;
183 t["offset_kg"] = 18.0; // stale: computed against the old curve
184 t["set_at_ms"] = 1757800000000.0;
185 t["curve_fp"] = 12345;
186 root["tares"] = json::array({t});
187 {
188 std::ofstream f(path);
189 f << root.dump(2);
190 }
191
192 LcTareStore s(path);
193 CHECK(s.load() == 1, "one tare should load");
194 CHECK(std::fabs(s.tare_for(4201)->offset_kg - 18.0) < 1e-9, "loads the stale value verbatim");
195
196 // The startup recompute is what corrects it. If the tare file is loaded AFTER the live store
197 // reload instead of before, this never runs and the stand carries the stale offset all run.
198 s.recompute_all([](uint16_t) {
199 return linear(0.020);
200 });
201 CHECK(std::fabs(s.tare_for(4201)->offset_kg - 20.0) < 1e-9,
202 "the startup recompute must correct a stale offset, got %f", s.tare_for(4201)->offset_kg);
203}
204
205// ── 6. the staleness fingerprint ────────────────────────────────────────────
206
208 const auto a = linear(0.020);
209 const auto b = linear(0.030);
210
212 "the same curve must fingerprint the same — otherwise every read looks stale");
214 "a changed curve must change the fingerprint");
215
216 // The fingerprint follows the curve the offset was computed against.
217 LcTareStore s(scratch("fp"));
218 CHECK(s.set(4201, lc_tare_entity(42, 1), 1000.0, a), "set should succeed");
219 CHECK(s.tare_for(4201)->curve_fp == LcTareStore::fingerprint(a), "fp must match the set curve");
220 s.recompute(4201, b);
222 "fp must follow a recompute onto a new curve");
223}
224
225// ── 7. the audit that catches a missed recompute hook ───────────────────────
226
228 LcTareStore s(scratch("audit"));
229 CHECK(s.set(4201, lc_tare_entity(42, 1), 1000.0, linear(0.020)), "set should succeed");
230
231 // Nothing has changed: the audit must report zero, or it would cry wolf on every startup and
232 // the warning would stop meaning anything.
233 CHECK(s.recompute_stale([](uint16_t) {
234 return linear(0.020);
235 }) == 0,
236 "an unchanged curve must not be reported stale");
237 CHECK(std::fabs(s.tare_for(4201)->offset_kg - 20.0) < 1e-9, "and the offset is untouched");
238
239 // Now the curve moves WITHOUT a recompute — the shape of a missed hook, and of reading the
240 // tare file after the startup reload instead of before it.
241 const size_t stale = s.recompute_stale([](uint16_t) {
242 return linear(0.030);
243 });
244 CHECK(stale == 1, "a moved curve must be reported stale, got %zu", stale);
245 CHECK(std::fabs(s.tare_for(4201)->offset_kg - 30.0) < 1e-9, "and must be re-derived, got %f",
246 s.tare_for(4201)->offset_kg);
247
248 // Having fixed it, a second audit is quiet.
249 CHECK(s.recompute_stale([](uint16_t) {
250 return linear(0.030);
251 }) == 0,
252 "the audit must be quiet once it has healed");
253}
254
255// ── round trip ──────────────────────────────────────────────────────────────
256
258 const std::string path = scratch("roundtrip");
259 {
260 LcTareStore s(path);
261 CHECK(s.set(4201, lc_tare_entity(42, 1), 1000.0, linear(0.020)), "set");
262 CHECK(s.set(4206, lc_tare_entity(42, 6), -250.0, linear(0.020)), "set negative adc");
263 CHECK(s.save(), "save");
264 }
265 LcTareStore s2(path);
266 CHECK(s2.load() == 2, "two tares should load");
267 CHECK(!s2.load_failed(), "a good file is not a failed load");
268 CHECK(s2.tare_for(4201)->entity == "LC2_Cal.CH1", "entity round-trips");
269 CHECK(std::fabs(s2.tare_for(4206)->offset_kg + 5.0) < 1e-9,
270 "negative offset round-trips, got %f", s2.tare_for(4206)->offset_kg);
271
272 // A missing file is the normal post-session-start state, not a failure — it must not block
273 // the next save the way an unreadable file does.
274 LcTareStore s3(scratch("absent"));
275 CHECK(s3.load() == 0, "absent file loads nothing");
276 CHECK(!s3.load_failed(), "an absent file is not a failed load");
277}
278
280 const std::string path = scratch("corrupt");
281 {
282 std::ofstream f(path);
283 f << "{ this is not json";
284 }
285 LcTareStore s(path);
286 CHECK(s.load() == 0, "corrupt file loads nothing");
287 CHECK(s.load_failed(), "corrupt file must set load_failed");
288 CHECK(!s.save(), "save must be refused while load_failed");
289 CHECK(read_text(path) == "{ this is not json", "the file must be left exactly as found");
290}
291
292} // namespace
293
294int main() {
295 std::printf("LcTareStore tests\n");
305
306 if (g_failures == 0)
307 std::printf(" ✅ all passed\n");
308 else
309 std::printf(" %d failure(s)\n", g_failures);
310 return g_failures ? 1 : 0;
311}
* array(stored with pointer to save storage) array_t *array
Owns the per-channel tares for the calibration service.
bool load_failed() const
Set when load() could not read an existing file; blocks save() so an unreadable store is never replac...
bool set(uint16_t uid, const std::string &entity, double adc_at_tare, const Evaluator &eval)
Record a tare for uid at adc_at_tare and derive its offset.
size_t load()
Load the JSON record.
const LcTare * tare_for(uint16_t uid) const
void recompute(uint16_t uid, const Evaluator &eval)
Re-derive offset_kg for one uid under the current curve.
void set_curves_trusted(bool trusted)
False while the curves cannot be trusted — set when the cubic store failed to load.
size_t recompute_stale(const std::function< Evaluator(uint16_t)> &eval_for)
Re-derive only the tares whose recorded fingerprint disagrees with the curve now live,...
bool save() const
Atomically write the JSON record (temp + rename).
void recompute_all(const std::function< Evaluator(uint16_t)> &eval_for)
Re-derive every tare; eval_for supplies each uid's evaluator.
std::function< double(double adc)> Evaluator
adc -> kg through whichever model this uid streams (select_lc_kg: cubic fit or the datasheet physics ...
static uint64_t fingerprint(const Evaluator &eval)
A fingerprint of what a curve does, not of the coefficients that describe it: the evaluator is sample...
static STAR_MCP3201 adc(MCP3201_CS_PIN, &mcp_spi)
int32_t p
Definition flight_e2e.c:78
*brief create JSON pointer *sa reference_tokens const string_t const string_t & b
Definition json.hpp:13878
*brief create JSON pointer *sa reference_tokens const string_t & a
Definition json.hpp:13877
basic_json<> json
default specialization
Definition json.hpp:3422
LcTareStore::Evaluator linear(double kg_per_count)
A linear adc->kg curve, standing in for select_lc_kg.
std::string read_text(const std::string &path)
std::string lc_tare_entity(uint8_t board_id, uint8_t connector)
The publish-path entity name for an LC channel: "LC<slot>_Cal.CH<connector>", where slot is board_id ...
One load cell's tare: the scale reads zero at the load it was holding when the operator pressed Tare.
static int g_failures
static uint8_t scratch[BS]
std::string lc_tare_entity(uint8_t board_id, uint8_t connector)
The publish-path entity name for an LC channel: "LC<slot>_Cal.CH<connector>", where slot is board_id ...
#define CHECK(cond,...)
std::string lc_tare_entity(uint8_t board_id, uint8_t connector)
LcTareStore — the guards that keep a load-cell tare from becoming a confident wrong number.
int main()