STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_cubic_store_namespace.cpp
Go to the documentation of this file.
1/**
2 * Unit tests for the PT/LC split in fsw::calibration::CubicCalibrationStore.
3 *
4 * logical_ch is (slot - 1) * 10 + connector with slot = board_id % 10, and check_board_slots
5 * only stops two boards of the SAME kind sharing a slot. So PT board 22 and LC board 42 are
6 * both slot 2, and PT uid 2201 and LC uid 4201 both land on logical channel 11. The
7 * logical-keyed maps were written from a uid-ordered map, so the higher uid silently won: on
8 * 2026-09-13 the LOX Scale's curve replaced GN2 High's (a 5000 psi transducer) on disk, and
9 * evaluating GN2 High's ADCs against it gave 1.2e10 psi.
10 *
11 * These tests pin the split and the recovery. Recovery needs no migration: load() reads only
12 * cubic_state and recomputes every fit from the points, which survived.
13 *
14 * 1. the field collision at logical 11 4. legacy file without "kind"
15 * 2. the second live collision at logical 1 5. add_point refuses an unknown uid
16 * 3. kind round-trips through the file 6. recovery regenerates the PT curve
17 */
18
19#include <cinttypes>
20#include <cmath>
21#include <cstdio>
22#include <filesystem>
23#include <fstream>
24#include <nlohmann/json.hpp>
25#include <string>
26
28
31using json = nlohmann::json;
32
33static int g_failures = 0;
34
35#define CHECK(cond, ...) \
36 do { \
37 if (!(cond)) { \
38 g_failures++; \
39 std::printf(" ❌ [%s:%d] ", __func__, __LINE__); \
40 std::printf(__VA_ARGS__); \
41 std::printf("\n"); \
42 } \
43 } while (0)
44
45namespace {
46
47/** A unique scratch path per test — hermetic, no committed fixtures, parallel-safe. */
48std::string scratch(const char* name) {
49 static int seq = 0;
50 auto p = std::filesystem::temp_directory_path() /
51 ("cubic_ns_" + std::string(name) + "_" + std::to_string(++seq) + ".json");
52 std::filesystem::remove(p);
53 return p.string();
54}
55
56json read_json(const std::string& path) {
57 std::ifstream f(path);
58 json j;
59 f >> j;
60 return j;
61}
62
63double eval_abcd(const json& arr, double x) {
64 const double A = arr[0], B = arr[1], C = arr[2], D = arr[3];
65 return A * x * x * x + B * x * x + C * x + D;
66}
67
68/** GN2 High's real captured shape: a 5000 psi PT, ADC codes in the hundreds of millions. */
69void add_pt_points(CubicCalibrationStore& s, uint16_t uid) {
70 s.add_point(uid, 413578828.0, 0.0);
71 s.add_point(uid, 488735724.0, 225.0);
72 s.add_point(uid, 546113706.0, 400.0);
73 s.add_point(uid, 579791093.0, 501.0);
74}
75
76/** The LOX Scale's real captured shape: a load cell, codes in the hundreds of thousands. */
77void add_lc_points(CubicCalibrationStore& s, uint16_t uid) {
78 s.add_point(uid, 598340.0, 0.0);
79 s.add_point(uid, 802135.0, 4.536);
80 s.add_point(uid, 1013776.0, 9.072);
81 s.add_point(uid, 1114800.0, 11.34);
82}
83
84// ── 1. The collision that actually happened ──────────────────────────────────────────
86 const std::string path = scratch("collision11");
88 // PT board 22 → slot 2, connector 1 → logical 11. LC board 42 → the same logical 11.
89 s.register_channel(2201, 22, 1, 11, "GN2 High", "cubic", SensorKind::PT);
90 s.register_channel(4201, 42, 1, 11, "LOX Scale", "cubic", SensorKind::LC);
91 add_pt_points(s, 2201);
92 add_lc_points(s, 4201);
93 CHECK(s.save(), "save should succeed");
94
95 const json j = read_json(path);
96 CHECK(j.contains("calibration_polynomials") && j["calibration_polynomials"].contains("11"),
97 "PT map should hold logical 11");
98 CHECK(j.contains("lc_calibration_polynomials") &&
99 j["lc_calibration_polynomials"].contains("11"),
100 "LC map should hold logical 11");
101
102 // The PT map must evaluate the PT's own points — this is what used to give ~1e10.
103 const double psi = eval_abcd(j["calibration_polynomials"]["11"], 579791093.0);
104 CHECK(std::fabs(psi - 501.0) < 5.0, "PT logical 11 should give ~501 psi, got %.4g", psi);
105
106 // And the LC map the load cell's.
107 const double kg = eval_abcd(j["lc_calibration_polynomials"]["11"], 1114800.0);
108 CHECK(std::fabs(kg - 11.34) < 0.1, "LC logical 11 should give ~11.34 kg, got %.4g", kg);
109}
110
111// ── 2. The other live collision ──────────────────────────────────────────────────────
113 const std::string path = scratch("collision1");
115 s.register_channel(2101, 21, 1, 1, "Fuel Fill Vent", "cubic", SensorKind::PT);
116 s.register_channel(4101, 41, 1, 1, "Fuel Scale", "cubic", SensorKind::LC);
117 add_pt_points(s, 2101);
118 add_lc_points(s, 4101);
119 CHECK(s.save(), "save should succeed");
120
121 const json j = read_json(path);
122 const double psi = eval_abcd(j["calibration_polynomials"]["1"], 579791093.0);
123 const double kg = eval_abcd(j["lc_calibration_polynomials"]["1"], 1114800.0);
124 CHECK(std::fabs(psi - 501.0) < 5.0, "PT logical 1 should give ~501 psi, got %.4g", psi);
125 CHECK(std::fabs(kg - 11.34) < 0.1, "LC logical 1 should give ~11.34 kg, got %.4g", kg);
126}
127
128// ── 3. kind survives the file, on the orphan path ────────────────────────────────────
130 const std::string path = scratch("roundtrip");
131 {
133 s.register_channel(2201, 22, 1, 11, "GN2 High", "cubic", SensorKind::PT);
134 s.register_channel(4201, 42, 1, 11, "LOX Scale", "cubic", SensorKind::LC);
135 add_pt_points(s, 2201);
136 add_lc_points(s, 4201);
137 s.save();
138 }
139 // Fresh store, NO register_channel: every entry takes the orphan path and must get its
140 // kind from the file, or the two would collapse back into one map.
142 CHECK(s2.load() == 2, "both channels should load");
143 CHECK(s2.save(), "re-save should succeed");
144
145 const json j = read_json(path);
146 CHECK(j["calibration_polynomials"].contains("11"), "PT map should survive a blind reload");
147 CHECK(j["lc_calibration_polynomials"].contains("11"), "LC map should survive a blind reload");
148 const double psi = eval_abcd(j["calibration_polynomials"]["11"], 579791093.0);
149 CHECK(std::fabs(psi - 501.0) < 5.0, "PT curve should still be the PT's, got %.4g", psi);
150}
151
152// ── 4. A file written before the split ───────────────────────────────────────────────
154 const std::string path = scratch("legacy");
155 { // hand-build a cubic_state with no "kind" anywhere
156 json j;
157 j["cubic_state"] = json::object();
158 for (const auto& [uid, role] : {std::pair<const char*, const char*>{"2201", "GN2 High"},
159 std::pair<const char*, const char*>{"4201", "LOX Scale"}}) {
160 json cj;
161 cj["boardId"] = std::string(uid).substr(0, 2) == "22" ? 22 : 42;
162 cj["connector"] = 1;
163 cj["logicalCh"] = 11;
164 cj["role"] = role;
165 cj["active_model"] = "cubic";
166 cj["points"] = json::array();
167 j["cubic_state"][uid] = cj;
168 }
169 std::ofstream f(path);
170 f << j.dump(2);
171 }
173 // Config registers both, with their true kinds — config wins over the file, as for
174 // active_model. A legacy file therefore comes out right for every configured channel.
175 s.register_channel(2201, 22, 1, 11, "GN2 High", "cubic", SensorKind::PT);
176 s.register_channel(4201, 42, 1, 11, "LOX Scale", "cubic", SensorKind::LC);
177 CHECK(s.load() == 2, "legacy entries should load");
178 add_pt_points(s, 2201);
179 add_lc_points(s, 4201);
180 CHECK(s.save(), "save should succeed");
181
182 const json j = read_json(path);
183 const double psi = eval_abcd(j["calibration_polynomials"]["11"], 579791093.0);
184 const double kg = eval_abcd(j["lc_calibration_polynomials"]["11"], 1114800.0);
185 CHECK(std::fabs(psi - 501.0) < 5.0, "legacy PT should land in the PT map, got %.4g", psi);
186 CHECK(std::fabs(kg - 11.34) < 0.1, "legacy LC should land in the LC map, got %.4g", kg);
187}
188
189// ── 5. An unknown uid is refused, not invented ───────────────────────────────────────
191 const std::string path = scratch("unknown");
193 s.register_channel(2201, 22, 1, 11, "GN2 High", "cubic", SensorKind::PT);
194
195 const auto fit = s.add_point(9999, 123456.0, 10.0);
196 CHECK(!fit.valid, "an unregistered uid must not produce a fit");
197 CHECK(s.channel(9999) == nullptr, "an unregistered uid must not create a channel");
198 CHECK(s.uids().size() == 1, "uids() should still hold just the registered one, got %zu",
199 s.uids().size());
200}
201
202// ── 6. Recovery: the overwritten PT curve comes back from its surviving points ───────
204 const std::string path = scratch("recovery");
205 { // Reproduce the corrupted file: both kinds present, one shared logical map, LC winning.
207 s.register_channel(2201, 22, 1, 11, "GN2 High", "cubic", SensorKind::PT);
208 s.register_channel(4201, 42, 1, 11, "LOX Scale", "cubic", SensorKind::LC);
209 add_pt_points(s, 2201);
210 add_lc_points(s, 4201);
211 s.save();
212
213 json j = read_json(path);
214 j["calibration_polynomials"]["11"] = j["lc_calibration_polynomials"]["11"]; // the damage
215 j.erase("lc_calibration_polynomials");
216 {
217 std::ofstream f(path);
218 f << j.dump(2);
219 }
220
221 const double bad = eval_abcd(read_json(path)["calibration_polynomials"]["11"], 579791093.0);
222 CHECK(std::fabs(bad - 501.0) > 1000.0,
223 "sanity: the corrupted curve should be absurd (%.4g)", bad);
224 }
225
226 // Load + save is the whole recovery. No migration, no hand-editing.
228 s.register_channel(2201, 22, 1, 11, "GN2 High", "cubic", SensorKind::PT);
229 s.register_channel(4201, 42, 1, 11, "LOX Scale", "cubic", SensorKind::LC);
230 CHECK(s.load() == 2, "both channels should load from cubic_state");
231 CHECK(s.save(), "save should succeed");
232
233 const json j = read_json(path);
234 const double psi = eval_abcd(j["calibration_polynomials"]["11"], 579791093.0);
235 CHECK(std::fabs(psi - 501.0) < 5.0, "PT curve should be restored to ~501 psi, got %.4g", psi);
236}
237
238// ── 7. An unreadable store is never replaced by an empty one ─────────────────────────
240 const std::string path = scratch("corrupt");
241 {
242 std::ofstream f(path);
243 f << "{ this is not json";
244 }
246 CHECK(s.load() == 0, "a corrupt file loads nothing");
247 CHECK(!s.save(), "save must refuse after a failed load — the file is the only copy");
248
249 std::ifstream f(path);
250 std::string content((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
251 CHECK(content == "{ this is not json", "the original bytes must be untouched, got '%s'",
252 content.c_str());
253}
254
255} // namespace
256
257// ── 8. A role that moves between an ESP32 board and a LabJack T7 ─────────────────────
258// Calibration is filed by role, so "LOX Tank" moving from PT board 21 to T7 board 81 would carry
259// a fit in ADC codes (hundreds of millions per volt-ish) onto a channel that reads microvolts.
261 const std::string path = scratch("t7move");
262 {
264 s.register_channel(2101, 21, 1, 1, "LOX Tank", "cubic", SensorKind::PT);
265 add_pt_points(s, 2101);
266 CHECK(s.save(), "save should succeed");
267 CHECK(read_json(path)["cubic_state"]["2101"]["rawUnit"] == "adc",
268 "an ESP32 channel is saved with rawUnit adc");
269 }
270 {
271 // Now the role lives on a T7: uid 8101, logical channel 129, readings in uV.
273 s.register_channel(8101, 81, 1, 129, "LOX Tank", "cubic", SensorKind::PT, "uV");
274 s.load();
275 const auto* t7 = s.channel(8101);
276 CHECK(t7 != nullptr && t7->points.empty() && !t7->fit.valid,
277 "the ADC-code fit must NOT attach to the T7 channel");
278 const auto* old = s.channel(2101);
279 CHECK(old != nullptr && old->points.size() == 4 && old->raw_unit == "adc",
280 "the ESP32 points stay, as an orphan at their own uid");
281 CHECK(s.save(), "save should succeed");
282 const json j = read_json(path);
283 CHECK(j["cubic_state"].contains("2101") && j["cubic_state"]["2101"]["numPoints"] == 4,
284 "and survive the next save instead of being dropped");
285 CHECK(j["cubic_state"]["8101"]["rawUnit"] == "uV", "the T7 channel is saved as uV");
286 }
287}
288
289// ── 9. Same unit still follows the role, and a T7 orphan keeps its logical channel ────
291 const std::string path = scratch("t7follow");
292 {
294 s.register_channel(8101, 81, 1, 129, "LOX Tank", "cubic", SensorKind::PT, "uV");
295 s.add_point(8101, 0.0, 0.0);
296 s.add_point(8101, 2500000.0, 500.0);
297 s.add_point(8101, 5000000.0, 1000.0);
298 CHECK(s.save(), "save should succeed");
299 }
300 {
301 // Rewired from AIN1 to AIN3 on the same T7.
303 s.register_channel(8103, 81, 3, 131, "LOX Tank", "cubic", SensorKind::PT, "uV");
304 s.load();
305 const auto* moved = s.channel(8103);
306 CHECK(moved != nullptr && moved->points.size() == 3 && moved->fit.valid,
307 "a uV fit follows its role to another T7 input");
308 CHECK(s.save(), "save should succeed");
309 }
310 {
311 // The T7 is gone from config: its entry is an orphan, and must keep logical channel 129
312 // rather than be re-derived as ESP32 slot 1 connector 1 (logical 1, a live PT's).
314 s.register_channel(2101, 21, 1, 1, "Fuel Upstream", "cubic", SensorKind::PT);
315 s.load();
316 const auto* orphan = s.channel(8103);
317 CHECK(orphan != nullptr && orphan->logical_ch == 131 && orphan->raw_unit == "uV",
318 "a T7 orphan keeps its own logical channel and unit");
319 CHECK(s.save(), "save should succeed");
320 const json j = read_json(path);
321 CHECK(!j["calibration_polynomials"].contains("1"),
322 "nothing lands on ESP32 logical channel 1");
323 }
324}
325
326// ── 10. A DISABLED board's entry must not reach a live sensor's logical channel ───────
327// 2026-11-09, the stand: LC board 41 was set enabled = false. Its three saved entries
328// (connectors 1, 6, 7 — one point each, so no valid fit) resumed as orphans, and the service's
329// LC/PT discriminant was built from ENABLED boards, so it took them for PTs. Iterating uids in
330// order, it set PT logical 1/6/7 from 2101/2106/2107 and then CLEARED them from 4101/4106/4107.
331// FT-PT-R, FF-PT and DR-PT-G streamed 0 psi with healthy raw ADC and a good cubic on disk.
332// cal_target() is what the service now asks, and an orphan answers None.
334 const std::string path = scratch("disabledlc");
335 { // Both boards enabled: the PT is calibrated, the load cell has a single point.
337 s.register_channel(2101, 21, 1, 1, "FT-PT-R", "cubic", SensorKind::PT);
338 s.register_channel(4101, 41, 1, 1, "LC-1", "linear", SensorKind::LC);
339 add_pt_points(s, 2101);
340 s.add_point(4101, 598340.0, 0.0);
341 CHECK(s.save(), "save should succeed");
342 }
343 // LC board 41 is now disabled: only the PT board registers.
345 s.register_channel(2101, 21, 1, 1, "FT-PT-R", "cubic", SensorKind::PT);
346 CHECK(s.load() == 2, "both entries should still load — an orphan's points are kept");
347
348 const auto* pt = s.channel(2101);
349 const auto* orphan = s.channel(4101);
350 CHECK(pt != nullptr && orphan != nullptr, "both channels should be present");
351 if (pt == nullptr || orphan == nullptr)
352 return;
353
354 // The collision is the premise: if these ever stop sharing a logical channel the test below
355 // would pass for the wrong reason.
356 CHECK(pt->logical_ch == 1 && orphan->logical_ch == 1,
357 "premise: PT 2101 and LC 4101 share logical channel 1 (got %u and %u)",
358 static_cast<unsigned>(pt->logical_ch), static_cast<unsigned>(orphan->logical_ch));
359 // And so is the invalid fit: one point under a linear model is what made the service clear.
360 CHECK(!orphan->fit.valid, "premise: a one-point linear fit is invalid");
361 CHECK(pt->fit.valid, "premise: the PT's own cubic is good");
362
364 "a live PT applies to the PT manager");
366 "a disabled board's entry must reach NEITHER manager — it would clear logical 1");
367
368 // It must also not be mistaken for the LC it is: were it applied as an LC it would clear
369 // LC logical 1, and a live LC board in slot 1 would lose its curve the same way.
370 CHECK(orphan->kind == SensorKind::LC, "the orphan still knows it is a load cell");
371}
372
373// ── 11. A removed board's still-good curve must not win the logical-channel map ───────
374// The same collision one step out, and inside a single kind where the SensorKind split cannot
375// help: `calibration_polynomials` is keyed by logical channel and written in uid order, and the
376// Node backend's loadCalibrationJSON keys its own CalibrationMap from it. A PT board in slot 1
377// that is dropped from config keeps a VALID fit, and its uid sorts after the live board's — so it
378// used to overwrite the live sensor's curve on disk, in the file that is the only record of it.
380 const std::string path = scratch("removedpt");
381 { // Two PT boards, both slot 1 (board 21 and board 91) → both logical channel 1.
383 s.register_channel(2101, 21, 1, 1, "FT-PT-R", "cubic", SensorKind::PT);
384 s.register_channel(9101, 91, 1, 1, "Spare PT", "cubic", SensorKind::PT);
385 add_pt_points(s, 2101);
386 // The spare's own curve: same shape, deliberately offset so the two are told apart.
387 s.add_point(9101, 413578828.0, 1000.0);
388 s.add_point(9101, 488735724.0, 1225.0);
389 s.add_point(9101, 546113706.0, 1400.0);
390 s.add_point(9101, 579791093.0, 1501.0);
391 CHECK(s.save(), "save should succeed");
392 }
393 // Board 91 is gone from config. Only board 21 registers.
395 s.register_channel(2101, 21, 1, 1, "FT-PT-R", "cubic", SensorKind::PT);
396 CHECK(s.load() == 2, "both entries load");
397
398 const auto* orphan = s.channel(9101);
399 CHECK(orphan != nullptr && orphan->logical_ch == 1 && orphan->fit.valid,
400 "premise: the orphan shares logical channel 1 AND still has a valid fit");
401 CHECK(s.save(), "save should succeed");
402
403 const json j = read_json(path);
404 CHECK(j["calibration_polynomials"].contains("1"), "the live PT keeps its map entry");
405 const double psi = eval_abcd(j["calibration_polynomials"]["1"], 579791093.0);
406 CHECK(std::fabs(psi - 501.0) < 5.0,
407 "logical 1 must be the LIVE sensor's curve (~501 psi), got %.4g", psi);
408 // And the orphan's points survive for when its board comes back.
409 CHECK(j["cubic_state"].contains("9101") && j["cubic_state"]["9101"]["numPoints"] == 4,
410 "the orphan's points are kept in cubic_state");
411}
412
413int main() {
414 std::printf("CubicCalibrationStore PT/LC namespace tests\n");
426
427 if (g_failures == 0)
428 std::printf(" ✅ all passed\n");
429 else
430 std::printf(" %d failure(s)\n", g_failures);
431 return g_failures ? 1 : 0;
432}
* array(stored with pointer to save storage) array_t *array
Owns the per-channel captured points and the fitted cubic for the calibration service.
size_t load()
Load the JSON record, resume points, and re-fit each channel.
void register_channel(uint16_t uid, uint8_t board_id, uint8_t connector, uint8_t logical_ch, const std::string &role, const std::string &active_model="cubic", SensorKind kind=SensorKind::PT, const std::string &raw_unit="adc")
Attach/refresh identity + role + streaming model for a uid (idempotent; call at startup).
CubicFit add_point(uint16_t uid, double adc, double psi)
Append a capture point (raw ADC, reference PSI), cap history, and re-fit.
const CubicChannel * channel(uint16_t uid) const
bool save() const
Atomically write the JSON record (temp + rename).
int32_t p
Definition flight_e2e.c:78
bool contains(const BasicJsonType *ptr) const
Definition json.hpp:14383
basic_json<> json
default specialization
Definition json.hpp:3422
void add_lc_points(CubicCalibrationStore &s, uint16_t uid)
The LOX Scale's real captured shape: a load cell, codes in the hundreds of thousands.
void add_pt_points(CubicCalibrationStore &s, uint16_t uid)
GN2 High's real captured shape: a 5000 psi PT, ADC codes in the hundreds of millions.
CalTarget cal_target(const CubicChannel &ch)
SensorKind
Which family of sensor a channel belongs to.
static int g_failures
void removed_board_does_not_win_the_logical_map()
#define CHECK(cond,...)
void t7_role_follows_within_t7()
void role_moved_to_t7_keeps_its_unit()
void disabled_board_orphan_is_not_applied()
static uint8_t scratch[BS]
static std::string t7(const std::string &channels, const std::string &extra="", const std::string &name="lj", int board_id=81)
Definition test_t7.cpp:93