STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
calibration_main.cpp
Go to the documentation of this file.
1/**
2 * @file calibration_main.cpp
3 * @brief Standalone Calibration Service — subscribes directly to Elodin, applies calibration,
4 * publishes calibrated VTables back to Elodin.
5 *
6 * Connects directly to Elodin DB (port 2240) and subscribes to the raw sensor
7 * data (PT, TC, RTD, LC). Publishes calibrated values to the same Elodin instance. No relay
8 * dependency — fully independent of the relay and backend restart cycles.
9 *
10 * Raw data path (always immediate):
11 * daq_bridge → Elodin [0x20xx raw] → relay → backend → GUI
12 * Calibrated path (~1 ms behind):
13 * calibration_service ─(subscribes)→ Elodin [0x20xx raw]
14 * ─(publishes)→ Elodin [0x20xx+0x10 cal] → relay → backend → GUI
15 *
16 * Usage:
17 * ./calibration_service [--config PATH] [--elodin-host HOST] [--elodin-port PORT]
18 * CAL_VERBOSE=1 for per-packet debug output
19 * LP PT (non–HP board): default = factory cubic from CSV/JSON (same priority as letsfix / stable
20 * stack). RobustCalibrationManager still seeds from that factory curve and loads
21 * adjustments.json; streaming uses factory unless you opt into robust below. CAL_USE_ROBUST_PT=1 —
22 * 100% robust mean for streaming (when sensor initialized) CAL_USE_ROBUST_BLEND=1 — 75% robust +
23 * 25% factory CAL_USE_FACTORY_PT=1 — force factory cubic (same as default; explicit)
24 * CAL_BACKUP_PATH — override robust prior JSON (else the latest calibration_backups/ JSON)
25 */
26
27#include <algorithm>
28#include <array>
29#include <atomic>
30#include <chrono>
31#include <cmath>
32#include <csignal>
33#include <cstdlib>
34#include <cstring>
35#include <deque>
36#include <filesystem>
37#include <fstream>
38#include <iostream>
39#include <limits>
40#include <map>
41#include <mutex>
42#include <optional>
43#include <set>
44#include <sstream>
45#include <string>
46#include <thread>
47#include <unordered_map>
48#include <vector>
49
59#include "calibration/TypeK.hpp"
64#include "config/Config.hpp"
69#include "t7/T7Calibration.hpp"
70#include "t7/T7Convert.hpp"
71#include "t7/T7Stream.hpp"
72
73namespace {
74
75/**
76 * Hysteresis: 0 = treat as below loop, 1 = in 4–20 mA band (stops 3.99↔4.01 PSI chatter).
77 * Keyed by board slot so one board's channel going live cannot unlatch another board's
78 * same-numbered channel.
79 */
80std::map<uint8_t, std::array<uint8_t, 11>> g_hp_ma_live;
81
82/** Per-sensor PT output smoothing (ESP32 and T7), seeded with each sensor's first reading. */
84
85/**
86 * Which model a PT streams: operator/factory cubic, the robust learner, the 75/25 blend, or the
87 * datasheet physics conversion (ratiometric for 0-5 V boards, 4-20 mA for current-loop). Chosen per
88 * sensor from [calibration_model_<board>] in config.toml (see main); a CAL_USE_* env var, when set,
89 * overrides all LP sensors via g_env_override.
90 */
91enum class PtModel { Cubic, Robust, Blend, Physics };
92
93/** uid (board_id*100 + connector) -> streaming model. Non-default resolutions are stored (4-20 mA
94 * boards default to Physics, 0-5 V to Cubic); an absent uid means Cubic. */
95std::unordered_map<uint16_t, PtModel> g_pt_model;
96
97/** uid -> role name (from [sensor_roles_*]). Durable calibration is filed under the role, so it
98 * follows the sensor when its connector changes (see re-file-by-role in the stores). Empty for
99 * connectors with no configured role. */
100std::unordered_map<uint16_t, std::string> g_uid_role;
101
102/** Per-sensor physics-mode parameters (from [calibration_full_scale_*] /
103 * [calibration_sense_resistor_*], falling back to the board defaults). full_scale is PSI at full
104 * ADC / at 20 mA; sense_resistor Ω is the 4-20 mA shunt. Only present for uids the config/board
105 * provides. */
106std::unordered_map<uint16_t, double> g_pt_full_scale;
107std::unordered_map<uint16_t, double> g_pt_sense_resistor;
108
109/** Set once at startup: when a CAL_USE_* PT env var is present it forces every sensor, else nullopt
110 * and the per-sensor config governs. */
111std::optional<PtModel> g_env_override;
112
113/**
114 * Which model an LC streams: a straight-line fit through the operator's captured points, a cubic
115 * fit through them, or the datasheet physics conversion (sensitivity/PGA-gain formula). No
116 * robust/blend — load cells don't need drift-learning. Chosen per sensor from
117 * [calibration_model_<lc_board>] in config; absent from the map means Linear (see lc_model_for).
118 *
119 * Why linear is the default: a load cell is linear to a few hundredths of a percent, and its zero
120 * drifts. A tare is taken as an ADC code and evaluated through the live curve, so on a line it
121 * cancels a pure zero shift exactly, wherever the zero has wandered. A cubic fitted over 400-500k
122 * and evaluated at a 200k zero extrapolates a full span past its data and bends, so the same tare
123 * leaves a load-dependent error. Until a linear sensor has two captured points it streams physics
124 * — exactly what an unconfigured LC streamed before this model existed — so a rig with no LC
125 * captures reads the same as before.
126 */
127enum class LcModel { Linear, Cubic, Physics };
128
129/** uid -> LC streaming model. A uid absent from this map defaults to Linear (see lc_model_for). */
130std::unordered_map<uint16_t, LcModel> g_lc_model;
131
132/** Per-sensor LC physics-mode parameters (from [calibration_full_scale_*] /
133 * [calibration_sensitivity_*] / [calibration_pga_*], falling back to [calibration.lc] globals).
134 * Only present for uids the config/board provides. */
135std::unordered_map<uint16_t, double> g_lc_full_scale;
136std::unordered_map<uint16_t, double> g_lc_sensitivity;
137std::unordered_map<uint16_t, double> g_lc_pga_gain;
138
139/** Every LC uid, populated at startup — the discriminant apply_capture/apply_clear/
140 * reload_live_store use to route a shared uid-keyed cubic_store entry to lc_calibration instead
141 * of pt_calibration/robust_manager (PT and LC uids share the store; their board_id ranges never
142 * collide, but nothing else tags which kind a uid is). */
143std::set<uint16_t> g_lc_uids;
144/** Board ids of every LC board, so a connector outside active_connectors still routes as LC. */
145std::set<uint8_t> g_lc_board_ids;
146
147/** Latest thermocouple cold-junction temperature (°C) per T7 slot, from t7_bridge's CJC table
148 * {0x26, (slot-1)*0x20 + 0x0F}. Absent until the first one arrives; a T7 TC sample with no
149 * junction yet is not published rather than converted against a guess. */
150std::map<uint8_t, double> g_t7_cold_junction_c;
151
152} // namespace
153
154static std::atomic<bool> running{true};
155
156static bool verbose() {
157 const char* v = std::getenv("CAL_VERBOSE");
158 return v && (v[0] == '1' || v[0] == 'y' || v[0] == 'Y');
159}
160
161static bool env_flag_true(const char* name) {
162 const char* v = std::getenv(name);
163 return v && (v[0] == '1' || v[0] == 'y' || v[0] == 'Y' || v[0] == 't' || v[0] == 'T');
164}
165
166/** Parse a config model string to the enum; "cubic" and anything unrecognized map to Cubic. */
167static PtModel parse_pt_model(const std::string& s) {
168 if (s == "robust")
169 return PtModel::Robust;
170 if (s == "physics")
171 return PtModel::Physics;
172 if (s == "blend")
173 return PtModel::Blend;
174 return PtModel::Cubic;
175}
176
177/** Per-sensor streaming model; sensors absent from the map default to factory cubic. */
178static PtModel pt_model_for(uint16_t uid) {
179 auto it = g_pt_model.find(uid);
180 return it == g_pt_model.end() ? PtModel::Cubic : it->second;
181}
182
183/** Per-sensor physics full-scale PSI; default 1000 (0-5 V -> 0-1000 PSI) when unset. */
184static double pt_full_scale_for(uint16_t uid) {
185 auto it = g_pt_full_scale.find(uid);
186 return it != g_pt_full_scale.end() ? it->second : 1000.0;
187}
188
189/** Per-sensor 4-20 mA shunt resistance (Ω); default 120 when unset. */
190static double pt_sense_resistor_for(uint16_t uid) {
191 auto it = g_pt_sense_resistor.find(uid);
192 return it != g_pt_sense_resistor.end() ? it->second : 120.0;
193}
194
195/** Config-string form of a model, for the cubic store's per-uid active_model tag. */
196static const char* pt_model_name(PtModel m) {
197 switch (m) {
198 case PtModel::Robust:
199 return "robust";
200 case PtModel::Physics:
201 return "physics";
202 case PtModel::Blend:
203 return "blend";
204 default:
205 return "cubic";
206 }
207}
208
209/** Parse a config model string for an LC role: "linear" and "cubic" map to their fits, everything
210 * else (including unrecognized/"robust"/"blend", which LC doesn't support) maps to Physics, as
211 * it always has. */
212static LcModel parse_lc_model(const std::string& s) {
213 if (s == "linear")
214 return LcModel::Linear;
215 return s == "cubic" ? LcModel::Cubic : LcModel::Physics;
216}
217
218/** Per-sensor LC streaming model; a uid absent from g_lc_model defaults to Linear, which streams
219 * physics until it has a fit (see select_lc_kg), so an uncaptured LC reads as it always did. */
220static LcModel lc_model_for(uint16_t uid) {
221 auto it = g_lc_model.find(uid);
222 return it == g_lc_model.end() ? LcModel::Linear : it->second;
223}
224
225/** True when this LC's published kilograms are its datasheet physics conversion: physics itself,
226 * or a linear sensor that has no fit yet. Such a reading is "calibrated" in the status sense. */
227static bool lc_streams_physics(uint16_t uid, bool fit_ok) {
228 const LcModel m = lc_model_for(uid);
229 return m == LcModel::Physics || (m == LcModel::Linear && !fit_ok);
230}
231
232/** Per-sensor LC physics full-scale kg; falls back to the [calibration.lc] global when unset. */
233static double lc_full_scale_for(uint16_t uid, double fallback) {
234 auto it = g_lc_full_scale.find(uid);
235 return it != g_lc_full_scale.end() ? it->second : fallback;
236}
237
238/** Per-sensor LC sensitivity mV/V; falls back to the [calibration.lc] global when unset. */
239static double lc_sensitivity_for(uint16_t uid, double fallback) {
240 auto it = g_lc_sensitivity.find(uid);
241 return it != g_lc_sensitivity.end() ? it->second : fallback;
242}
243
244/** Per-sensor LC PGA gain; falls back to the [calibration.lc] global when unset. */
245static double lc_pga_gain_for(uint16_t uid, double fallback) {
246 auto it = g_lc_pga_gain.find(uid);
247 return it != g_lc_pga_gain.end() ? it->second : fallback;
248}
249
250/** Config-string form of an LC model, for the cubic store's per-uid active_model tag. */
251static const char* lc_model_name(LcModel m) {
252 switch (m) {
253 case LcModel::Linear:
254 return "linear";
255 case LcModel::Cubic:
256 return "cubic";
257 case LcModel::Physics:
258 default:
259 return "physics";
260 }
261}
262
263/** Ratiometric PT, 0.5-4.5 V at 5 V excitation: excitation is the ADC reference, so the output
264 * is 10 % of full code at zero and 90 % at full scale whatever the supply actually is —
265 * psi = (adc / 2^31 - 0.1) / 0.8 * full_scale. Used by the `physics` model on non-loop boards.
266 * The bench calibrations of 2026-02 put four of the stand's PTs within ~1.5 % of this. */
267constexpr double kRatiometricPtZeroFrac = 0.1; // 0.5 V of 5 V
268constexpr double kRatiometricPtSpanFrac = 0.8; // 4.5 V - 0.5 V of 5 V
269static double convert_ratiometric_pt_to_pressure(int32_t adc_raw, double full_scale_psi) {
270 constexpr double ADC_MAX = 2147483648.0; // 2^31
271 if (!(full_scale_psi > 0.0))
272 return 0.0;
273 const double frac = static_cast<double>(adc_raw) / ADC_MAX;
274 const double psi = (frac - kRatiometricPtZeroFrac) / kRatiometricPtSpanFrac * full_scale_psi;
275 if (!std::isfinite(psi))
276 return 0.0;
277 return std::clamp(psi, -0.05 * full_scale_psi, full_scale_psi * 1.05);
278}
279
280/**
281 * The single PT source-selection rule, shared by the streaming path and the Zero-All helper so both
282 * agree. `physics` (the datasheet conversion — ratiometric or 4-20 mA per the board) is also the
283 * universal fallback: a cubic/robust sensor that isn't yet fit/seeded streams physics rather than
284 * 0. An env override (CAL_USE_*), when set, forces cubic/robust/blend across all sensors.
285 */
286static double select_pt_psi(uint16_t uid, double psi_fac, double psi_rob, double psi_phys,
287 bool fac_ok, bool has_robust) {
289 switch (m) {
290 case PtModel::Physics:
291 // Physics is a datasheet conversion, used only when explicitly selected.
292 return psi_phys;
293 case PtModel::Robust:
294 // Robust streams its learned model; before any captured points its baseline is 0, so an
295 // uncalibrated robust sensor reads nothing (0) rather than a physics fallback.
296 return has_robust ? psi_rob : 0.0;
297 case PtModel::Blend: {
298 if (fac_ok && has_robust) {
299 constexpr double kFactoryWeight = 0.25; // 75% robust + 25% factory
300 return (1.0 - kFactoryWeight) * psi_rob + kFactoryWeight * psi_fac;
301 }
302 return fac_ok ? psi_fac : (has_robust ? psi_rob : 0.0);
303 }
304 case PtModel::Cubic:
305 default:
306 // Uncalibrated / cleared cubic reads nothing (0) — no factory/physics fallback.
307 return fac_ok ? psi_fac : 0.0;
308 }
309}
310
311/**
312 * The LC source-selection rule, mirroring select_pt_psi minus robust/blend (LC doesn't learn
313 * drift). `kg_fit` / `fit_ok` are this uid's fitted curve — linear or cubic per its model, since
314 * the store fits to the model's degree and that one curve is what lc_calibration holds. A cubic
315 * uid with no fit reads 0, the "physics-or-nothing" rule PT's cubic mode uses once explicitly
316 * opted in. A linear uid with no fit streams physics instead: linear is the default for every LC,
317 * so it must read exactly what an unconfigured LC read before it existed.
318 */
319static double select_lc_kg(uint16_t uid, double kg_fit, double kg_phys, bool fit_ok) {
320 switch (lc_model_for(uid)) {
321 case LcModel::Linear:
322 return fit_ok ? kg_fit : kg_phys;
323 case LcModel::Cubic:
324 return fit_ok ? kg_fit : 0.0;
325 case LcModel::Physics:
326 default:
327 return kg_phys;
328 }
329}
330
331/**
332 * 4-20 mA HP PT → PSI. Wire raw is u32 full-scale to 2^31.
333 *
334 * A 4-20 mA transmitter regulates its loop current independently of its supply, so the reading is
335 * absolute and needs no excitation reference — the shunt voltage against the internal 2.5 V ref is
336 * the whole measurement. Optional per-channel mA hysteresis kills threshold flicker at ~4 mA
337 * open-circuit noise.
338 */
339static double convert_hp_pt_to_pressure(uint8_t local_ch, uint32_t adc_raw,
341 double full_scale_psi, double sense_resistor_ohms,
342 std::array<uint8_t, 11>& live_state) {
343 // full_scale_psi and sense_resistor_ohms are the per-sensor physics params (config override or
344 // board default); the ADC reference is a board-level hardware property.
345 const double adc_ref_voltage = board.adc_ref_voltage;
346
347 constexpr double ADC_MAX = 2147483648.0;
348 constexpr double I_MIN_MA = 4.0;
349 constexpr double I_SPAN_MA = 16.0;
350 // Hysteresis band (mA) — ~0.5% of span; widen with CAL_HP_MA_HYST=0 to disable (both 0).
351 const char* hyst_env = std::getenv("CAL_HP_MA_HYST");
352 const double hyst = (hyst_env && hyst_env[0] == '0' && hyst_env[1] == '\0') ? 0.0 : 0.08;
353 const double i_on = I_MIN_MA + hyst;
354 const double i_off = I_MIN_MA - std::min(hyst * 0.75, 0.06);
355
356 if (local_ch < 1 || local_ch > 10)
357 return 0.0;
358 if (sense_resistor_ohms <= 0.0 || !std::isfinite(adc_ref_voltage))
359 return 0.0;
360
361 // Clamp to [0, 2^31-1] for voltage ratio (do not reject 2^31.. as hard zero).
362 uint32_t adc = std::min(adc_raw, static_cast<uint32_t>(ADC_MAX) - 1u);
363
364 double v_sense = (static_cast<double>(adc) / ADC_MAX) * adc_ref_voltage;
365
366 double i_ma = (v_sense / sense_resistor_ohms) * 1000.0;
367 if (!std::isfinite(v_sense) || !std::isfinite(i_ma))
368 return 0.0;
369
370 uint8_t& live = live_state[local_ch];
371 if (hyst > 0.0) {
372 if (!live) {
373 if (i_ma < i_on)
374 return 0.0;
375 live = 1;
376 } else {
377 if (i_ma < i_off) {
378 live = 0;
379 return 0.0;
380 }
381 }
382 } else {
383 if (i_ma < I_MIN_MA)
384 return 0.0;
385 }
386
387 if (i_ma > 20.0)
388 i_ma = 20.0;
389 i_ma = std::max(i_ma, I_MIN_MA);
390
391 double psi = ((i_ma - I_MIN_MA) / I_SPAN_MA) * full_scale_psi;
392 if (!std::isfinite(psi))
393 return 0.0;
394 return std::clamp(psi, 0.0, full_scale_psi * 1.05);
395}
396
397/**
398 * K-type thermocouple: raw ADC → voltage → temperature (°C).
399 * ITS-90 rational polynomial with 5 sub-ranges (-6.4 mV to 69.6 mV).
400 * Coefficients from NIST ITS-90 Thermocouple Database (Type K inverse).
401 * Each range: T = T0 + (x * num) / den, where x = V_mV - V0.
402 */
403static double convert_tc_adc_to_temp_c(int32_t adc_raw, double adc_ref_voltage) {
404 constexpr double ADC_MAX = 2147483648.0; // 2^31
405 double voltage_v = (static_cast<double>(adc_raw) / ADC_MAX) * adc_ref_voltage;
406 double v_mv = voltage_v * 1000.0;
407 // The curve lives in lib (calibration/TypeK.hpp) so t7_bridge uses the same numbers. It
408 // returns NaN out of range; this path has always published 0.0 there.
409 const double t = fsw::calibration::type_k_c_from_mv(v_mv);
410 return std::isfinite(t) ? t : 0.0;
411}
412
413/**
414 * TC/RTD calibrated temperature before Elodin publish. Polynomial fits can diverge when raw ADC
415 * is far off-scale (e.g. multi-million counts); thin backend rejects absurd temperature_c.
416 */
417static double clamp_tc_rtd_temp_publish_c(double t_c) {
418 if (!std::isfinite(t_c))
419 return 0.0;
420 return std::clamp(t_c, -300.0, 2200.0);
421}
422
423/**
424 * Pt1000 RTD: raw ADC → voltage → resistance → temperature (°C), on the full IEC 60751 curve
425 * (rtd::rtd_board_adc_to_temp_c). NaN for an open or shorted element: publish nothing.
426 */
427static double convert_rtd_adc_to_temp_c(int32_t adc_raw, double adc_ref_voltage,
428 double excitation_ua, double r0_ohm) {
429 return fsw::calibration::rtd::rtd_board_adc_to_temp_c(adc_raw, adc_ref_voltage, excitation_ua,
430 r0_ohm);
431}
432
433/**
434 * Actuator current-sense: 12-bit ADC → current (amps).
435 * 3.3V reference, V-to-I transfer function = 1:1.
436 */
437static double convert_act_adc_to_current(uint32_t adc_raw) {
438 constexpr double ADC_MAX_12BIT = 4095.0;
439 constexpr double V_REF = 3.3;
440 return (static_cast<double>(adc_raw) / ADC_MAX_12BIT) * V_REF;
441}
442
443/**
444 * Ratiometric load cell: raw ADC → force (kg).
445 * Reference = excitation, so voltage cancels: code_fs = (sensitivity * PGA_gain) * 2^31.
446 * force = (code / code_fs) * full_scale_value.
447 */
448static double convert_lc_adc_to_force(int32_t adc_raw, double sensitivity_mv_per_v, double pga_gain,
449 double full_scale_value) {
450 constexpr double ADC_FULL_SCALE = 2147483648.0; // 2^31
451 if (pga_gain <= 0.0 || sensitivity_mv_per_v <= 0.0)
452 return 0.0;
453 double code_fs = (sensitivity_mv_per_v / 1000.0) * pga_gain * ADC_FULL_SCALE;
454 if (code_fs <= 0.0)
455 return 0.0;
456 return (static_cast<double>(adc_raw) / code_fs) * full_scale_value;
457}
458
459static void signalHandler(int /*sig*/) {
460 std::cout << "\n[CalibrationService] Caught signal, shutting down..." << std::endl;
461 running = false;
462}
463
464/** Monotonic now, in ns. The capture window judges liveness on OUR clock and never the
465 * board's, so a board whose clock is skewed or has just reset cannot make a dead channel
466 * look live (or a live one look stale). */
467/** Wall-clock unix seconds, for stamping a record a human will read. */
468static double unix_now() {
469 return std::chrono::duration<double>(std::chrono::system_clock::now().time_since_epoch())
470 .count();
471}
472
473static uint64_t mono_ns() {
474 return static_cast<uint64_t>(std::chrono::duration_cast<std::chrono::nanoseconds>(
475 std::chrono::steady_clock::now().time_since_epoch())
476 .count());
477}
478
479/**
480 * Map Elodin PT raw packet low byte + connector ch → robust calibration uid (board_id*100+ch).
481 * Uses the same slot rule as daq_bridge: slot = board_id % 10, with 0 treated as 10.
482 */
483static uint16_t resolve_pt_sensor_uid(uint8_t type_lo, uint8_t ch,
484 const std::vector<fsw::elodin::BoardChannels>& pt_boards) {
485 if (ch == 0 || ch > 10)
486 return static_cast<uint16_t>(100u + ch);
487 uint8_t bn_slot = 1;
488 if (type_lo >= ch) {
489 unsigned delta = static_cast<unsigned>(type_lo - ch);
490 bn_slot = static_cast<uint8_t>(delta / 0x20u + 1u);
491 if (bn_slot < 1)
492 bn_slot = 1;
493 if (bn_slot > 10)
494 bn_slot = 10;
495 }
496 for (const auto& bc : pt_boards) {
497 int mod = static_cast<int>(bc.board_id % 10);
498 int slot = (mod == 0) ? 10 : mod;
499 if (slot == static_cast<int>(bn_slot))
500 return static_cast<uint16_t>(bc.board_id) * 100u + ch;
501 }
502 return static_cast<uint16_t>(100u + ch);
503}
504
505/**
506 * Map Elodin LC raw packet low byte + connector ch -> LC uid (board_id*100+ch). Mirrors
507 * resolve_pt_sensor_uid (kept as a separate copy rather than a shared/generalized helper so the
508 * PT/abort-threshold path is untouched by this change).
509 */
510static uint16_t resolve_lc_sensor_uid(uint8_t type_lo, uint8_t ch,
511 const std::vector<fsw::elodin::BoardChannels>& lc_boards) {
512 if (ch == 0 || ch > 10)
513 return static_cast<uint16_t>(100u + ch);
514 uint8_t bn_slot = 1;
515 if (type_lo >= ch) {
516 unsigned delta = static_cast<unsigned>(type_lo - ch);
517 bn_slot = static_cast<uint8_t>(delta / 0x20u + 1u);
518 if (bn_slot < 1)
519 bn_slot = 1;
520 if (bn_slot > 10)
521 bn_slot = 10;
522 }
523 for (const auto& bc : lc_boards) {
524 int mod = static_cast<int>(bc.board_id % 10);
525 int slot = (mod == 0) ? 10 : mod;
526 if (slot == static_cast<int>(bn_slot))
527 return static_cast<uint16_t>(bc.board_id) * 100u + ch;
528 }
529 return static_cast<uint16_t>(100u + ch);
530}
531
532/** A sensor needs at least this many captured points before its cubic/robust curve is trusted to
533 * invert an abort threshold. Below it, the fit is too loose — a 1-2 point cubic can invert to an
534 * ADC far from the intended pressure — so the abort threshold falls back to physics (the datasheet
535 * mapping), which is always monotonic and sane. See invert_abort_psi_to_adc /
536 * write_abort_thresholds. */
537constexpr int kMinAbortCalPoints = 3;
538
539/**
540 * Invert an abort-procedure pressure (the level the boards vent DOWN below before completing an
541 * autonomous abort) to the raw ADC code the firmware compares against (dp.data < threshold_adc),
542 * for uid's model, by bisecting the SAME forward conversion the stream uses — so the board's
543 * "vented to safe" gate fires at exactly the pressure the operator configured on the curve they
544 * see.
545 *
546 * force_physics forces the datasheet (ratiometric / 4-20 mA) mapping regardless of the sensor's
547 * active model, used when the cubic/robust fit is too sparse to trust (see kMinAbortCalPoints).
548 *
549 * Monotonic increasing in adc for every model, so bisection is valid. Returns nullopt when the
550 * target is unreachable in [0, 2^31) — e.g. an uncalibrated cubic whose forward is flat 0, or
551 * full_scale 0 — so a sensor with no trustworthy mapping gets no threshold (the firmware then
552 * relies on its PT_ABORT_THRESHOLD_TIMEOUT) rather than a wrong one.
553 */
554static std::optional<int32_t> invert_abort_psi_to_adc(
555 uint16_t uid, uint8_t pt_log_ch, bool is_loop, const fsw::config::PtBoardConfig* loop_board,
556 double target_psi, bool force_physics,
557 const fsw::calibration::PTCalibrationManager& pt_calibration,
559 if (!(target_psi > 0.0))
560 return std::nullopt;
561 const double full_scale = pt_full_scale_for(uid);
562 const double sense_resistor = pt_sense_resistor_for(uid);
563 const bool fac_ok = pt_calibration.is_calibrated(pt_log_ch);
564 const bool has_rob = robust_manager.has_sensor(uid);
565 auto forward = [&](int64_t adc) -> double {
566 const int32_t a = static_cast<int32_t>(adc);
567 std::array<uint8_t, 11> hyst{}; // fresh state → the static (unhysteresed) mapping
568 const double psi_phys =
569 is_loop && loop_board != nullptr
570 ? convert_hp_pt_to_pressure(static_cast<uint8_t>(uid % 100),
571 static_cast<uint32_t>(adc), *loop_board, full_scale,
572 sense_resistor, hyst)
574 if (force_physics)
575 return psi_phys;
576 const double psi_fac = fac_ok ? pt_calibration.calculate_pressure(pt_log_ch, a) : 0.0;
577 const double psi_rob = robust_manager.predict_pressure_psi(uid, a);
578 return select_pt_psi(uid, psi_fac, psi_rob, psi_phys, fac_ok, has_rob);
579 };
580 constexpr int64_t kAdcMax = 2147483647; // 2^31 - 1
581 if (forward(kAdcMax) < target_psi)
582 return std::nullopt; // unreachable: uncalibrated, or target above full scale
583 if (forward(0) > target_psi)
584 return std::nullopt; // target below the sensor's zero — nothing to gate on
585 int64_t lo = 0, hi = kAdcMax;
586 for (int i = 0; i < 40 && hi - lo > 1; ++i) {
587 const int64_t mid = lo + (hi - lo) / 2;
588 if (forward(mid) < target_psi)
589 lo = mid;
590 else
591 hi = mid;
592 }
593 return static_cast<int32_t>(hi);
594}
595
596int main(int argc, char* argv[]) {
597 std::string config_path = "config/config.toml";
598 std::string elodin_host = "127.0.0.1";
599 uint16_t elodin_port = 2240;
600 std::string adjustments_path = "scripts/calibration/calibrations/adjustments.json";
601
602 for (int i = 1; i < argc; ++i) {
603 std::string arg = argv[i];
604 if (arg == "--config" && i + 1 < argc)
605 config_path = argv[++i];
606 else if ((arg == "--host" || arg == "--elodin-host") && i + 1 < argc)
607 elodin_host = argv[++i];
608 else if ((arg == "--port" || arg == "--elodin-port") && i + 1 < argc)
609 elodin_port = static_cast<uint16_t>(std::atoi(argv[++i]));
610 else if (arg == "--adjustments" && i + 1 < argc)
611 adjustments_path = argv[++i];
612 else if (arg == "--help" || arg == "-h") {
613 std::cout << "Usage: " << argv[0]
614 << " [--config PATH] [--elodin-host HOST] [--elodin-port PORT]\n";
615 return 0;
616 }
617 }
618
619 // If the caller didn't explicitly pass an adjustments file, default to the latest
620 // robust calibration backup so priors are always well-informed.
621 const std::string default_adj = "scripts/calibration/calibrations/adjustments.json";
622 if (adjustments_path == default_adj) {
623 const char* env_override = std::getenv("CAL_BACKUP_PATH");
624 if (env_override && env_override[0] != '\0') {
625 adjustments_path = env_override;
626 } else {
627 const std::string backup_dir = "calibration_backups";
628 std::string best_path;
629 auto best_time = std::filesystem::file_time_type::min();
630 try {
631 for (const auto& entry : std::filesystem::directory_iterator(backup_dir)) {
632 if (!entry.is_regular_file())
633 continue;
634 const std::string name = entry.path().filename().string();
635 if (entry.path().extension() != ".json")
636 continue;
637 if (name.rfind("calibration_backup_", 0) != 0)
638 continue;
639 const auto t = entry.last_write_time();
640 if (t > best_time) {
641 best_time = t;
642 best_path = entry.path().string();
643 }
644 }
645 } catch (...) {
646 // If directory doesn't exist, we'll fall back to the default adjustments.json.
647 }
648 if (!best_path.empty())
649 adjustments_path = best_path;
650 }
651 }
652
653 std::cout << "=== Calibration Service (C++) ===" << std::endl;
654 std::cout << " Elodin DB: " << elodin_host << ":" << elodin_port << std::endl;
655
656 std::signal(SIGINT, signalHandler);
657 std::signal(SIGTERM, signalHandler);
658
659 // Parse config once (paths + [calibration.*] params + per-sensor models) before the loaders.
660 const fsw::config::Config cal_cfg = fsw::config::load(config_path);
661 auto or_default = [](const std::string& v, const char* d) {
662 return v.empty() ? d : v;
663 };
664
665 // Load calibration coefficients
666 // Physics-or-nothing baseline: PT cubics come ONLY from operator captures (the cubic store,
667 // resumed below) or an explicit bench-cal import — never auto-loaded from a factory file. An
668 // uncalibrated cubic/robust sensor streams 0. set_default_paths still sets the default CSV the
669 // bench-cal import reads.
670 fsw::calibration::PTCalibrationManager pt_calibration(/*auto_load=*/false);
671 pt_calibration.set_default_paths(
672 "scripts/calibration/calibrations",
673 "firmware/PT_Board/Calibration/PT Calibration Attempt 2026-02-04_test2.csv");
674
676
677 // json_dir + first csv_paths entry now come from [calibration.<type>] in config (was
678 // hardcoded); empty keeps the historical default dir.
679 fsw::calibration::SensorCalibrationManager tc_calibration("TC", "°C", 3);
680 tc_calibration.load_calibration(
681 or_default(cal_cfg.calibration.tc_json_dir, "scripts/calibration/calibrations/tc"),
682 cal_cfg.calibration.tc_csv_path);
683
684 fsw::calibration::SensorCalibrationManager rtd_calibration("RTD", "°C", 3);
685 rtd_calibration.load_calibration(
686 or_default(cal_cfg.calibration.rtd_json_dir, "scripts/calibration/calibrations/rtd"),
687 cal_cfg.calibration.rtd_csv_path);
688
689 fsw::calibration::SensorCalibrationManager lc_calibration("LC", "kg", 3);
690 lc_calibration.load_calibration(
691 or_default(cal_cfg.calibration.lc_json_dir, "scripts/calibration/calibrations/lc"),
692 cal_cfg.calibration.lc_csv_path);
693
694 std::cout << "[Calibration] PT: physics-or-nothing baseline — cubics come from operator "
695 "captures / bench-cal import only (resumed from the cubic store below)"
696 << std::endl;
697 std::cout << "[Calibration] TC: " << tc_calibration.calibrated_count() << " channels"
698 << std::endl;
699 std::cout << "[Calibration] RTD: " << rtd_calibration.calibrated_count() << " channels"
700 << std::endl;
701 std::cout << "[Calibration] LC: " << lc_calibration.calibrated_count() << " channels"
702 << std::endl;
703
704 // Collect active boards with local channels for VTable registration (board-namespaced).
705 // Shared with daq_bridge and sequencer_service so all three agree on which boards are live
706 // and which Elodin slot each one owns.
708 auto active_boards = fsw::config::load_active_boards(config_path);
709 auto boards_of = [&](fsw::config::ActiveBoardKind kind) {
710 auto it = active_boards.find(kind);
711 return it == active_boards.end() ? std::vector<BoardChannels>{} : it->second;
712 };
713 std::vector<BoardChannels> pt_boards = boards_of(fsw::config::ActiveBoardKind::PT);
714 std::vector<BoardChannels> tc_boards = boards_of(fsw::config::ActiveBoardKind::TC);
715 std::vector<BoardChannels> rtd_boards = boards_of(fsw::config::ActiveBoardKind::RTD);
716 std::vector<BoardChannels> lc_boards = boards_of(fsw::config::ActiveBoardKind::LC);
717 std::vector<BoardChannels> enc_boards = boards_of(fsw::config::ActiveBoardKind::ENCODER);
718 std::vector<BoardChannels> act_boards = boards_of(fsw::config::ActiveBoardKind::ACTUATOR);
719 std::vector<BoardChannels> bv_boards = boards_of(fsw::config::ActiveBoardKind::BALL_VALVE);
720
721 // Per-PT-board sensor interface and 4-20 mA conversion parameters, keyed by Elodin slot.
722 // board_simulator.py back-calculates i_ma from the target PSI and sends valid 4-20 mA ADC
723 // codes for current-loop boards, so the 4-20 mA path is correct in sim and on hardware.
724 const auto pt_board_configs = fsw::config::load_pt_boards(config_path);
725
726 /** The board's 4-20 mA config, or nullptr when this slot is not a current-loop PT board. */
727 auto current_loop_board = [&](uint8_t board_number) -> const fsw::config::PtBoardConfig* {
728 auto it = pt_board_configs.find(board_number);
729 if (it == pt_board_configs.end() || !fsw::config::is_current_loop(it->second))
730 return nullptr;
731 return &it->second;
732 };
733
734 for (const auto& [slot, board] : pt_board_configs) {
736 continue;
737 size_t channel_count = 0;
738 for (const auto& bc : pt_boards)
739 if (bc.board_number == slot)
740 channel_count = bc.channels.size();
741 std::cout << "[Calibration] HP PT slot " << static_cast<int>(slot) << " (board_id "
742 << static_cast<int>(board.board_id) << "): " << channel_count
743 << " channels (4-20 mA, " << board.full_scale_psi << " PSI full scale, "
744 << board.sense_resistor_ohms << " ohm shunt)" << std::endl;
745 }
746
747 // [calibration.tc/.rtd/.lc] default formula params, via fsw::config (toml++). cal_cfg parsed
748 // above.
749 const double tc_adc_ref_voltage = cal_cfg.calibration.tc_adc_ref_voltage;
750 const double rtd_adc_ref_voltage = cal_cfg.calibration.rtd_adc_ref_voltage;
751 const double rtd_excitation_ua = cal_cfg.calibration.rtd_excitation_ua;
752 const double rtd_r0_ohm = cal_cfg.calibration.rtd_r0_ohm; // Pt1000
753 const double lc_sensitivity_mv_per_v = cal_cfg.calibration.lc_sensitivity_mv_per_v;
754 const double lc_pga_gain = cal_cfg.calibration.lc_pga_gain;
755 const double lc_full_scale_value = cal_cfg.calibration.lc_full_scale_value; // kg
756 std::cout << "[Calibration] TC default: ITS-90 K-type, Vref=" << tc_adc_ref_voltage << "V"
757 << std::endl;
758 std::cout << "[Calibration] RTD default: CVD Pt" << (int)rtd_r0_ohm
759 << ", Vref=" << rtd_adc_ref_voltage << "V, I=" << rtd_excitation_ua << "µA"
760 << std::endl;
761 std::cout << "[Calibration] LC default: " << lc_sensitivity_mv_per_v
762 << "mV/V, PGA=" << lc_pga_gain << ", FS=" << lc_full_scale_value << "kg" << std::endl;
763
764 // ---- Per-sensor streaming model + physics params from config ----
765 // [calibration_model_<board_key>] maps a PT role -> "cubic"|"robust"|"physics"|"blend"; resolve
766 // it to the uid the streaming path uses (board_id*100 + connector) via
767 // [sensor_roles_<board_key>]. Every PT (incl. 4-20 mA) participates: cubic/robust operate on
768 // raw ADC and apply to all, while `physics` is the datasheet conversion (ratiometric or 4-20 mA
769 // per board). Default by interface: 4-20 mA -> physics (today's behavior), 0-5 V -> cubic.
770 // Physics params (full-scale, sense-resistor) come from
771 // [calibration_full_scale_<board>]/[calibration_sense_resistor_<board>] else board defaults. A
772 // CAL_USE_* env var overrides the model for every sensor (dev/testing).
773 if (env_flag_true("CAL_USE_FACTORY_PT"))
775 else if (env_flag_true("CAL_USE_ROBUST_BLEND"))
777 else if (env_flag_true("CAL_USE_ROBUST_PT"))
779
780 g_pt_model.clear();
781 g_uid_role.clear();
782 g_pt_full_scale.clear();
783 g_pt_sense_resistor.clear();
784 for (const auto& b : cal_cfg.boards) {
785 if (b.type != "PT" || !b.enabled || b.board_id < 0)
786 continue;
787 const bool is_loop = b.has_hp_pt_keys || b.pt_type == "4-20 mA absolute";
788 const std::string board_key =
789 b.section.rfind("boards.", 0) == 0 ? b.section.substr(7) : b.section;
790 const auto* roles = cal_cfg.sensor_roles_for("sensor_roles_" + board_key);
791 if (roles == nullptr)
792 continue; // no role map -> connectors default to cubic (0-5 V) at the streaming site
793 const auto* models = cal_cfg.calibration_model_for("calibration_model_" + board_key);
794 const auto* full_scales = cal_cfg.full_scale_for("calibration_full_scale_" + board_key);
795 const auto* resistors =
796 cal_cfg.sense_resistor_for("calibration_sense_resistor_" + board_key);
797 // Board-level physics defaults: 4-20 mA boards use their hp_pt_* params; a 0-5 V board maps
798 // 0-5 V -> 0-1000 PSI unless a per-sensor full-scale overrides it.
799 const double board_full_scale = is_loop ? b.hp_pt_full_scale_psi : 1000.0;
800 const double board_resistor = b.hp_pt_sense_resistor_ohms;
801 for (const auto& [role, connector] : *roles) {
802 if (connector < 1 || connector > 99)
803 continue;
804 const uint16_t uid = static_cast<uint16_t>(b.board_id * 100 + connector);
805 g_uid_role[uid] = role; // the identity durable cal is filed under
806
807 PtModel m = is_loop ? PtModel::Physics : PtModel::Cubic; // interface-aware default
808 if (models != nullptr) {
809 auto it = models->find(role);
810 if (it != models->end())
811 m = parse_pt_model(it->second);
812 }
813 if (m != PtModel::Cubic) // Cubic is pt_model_for's default; store the rest
814 g_pt_model[uid] = m;
815
816 double fs = board_full_scale, rs = board_resistor;
817 if (full_scales != nullptr) {
818 auto it = full_scales->find(role);
819 if (it != full_scales->end() && it->second > 0.0)
820 fs = it->second;
821 }
822 if (resistors != nullptr) {
823 auto it = resistors->find(role);
824 if (it != resistors->end() && it->second > 0.0)
825 rs = it->second;
826 }
827 g_pt_full_scale[uid] = fs;
828 g_pt_sense_resistor[uid] = rs;
829 }
830 }
831
832 // ---- Per-sensor LC streaming model + physics params from config (mirrors the PT block above,
833 // minus robust/blend). A board with no [sensor_roles_<board>] section contributes no per-role
834 // overrides here — its uids simply fall back to the [calibration.lc] globals at the streaming
835 // site (lc_full_scale_for/lc_sensitivity_for/lc_pga_gain_for), same as every LC board today.
836 g_lc_model.clear();
837 g_lc_full_scale.clear();
838 g_lc_sensitivity.clear();
839 g_lc_pga_gain.clear();
840 for (const auto& b : cal_cfg.boards) {
841 if (b.type != "LC" || !b.enabled || b.board_id < 0)
842 continue;
843 const std::string board_key =
844 b.section.rfind("boards.", 0) == 0 ? b.section.substr(7) : b.section;
845 const auto* roles = cal_cfg.sensor_roles_for("sensor_roles_" + board_key);
846 if (roles == nullptr)
847 continue; // no role map -> connectors default to linear (see lc_model_for)
848 const auto* models = cal_cfg.calibration_model_for("calibration_model_" + board_key);
849 const auto* full_scales = cal_cfg.full_scale_for("calibration_full_scale_" + board_key);
850 const auto* sensitivities = cal_cfg.sensitivity_for("calibration_sensitivity_" + board_key);
851 const auto* pga_gains = cal_cfg.pga_gain_for("calibration_pga_" + board_key);
852 for (const auto& [role, connector] : *roles) {
853 if (connector < 1 || connector > 99)
854 continue;
855 const uint16_t uid = static_cast<uint16_t>(b.board_id * 100 + connector);
856 g_uid_role[uid] = role; // shared with PT — role names are unique across the whole rig
857
858 if (models != nullptr) {
859 auto it = models->find(role);
860 if (it != models->end())
861 g_lc_model[uid] = parse_lc_model(it->second);
862 }
863 if (full_scales != nullptr) {
864 auto it = full_scales->find(role);
865 if (it != full_scales->end() && it->second > 0.0)
866 g_lc_full_scale[uid] = it->second;
867 }
868 if (sensitivities != nullptr) {
869 auto it = sensitivities->find(role);
870 if (it != sensitivities->end() && it->second > 0.0)
871 g_lc_sensitivity[uid] = it->second;
872 }
873 if (pga_gains != nullptr) {
874 auto it = pga_gains->find(role);
875 if (it != pga_gains->end() && it->second > 0.0)
876 g_lc_pga_gain[uid] = it->second;
877 }
878 }
879 }
880
881 // ---- LabJack T7 inputs (t7_bridge) — the same PT / LC machinery, fed from a different place.
882 // A T7 input is a uid (board_id*100 + AIN) with a role, a model and a store entry like any
883 // ESP32 sensor; its "ADC code" is its reading in microvolts (t7::t7_counts), its logical
884 // channel sits above the ESP32 range (t7::t7_logical_channel), and its physics conversion is
885 // its channel row's datasheet numbers (t7/T7Convert.hpp). The model comes from
886 // [calibration_model_<t7 board>] by role, as for an ESP32 board; with none given a T7 input
887 // defaults to physics — it measures volts against the T7's own calibration, so the datasheet
888 // conversion is already a real reading, exactly as for a 4-20 mA board.
889 const fsw::t7::T7CalibrationMap t7_map(cal_cfg);
890 if (!cal_cfg.t7_errors.empty())
891 std::cerr << "[Calibration] T7: config has " << cal_cfg.t7_errors.size()
892 << " problem(s); t7_bridge will not stream, so no T7 input is calibrated"
893 << std::endl;
894 for (const auto& t : t7_map.all()) {
895 if (t.channel.kind != "PT" && t.channel.kind != "LC")
896 continue; // TC / RTD: physics only (no model to choose)
897 g_uid_role[t.uid] = t.channel.role;
898 std::optional<std::string> model;
899 if (const auto* models =
900 cal_cfg.calibration_model_for("calibration_model_" + t.board_key)) {
901 auto it = models->find(t.channel.role);
902 if (it != models->end())
903 model = it->second;
904 }
905 if (t.channel.kind == "PT")
906 g_pt_model[t.uid] = model ? parse_pt_model(*model) : PtModel::Physics;
907 else if (model)
908 g_lc_model[t.uid] = parse_lc_model(*model); // absent = physics (lc_model_for)
909 }
910 std::cout << "[Calibration] T7: " << t7_map.all().size() << " input(s) on "
911 << cal_cfg.t7_boards.size() << " board(s)" << std::endl;
912
913 // Ordered uid->role for RobustCalibrationManager save/load, so the robust learned state is
914 // filed by role and re-attaches to the role's current connector (mirrors the cubic store).
915 // T7 inputs are left out: the robust file has no unit tag, and a role moving between an ESP32
916 // board (ADC codes) and a T7 (µV) must not carry its learned state across. They are filed by
917 // uid instead, which is stable for a given T7 and AIN.
918 std::map<uint16_t, std::string> uid_role;
919 for (const auto& [u, r] : g_uid_role)
920 if (t7_map.by_uid(u) == nullptr)
921 uid_role.emplace(u, r);
922
923 // Robust is NOT factory-seeded here anymore. Its per-sensor baseline is the operator's shared
924 // cubic fit, so it's initialized AFTER the cubic store loads (see below). A sensor with no
925 // captured points gets a zero baseline and reads 0 until it learns from captures.
926
927 // ---- Operator-built cubic PT calibration (see CubicCalibrationStore) ----
928 // The store owns per-connector captured (adc, psi) points and the fitted cubic; it is the sole
929 // source of PT cubics (there is no factory overlay). Captured points are shared: each capture
930 // feeds both this cubic fit and the robust learner.
932 "scripts/calibration/calibrations/cubic_calibration.json");
933 // Load-cell tares: display-only offsets the backend subtracts on the way to the browser.
934 // Deliberately NOT part of the cubic store — a tare is not a calibration point, and folding
935 // one in as a captured zero would tilt the whole fit rather than shift its intercept.
936 // Cleared by the backend at session start (while this service is down); survives a restart
937 // inside a session. See LcTareStore.hpp.
938 fsw::calibration::LcTareStore lc_tare_store("scripts/calibration/calibrations/lc_tare.json");
939 // What a capture records. Bounded in TIME, not in samples: the old 128-sample ring was
940 // sized for a 250 Hz PT and spanned 9.3 s on a 13.7 Hz load cell, so a capture taken
941 // soon after a load change averaged the old load in. See CaptureWindow.hpp.
942 fsw::calibration::CaptureWindow capture_window;
943 for (const auto& bc : pt_boards) {
944 for (uint8_t local_ch : bc.channels) {
945 const uint16_t uid = static_cast<uint16_t>(bc.board_id) * 100u + local_ch;
946 const uint8_t log_ch =
947 fsw::calibration::pt_logical_calibration_channel(bc.board_number, local_ch);
948 const auto rit = g_uid_role.find(uid);
949 const std::string role = rit != g_uid_role.end() ? rit->second : std::string();
950 cubic_store.register_channel(uid, bc.board_id, local_ch, log_ch, role,
953 }
954 }
955
956 // ---- Operator-built cubic LC calibration — registers into the SAME cubic_store as PT (uid
957 // board_id ranges never collide), so /api/cubic_calibration, calibration-profile save/load, and
958 // the seed-from-default mechanism all pick up LC channels for free. g_lc_uids is the
959 // discriminant apply_capture/apply_clear/reload_live_store use to route a store entry to
960 // lc_calibration instead of pt_calibration/robust_manager.
962 g_lc_board_ids.clear();
963 for (const auto& bc : lc_boards) {
964 g_lc_board_ids.insert(bc.board_id);
965 for (uint8_t local_ch : bc.channels) {
966 const uint16_t uid = static_cast<uint16_t>(bc.board_id) * 100u + local_ch;
967 const uint8_t log_ch =
968 fsw::calibration::pt_logical_calibration_channel(bc.board_number, local_ch);
969 const auto rit = g_uid_role.find(uid);
970 const std::string role = rit != g_uid_role.end() ? rit->second : std::string();
971 g_lc_uids.insert(uid);
972 cubic_store.register_channel(uid, bc.board_id, local_ch, log_ch, role,
975 }
976 }
977
978 // T7 PT / LC inputs: the same store, tagged as microvolt channels so a fit never crosses
979 // between them and the ESP32 boards' ADC codes (CubicCalibrationStore::load).
980 for (const auto& t : t7_map.all()) {
981 const bool is_lc = t.channel.kind == "LC";
982 if (t.channel.kind != "PT" && !is_lc)
983 continue;
984 if (is_lc)
985 g_lc_uids.insert(t.uid);
986 cubic_store.register_channel(
987 t.uid, static_cast<uint8_t>(t.board_id), static_cast<uint8_t>(t.channel.ain),
988 t.logical_ch, t.channel.role,
989 is_lc ? lc_model_name(lc_model_for(t.uid)) : pt_model_name(pt_model_for(t.uid)),
992 }
993
994 // ---- Unified capture/clear routing (see the [calibration_model_*] plan)
995 // ---------------------- One capture path: the frontend sends {uid, ref}; the service routes by
996 // the uid's configured model. Cubic/Blend -> record point + fit + apply to pt_calibration.
997 // Robust -> record point for display + feed the RLS learner + refresh the sampled display
998 // curve. Same split for clear.
999 auto sample_robust_curve = [&](uint16_t uid) -> std::vector<std::pair<double, double>> {
1000 std::vector<std::pair<double, double>> curve;
1001 const fsw::calibration::CubicChannel* cch = cubic_store.channel(uid);
1002 if (cch == nullptr || cch->points.empty())
1003 return curve;
1004 double amin = cch->points.front().adc, amax = amin;
1005 for (const auto& p : cch->points) {
1006 amin = std::min(amin, p.adc);
1007 amax = std::max(amax, p.adc);
1008 }
1009 if (!(amax > amin))
1010 amax = amin + 1.0;
1011 constexpr int kSamples = 40;
1012 curve.reserve(kSamples + 1);
1013 for (int i = 0; i <= kSamples; ++i) {
1014 const double adc = amin + (amax - amin) * i / kSamples;
1015 const double psi =
1016 robust_manager.predict_pressure_psi(uid, static_cast<int32_t>(std::llround(adc)));
1017 curve.emplace_back(adc, psi);
1018 }
1019 return curve;
1020 };
1021 // Shared points: every capture — regardless of the sensor's active model — feeds BOTH the cubic
1022 // fit and the robust learner, so switching a sensor's model later reuses the same points, and
1023 // the merged UI can preview all three curves. The robust display curve is refreshed for every
1024 // sensor that has points (not just robust ones).
1025 // Recompute the boards' abort-procedure "vent to safe" thresholds from the CURRENT calibration
1026 // and write them for config_broadcast to broadcast. Called at startup and after every capture /
1027 // clear / live profile swap, so a mid-session calibration updates the hardware threshold within
1028 // a broadcast cycle — no restart. Each abort PSI is inverted through the sensor's active model;
1029 // a sensor with fewer than kMinAbortCalPoints captured points falls back to physics so a sparse
1030 // fit can't emit a wildly wrong ADC, and one with no usable mapping is omitted (the firmware
1031 // then leans on PT_ABORT_THRESHOLD_TIMEOUT) rather than given a wrong gate.
1032 auto write_abort_thresholds = [&]() {
1033 const std::string out_path = "scripts/calibration/calibrations/abort_thresholds.txt";
1034 const long long now_unix = std::chrono::duration_cast<std::chrono::seconds>(
1035 std::chrono::system_clock::now().time_since_epoch())
1036 .count();
1037 std::ostringstream body;
1038 body << "# abort thresholds — one line per PT: uid target_psi raw_adc\n";
1039 body << "# regenerated on every capture/clear/reload. generated_unix " << now_unix << "\n";
1040 for (const auto& [role, target_psi] : cal_cfg.abort_pts) {
1041 uint16_t uid = 0;
1042 bool found = false;
1043 for (const auto& [u, r] : g_uid_role)
1044 if (r == role) {
1045 uid = u;
1046 found = true;
1047 break;
1048 }
1049 if (!found) {
1050 std::cerr << "[Calibration] abort_pts \"" << role
1051 << "\": no PT sensor_role declares this name — no threshold emitted"
1052 << std::endl;
1053 continue;
1054 }
1055 if (t7_map.by_uid(uid) != nullptr) {
1056 // The threshold is an ADC code an ESP32 board compares its own reading against
1057 // while it vents autonomously; a T7 is not on that board, so it cannot gate it.
1058 std::cerr << "[Calibration] abort_pts \"" << role
1059 << "\" is a LabJack T7 input — board abort thresholds can only use an "
1060 "ESP32 PT; no threshold emitted"
1061 << std::endl;
1062 continue;
1063 }
1064 const fsw::calibration::CubicChannel* cch = cubic_store.channel(uid);
1065 if (cch == nullptr)
1066 continue;
1067 uint8_t board_number = static_cast<uint8_t>((uid / 100) % 10);
1068 if (board_number == 0)
1069 board_number = 10;
1070 const fsw::config::PtBoardConfig* loop_board = current_loop_board(board_number);
1071 const bool is_loop = loop_board != nullptr;
1072 const int npoints = static_cast<int>(cch->points.size());
1073 const PtModel model = g_env_override ? *g_env_override : pt_model_for(uid);
1074 // Physics-model sensors always use physics; cubic/robust/blend use their own curve only
1075 // with enough captured points to trust it, else fall back to physics.
1076 const bool sparse = (model != PtModel::Physics) && (npoints < kMinAbortCalPoints);
1077 std::optional<int32_t> adc =
1078 invert_abort_psi_to_adc(uid, cch->logical_ch, is_loop, loop_board, target_psi,
1079 /*force_physics=*/sparse, pt_calibration, robust_manager);
1080 // Model curve couldn't reach the target (bad fit) → fall back to physics; a datasheet
1081 // gate beats no gate.
1082 if (!adc && model != PtModel::Physics && !sparse)
1083 adc =
1084 invert_abort_psi_to_adc(uid, cch->logical_ch, is_loop, loop_board, target_psi,
1085 /*force_physics=*/true, pt_calibration, robust_manager);
1086 if (!adc) {
1087 std::cerr << "[Calibration] abort_pts \"" << role << "\" (" << target_psi
1088 << " psi): no usable mapping (full_scale 0?) — no threshold emitted"
1089 << std::endl;
1090 continue;
1091 }
1092 if (sparse)
1093 std::cout << "[Calibration] abort_pts \"" << role << "\": only " << npoints
1094 << " cal point(s) (<" << kMinAbortCalPoints
1095 << ") — using physics threshold" << std::endl;
1096 body << uid << " " << target_psi << " " << static_cast<uint32_t>(*adc) << "\n";
1097 }
1098 const std::string tmp = out_path + ".tmp";
1099 {
1100 std::ofstream f(tmp, std::ios::trunc);
1101 f << body.str();
1102 }
1103 std::error_code ec;
1104 std::filesystem::rename(tmp, out_path, ec);
1105 if (ec)
1106 std::cerr << "[Calibration] abort_thresholds write failed: " << ec.message()
1107 << std::endl;
1108 };
1109
1110 auto apply_pt_capture = [&](uint16_t uid, double adc_avg, double ref) {
1111 const fsw::calibration::CubicFit fit = cubic_store.add_point(uid, adc_avg, ref);
1112 const fsw::calibration::CubicChannel* cch = cubic_store.channel(uid);
1113 if (fit.valid && cch != nullptr)
1114 pt_calibration.set_calibration(
1115 cch->logical_ch, fsw::calibration::PTCalibrationCoeffs(fit.A, fit.B, fit.C, fit.D));
1116 robust_manager.update_calibration(uid, static_cast<int32_t>(std::llround(adc_avg)), ref);
1117 cubic_store.set_fit_curve(uid, sample_robust_curve(uid));
1118 cubic_store.save();
1119 // Persist robust learning on every capture (not only the 5-min auto-save / clean stop), so
1120 // a crash can't lose it.
1121 robust_manager.save_adjustments(adjustments_path, &uid_role);
1122 // This capture may have changed the sensor's curve → re-emit its abort threshold so the
1123 // boards' vent-to-safe gate tracks the new calibration within a broadcast cycle.
1124 write_abort_thresholds();
1125 };
1126 // adc -> kg exactly as the LC publish path computes it (see the 0x23 branch): the cubic fit
1127 // if this uid streams one, else the datasheet physics conversion. A tare offset is derived
1128 // through the SAME model selection the live sample goes through, so flipping a uid between
1129 // cubic and physics carries its tare correctly instead of being a fourth special case.
1130 auto lc_eval_for = [&](uint16_t uid) -> fsw::calibration::LcTareStore::Evaluator {
1131 return [&, uid](double adc) -> double {
1132 if (const fsw::t7::T7CalChannel* t = t7_map.by_uid(uid)) {
1133 // A T7 load cell: the code is µV; physics is its own channel's bridge numbers.
1134 const int32_t code = static_cast<int32_t>(adc);
1135 const bool cubic_ok = lc_calibration.is_calibrated(t->logical_ch);
1136 const double kg_cubic =
1137 cubic_ok ? lc_calibration.calculate(t->logical_ch, code) : 0.0;
1138 const double kg_phys = fsw::t7::lc_kg(fsw::t7::t7_volts(adc), t->channel);
1139 return select_lc_kg(uid, kg_cubic, std::isfinite(kg_phys) ? kg_phys : 0.0,
1140 cubic_ok);
1141 }
1142 uint8_t board_number = static_cast<uint8_t>((uid / 100) % 10);
1143 if (board_number == 0)
1144 board_number = 10;
1145 const uint8_t connector = static_cast<uint8_t>(uid % 100);
1146 const uint8_t lc_log_ch =
1147 fsw::calibration::pt_logical_calibration_channel(board_number, connector);
1148 const int32_t code = static_cast<int32_t>(adc);
1149 const bool cubic_ok = lc_calibration.is_calibrated(lc_log_ch);
1150 const double kg_cubic = cubic_ok ? lc_calibration.calculate(lc_log_ch, code) : 0.0;
1151 const double kg_phys = convert_lc_adc_to_force(
1152 code, lc_sensitivity_for(uid, lc_sensitivity_mv_per_v),
1153 lc_pga_gain_for(uid, lc_pga_gain), lc_full_scale_for(uid, lc_full_scale_value));
1154 return select_lc_kg(uid, kg_cubic, kg_phys, cubic_ok);
1155 };
1156 };
1157 // Re-derive a standing tare's kilograms from the ADC code it was taken at. Called from EVERY
1158 // site that can change an LC curve; miss one and the stand carries an offset computed against
1159 // a curve that no longer exists — the "tank reads 2 kg after a better fit" bug.
1160 //
1161 // curves_trusted tracks the cubic store: if its file could not be read, every curve here is a
1162 // fallback, and persisting an offset derived from one would replace a good number with a
1163 // confident wrong one.
1164 auto recompute_tare = [&](uint16_t uid) {
1165 lc_tare_store.set_curves_trusted(!cubic_store.load_failed());
1166 lc_tare_store.recompute(uid, lc_eval_for(uid));
1167 lc_tare_store.save();
1168 };
1169 auto recompute_all_tares = [&]() {
1170 lc_tare_store.set_curves_trusted(!cubic_store.load_failed());
1171 lc_tare_store.recompute_all([&](uint16_t u) {
1172 return lc_eval_for(u);
1173 });
1174 lc_tare_store.save();
1175 };
1176 // LC capture: cubic fit only — no robust learner (LC doesn't need drift-learning) and no abort
1177 // thresholds (a PT-only concept; abort_pts names PT roles).
1178 auto apply_lc_capture = [&](uint16_t uid, double adc_avg, double ref) {
1179 const fsw::calibration::CubicFit fit = cubic_store.add_point(uid, adc_avg, ref);
1180 const fsw::calibration::CubicChannel* cch = cubic_store.channel(uid);
1181 if (fit.valid && cch != nullptr)
1183 fit.A, fit.B, fit.C, fit.D, "kg"));
1184 cubic_store.save();
1185 // The curve just moved under any standing tare on this channel.
1186 recompute_tare(uid);
1187 };
1188 // Every capture/clear is routed by uid kind (g_lc_uids), so cmd_type 0/3/4/5/6 in the
1189 // CalibrationCommand handler below work unchanged for both PT and LC uids.
1190 // One capture decision for every command that takes one (Zero, Zero-All, cmd 1, 3, 5).
1191 // Each used to open-code the same unbounded mean with its own last-value fallback; that
1192 // fallback is gone, because a capture that cannot be substantiated by fresh data must not
1193 // become a point on a live stand.
1194 auto take_capture = [&](uint16_t uid, const char* what) -> fsw::calibration::CaptureResult {
1195 const fsw::calibration::CaptureResult r = capture_window.capture(uid, mono_ns());
1196 if (!r.ok) {
1197 std::cout << "[Cal] " << what << " uid=" << static_cast<int>(uid) << " REJECTED ("
1198 << r.reason << "): newest sample " << r.age_ms << "ms old, " << r.n
1199 << " in last " << r.window_ms << "ms (limit " << r.max_age_ms << "ms)"
1200 << std::endl;
1201 }
1202 return r;
1203 };
1204 // The fields that would have made the 2026-09-13 load-cell failure obvious at the time.
1205 auto capture_detail = [](const fsw::calibration::CaptureResult& r) {
1206 std::ostringstream o;
1207 o << "n=" << r.n << " span=" << r.span_ms << "ms age=" << r.age_ms << "ms adc=" << r.adc_avg
1208 << " spread=" << r.spread << " drift=" << r.drift << " z=" << r.drift_z
1209 << (r.settled ? "" : " UNSETTLED");
1210 return o.str();
1211 };
1212
1213 // g_lc_uids only holds connectors in active_connectors, so an LC channel outside that list
1214 // used to fall through to the PT path and write a PT logical channel. The board id settles
1215 // the kind regardless of which connectors are declared.
1216 auto is_lc_uid = [&](uint16_t uid) {
1217 return g_lc_uids.count(uid) > 0 ||
1218 g_lc_board_ids.count(static_cast<uint8_t>(uid / 100)) > 0;
1219 };
1220 auto apply_capture = [&](uint16_t uid, double adc_avg, double ref) {
1221 if (is_lc_uid(uid))
1222 apply_lc_capture(uid, adc_avg, ref);
1223 else
1224 apply_pt_capture(uid, adc_avg, ref);
1225 };
1226 // Clear = back to nothing: drop the captured points, remove the operator cubic (so cubic reads
1227 // 0), and reset the robust learner (PT only). No factory/baseline revert.
1228 auto apply_pt_clear = [&](uint16_t uid) {
1229 const fsw::calibration::CubicChannel* cch = cubic_store.channel(uid);
1230 // Only a LIVE channel's logical slot is its own to clear. An orphan (a disabled or
1231 // removed board, still listed on the calibration page because its points are kept) shares
1232 // its logical channel with whatever live board now owns that slot — so Clear on a dead
1233 // LC-41 row would wipe FT-PT-R's cubic. Drop the points; leave the live map alone.
1235 pt_calibration.clear_calibration(cch->logical_ch);
1236 cubic_store.clear_channel(uid);
1237 robust_manager.reset_adjustment(uid);
1238 cubic_store.save();
1239 robust_manager.save_adjustments(adjustments_path, &uid_role);
1240 // Clearing drops the curve → re-emit (this sensor now falls back to physics or is omitted).
1241 write_abort_thresholds();
1242 };
1243 auto apply_lc_clear = [&](uint16_t uid) {
1244 const fsw::calibration::CubicChannel* cch = cubic_store.channel(uid);
1245 // Live channels only — see apply_pt_clear.
1247 lc_calibration.clear_calibration(cch->logical_ch);
1248 cubic_store.clear_channel(uid);
1249 cubic_store.save();
1250 // A cleared channel falls back to the physics conversion, which is a different curve.
1251 // The tare survives and re-derives through it, so the same load still reads zero.
1252 recompute_tare(uid);
1253 };
1254 auto apply_clear = [&](uint16_t uid) {
1255 if (is_lc_uid(uid))
1256 apply_lc_clear(uid);
1257 else
1258 apply_pt_clear(uid);
1259 };
1260
1261 // Seed on first run: if the live store file is missing, copy the committed default (a curated,
1262 // version-controlled calibration snapshot) into place so a fresh checkout / new machine boots
1263 // with the team's calibration instead of an empty store. The live file is gitignored and
1264 // rewritten on every capture; cubic_calibration.default.json is the tracked source you promote
1265 // a known-good cal into (see scripts/calibration/promote_default.sh).
1266 {
1267 const std::filesystem::path live_path(
1268 "scripts/calibration/calibrations/cubic_calibration.json");
1269 const std::filesystem::path seed_path(
1270 "scripts/calibration/calibrations/cubic_calibration.default.json");
1271 std::error_code ec;
1272 if (!std::filesystem::exists(live_path, ec) && std::filesystem::exists(seed_path, ec)) {
1273 std::filesystem::copy_file(seed_path, live_path,
1274 std::filesystem::copy_options::overwrite_existing, ec);
1275 if (ec)
1276 std::cout << "[Calibration] Cubic: seed copy failed (" << ec.message() << ")"
1277 << std::endl;
1278 else
1279 std::cout
1280 << "[Calibration] Cubic: seeded live store from cubic_calibration.default.json"
1281 << std::endl;
1282 }
1283 }
1284
1285 // Load (or reload) the live cubic store from disk and re-apply it to the running stream: each
1286 // channel's fitted cubic → pt_calibration, and robust reseeded from that same cubic baseline.
1287 // Used at startup AND when a calibration profile is swapped live (cmd 7): the file on disk is
1288 // the source of truth, so re-reading it is all that's needed to switch the whole rig's cal.
1289 // restore_learned=true (startup): additionally restore the learned robust θ from
1290 // adjustments.json on top of the cubic-seeded baseline.
1291 // restore_learned=false (profile swap): reset + reseed robust from the profile only — the
1292 // profile is the whole cal, so previously learned θ is
1293 // dropped.
1294 auto reload_live_store = [&](bool restore_learned) -> size_t {
1295 const size_t loaded = cubic_store.load();
1296 for (uint16_t uid : cubic_store.uids()) {
1297 const fsw::calibration::CubicFit* fit = cubic_store.fit_for(uid);
1298 const fsw::calibration::CubicChannel* cch = cubic_store.channel(uid);
1299 if (cch == nullptr)
1300 continue;
1301 // An ORPHAN — an entry whose board the running config no longer declares — has no
1302 // sensor streaming, and its logical channel belongs to whatever live board owns that
1303 // slot: disabled LC board 41's connectors 1/6/7 name PT board 21's logical 1/6/7.
1304 // Neither apply nor clear; its points stay on disk and re-attach by role when the
1305 // board comes back. See CubicChannel::live / cal_target().
1308 continue;
1309 const bool fit_ok = fit != nullptr && fit->valid;
1311 // LC: cubic fit only, no robust baseline to reseed.
1312 if (fit_ok)
1313 lc_calibration.set_calibration(cch->logical_ch,
1315 fit->A, fit->B, fit->C, fit->D, "kg"));
1316 else
1317 lc_calibration.clear_calibration(cch->logical_ch);
1318 continue;
1319 }
1320 if (fit_ok)
1321 pt_calibration.set_calibration(
1322 cch->logical_ch,
1323 fsw::calibration::PTCalibrationCoeffs(fit->A, fit->B, fit->C, fit->D));
1324 else
1325 // No valid cubic (e.g. a blank profile) → clear any stale cubic so it reads 0.
1326 pt_calibration.clear_calibration(cch->logical_ch);
1328 fit_ok ? fsw::calibration::PTCalibrationCoeffs(fit->A, fit->B, fit->C, fit->D)
1329 : fsw::calibration::PTCalibrationCoeffs(0.0, 0.0, 0.0, 0.0);
1330 robust_manager.reseed_sensor(uid, baseline);
1331 }
1332 // Say out loud which entries did NOT reach the stream, and which live sensor each one
1333 // shadows. Silence here is what made 2026-11-09 take an afternoon: disabling LC board 41
1334 // left three PTs reading 0 psi and nothing in any log connected the two. An orphan is
1335 // normal (a board off for the day keeps its cal); an orphan sitting on a LIVE sensor's
1336 // logical channel is the thing to see at a glance.
1337 {
1338 std::map<uint8_t, std::string> live_logical; // logical_ch -> "role (uid)"
1339 for (uint16_t uid : cubic_store.uids()) {
1340 const fsw::calibration::CubicChannel* c = cubic_store.channel(uid);
1341 if (c != nullptr && c->live)
1342 live_logical[c->logical_ch] =
1343 (c->role.empty() ? std::string("<no role>") : c->role) + " (uid " +
1344 std::to_string(uid) + ")";
1345 }
1346 size_t orphans = 0;
1347 for (uint16_t uid : cubic_store.uids()) {
1348 const fsw::calibration::CubicChannel* c = cubic_store.channel(uid);
1349 if (c == nullptr || c->live)
1350 continue;
1351 ++orphans;
1352 const auto hit = live_logical.find(c->logical_ch);
1353 if (hit == live_logical.end())
1354 continue;
1355 std::cout << "[Calibration] Cubic: uid " << uid << " ('"
1356 << (c->role.empty() ? "<no role>" : c->role) << "', board "
1357 << static_cast<int>(c->board_id)
1358 << " is not in the running config) sits on logical channel "
1359 << static_cast<int>(c->logical_ch) << ", which belongs to " << hit->second
1360 << " — its curve is NOT applied and does not touch that "
1361 "sensor."
1362 << std::endl;
1363 }
1364 if (orphans > 0)
1365 std::cout << "[Calibration] Cubic: " << orphans
1366 << " entr(ies) kept for boards not in the running config (points "
1367 "preserved; they re-attach by role when the board returns)."
1368 << std::endl;
1369 }
1370 // Restore learned robust θ only on process start (not on a live profile swap).
1371 if (restore_learned)
1372 robust_manager.load_adjustments(adjustments_path, &uid_role);
1373 // Sample the robust preview curve for EVERY PT sensor that has points (not just robust
1374 // ones), so the merged UI can show "what robust would look like" before you switch to it.
1375 // LC has no robust display.
1376 for (uint16_t uid : cubic_store.uids()) {
1377 if (is_lc_uid(uid))
1378 continue;
1379 const fsw::calibration::CubicChannel* cch = cubic_store.channel(uid);
1380 if (cch != nullptr && !cch->points.empty())
1381 cubic_store.set_fit_curve(uid, sample_robust_curve(uid));
1382 }
1383 // Whole-rig cal just (re)loaded — startup or a live profile swap (cmd 7). Emit the abort
1384 // thresholds from it so the boards' vent-to-safe gate matches the newly active calibration.
1385 write_abort_thresholds();
1386 // Every LC curve may have just changed, so every standing tare's kilograms are stale.
1387 // This is why the tare file is loaded BEFORE the first call to this lambda: recomputing
1388 // an empty map does nothing, and the stale offsets would then survive all session.
1389 recompute_all_tares();
1390 return loaded;
1391 };
1392
1393 std::cout << "[Calibration] Robust adjustments path: " << adjustments_path << std::endl;
1394 std::cout << "[Calibration] (override with --adjustments, CAL_BACKUP_PATH, or "
1395 "calibration_backups/calibration_backup_*.json mtime)"
1396 << std::endl;
1397 // Resume standing tares BEFORE the live store reload below, not after.
1398 //
1399 // Ordering is load-bearing. reload_live_store() ends by recomputing every tare against the
1400 // curves it just applied; if the tare file were read after that call, the recompute would run
1401 // over an empty map and never happen. The case that bites is a calibration profile swapped on
1402 // disk while this service was down: the offsets on disk belong to the old curves, and without
1403 // the startup recompute a tared tank reads a wrong nonzero at rest with nothing to explain it.
1404 const size_t tares_loaded = lc_tare_store.load();
1405 if (tares_loaded > 0)
1406 std::cout << "[Calibration] LC tare: resumed " << tares_loaded
1407 << " standing tare(s) from lc_tare.json" << std::endl;
1408
1409 // Resume previously captured points + learned robust state from disk.
1410 const size_t cubic_loaded = reload_live_store(/*restore_learned=*/true);
1411 if (cubic_loaded > 0)
1412 std::cout << "[Calibration] Cubic: resumed " << cubic_loaded
1413 << " channel(s) from cubic_calibration.json" << std::endl;
1414 // Audit, not a second recompute: by here every restored tare should already have been
1415 // re-derived by the reload above, so the expected answer is zero. A non-zero answer means a
1416 // curve moved without its recompute running — most likely this file was read after the
1417 // reload rather than before it — and the offsets it just fixed were being applied against a
1418 // curve that no longer exists. Self-healing, but never silently.
1419 {
1420 lc_tare_store.set_curves_trusted(!cubic_store.load_failed());
1421 const size_t stale = lc_tare_store.recompute_stale([&](uint16_t u) {
1422 return lc_eval_for(u);
1423 });
1424 if (stale > 0) {
1425 std::cout << "[Calibration] LC tare: WARNING — " << stale
1426 << " tare(s) were stale against the live curves and have been re-derived. "
1427 "A curve changed without recomputing its tare; check that lc_tare_store"
1428 ".load() still runs BEFORE the first reload_live_store()."
1429 << std::endl;
1430 lc_tare_store.save();
1431 }
1432 }
1433 // Always persist at startup so the record (with each channel's active_model) exists immediately
1434 // — the UI / cal_model_select read it before any capture.
1435 cubic_store.save();
1436 if (g_env_override) {
1437 const char* label = *g_env_override == PtModel::Cubic ? "factory cubic (CAL_USE_FACTORY_PT)"
1439 ? "75% robust + 25% factory (CAL_USE_ROBUST_BLEND)"
1440 : "100% robust when initialized (CAL_USE_ROBUST_PT)";
1441 std::cout << "[Calibration] LP PT: env override — all sensors " << label << std::endl;
1442 } else {
1443 size_t robust_n = 0, blend_n = 0, physics_n = 0;
1444 for (const auto& [uid, m] : g_pt_model) {
1445 if (m == PtModel::Robust)
1446 ++robust_n;
1447 else if (m == PtModel::Blend)
1448 ++blend_n;
1449 else if (m == PtModel::Physics)
1450 ++physics_n;
1451 }
1452 std::cout
1453 << "[Calibration] PT: per-sensor model from config — default cubic (0-5 V) / physics "
1454 "(4-20 mA); "
1455 << robust_n << " robust, " << physics_n << " physics, " << blend_n << " blend"
1456 << std::endl;
1457 for (const auto& [uid, m] : g_pt_model)
1458 std::cout << "[Calibration] uid " << uid << " -> " << pt_model_name(m) << std::endl;
1459 }
1460 {
1461 size_t lc_linear_n = 0, lc_cubic_n = 0, lc_physics_n = 0;
1462 for (uint16_t uid : g_lc_uids) {
1463 const LcModel m = lc_model_for(uid);
1464 (m == LcModel::Linear ? lc_linear_n
1465 : m == LcModel::Cubic ? lc_cubic_n
1466 : lc_physics_n)++;
1467 }
1468 std::cout << "[Calibration] LC: per-sensor model from config — default linear (physics "
1469 "until 2 points); "
1470 << lc_linear_n << " linear, " << lc_cubic_n << " cubic, " << lc_physics_n
1471 << " physics" << std::endl;
1472 }
1473
1474 if (verbose())
1475 std::cout << "[Cal] CAL_VERBOSE=1 — debug output enabled" << std::endl;
1476
1477 // Single ElodinClient for both subscribe (read) and publish (write)
1478 fsw::elodin::ElodinClient elodin_client;
1479
1480 // Every table this service writes, and every raw table it calibrates (ball valves have their
1481 // own type_hi bytes, so they sit beside the six sensor kinds rather than among them; a LabJack
1482 // T7 is one more source, under 0x26).
1483 auto register_calibrated = [&]() {
1485 elodin_client, pt_boards, tc_boards, rtd_boards, lc_boards, enc_boards, act_boards);
1486 if (!bv_boards.empty())
1488 bv_boards);
1489 std::map<uint8_t, std::vector<fsw::elodin::DatabaseConfig::T7Table>> t7_by_slot;
1490 for (const auto& t : t7_map.all())
1491 t7_by_slot[t.slot].push_back(
1492 {static_cast<uint8_t>(t.channel.ain), fsw::t7::calibrated_field(t.channel.kind)});
1493 for (const auto& [slot, tables] : t7_by_slot)
1495 };
1496 // A T7's raw tables (inputs + each TC slot's cold junction) go through the same tracker as the
1497 // ESP32 ones: t7_bridge registers them when it connects, often after this service subscribed,
1498 // and a refused table is asked for again by name. Re-subscribing to a T7 merely because it
1499 // has gone quiet (an unplugged T7) would stack a second stream on a table the DB accepted.
1500 auto raw_tables = [&]() {
1501 auto t = fsw::elodin::raw_sensor_tables(pt_boards, act_boards, tc_boards, rtd_boards,
1502 lc_boards, enc_boards);
1503 const auto bv = fsw::elodin::ball_valve_raw_tables(bv_boards);
1504 t.insert(t.end(), bv.begin(), bv.end());
1505 const auto t7 = t7_map.raw_tables();
1506 t.insert(t.end(), t7.begin(), t7.end());
1507 return t;
1508 };
1509
1510 // Every subscription goes out under its own request id, so a refusal (the DB answers a
1511 // subscription to a table nobody has registered yet with ErrorResponse) names the table to
1512 // ask for again. See SubscriptionTracker.hpp.
1514 auto subscribe_all = [&]() -> bool {
1515 sub_tracker.reset();
1516 auto tables = raw_tables();
1518 bool ok = true;
1519 for (const auto& t : tables)
1520 ok = elodin_client.subscribe_table(t.first, t.second, sub_tracker.assign(t)) && ok;
1521 return ok;
1522 };
1523
1524 auto connect_and_register = [&]() -> bool {
1525 if (!elodin_client.connect(elodin_host, elodin_port)) {
1526 std::cerr << "[Cal] Failed to connect to Elodin at " << elodin_host << ":"
1527 << elodin_port << std::endl;
1528 return false;
1529 }
1530 register_calibrated();
1531 // The raw sensor tables this service calibrates, built from the boards actually in
1532 // config. This used to be subscribe_stream(), which guessed boards 1-8 x channels 1-10
1533 // and sent 481 subscribe messages regardless of the rig.
1534 if (!subscribe_all()) {
1535 std::cerr << "[Cal] Failed to subscribe to raw sensor tables / CalibrationCommand"
1536 << std::endl;
1537 return false;
1538 }
1539 std::cout << "[Cal] Connected to Elodin, registered calibrated VTables, subscribed."
1540 << std::endl;
1541 return true;
1542 };
1543
1544 if (!connect_and_register())
1545 return 1;
1546 // Allow read_packet() to yield every 1 s so a refused subscription is asked for again (below)
1547 // on the tracker's 1 s retry even when nothing else is arriving.
1548 elodin_client.set_recv_timeout_ms(1000);
1549
1550 // Generous, but not for the reason the previous comment claimed ("handles large Elodin
1551 // subscription-ACK bursts"). ACKs are 19-23 bytes and arrive one per read_packet() call, so
1552 // their number never mattered; the largest single packet on this stream is a ~179-byte VTable
1553 // definition. The size was really working around read_packet(), which used to corrupt the
1554 // connection permanently if any packet exceeded the buffer. That is fixed — an oversized
1555 // packet is now skipped — so this is ordinary headroom for VTables that grow.
1556 uint8_t pkt_buf[65536];
1557 int packet_count = 0;
1558 static std::atomic<bool> logged_ch5{false};
1559 auto last_save = std::chrono::steady_clock::now();
1560 std::map<uint16_t, int32_t> last_adc_map;
1561 // Every re-subscribe adds a copy of the command stream; handle each command once.
1562 fsw::calibration::CommandDedupe command_dedupe;
1563
1564 while (running) {
1565 if (!elodin_client.is_connected()) {
1566 std::cerr << "[Cal] Elodin disconnected, retrying in 2s..." << std::endl;
1567 std::this_thread::sleep_for(std::chrono::seconds(2));
1568 if (elodin_client.reconnect()) {
1569 register_calibrated();
1570 subscribe_all();
1571 elodin_client.set_recv_timeout_ms(1000);
1572 // Samples either side of the gap are not a continuous record; averaging
1573 // across it would blend pre-disconnect codes into the next capture.
1574 capture_window.clear();
1575 last_adc_map.clear();
1576 std::cout << "[Cal] Reconnected to Elodin" << std::endl;
1577 }
1578 continue;
1579 }
1580
1581 // A table the DB refused (its publisher had not registered it yet) is asked for again
1582 // until it is accepted; the refusal names it (SubscriptionTracker).
1583 //
1584 // Nothing else is re-sent. There used to be a fallback here that re-subscribed EVERY table
1585 // after 5 s with no packets, but elodin-db treats a second subscribe on one connection as
1586 // a second subscription and delivers the table twice: each firing stacked another copy of
1587 // every stream, the command table included, so after N firings each capture, zero and
1588 // tare was applied N+1 times (the stand re-subscribed 57 times in a quiet minute on
1589 // 2026-11-07). A quiet stream is quiet because its board is; the DB refuses a table that
1590 // does not exist yet, and a dropped connection resubscribes on reconnect above.
1591 {
1592 const auto now_s = std::chrono::steady_clock::now();
1593 for (const auto& t : sub_tracker.due(now_s))
1594 elodin_client.subscribe_table(t.first, t.second, sub_tracker.assign(t));
1595 }
1596
1597 ssize_t pkt_len = elodin_client.read_packet(pkt_buf, sizeof(pkt_buf));
1598 if (pkt_len <= 0)
1599 continue;
1600 if (pkt_len < 8)
1601 continue;
1602 if (fsw::elodin::is_error_response(pkt_buf, static_cast<size_t>(pkt_len))) {
1603 const auto t = sub_tracker.rejected(pkt_buf[7], std::chrono::steady_clock::now());
1604 if (t.first != 0 || t.second != 0)
1605 std::cout << "[Cal] DB refused the subscription to [0x" << std::hex
1606 << static_cast<int>(t.first) << ",0x" << static_cast<int>(t.second)
1607 << std::dec << "] (not registered yet) — asking again" << std::endl;
1608 continue;
1609 }
1610
1611 const uint8_t type_hi = pkt_buf[5];
1612 const uint8_t type_lo = pkt_buf[6];
1613 const uint8_t ty = pkt_buf[4];
1614
1615 // Elodin TABLE packets can arrive with type 0 or 1 depending on stream path/version.
1616 if (ty != 0 && ty != 1)
1617 continue;
1618
1619 // Only log first few ACTUAL sensor/command packets (skip registration ACKs with type_hi >=
1620 // 0x80)
1621 static int debug_limit = 0;
1622 if (debug_limit < 10 && type_hi < 0x80) {
1623 std::cout << "[Cal] Received packet ty=" << (int)ty << " id=[0x" << std::hex
1624 << (int)type_hi << ",0x" << (int)type_lo << std::dec << "]"
1625 << " pkt_len=" << pkt_len << std::endl;
1626 debug_limit++;
1627 }
1628
1629 // Only process RAW sensor packets or Calibration Commands.
1630 if (type_hi == 0x46) {
1631 // CalibrationCommand: ts(8) | cmd(1)@8 | pad(1)@9 | sensor_id(2 LE)@10 | ref_f32(4)@12.
1632 // sensor_id is at even offset 10 so it is an aligned u16 in the Elodin VTable and its
1633 // high byte survives (uid = board_id*100+connector can exceed 255).
1634 if (pkt_len >= 8 + 16) {
1635 const uint8_t* p = pkt_buf + 8;
1636 if (command_dedupe.seen(p))
1637 continue; // the same command again, from a second subscription
1638 uint8_t cmd_type = p[8];
1639 uint16_t sensor_id =
1640 static_cast<uint16_t>(p[10]) | (static_cast<uint16_t>(p[11]) << 8);
1641 // memcpy, not a `const float*` cast: p is pkt_buf + 8, so p + 12 inherits the
1642 // buffer's alignment and nothing guarantees it is 4-aligned (cppcheck
1643 // invalidPointerCast). Same instruction, defined behaviour.
1644 float ref_val;
1645 std::memcpy(&ref_val, p + 12, sizeof(ref_val));
1646
1647 std::cout << "[Cal] Received CalibrationCommand: type=" << (int)cmd_type
1648 << " sensor=" << static_cast<int>(sensor_id) << " ref=" << ref_val
1649 << std::endl;
1650
1651 if (cmd_type == 0) { // Zero All — capture a 0 reference point on every PT + LC
1652 // A "zero" is just a captured reference point at 0 (psi or kg): it feeds the
1653 // same shared fit as any other capture, persists, and naturally averages
1654 // repeated zeroes — capturing real zero-drift over time rather than assuming a
1655 // uniform tare. Physics sensors are captured too: the point is stored, not
1656 // applied.
1657 int zeroed = 0;
1658 if (sensor_id == 0) { // All sensors
1659 // Enumerate sensors that are actually SENDING, not every uid ever seen.
1660 // Iterating a last-value map zeroed channels whose board had gone away,
1661 // from codes of unbounded age.
1662 std::vector<uint16_t> skipped;
1663 for (uint16_t id : capture_window.uids()) {
1664 // Physics sensors are captured too. The point does not change what
1665 // physics streams — that stays the datasheet conversion — but it is
1666 // recorded in the shared store, so a sensor can be calibrated while in
1667 // physics mode and switched to cubic or robust afterwards with its
1668 // points already there. Skipping them meant the only way to collect
1669 // points was to change a sensor's mode first, mid-campaign.
1670 const fsw::calibration::CaptureResult r = take_capture(id, "Zero All");
1671 if (!r.ok) {
1672 skipped.push_back(id);
1673 continue;
1674 }
1675 apply_capture(id, r.adc_avg, 0.0);
1676 ++zeroed;
1677 }
1678 std::cout << "[Cal] Zero All: captured 0 on " << zeroed << " PT/LC sensors";
1679 if (!skipped.empty()) {
1680 std::cout << "; skipped " << skipped.size() << " with no fresh data (";
1681 for (size_t i = 0; i < skipped.size(); i++)
1682 std::cout << (i ? " " : "") << static_cast<int>(skipped[i]);
1683 std::cout << ")";
1684 }
1685 std::cout << std::endl;
1686 } else {
1687 const fsw::calibration::CaptureResult r = take_capture(sensor_id, "Zero");
1688 if (r.ok) {
1689 apply_capture(sensor_id, r.adc_avg, 0.0);
1690 ++zeroed;
1691 }
1692 std::cout << "[Cal] Zero: captured 0 on uid " << static_cast<int>(sensor_id)
1693 << " (" << zeroed << ")" << std::endl;
1694 }
1695 } else if (cmd_type == 1) { // Capture Reference
1696 // Through the same window as every other capture: this used to feed the
1697 // robust learner one last-value sample of unbounded age.
1699 take_capture(sensor_id, "Capture ref");
1700 if (r.ok) {
1701 robust_manager.update_calibration(sensor_id,
1702 static_cast<int32_t>(r.adc_avg), ref_val);
1703 }
1704 } else if (cmd_type == 2) { // Save
1705 robust_manager.save_adjustments(adjustments_path, &uid_role);
1706 std::cout << "[Cal] Adjustments saved to " << adjustments_path << std::endl;
1707 } else if (cmd_type == 3) { // Capture cubic point (operator-built factory cubic)
1709 take_capture(sensor_id, "Cubic capture");
1710 const bool have_adc = cr.ok;
1711 const double adc_avg = cr.adc_avg;
1712 if (have_adc) {
1713 // Legacy cubic capture is now a shared capture (feeds cubic + robust).
1714 apply_capture(sensor_id, adc_avg, ref_val);
1715 std::cout << "[Cal] Cubic capture uid=" << static_cast<int>(sensor_id)
1716 << " adc=" << adc_avg << " psi=" << ref_val << std::endl;
1717 } else {
1718 std::cout << "[Cal] Cubic capture: no ADC seen yet for uid "
1719 << static_cast<int>(sensor_id) << std::endl;
1720 }
1721 } else if (cmd_type == 4) { // Clear channel → nothing (no factory revert)
1722 apply_clear(sensor_id);
1723 std::cout << "[Cal] Cubic cleared uid=" << static_cast<int>(sensor_id)
1724 << std::endl;
1725 } else if (cmd_type == 5) { // Unified capture point — routed by configured model
1726 const fsw::calibration::CaptureResult cr = take_capture(sensor_id, "Capture");
1727 if (cr.ok) {
1728 apply_capture(sensor_id, cr.adc_avg, ref_val);
1729 // Recorded either way; `settled` is how the GUI flags a capture taken
1730 // while the reading was still moving.
1731 cubic_store.note_capture(sensor_id, fsw::calibration::CaptureQuality{
1732 true, unix_now(), cr.adc_avg, cr.n,
1733 cr.window_ms, cr.spread, cr.drift,
1734 cr.drift_z, cr.settled});
1735 std::cout << "[Cal] Capture uid=" << static_cast<int>(sensor_id) << " ("
1736 << pt_model_name(pt_model_for(sensor_id)) << ") "
1737 << capture_detail(cr) << " ref=" << ref_val << std::endl;
1738 } else {
1739 std::cout << "[Cal] Capture: no ADC seen yet for uid "
1740 << static_cast<int>(sensor_id) << std::endl;
1741 }
1742 } else if (cmd_type == 6) { // Unified new calibration / clear — routed by model
1743 apply_clear(sensor_id);
1744 std::cout << "[Cal] New calibration uid=" << static_cast<int>(sensor_id) << " ("
1745 << pt_model_name(pt_model_for(sensor_id)) << ")" << std::endl;
1746 } else if (cmd_type == 7) { // Reload live store — the backend swapped the cal file
1747 // A calibration profile was loaded / a blank was created on disk by the
1748 // backend; re-read the live store and re-apply it to the running stream (reset
1749 // + reseed robust from the new profile). No session restart needed.
1750 // The capture window is deliberately NOT cleared here: it holds raw ADC
1751 // counts, which are calibration-independent, so dropping them would only
1752 // make the next capture fail for no reason.
1753 const size_t n = reload_live_store(/*restore_learned=*/false);
1754 std::cout << "[Cal] Reloaded live calibration store (" << n
1755 << " channel(s)) after profile swap" << std::endl;
1756 } else if (cmd_type == 8) { // LC tare — display only, never touches the fit
1757 // ref_val: 0 = set, 1 = clear. sensor_id 0 = every LC channel.
1758 //
1759 // A tare is NOT a captured zero, and this is the one command where that
1760 // distinction is the whole point. Zero-All (cmd 0) records a real 0 kg
1761 // reference point into the shared fit, which is correct for a vented PT and
1762 // wrong for a load cell holding a tank: that tank is not at 0 kg, so the
1763 // point would be false and, because the fit is least-squares over every
1764 // point, it would tilt the whole cubic rather than shift its intercept.
1765 const bool clearing = ref_val >= 0.5f;
1766 std::vector<uint16_t> targets;
1767 if (sensor_id == 0) {
1768 if (clearing) {
1769 targets = lc_tare_store.uids();
1770 } else {
1771 // Only channels actually streaming can be tared — same rule Zero-All
1772 // uses. A uid in a last-value map may have had its board go away.
1773 for (uint16_t id : capture_window.uids())
1774 if (is_lc_uid(id))
1775 targets.push_back(id);
1776 }
1777 } else if (is_lc_uid(sensor_id)) {
1778 targets.push_back(sensor_id);
1779 } else {
1780 std::cout << "[Cal] Tare: uid " << static_cast<int>(sensor_id)
1781 << " is not a load cell — ignored" << std::endl;
1782 }
1783
1784 size_t done = 0;
1785 for (uint16_t id : targets) {
1786 if (clearing) {
1787 lc_tare_store.clear(id);
1788 ++done;
1789 continue;
1790 }
1791 // Through the capture window, never last_adc_map: a tare taken from a
1792 // stale code silently biases every reading on the channel for the rest
1793 // of the run, which is the same failure take_capture exists to refuse.
1794 const fsw::calibration::CaptureResult r = take_capture(id, "Tare");
1795 if (!r.ok)
1796 continue;
1797 const uint8_t board_id = static_cast<uint8_t>(id / 100);
1798 const uint8_t connector = static_cast<uint8_t>(id % 100);
1799 // Keyed by the calibrated entity this service publishes, which the backend
1800 // subtracts the offset from: LC<slot>_Cal.CH<n>, or T7<slot>_Cal.AIN<n>.
1801 const fsw::t7::T7CalChannel* t7 = t7_map.by_uid(id);
1802 const std::string entity =
1804 t7->slot, static_cast<uint8_t>(t7->channel.ain))
1806 if (lc_tare_store.set(id, entity, r.adc_avg, lc_eval_for(id))) {
1807 ++done;
1808 const fsw::calibration::LcTare* t = lc_tare_store.tare_for(id);
1809 std::cout << "[Cal] Tare uid=" << static_cast<int>(id) << " "
1810 << capture_detail(r)
1811 << " offset=" << (t != nullptr ? t->offset_kg : 0.0) << "kg"
1812 << std::endl;
1813 }
1814 }
1815 lc_tare_store.save();
1816 std::cout << "[Cal] " << (clearing ? "Tare clear" : "Tare") << ": " << done
1817 << " load cell(s)" << std::endl;
1818 }
1819 }
1820 continue;
1821 }
1822
1823 // ── LabJack T7 raw (t7_bridge) ─────────────────────────────────────────────────────────
1824 // The same model selection, capture window and outputs as the ESP32 PT / LC / TC branches
1825 // below; what differs is only the input: a float in volts, turned into a µV code.
1826 if (type_hi == fsw::t7::kTypeHi) {
1827 const uint8_t offset = type_lo & 0x1F;
1828 if (offset >= 0x10 || pkt_len - 8 < 21)
1829 continue; // >= 0x10 is our own calibrated output
1830 const uint8_t* p = pkt_buf + 8;
1831 uint64_t ts_ns = 0;
1832 float volts_f = 0.0f;
1833 std::memcpy(&ts_ns, p, sizeof(ts_ns));
1834 std::memcpy(&volts_f, p + 12, sizeof(volts_f));
1835 const uint64_t rx_ns = mono_ns();
1836 const uint8_t slot = static_cast<uint8_t>((type_lo >> 5) + 1);
1837 if (!std::isfinite(volts_f))
1838 continue;
1839 if (offset == fsw::t7::kCjcTableOffset) {
1840 g_t7_cold_junction_c[slot] = volts_f; // this table's float is °C
1841 continue;
1842 }
1843 const fsw::t7::T7CalChannel* t = t7_map.by_table(slot, offset);
1844 if (t == nullptr)
1845 continue;
1846 const double volts = volts_f;
1847 const int32_t code = fsw::t7::t7_counts(volts);
1848 const uint16_t uid = t->uid;
1849 const uint8_t ain = static_cast<uint8_t>(t->channel.ain);
1850 const auto out_id = fsw::t7::calibrated_table_id(slot, ain);
1851 auto publish = [&](double value, uint8_t cal_status) {
1853 ts_ns, ain, std::array<uint8_t, 3>{0, 0, 0}, static_cast<float>(value), code,
1854 cal_status);
1855 elodin_client.publish(out_id, msg);
1856 };
1857
1858 elodin_client.begin_batch();
1859 if (t->channel.kind == "PT") {
1860 last_adc_map[uid] = code;
1861 capture_window.push(uid, code, ts_ns, rx_ns);
1862 const bool fac_ok = pt_calibration.is_calibrated(t->logical_ch);
1863 const double psi_fac =
1864 fac_ok ? pt_calibration.calculate_pressure(t->logical_ch, code) : 0.0;
1865 const double psi_rob = robust_manager.predict_pressure_psi(uid, code);
1866 double psi_phys = fsw::t7::pt_psi(volts, t->channel);
1867 if (!std::isfinite(psi_phys))
1868 psi_phys = 0.0;
1869 const bool has_rob = robust_manager.has_sensor(uid);
1870 double psi = select_pt_psi(uid, psi_fac, psi_rob, psi_phys, fac_ok, has_rob);
1871 const PtModel eff_model = g_env_override ? *g_env_override : pt_model_for(uid);
1872 uint8_t cal_status = 1;
1873 if (eff_model == PtModel::Cubic)
1874 cal_status = fac_ok ? 1u : 0u;
1875 else if (eff_model == PtModel::Robust)
1876 cal_status = has_rob ? 1u : 0u;
1877 else if (eff_model == PtModel::Blend)
1878 cal_status = (fac_ok && has_rob) ? 1u : 0u;
1879 psi = g_pt_smoother.step(uid, psi);
1880 psi = std::isfinite(psi) ? std::clamp(psi, -3000.0, 20000.0) : 0.0;
1881 publish(psi, cal_status);
1882 } else if (t->channel.kind == "LC") {
1883 last_adc_map[uid] = code;
1884 capture_window.push(uid, code, ts_ns, rx_ns);
1885 const bool cubic_ok = lc_calibration.is_calibrated(t->logical_ch);
1886 const double kg_cubic =
1887 cubic_ok ? lc_calibration.calculate(t->logical_ch, code) : 0.0;
1888 double kg_phys = fsw::t7::lc_kg(volts, t->channel);
1889 if (!std::isfinite(kg_phys))
1890 kg_phys = 0.0;
1891 double kg = select_lc_kg(uid, kg_cubic, kg_phys, cubic_ok);
1892 const uint8_t cal_status =
1893 lc_model_for(uid) == LcModel::Physics ? 1u : (cubic_ok ? 1u : 0u);
1894 publish(std::isfinite(kg) ? kg : 0.0, cal_status);
1895 } else if (t->channel.kind == "TC") {
1896 const auto cj = g_t7_cold_junction_c.find(slot);
1897 const double temp_c = cj != g_t7_cold_junction_c.end()
1898 ? fsw::t7::tc_c(volts, cj->second)
1899 : std::numeric_limits<double>::quiet_NaN();
1900 if (std::isfinite(temp_c))
1902 } else if (t->channel.kind == "RTD") {
1903 // Physics only, as a T7 TC: the LJTick-Resistance divider and the IEC 60751 curve.
1904 const double temp_c = fsw::t7::rtd_c(volts, t->channel);
1905 if (std::isfinite(temp_c))
1907 }
1908 elodin_client.flush_batch();
1909 packet_count++;
1910 continue;
1911 }
1912
1913 if ((type_hi < 0x20 || type_hi > 0x24) && type_hi != 0x30 &&
1915 continue;
1916 // Within each 32-slot block: raw = offset 0x01-0x0A, cal = offset 0x11-0x1A
1917 // Check if this is a calibrated packet (our own output) by testing if
1918 // the offset within the block is >= 0x10
1919 uint8_t block_offset = type_lo & 0x1F; // position within 32-slot block
1920 if (type_hi != 0x30) {
1921 if (block_offset >= 0x10)
1922 continue; // calibrated packet (our own output)
1923 if (block_offset == 0 || block_offset > 10)
1924 continue; // channel must be 1-10
1925 } else {
1926 // ACT raw 0x30 only: our calibrated republish uses type_hi 0x31, so 0x30 is never our
1927 // echo. Allow 0x0B–0x0F (wire indices 11–15) which the old `> 10` guard dropped;
1928 // decodeLow uses (block_offset & 0x0F) for the logical channel nibble.
1929 if (block_offset == 0 || block_offset > 0x0F)
1930 continue;
1931 }
1932
1933 const ssize_t payload_len = pkt_len - 8;
1934 if (payload_len < 21) {
1935 if (debug_limit < 20) {
1936 std::cout << "[Cal] Dropped small payload: " << payload_len << std::endl;
1937 debug_limit++;
1938 }
1939 continue;
1940 }
1941
1942 // Parse 21-byte raw sensor payload directly (ADS1262 etc. use signed 32-bit codes at +12)
1943 const uint8_t* p = pkt_buf + 8;
1944 const uint64_t ts_ns = *reinterpret_cast<const uint64_t*>(p);
1945 // Arrival on our own monotonic clock, read once per packet: ts_ns says how samples
1946 // are SPACED, rx_ns says whether the sensor is still talking to us.
1947 const uint64_t rx_ns = mono_ns();
1948 const uint8_t ch_payload = p[8];
1949 const uint8_t ch_eff = block_offset;
1950 if (ch_payload != ch_eff && type_hi == 0x20) {
1951 static int ch_mismatch_warn = 0;
1952 if (ch_mismatch_warn < 8) {
1953 std::cerr << "[Cal] WARN: PT payload ch=" << static_cast<int>(ch_payload)
1954 << " != packet-id ch=" << static_cast<int>(ch_eff) << " [0x" << std::hex
1955 << static_cast<int>(type_hi) << "," << static_cast<int>(type_lo)
1956 << std::dec << "]" << std::endl;
1957 ch_mismatch_warn++;
1958 }
1959 }
1960 const int32_t adc_i32 = *reinterpret_cast<const int32_t*>(p + 12);
1961 const uint32_t adc_u32 = *reinterpret_cast<const uint32_t*>(p + 12);
1962 // p[16-19] = sample_timestamp_ms (unused in calibration output)
1963 // p[20] = status_flags (unused in calibration output)
1964
1965 // Board-namespaced: raw low byte = (board_number-1)*0x20 + channel
1966 // Calibrated = raw_lo + 0x10 (within same 32-slot block)
1967 uint8_t cal_lo = static_cast<uint8_t>(type_lo + 0x10);
1968 uint8_t board_number = static_cast<uint8_t>((type_lo >> 5) + 1);
1969
1970 elodin_client.begin_batch();
1971
1972 if (type_hi == 0x20) { // PT raw — unified: every PT runs through model selection.
1973 const fsw::config::PtBoardConfig* loop_board = current_loop_board(board_number);
1974 const bool is_loop = loop_board != nullptr;
1975 const uint16_t uid = resolve_pt_sensor_uid(type_lo, ch_eff, pt_boards);
1976
1977 // The ADC cubic/robust and captures work in: for a 4-20 mA board, the shunt code with a
1978 // negative reading (an open loop's noise below 0 V) taken as no current -- never as
1979 // full scale (see HpPtCode.hpp); else the raw i32.
1980 const uint32_t hp_wire_adc = is_loop ? fsw::calibration::hp_pt_shunt_code(adc_u32) : 0u;
1981 const int32_t cal_adc = is_loop ? static_cast<int32_t>(hp_wire_adc) : adc_i32;
1982
1983 last_adc_map[uid] = cal_adc;
1984 capture_window.push(uid, cal_adc, ts_ns, rx_ns);
1985
1986 const uint8_t pt_log_ch =
1988 const bool fac_ok = pt_calibration.is_calibrated(pt_log_ch);
1989 const double psi_fac =
1990 fac_ok ? pt_calibration.calculate_pressure(pt_log_ch, cal_adc) : 0.0;
1991 const double psi_rob = robust_manager.predict_pressure_psi(uid, cal_adc);
1992 const double psi_phys =
1993 is_loop
1994 ? convert_hp_pt_to_pressure(ch_eff, hp_wire_adc, *loop_board,
1996 g_hp_ma_live[board_number])
1998 const bool has_rob = robust_manager.has_sensor(uid);
1999
2000 double psi = select_pt_psi(uid, psi_fac, psi_rob, psi_phys, fac_ok, has_rob);
2001
2002 // cal_status: 1 when the selected model's own source is ready (physics always); 0 means
2003 // it fell back to physics because the chosen cubic/robust model isn't calibrated yet.
2004 const PtModel eff_model = g_env_override ? *g_env_override : pt_model_for(uid);
2005 uint8_t cal_status = 1;
2006 if (eff_model == PtModel::Cubic)
2007 cal_status = fac_ok ? 1u : 0u;
2008 else if (eff_model == PtModel::Robust)
2009 cal_status = has_rob ? 1u : 0u;
2010 else if (eff_model == PtModel::Blend)
2011 cal_status = (fac_ok && has_rob) ? 1u : 0u;
2012
2013 // EMA smoothing (zeroing is now a captured 0 psi point that flows through the fit
2014 // above, not a post-hoc offset).
2015 psi = g_pt_smoother.step(uid, psi);
2016 if (verbose() && packet_count % 100 == 0)
2017 std::cout << "[Cal] PT B" << static_cast<int>(board_number) << " ch"
2018 << static_cast<int>(ch_eff) << (is_loop ? " (4-20mA)" : "")
2019 << " adc=" << cal_adc << " psi=" << psi << std::endl;
2020
2021 if (!std::isfinite(psi))
2022 psi = 0.0;
2023 else if (is_loop)
2024 psi = std::clamp(psi, 0.0, pt_full_scale_for(uid) * 1.2);
2025 else
2026 psi = std::clamp(psi, -3000.0, 20000.0);
2027
2029 ts_ns, ch_eff, std::array<uint8_t, 3>{0, 0, 0}, static_cast<float>(psi),
2030 is_loop ? adc_u32 : static_cast<uint32_t>(adc_i32), cal_status);
2031 elodin_client.publish(static_cast<uint16_t>((type_hi << 8) | cal_lo), cal_msg);
2032
2033 } else if (type_hi == 0x21) { // TC raw
2034 double temp_c;
2035 uint8_t cal_status;
2036 if (tc_calibration.is_calibrated(ch_eff)) {
2037 temp_c = tc_calibration.calculate(ch_eff, adc_i32);
2038 cal_status = 1;
2039 } else {
2040 temp_c = convert_tc_adc_to_temp_c(adc_i32, tc_adc_ref_voltage);
2041 cal_status = 0;
2042 }
2043 temp_c = clamp_tc_rtd_temp_publish_c(temp_c);
2045 ts_ns, ch_eff, std::array<uint8_t, 3>{0, 0, 0}, static_cast<float>(temp_c),
2046 static_cast<uint32_t>(adc_i32), cal_status);
2047 elodin_client.publish(static_cast<uint16_t>((type_hi << 8) | cal_lo), cal_msg);
2048
2049 } else if (type_hi == 0x22) { // RTD raw
2050 double temp_c;
2051 uint8_t cal_status;
2052 if (rtd_calibration.is_calibrated(ch_eff)) {
2053 temp_c = rtd_calibration.calculate(ch_eff, adc_i32);
2054 cal_status = 1;
2055 } else {
2056 temp_c = convert_rtd_adc_to_temp_c(adc_i32, rtd_adc_ref_voltage, rtd_excitation_ua,
2057 rtd_r0_ohm);
2058 cal_status = 0;
2059 }
2060 // An open or shorted element has no temperature. It used to publish one anyway: about
2061 // +395 C (open, ADC at the rail) or -242 C (short). Now the channel goes quiet and the
2062 // GUI shows it stale.
2063 if (std::isfinite(temp_c)) {
2064 temp_c = clamp_tc_rtd_temp_publish_c(temp_c);
2066 ts_ns, ch_eff, std::array<uint8_t, 3>{0, 0, 0}, static_cast<float>(temp_c),
2067 static_cast<uint32_t>(adc_i32), cal_status);
2068 elodin_client.publish(static_cast<uint16_t>((type_hi << 8) | cal_lo), cal_msg);
2069 }
2070
2071 } else if (type_hi == 0x23) { // LC raw — unified: every LC runs through model selection
2072 // (cubic operator fit, or the datasheet physics formula).
2073 const uint16_t uid = resolve_lc_sensor_uid(type_lo, ch_eff, lc_boards);
2074
2075 last_adc_map[uid] = adc_i32;
2076 capture_window.push(uid, adc_i32, ts_ns, rx_ns);
2077
2078 const uint8_t lc_log_ch =
2080 const bool cubic_ok = lc_calibration.is_calibrated(lc_log_ch);
2081 const double kg_cubic = cubic_ok ? lc_calibration.calculate(lc_log_ch, adc_i32) : 0.0;
2082 const double kg_phys = convert_lc_adc_to_force(
2083 adc_i32, lc_sensitivity_for(uid, lc_sensitivity_mv_per_v),
2084 lc_pga_gain_for(uid, lc_pga_gain), lc_full_scale_for(uid, lc_full_scale_value));
2085
2086 double force_kg = select_lc_kg(uid, kg_cubic, kg_phys, cubic_ok);
2087 const uint8_t cal_status =
2088 lc_streams_physics(uid, cubic_ok) ? 1u : (cubic_ok ? 1u : 0u);
2089 if (!std::isfinite(force_kg))
2090 force_kg = 0.0;
2091
2093 ts_ns, ch_eff, std::array<uint8_t, 3>{0, 0, 0}, static_cast<float>(force_kg),
2094 static_cast<uint32_t>(adc_i32), cal_status);
2095 elodin_client.publish(static_cast<uint16_t>((type_hi << 8) | cal_lo), cal_msg);
2096
2097 } else if (type_hi == 0x30) { // Actuator raw current-sense (12-bit ADC)
2098 double current_a = convert_act_adc_to_current(adc_u32);
2099 uint8_t cal_status = 1;
2100 // ACT calibrated VTable registered under 0x31 (to avoid collision with raw 0x30)
2102 ts_ns, ch_eff, std::array<uint8_t, 3>{0, 0, 0}, static_cast<float>(current_a),
2103 adc_u32, cal_status);
2104 elodin_client.publish(static_cast<uint16_t>((0x31 << 8) | cal_lo), cal_msg);
2105 } else if (type_hi == fsw::elodin::kBallValveRawHi) { // Ball valve position (4-20 mA)
2106 const auto pos = fsw::calibration::valve_position::from_counts(adc_u32);
2108 ts_ns, ch_eff, std::array<uint8_t, 3>{0, 0, 0}, pos.percent_open, adc_u32,
2109 pos.status);
2110 elodin_client.publish(
2111 static_cast<uint16_t>((fsw::elodin::kBallValveCalHi << 8) | cal_lo), cal_msg);
2112 }
2113
2114 elodin_client.flush_batch();
2115
2116 packet_count++;
2117 if (packet_count % 10000 == 0)
2118 std::cout << "[Cal] Processed " << packet_count << " raw packets (type=0x" << std::hex
2119 << static_cast<int>(type_hi) << " ch=" << static_cast<int>(ch_eff) << std::dec
2120 << ")" << std::endl;
2121
2122 // Periodic auto-save every 5 minutes
2123 auto now = std::chrono::steady_clock::now();
2124 if (std::chrono::duration_cast<std::chrono::seconds>(now - last_save).count() > 300) {
2125 robust_manager.save_adjustments(adjustments_path, &uid_role);
2126 last_save = now;
2127 }
2128 }
2129
2130 // Persist on shutdown so a clean SIGINT/SIGTERM doesn't drop up to ~5 min of learning since the
2131 // last periodic auto-save. (The cubic store already saves per capture; this covers robust θ.)
2132 if (robust_manager.save_adjustments(adjustments_path, &uid_role))
2133 std::cout << "[Cal] Saved robust adjustments on shutdown → " << adjustments_path
2134 << std::endl;
2135 cubic_store.save();
2136
2137 std::cout << "[Cal] Stopped." << std::endl;
2138 return 0;
2139}
The ADC a calibration capture records: a bounded, recent, checked mean.
static uint16_t resolve_pt_sensor_uid(uint8_t type_lo, uint8_t ch, const std::vector< fsw::elodin::BoardChannels > &pt_boards)
Map Elodin PT raw packet low byte + connector ch → robust calibration uid (board_id*100+ch).
static PtModel pt_model_for(uint16_t uid)
Per-sensor streaming model; sensors absent from the map default to factory cubic.
static double pt_sense_resistor_for(uint16_t uid)
Per-sensor 4-20 mA shunt resistance (Ω); default 120 when unset.
static bool verbose()
static double convert_lc_adc_to_force(int32_t adc_raw, double sensitivity_mv_per_v, double pga_gain, double full_scale_value)
Ratiometric load cell: raw ADC → force (kg).
static double select_pt_psi(uint16_t uid, double psi_fac, double psi_rob, double psi_phys, bool fac_ok, bool has_robust)
The single PT source-selection rule, shared by the streaming path and the Zero-All helper so both agr...
constexpr double kRatiometricPtZeroFrac
Ratiometric PT, 0.5-4.5 V at 5 V excitation: excitation is the ADC reference, so the output is 10 % o...
static LcModel parse_lc_model(const std::string &s)
Parse a config model string for an LC role: "linear" and "cubic" map to their fits,...
static double lc_full_scale_for(uint16_t uid, double fallback)
Per-sensor LC physics full-scale kg; falls back to the [calibration.lc] global when unset.
static double lc_sensitivity_for(uint16_t uid, double fallback)
Per-sensor LC sensitivity mV/V; falls back to the [calibration.lc] global when unset.
static double convert_ratiometric_pt_to_pressure(int32_t adc_raw, double full_scale_psi)
static double convert_hp_pt_to_pressure(uint8_t local_ch, uint32_t adc_raw, const fsw::config::PtBoardConfig &board, double full_scale_psi, double sense_resistor_ohms, std::array< uint8_t, 11 > &live_state)
4-20 mA HP PT → PSI.
static double pt_full_scale_for(uint16_t uid)
Per-sensor physics full-scale PSI; default 1000 (0-5 V -> 0-1000 PSI) when unset.
static LcModel lc_model_for(uint16_t uid)
Per-sensor LC streaming model; a uid absent from g_lc_model defaults to Linear, which streams physics...
static const char * lc_model_name(LcModel m)
Config-string form of an LC model, for the cubic store's per-uid active_model tag.
static uint64_t mono_ns()
constexpr double kRatiometricPtSpanFrac
static bool lc_streams_physics(uint16_t uid, bool fit_ok)
True when this LC's published kilograms are its datasheet physics conversion: physics itself,...
static uint16_t resolve_lc_sensor_uid(uint8_t type_lo, uint8_t ch, const std::vector< fsw::elodin::BoardChannels > &lc_boards)
Map Elodin LC raw packet low byte + connector ch -> LC uid (board_id*100+ch).
static double unix_now()
Monotonic now, in ns.
static double select_lc_kg(uint16_t uid, double kg_fit, double kg_phys, bool fit_ok)
The LC source-selection rule, mirroring select_pt_psi minus robust/blend (LC doesn't learn drift).
static double lc_pga_gain_for(uint16_t uid, double fallback)
Per-sensor LC PGA gain; falls back to the [calibration.lc] global when unset.
static double clamp_tc_rtd_temp_publish_c(double t_c)
TC/RTD calibrated temperature before Elodin publish.
static double convert_rtd_adc_to_temp_c(int32_t adc_raw, double adc_ref_voltage, double excitation_ua, double r0_ohm)
Pt1000 RTD: raw ADC → voltage → resistance → temperature (°C), on the full IEC 60751 curve (rtd::rtd_...
static std::optional< int32_t > invert_abort_psi_to_adc(uint16_t uid, uint8_t pt_log_ch, bool is_loop, const fsw::config::PtBoardConfig *loop_board, double target_psi, bool force_physics, const fsw::calibration::PTCalibrationManager &pt_calibration, fsw::calibration::RobustCalibrationManager &robust_manager)
Invert an abort-procedure pressure (the level the boards vent DOWN below before completing an autonom...
static bool env_flag_true(const char *name)
static PtModel parse_pt_model(const std::string &s)
Parse a config model string to the enum; "cubic" and anything unrecognized map to Cubic.
static double convert_tc_adc_to_temp_c(int32_t adc_raw, double adc_ref_voltage)
K-type thermocouple: raw ADC → voltage → temperature (°C).
static const char * pt_model_name(PtModel m)
Config-string form of a model, for the cubic store's per-uid active_model tag.
constexpr int kMinAbortCalPoints
A sensor needs at least this many captured points before its cubic/robust curve is trusted to invert ...
static void signalHandler(int)
static double convert_act_adc_to_current(uint32_t adc_raw)
Actuator current-sense: 12-bit ADC → current (amps).
static std::atomic< bool > running
Per-sensor ring of recent samples, and the capture decision over it.
CaptureResult capture(uint16_t uid, uint64_t now_rx_ns) const
What this sensor reads now, or why that cannot be answered.
void clear()
Forget everything.
void push(uint16_t uid, int32_t adc, uint64_t ts_ns, uint64_t rx_ns)
Record one sample.
std::vector< uint16_t > uids() const
Every uid with at least one sample.
Drops a CalibrationCommand the service has already handled.
bool seen(const uint8_t *cmd)
true if this exact command was already seen (drop it); otherwise records it.
Owns the per-channel captured points and the fitted cubic for the calibration service.
void note_capture(uint16_t uid, const CaptureQuality &q)
Record how the last capture on this uid went, for the UI to show.
size_t load()
Load the JSON record, resume points, and re-fit each channel.
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).
bool load_failed() const
True when load() could not read an existing file, so every curve here is a fallback rather than the o...
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).
Owns the per-channel tares for the calibration service.
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.
std::vector< uint16_t > uids() const
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 ...
void set_calibration(uint8_t channel_id, const PTCalibrationCoeffs &coeffs)
Set/replace the cubic coefficients for a logical channel at runtime.
bool is_calibrated(uint8_t channel_id) const
Check if a channel has calibration.
double calculate_pressure(uint8_t channel_id, int32_t adc_code) const
Calculate calibrated pressure for a channel.
void clear_calibration(uint8_t channel_id)
Remove any cubic for a logical channel, so is_calibrated() returns false again.
static void set_default_paths(const std::string &json_dir, const std::string &csv_path)
Set default calibration file paths.
double step(uint16_t uid, double value)
Feed one reading; returns the smoothed value.
Runtime PT calibration via RobustCalibrationFramework (9-D basis, TLS / Bayesian / RLS / GLR / bias),...
void update_calibration(uint16_t sensor_id, int32_t adc_code, double reference_pressure)
bool load_adjustments(const std::string &path, const std::map< uint16_t, std::string > *uid_to_role=nullptr)
Restore per-sensor θ.
bool save_adjustments(const std::string &path, const std::map< uint16_t, std::string > *uid_to_role=nullptr) const
Persist per-sensor θ.
void reseed_sensor(uint16_t sensor_id, const PTCalibrationCoeffs &baseline)
Reseed a sensor's framework cleanly from the given cubic baseline, discarding any learned adjustments...
double predict_pressure_psi(uint16_t sensor_id, int32_t adc_code)
Mean PSI from the robust model (default environment).
bool has_sensor(uint16_t sensor_id) const
True if this sensor_id was initialized for robust tracking (factory-baselined).
double calculate(uint8_t channel_id, int32_t raw_counts) const
bool load_calibration(const std::string &json_dir, const std::string &csv_path)
Auto-discover & load from default directories.
void set_calibration(uint8_t channel_id, const PolynomialCalibration &cal)
Manually set coefficients for a channel.
bool is_calibrated(uint8_t channel_id) const
void clear_calibration(uint8_t channel_id)
Remove a channel's calibration so is_calibrated() returns false again.
static bool register_ball_valve_calibrated_tables(ElodinClient &client, const std::vector< BoardChannels > &bv_boards)
Register ball valve CALIBRATED position VTables [0x34] (calibration_service).
static bool register_calibrated_tables(ElodinClient &client, const std::vector< BoardChannels > &pt_boards, const std::vector< BoardChannels > &tc_boards, const std::vector< BoardChannels > &rtd_boards, const std::vector< BoardChannels > &lc_boards, const std::vector< BoardChannels > &enc_boards, const std::vector< BoardChannels > &act_boards)
Register CALIBRATED VTables.
static bool register_t7_calibrated_tables(ElodinClient &client, uint8_t slot, const std::vector< T7Table > &tables)
Standalone Elodin database client.
bool flush_batch()
Flush (send) the accumulated batch in a single TCP write.
bool is_connected() const
Check if connected.
void set_recv_timeout_ms(int timeout_ms)
Set socket receive timeout so read_packet() can yield.
bool publish(uint16_t message_id, const MessageType &message)
Publish a message to a table.
void begin_batch()
Begin batching — subsequent publish() calls go into an internal buffer.
bool connect(const std::string &host, uint16_t port=2240)
Connect to Elodin database.
ssize_t read_packet(uint8_t *packet_buffer, size_t max_len)
Read complete packet from Elodin (header + payload).
bool subscribe_table(uint8_t hi, uint8_t lo, uint8_t request_id)
One table, under request_id, which the DB echoes if it refuses it (ErrorResponse [0xE0,...
bool reconnect()
Attempt to reconnect using the last-used host/port.
void reset()
A new connection: every subscription is sent afresh.
Table rejected(uint8_t request_id, Clock::time_point now)
The DB refused the subscription sent under request_id.
std::vector< Table > due(Clock::time_point now)
Refused tables whose wait is over; each is forgotten here until rejected again.
uint8_t assign(Table table)
The request id to subscribe table under.
Every PT / LC / TC input on every enabled T7, from config.
const T7CalChannel * by_uid(uint16_t uid) const
const std::vector< T7CalChannel > & all() const
std::vector< std::pair< uint8_t, uint8_t > > raw_tables() const
Raw tables calibration_service subscribes to: every input above, plus each TC slot's CJC.
const T7CalChannel * by_table(uint8_t slot, uint8_t ain) const
The input behind raw table {0x26, (slot-1)*0x20 + ain}, or nullptr.
static uint8_t board_id
Definition main.cpp:30
static uint8_t pkt_buf[64]
Definition main.cpp:28
static STAR_MCP3201 adc(MCP3201_CS_PIN, &mcp_spi)
long target
Definition main.cpp:38
static bool ok
Definition flight_e2e.c:435
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
size_type count(const key_type &key) const
Definition json.hpp:19264
return o
Definition json.hpp:13897
PtModel
Which model a PT streams: operator/factory cubic, the robust learner, the 75/25 blend,...
std::unordered_map< uint16_t, double > g_lc_pga_gain
std::unordered_map< uint16_t, double > g_pt_full_scale
Per-sensor physics-mode parameters (from [calibration_full_scale_*] / [calibration_sense_resistor_*],...
std::unordered_map< uint16_t, double > g_lc_full_scale
Per-sensor LC physics-mode parameters (from [calibration_full_scale_*] / [calibration_sensitivity_*] ...
std::unordered_map< uint16_t, double > g_pt_sense_resistor
std::unordered_map< uint16_t, LcModel > g_lc_model
uid -> LC streaming model.
std::set< uint8_t > g_lc_board_ids
Board ids of every LC board, so a connector outside active_connectors still routes as LC.
fsw::calibration::PtSmoother g_pt_smoother
Per-sensor PT output smoothing (ESP32 and T7), seeded with each sensor's first reading.
std::set< uint16_t > g_lc_uids
Every LC uid, populated at startup — the discriminant apply_capture/apply_clear/ reload_live_store us...
std::optional< PtModel > g_env_override
Set once at startup: when a CAL_USE_* PT env var is present it forces every sensor,...
LcModel
Which model an LC streams: a straight-line fit through the operator's captured points,...
std::map< uint8_t, std::array< uint8_t, 11 > > g_hp_ma_live
Hysteresis: 0 = treat as below loop, 1 = in 4–20 mA band (stops 3.99↔4.01 PSI chatter).
std::unordered_map< uint16_t, double > g_lc_sensitivity
std::unordered_map< uint16_t, std::string > g_uid_role
uid -> role name (from [sensor_roles_*]).
std::map< uint8_t, double > g_t7_cold_junction_c
Latest thermocouple cold-junction temperature (°C) per T7 slot, from t7_bridge's CJC table {0x26,...
std::unordered_map< uint16_t, PtModel > g_pt_model
uid (board_id*100 + connector) -> streaming model.
CommsMessage< uint64_t, uint8_t, std::array< uint8_t, 3 >, float, uint32_t, uint8_t > CalibratedRTDMessage
Calibrated RTD message for Elodin.
CommsMessage< uint64_t, uint8_t, std::array< uint8_t, 3 >, float, uint32_t, uint8_t > CalibratedLCMessage
Calibrated Load Cell message for Elodin.
CommsMessage< uint64_t, uint8_t, std::array< uint8_t, 3 >, float, uint32_t, uint8_t > CalibratedTCMessage
Calibrated Thermocouple message for Elodin.
CommsMessage< uint64_t, uint8_t, std::array< uint8_t, 3 >, float, uint32_t, uint8_t > CalibratedACTMessage
Calibrated Actuator current-sense message for Elodin.
CommsMessage< uint64_t, uint8_t, std::array< uint8_t, 3 >, float, uint32_t, uint8_t > CalibratedPTMessage
Calibrated Pressure Transducer message for Elodin.
CommsMessage< uint64_t, uint8_t, std::array< uint8_t, 3 >, float, int32_t, uint8_t > T7CalibratedMessage
One T7 channel in engineering units, published by t7_bridge beside T7RawMessage.
CommsMessage< uint64_t, uint8_t, std::array< uint8_t, 3 >, float, uint32_t, uint8_t > CalibratedValvePositionMessage
Calibrated ball valve position message for Elodin.
double rtd_board_adc_to_temp_c(int32_t adc_raw, double adc_ref_voltage, double excitation_ua, double r0_ohms)
An ESP32 RTD board's reading -> °C: the ADC code (2^31 = adc_ref_voltage) is the voltage across the e...
Reading from_counts(uint32_t counts)
CalTarget cal_target(const CubicChannel &ch)
CalTarget
Which live calibration manager a store entry's fit belongs in — the one rule every caller that applie...
uint32_t hp_pt_shunt_code(uint32_t wire)
A 4-20 mA PT's wire code -> the shunt code the conversions and calibrations work in.
Definition HpPtCode.hpp:23
constexpr const char * kRawUnitMicrovolts
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 ...
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...
double type_k_c_from_mv(double mv)
Temperature (C) for a type K EMF in mV, referenced to 0 C.
Definition TypeK.cpp:9
std::map< ActiveBoardKind, std::vector< elodin::BoardChannels > > load_active_boards(const std::string &config_path)
Parse [boards.
std::map< uint8_t, PtBoardConfig > load_pt_boards(const std::string &config_path)
Parse the PT boards' sensor interface and 4-20 mA conversion parameters, keyed by Elodin slot.
bool is_current_loop(const PtBoardConfig &b)
True when this board's channels convert through the 4-20 mA current-loop path.
ActiveBoardKind
Board kinds from [boards.
Config load(const std::string &path)
Parse config.toml.
Definition Config.cpp:442
constexpr std::pair< uint8_t, uint8_t > kTableCalibrationCommand
constexpr uint8_t kBallValveCalHi
bool is_error_response(const uint8_t *pkt, size_t len)
ErrorResponse: header len(4) | ty(1) | msg id [0xE0, 0x1D] | request id(1).
std::vector< std::pair< uint8_t, uint8_t > > raw_sensor_tables(const std::vector< BoardChannels > &pt_boards, const std::vector< BoardChannels > &act_boards, const std::vector< BoardChannels > &tc_boards, const std::vector< BoardChannels > &rtd_boards, const std::vector< BoardChannels > &lc_boards, const std::vector< BoardChannels > &enc_boards)
Elodin table ids (hi, lo) for the RAW sensor VTables the given boards publish.
constexpr uint8_t kBallValveRawHi
Ball valve boards (BALL_VALVE) have their own type_hi bytes, so a valve never shares a key with an AC...
std::vector< std::pair< uint8_t, uint8_t > > ball_valve_raw_tables(const std::vector< BoardChannels > &bv_boards)
constexpr uint8_t kCjcTableOffset
Raw-table offset (within a slot's 32-entry block) of the cold-junction table: AIN15 does not exist on...
double lc_kg(double volts, const config::T7Channel &ch)
LC: a bridge's output is sensitivity x excitation at rated load.
Definition T7Convert.cpp:46
constexpr uint8_t kTypeHi
Elodin table-id high byte for T7 analog inputs (0x20-0x25 are the ESP32 sensor types).
Definition T7Stream.hpp:27
int32_t t7_counts(double volts)
The calibration "ADC code" for a T7 reading: microvolts, rounded and clamped to int32.
std::array< uint8_t, 2 > calibrated_table_id(uint8_t slot, uint8_t ain)
Calibrated table for one AIN: table_id + 0x10, e.g.
Definition T7Stream.cpp:30
const char * calibrated_field(const std::string &kind)
The Elodin field a calibrated channel of this kind publishes ("pressure_psi", ...),...
Definition T7Convert.cpp:70
double t7_volts(double counts)
Volts from a calibration code.
double tc_c(double volts, double cold_junction_c)
Type K thermocouple: the measured EMF plus the EMF the cold junction subtracts.
Definition T7Convert.cpp:53
std::string calibrated_entity_name(uint8_t slot, uint8_t ain)
Calibrated entity name, e.g.
Definition T7Stream.cpp:34
double pt_psi(double volts, const config::T7Channel &ch)
PT: linear between the signal's zero and full-scale outputs.
Definition T7Convert.cpp:25
double rtd_c(double volts, const config::T7Channel &ch)
RTD: ohms -> C on the IEC 60751 curve (full Callendar-Van Dusen, C term below 0 C).
Definition T7Convert.cpp:64
*brief user defined string literal for JSON values *sa std::size_t n
Definition json.hpp:24474
static void publish(self_test_t *t, uint32_t now_ms)
Definition self_test.c:216
Per-board channel info for board-namespaced entity registration.
How trustworthy the most recent capture on this channel was.
double drift_z
drift in standard errors
uint64_t window_ms
the window this sensor's rate called for
bool ok
false → the caller must NOT create a point
double drift
|mean(2nd half) - mean(1st half)|
double spread
max - min inside the window
size_t n
samples inside the window
bool settled
false → recorded, but flagged
Per-sensor calibration record: identity, captured points, and the current fit.
bool live
True when the RUNNING config declares a board/connector that owns this uid, i.e.
Result of a least-squares polynomial fit over a channel's captured points.
One load cell's tare: the scale reads zero at the load it was holding when the operator pressed Tare.
PT Calibration Coefficients.
The whole parsed config.toml, restricted to what C++ consumes.
Definition Config.hpp:264
const std::map< std::string, double > * sense_resistor_for(const std::string &section_key) const
[calibration_sense_resistor_<board>] role->Ω map, or nullptr when absent/empty.
Definition Config.cpp:420
std::vector< BoardConfig > boards
Definition Config.hpp:272
std::vector< T7Board > t7_boards
Definition Config.hpp:276
const std::map< std::string, double > * full_scale_for(const std::string &section_key) const
[calibration_full_scale_<board>] role->PSI map, or nullptr when absent/empty.
Definition Config.cpp:413
std::vector< std::string > t7_errors
Definition Config.hpp:277
CalibrationConfig calibration
Definition Config.hpp:284
const std::map< std::string, std::string > * calibration_model_for(const std::string &section_key) const
[calibration_model_<board>] role->model map, or nullptr when absent/empty.
Definition Config.cpp:405
std::map< std::string, double > abort_pts
Definition Config.hpp:289
const std::map< std::string, int > * sensor_roles_for(const std::string &board_key) const
[sensor_roles_<board>] with a legacy [sensor_roles] fallback.
Definition Config.cpp:395
const std::map< std::string, double > * sensitivity_for(const std::string &section_key) const
[calibration_sensitivity_<board>] role->mV/V map, or nullptr when absent/empty.
Definition Config.cpp:428
const std::map< std::string, double > * pga_gain_for(const std::string &section_key) const
[calibration_pga_<board>] role->gain map, or nullptr when absent/empty.
Definition Config.cpp:435
Per-board 4-20 mA conversion parameters, keyed by Elodin slot (board_number).
Per-board channel info for board-namespaced entity registration.
One T7 input as calibration sees it.
int main()
static boot_report_t r
std::string board(const std::string &name, const std::string &id, bool enabled=true)
static double volts(uint32_t code)
Shunt volts for a code, as calibration_main's convert_hp_pt_to_pressure computes it.
static SlugTables tables()
The tables every case validates against.
static std::string ch(int ain, const std::string &kind, const std::string &extra="", const std::string &board="lj")
Definition test_t7.cpp:98
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