STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
CubicCalibrationStore.cpp
Go to the documentation of this file.
2
3#include <Eigen/Dense>
4#include <algorithm>
5#include <chrono>
6#include <cmath>
7#include <filesystem>
8#include <fstream>
9#include <iostream>
10#include <nlohmann/json.hpp>
11
12#include "calibration/PTCalibration.hpp" // pt_logical_calibration_channel
13
14namespace fsw {
15namespace calibration {
16
17namespace {
18
19double unix_now_sec() {
20 return std::chrono::duration<double>(std::chrono::system_clock::now().time_since_epoch())
21 .count();
22}
23
24/** slot = board_id % 10 with 0 -> 10 (same rule as calibration_service / daq_bridge). */
25uint8_t slot_from_board_id(uint8_t board_id) {
26 int m = static_cast<int>(board_id) % 10;
27 return static_cast<uint8_t>(m == 0 ? 10 : m);
28}
29
30} // namespace
31
33 : file_path_(std::move(file_path)) {
34}
35
36void CubicCalibrationStore::register_channel(uint16_t uid, uint8_t board_id, uint8_t connector,
37 uint8_t logical_ch, const std::string& role,
38 const std::string& active_model, SensorKind kind,
39 const std::string& raw_unit) {
40 std::lock_guard<std::mutex> lock(mutex_);
41 auto& ch = channels_[uid];
42 ch.uid = uid;
43 ch.live = true; // the running config owns this uid: its logical_ch is a live sensor's
44 ch.board_id = board_id;
45 ch.connector = connector;
46 ch.logical_ch = logical_ch;
47 if (!role.empty())
48 ch.role = role;
49 const std::string was = ch.active_model;
50 if (!active_model.empty())
51 ch.active_model = active_model; // config truth; wins over a disk-restored value
52 ch.kind = kind; // config truth, likewise
53 ch.raw_unit = raw_unit; // config truth: which hardware this uid reads through
54 // A config edit that moves a sensor between cubic and linear (a profile swap, a reload) must
55 // re-derive the curve it streams from the points it already has; otherwise the old degree's
56 // fit keeps streaming under the new model's name until the next capture.
57 if (ch.active_model != was && !ch.points.empty())
58 refit(ch);
59}
60
62 if (ch.active_model == "robust") {
63 // Display-only points; the robust learner lives in the service.
64 ch.fit = CubicFit{};
65 ch.fit_cubic = CubicFit{};
66 ch.fit_linear = CubicFit{};
67 ch.status = ch.points.size() < 2 ? "PENDING" : "OK";
68 ch.last_error.clear();
69 return;
70 }
71 ch.fit_cubic = compute_fit(ch.points, 3);
72 ch.fit_linear = ch.points.size() >= 2 ? compute_fit(ch.points, 1) : CubicFit{};
73 const bool linear = ch.active_model == "linear";
74 ch.fit = linear ? ch.fit_linear : ch.fit_cubic;
75 if (ch.points.empty() || (linear && ch.points.size() < 2)) {
76 ch.status = "PENDING";
77 ch.last_error.clear();
78 } else if (!ch.fit.valid) {
79 ch.status = "ERROR";
80 ch.last_error = "fit produced non-finite coefficients";
81 } else if (ch.points.size() < 2) {
82 ch.status = "PENDING";
83 ch.last_error.clear();
84 } else {
85 ch.status = "OK";
86 ch.last_error.clear();
87 }
88}
89
90CubicFit CubicCalibrationStore::compute_fit(const std::vector<CubicPoint>& pts, int max_degree) {
91 CubicFit f;
92 const int n = static_cast<int>(pts.size());
93 if (n < 1)
94 return f;
95
96 double amin = pts[0].adc, amax = pts[0].adc, amean = 0.0;
97 for (const auto& p : pts) {
98 amin = std::min(amin, p.adc);
99 amax = std::max(amax, p.adc);
100 amean += p.adc;
101 }
102 amean /= static_cast<double>(n);
103 double scale = amax - amin;
104 if (!(scale > 0.0) || !std::isfinite(scale))
105 scale = 1.0;
106 f.norm_min = amean;
107 f.norm_scale = scale;
108
109 int degree = std::min(std::clamp(max_degree, 0, 3), n - 1);
110 if (degree < 0)
111 degree = 0;
112
113 Eigen::MatrixXd V(n, degree + 1);
114 Eigen::VectorXd y(n);
115 for (int i = 0; i < n; ++i) {
116 double xn = (pts[static_cast<size_t>(i)].adc - f.norm_min) / f.norm_scale;
117 double xp = 1.0;
118 for (int j = 0; j <= degree; ++j) {
119 V(i, j) = xp;
120 xp *= xn;
121 }
122 y(i) = pts[static_cast<size_t>(i)].psi;
123 }
124
125 Eigen::VectorXd p = V.colPivHouseholderQr().solve(y);
126 Eigen::VectorXd resid = V * p - y;
127 f.rmse = std::sqrt(resid.squaredNorm() / static_cast<double>(n));
128 f.degree = degree;
129 f.poly.assign(static_cast<size_t>(degree + 1), 0.0);
130 for (int j = 0; j <= degree; ++j)
131 f.poly[static_cast<size_t>(j)] = p(j);
132
133 // Expand normalized poly p((adc - m)/s) into raw powers of adc: with a = 1/s, b = -m/s,
134 // u = a*adc + b, so [A,B,C,D] follow from the binomial expansion of p0 + p1 u + p2 u^2 + p3
135 // u^3.
136 double pp[4] = {0.0, 0.0, 0.0, 0.0};
137 for (int j = 0; j <= degree && j < 4; ++j)
138 pp[j] = p(j);
139 const double a = 1.0 / f.norm_scale;
140 const double b = -f.norm_min / f.norm_scale;
141 f.A = pp[3] * a * a * a;
142 f.B = pp[2] * a * a + pp[3] * 3.0 * a * a * b;
143 f.C = pp[1] * a + pp[2] * 2.0 * a * b + pp[3] * 3.0 * a * b * b;
144 f.D = pp[0] + pp[1] * b + pp[2] * b * b + pp[3] * b * b * b;
145
146 f.valid = std::isfinite(f.A) && std::isfinite(f.B) && std::isfinite(f.C) && std::isfinite(f.D);
147 for (double c : f.poly)
148 if (!std::isfinite(c))
149 f.valid = false;
150 return f;
151}
152
153CubicFit CubicCalibrationStore::add_point(uint16_t uid, double adc, double psi) {
154 std::lock_guard<std::mutex> lock(mutex_);
155 // Refuse an unknown uid rather than inventing a channel for it. register_channel() runs at
156 // startup for every configured connector, so a uid that is not here did not come from the
157 // config — and `channels_[uid]` would have silently created one, guessing PT identity from
158 // the number and filing a load cell's point on a PT's logical channel.
159 auto cit = channels_.find(uid);
160 if (cit == channels_.end()) {
161 static bool warned = false;
162 if (!warned) {
163 warned = true;
164 std::cout << "[CubicStore] refusing capture for unregistered uid " << uid
165 << " (not in config); further such warnings suppressed" << std::endl;
166 }
167 return CubicFit{};
168 }
169 auto& ch = cit->second;
170
171 CubicPoint pt;
172 pt.adc = adc;
173 pt.psi = psi;
174 pt.t = unix_now_sec();
175 ch.points.push_back(pt);
176 if (ch.points.size() > kMaxPoints)
177 ch.points.erase(ch.points.begin(), ch.points.begin() + static_cast<std::ptrdiff_t>(
178 ch.points.size() - kMaxPoints));
179
180 ch.updated_at = pt.t;
181
182 // Robust: the point is display-only; the RLS learner is updated by the service, and refit
183 // leaves the fit invalid so this uid is excluded from the logical polynomial maps.
184 refit(ch);
185 return ch.fit;
186}
187
189 std::lock_guard<std::mutex> lock(mutex_);
190 auto it = channels_.find(uid);
191 if (it != channels_.end())
192 it->second.last_capture = q;
193}
194
196 const std::vector<std::pair<double, double>>& curve) {
197 std::lock_guard<std::mutex> lock(mutex_);
198 auto it = channels_.find(uid);
199 if (it != channels_.end())
200 it->second.fit_curve = curve;
201}
202
204 std::lock_guard<std::mutex> lock(mutex_);
205 auto it = channels_.find(uid);
206 if (it == channels_.end())
207 return;
208 it->second.points.clear();
209 it->second.fit = CubicFit{};
210 it->second.fit_cubic = CubicFit{};
211 it->second.fit_linear = CubicFit{};
212 it->second.fit_curve.clear();
213 it->second.status = "PENDING";
214 it->second.last_error.clear();
215 it->second.updated_at = unix_now_sec();
216}
217
218const CubicFit* CubicCalibrationStore::fit_for(uint16_t uid) const {
219 std::lock_guard<std::mutex> lock(mutex_);
220 auto it = channels_.find(uid);
221 if (it == channels_.end() || !it->second.fit.valid)
222 return nullptr;
223 return &it->second.fit;
224}
225
227 std::lock_guard<std::mutex> lock(mutex_);
228 auto it = channels_.find(uid);
229 return it == channels_.end() ? nullptr : &it->second;
230}
231
232std::vector<uint16_t> CubicCalibrationStore::uids() const {
233 std::lock_guard<std::mutex> lock(mutex_);
234 std::vector<uint16_t> out;
235 out.reserve(channels_.size());
236 for (const auto& [uid, ch] : channels_)
237 out.push_back(uid);
238 return out;
239}
240
242 using nlohmann::json;
243 json root;
244 root["sensor_type"] = "PT";
245 root["unit"] = "PSI";
246 root["framework"] = "cubic";
247 root["generated_unix"] = unix_now_sec();
248
249 json polys = json::object();
250 json poly_coeffs = json::object();
251 json norm_min = json::object();
252 json norm_scale = json::object();
253 json lc_polys = json::object();
254 json lc_poly_coeffs = json::object();
255 json lc_norm_min = json::object();
256 json lc_norm_scale = json::object();
257 json state = json::object();
258
259 // Logical-channel-keyed maps: consumed by PTCalibrationManager / loadCalibrationJSON (and the
260 // Node backend's own loadCalibrationJSON, which keys its CalibrationMap the same way).
261 //
262 // Split by kind — a PT and a load cell on the same slot share a logical channel, and writing
263 // both into one map let the higher uid overwrite the other (see SensorKind).
264 //
265 // And written LIVE FIRST, because the same collision exists inside a single kind, where the
266 // kind split cannot help: an orphan's logical channel is whatever live board now owns that
267 // slot, so a removed PT board's still-valid curve would overwrite the live PT that inherited
268 // the channel, in uid order, in the file that is the only record of either. A live channel
269 // therefore always wins its own logical channel; an orphan fills only a channel no live
270 // sensor claims, which keeps a blind reload (nothing registered, e.g. a reader that does not
271 // know the config) from emptying the overlay. Every entry's points are in cubic_state below
272 // regardless, so nothing is lost either way.
273 auto put_fit = [&](const CubicChannel& ch, bool only_if_absent) {
274 if (!ch.fit.valid)
275 return;
276 const std::string lkey = std::to_string(static_cast<int>(ch.logical_ch));
277 const bool is_lc = ch.kind == SensorKind::LC;
278 if (only_if_absent && (is_lc ? lc_polys : polys).contains(lkey))
279 return;
280 (is_lc ? lc_polys : polys)[lkey] = {ch.fit.A, ch.fit.B, ch.fit.C, ch.fit.D};
281 (is_lc ? lc_poly_coeffs : poly_coeffs)[lkey] = ch.fit.poly;
282 (is_lc ? lc_norm_min : norm_min)[lkey] = ch.fit.norm_min;
283 (is_lc ? lc_norm_scale : norm_scale)[lkey] = ch.fit.norm_scale;
284 };
285 for (const auto& [uid, ch] : channels_)
286 if (ch.live)
287 put_fit(ch, /*only_if_absent=*/false);
288 for (const auto& [uid, ch] : channels_)
289 if (!ch.live)
290 put_fit(ch, /*only_if_absent=*/true);
291
292 for (const auto& [uid, ch] : channels_) {
293 // uid-keyed rich state for the UI (points + per-channel fit + identity).
294 json cj;
295 cj["boardId"] = static_cast<int>(ch.board_id);
296 cj["connector"] = static_cast<int>(ch.connector);
297 cj["logicalCh"] = static_cast<int>(ch.logical_ch);
298 cj["role"] = ch.role;
299 cj["active_model"] = ch.active_model;
300 cj["kind"] = ch.kind == SensorKind::LC ? "LC" : "PT";
301 cj["rawUnit"] = ch.raw_unit;
302 cj["numPoints"] = ch.points.size();
303 cj["status"] = ch.status;
304 cj["last_error"] = ch.last_error;
305 cj["rmse"] = ch.fit.valid ? ch.fit.rmse : 0.0;
306 cj["degree"] = ch.fit.degree;
307 cj["updatedAt"] = ch.updated_at;
308 cj["coeffs"] = {{"A", ch.fit.A}, {"B", ch.fit.B}, {"C", ch.fit.C}, {"D", ch.fit.D}};
309 cj["polyCoeffs"] = ch.fit.poly;
310 cj["adcNormMin"] = ch.fit.norm_min;
311 cj["adcNormScale"] = ch.fit.norm_scale;
312 // Both candidate fits over the same points, for the calibration page's curve previews.
313 // `polyCoeffs` above stays the ACTIVE fit, so every existing reader is unchanged.
314 auto fit_json = [](const CubicFit& f) {
315 return json{{"valid", f.valid},
316 {"degree", f.degree},
317 {"rmse", f.valid ? f.rmse : 0.0},
318 {"polyCoeffs", f.poly},
319 {"adcNormMin", f.norm_min},
320 {"adcNormScale", f.norm_scale}};
321 };
322 cj["fits"] = {{"cubic", fit_json(ch.fit_cubic)}, {"linear", fit_json(ch.fit_linear)}};
323 if (ch.last_capture.valid) {
324 cj["last_capture"] = {{"t", ch.last_capture.t},
325 {"adc", ch.last_capture.adc},
326 {"n", ch.last_capture.n},
327 {"windowMs", ch.last_capture.window_ms},
328 {"spreadAdc", ch.last_capture.spread},
329 {"driftAdc", ch.last_capture.drift},
330 {"driftZ", ch.last_capture.drift_z},
331 {"settled", ch.last_capture.settled}};
332 }
333 json pts = json::array();
334 for (const auto& p : ch.points)
335 pts.push_back({{"adc", p.adc}, {"psi", p.psi}, {"t", p.t}});
336 cj["points"] = pts;
337 // Robust display overlay: sampled model curve (absent/empty for cubic uids).
338 if (!ch.fit_curve.empty()) {
339 json fc = json::array();
340 for (const auto& [adc, psi] : ch.fit_curve)
341 fc.push_back({{"adc", adc}, {"psi", psi}});
342 cj["fitCurve"] = fc;
343 }
344 state[std::to_string(static_cast<int>(uid))] = cj;
345 }
346
347 root["calibration_polynomials"] = polys;
348 root["calibration_poly_coeffs"] = poly_coeffs;
349 root["calibration_adc_norm_min"] = norm_min;
350 root["calibration_adc_norm_scale"] = norm_scale;
351 root["lc_calibration_polynomials"] = lc_polys;
352 root["lc_calibration_poly_coeffs"] = lc_poly_coeffs;
353 root["lc_calibration_adc_norm_min"] = lc_norm_min;
354 root["lc_calibration_adc_norm_scale"] = lc_norm_scale;
355 root["cubic_state"] = state;
356 return root.dump(2);
357}
358
360 std::lock_guard<std::mutex> lock(mutex_);
361 if (load_failed_) {
362 // A file we could not read is a file we must not replace: it is the only copy of the
363 // operator's captured points, and this store is empty precisely because the read failed.
364 return false;
365 }
366 try {
367 std::filesystem::path p(file_path_);
368 if (p.has_parent_path())
369 std::filesystem::create_directories(p.parent_path());
370 const std::string tmp = file_path_ + ".tmp";
371 {
372 std::ofstream f(tmp, std::ios::trunc);
373 if (!f.is_open())
374 return false;
375 f << serialize();
376 f.flush();
377 if (!f.good())
378 return false;
379 }
380 std::filesystem::rename(tmp, file_path_); // atomic replace on same filesystem
381 return true;
382 } catch (...) {
383 return false;
384 }
385}
386
388 std::lock_guard<std::mutex> lock(mutex_);
389 std::ifstream f(file_path_);
390 if (!f.is_open())
391 return 0;
392 std::string content((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
393
394 nlohmann::json root;
395 try {
396 root = nlohmann::json::parse(content);
397 } catch (...) {
398 // Corrupt/partial file — start clean rather than crash, but REMEMBER it. The service
399 // saves at startup, and a caller that cannot tell "unreadable" from "no file yet"
400 // would overwrite an operator's points with an empty store on the next write.
401 load_failed_ = true;
402 std::cout << "[CubicStore] " << file_path_
403 << " could not be parsed — refusing to overwrite it" << std::endl;
404 return 0;
405 }
406 if (!root.contains("cubic_state") || !root["cubic_state"].is_object()) {
407 load_failed_ = true;
408 std::cout << "[CubicStore] " << file_path_
409 << " has no cubic_state — refusing to overwrite it" << std::endl;
410 return 0;
411 }
412 load_failed_ = false;
413
414 // role -> current uid, from the channels register_channel() created before this load. Durable
415 // cal is filed by role, so an entry re-attaches to the connector its role now occupies — the
416 // calibration follows the sensor when its cable moves to a different board port.
417 std::map<std::string, uint16_t> role_to_uid;
418 for (const auto& [uid, ch] : channels_)
419 if (!ch.role.empty())
420 role_to_uid.emplace(ch.role, uid);
421
422 size_t loaded = 0;
423 for (auto& [id_str, cj] : root["cubic_state"].items()) {
424 uint16_t persisted_uid = 0;
425 try {
426 persisted_uid = static_cast<uint16_t>(std::stoi(id_str));
427 } catch (...) {
428 continue;
429 }
430
431 // The entry's durable identity is its role. Legacy entries (written before roles were
432 // tracked) adopt the role of the channel currently at their persisted uid — a one-time
433 // migration, after which they too follow moves.
434 std::string entry_role = cj.value("role", std::string());
435 if (entry_role.empty()) {
436 auto pit = channels_.find(persisted_uid);
437 if (pit != channels_.end())
438 entry_role = pit->second.role;
439 }
440
441 // Destination = where that role lives now (config truth); else the persisted uid.
442 // A legacy entry has no rawUnit: everything written before T7 support was an ADC code.
443 const std::string entry_unit = cj.value("rawUnit", std::string(kRawUnitAdc));
444 uint16_t uid = persisted_uid;
445 if (!entry_role.empty()) {
446 auto rit = role_to_uid.find(entry_role);
447 if (rit != role_to_uid.end()) {
448 if (channels_[rit->second].raw_unit == entry_unit) {
449 uid = rit->second;
450 } else {
451 std::cout << "[CubicStore] role '" << entry_role << "' cal was taken in "
452 << entry_unit << " but the channel it now names reads "
453 << channels_[rit->second].raw_unit
454 << " — NOT attached; recalibrate it. The old points are kept under "
455 "uid "
456 << persisted_uid << "." << std::endl;
457 }
458 }
459 }
460 // Even at its own uid, never merge across units (a board changed type, keeping its id).
461 {
462 auto dit = channels_.find(uid);
463 if (dit != channels_.end() && dit->second.uid != 0 &&
464 dit->second.raw_unit != entry_unit) {
465 std::cout << "[CubicStore] uid " << uid << " cal was taken in " << entry_unit
466 << " but uid " << uid << " now reads " << dit->second.raw_unit
467 << " — dropped; recalibrate it." << std::endl;
468 continue;
469 }
470 }
471
472 // Merge into the channel register_channel() created at startup (config is the source of
473 // truth for its identity/role/active_model); only synthesize identity when no current board
474 // owns the destination uid (an orphaned role no longer in config).
475 auto& ch = channels_[uid];
476 const bool registered = ch.uid != 0;
477 if (!registered) {
478 ch.uid = uid;
479 ch.board_id = static_cast<uint8_t>(uid / 100);
480 ch.connector = static_cast<uint8_t>(uid % 100);
481 // An orphan keeps the logical channel it was saved with when it has one: a T7
482 // channel's lives above the ESP32 range, and re-deriving it the ESP32 way would land
483 // its fit on a live ESP32 sensor's logical channel.
484 ch.logical_ch =
485 entry_unit == kRawUnitMicrovolts && cj.contains("logicalCh") &&
486 cj["logicalCh"].is_number_integer()
487 ? static_cast<uint8_t>(cj["logicalCh"].get<int>())
488 : pt_logical_calibration_channel(slot_from_board_id(ch.board_id), ch.connector);
489 ch.raw_unit = entry_unit;
490 ch.role = entry_role;
491 ch.active_model = cj.value("active_model", std::string("cubic"));
492 // Only an ORPHAN takes its kind from the file; a channel the config registered
493 // keeps config's, exactly as active_model does. A legacy file has no "kind", and
494 // defaulting an orphan to PT preserves what those maps have always meant.
495 ch.kind = cj.value("kind", std::string("PT")) == "LC" ? SensorKind::LC : SensorKind::PT;
496 }
497
498 ch.points.clear();
499 if (cj.contains("points") && cj["points"].is_array()) {
500 for (const auto& pj : cj["points"]) {
502 p.adc = pj.value("adc", 0.0);
503 p.psi = pj.value("psi", 0.0);
504 p.t = pj.value("t", 0.0);
505 ch.points.push_back(p);
506 }
507 }
508 if (ch.points.size() > kMaxPoints)
509 ch.points.erase(ch.points.begin(),
510 ch.points.begin() +
511 static_cast<std::ptrdiff_t>(ch.points.size() - kMaxPoints));
512
513 ch.fit_curve.clear();
514 if (cj.contains("fitCurve") && cj["fitCurve"].is_array())
515 for (const auto& pj : cj["fitCurve"])
516 ch.fit_curve.emplace_back(pj.value("adc", 0.0), pj.value("psi", 0.0));
517
518 // Robust: display-only points, no fit; the service re-samples fit_curve after restoring
519 // the robust model from adjustments.json.
520 refit(ch);
521 ch.updated_at = unix_now_sec();
522 if (uid != persisted_uid)
523 std::cout << "[CubicStore] role '" << entry_role << "' cal re-attached to connector "
524 << static_cast<int>(uid % 100) << " (was "
525 << static_cast<int>(persisted_uid % 100) << ") — sensor moved" << std::endl;
526 ++loaded;
527 }
528 return loaded;
529}
530
531} // namespace calibration
532} // namespace fsw
* array(stored with pointer to save storage) array_t *array
void note_capture(uint16_t uid, const CaptureQuality &q)
Record how the last capture on this uid went, for the UI to show.
std::map< uint16_t, CubicChannel > channels_
size_t load()
Load the JSON record, resume points, and re-fit each channel.
bool load_failed_
Set when load() could not read an existing file.
void clear_channel(uint16_t uid)
Drop all points, fit, and fit-curve for a uid (channel goes back to PENDING).
const CubicFit * fit_for(uint16_t uid) const
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).
static void refit(CubicChannel &ch)
Recompute fit_cubic / fit_linear / fit / status from ch.points per ch.active_model.
static CubicFit compute_fit(const std::vector< CubicPoint > &pts, int max_degree=3)
Least-squares polynomial of degree min(max_degree, n - 1) over pts.
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
void set_fit_curve(uint16_t uid, const std::vector< std::pair< double, double > > &curve)
Replace a robust uid's display fit-curve (sampled predict_pressure_psi points).
bool save() const
Atomically write the JSON record (temp + rename).
static uint8_t board_id
Definition main.cpp:30
static ActuatorControllerState state
Definition main.cpp:57
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
*brief remove last reference token *sa JSON pointer has no nullptr
Definition json.hpp:13968
bool contains(const BasicJsonType *ptr) const
Definition json.hpp:14383
basic_json<> json
default specialization
Definition json.hpp:3422
static osMutexId_t lock
Definition mode.c:11
uint8_t slot_from_board_id(uint8_t board_id)
slot = board_id % 10 with 0 -> 10 (same rule as calibration_service / daq_bridge).
Output smoothing for calibrated PT pressure, per sensor uid (ESP32 and LabJack T7 alike).
constexpr const char * kRawUnitAdc
CubicChannel::raw_unit values.
constexpr const char * kRawUnitMicrovolts
uint8_t pt_logical_calibration_channel(uint8_t board_slot_number, uint8_t local_connector)
PT board slot (1–10, from board_id % 10 with 0→10) + local connector (1–10) → factory/JSON logical ch...
SensorKind
Which family of sensor a channel belongs to.
Motorized ball valve position: raw ADC counts -> % open.
*brief user defined string literal for JSON values *sa std::size_t n
Definition json.hpp:24474
STL namespace.
How trustworthy the most recent capture on this channel was.
Per-sensor calibration record: identity, captured points, and the current fit.
Result of a least-squares polynomial fit over a channel's captured points.
One operator-captured calibration point: raw ADC code paired with a known reference PSI.
static std::string ch(int ain, const std::string &kind, const std::string &extra="", const std::string &board="lj")
Definition test_t7.cpp:98