STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
CubicCalibrationStore.hpp
Go to the documentation of this file.
1#ifndef CUBIC_CALIBRATION_STORE_HPP
2#define CUBIC_CALIBRATION_STORE_HPP
3
4#include <cstdint>
5#include <map>
6#include <mutex>
7#include <string>
8#include <utility>
9#include <vector>
10
11namespace fsw {
12namespace calibration {
13
14/** One operator-captured calibration point: raw ADC code paired with a known reference PSI. */
15struct CubicPoint {
16 double adc = 0.0;
17 double psi = 0.0;
18 double t = 0.0; // capture time (unix seconds), display/audit only
19};
20
21/**
22 * Result of a least-squares polynomial fit over a channel's captured points.
23 * Two equivalent representations are kept:
24 * - raw cubic psi = A*adc^3 + B*adc^2 + C*adc + D (consumed by PTCalibrationCoeffs, raw int32)
25 * - normalized psi = sum poly[i] * ((adc - norm_min)/norm_scale)^i (well-conditioned; TS side)
26 * The normalized form is what the frontend evaluates and what passes the backend's coefficient
27 * validator (which rejects |A| > 1e-11 on a raw-ADC cubic).
28 */
29struct CubicFit {
30 bool valid = false;
31 double A = 0.0, B = 0.0, C = 0.0, D = 0.0;
32 std::vector<double> poly; // normalized coeffs, length = degree + 1
33 double norm_min = 0.0;
34 double norm_scale = 1.0;
35 double rmse = 0.0;
36 int degree = 0;
37};
38
39/**
40 * Which family of sensor a channel belongs to.
41 *
42 * logical_ch is `(slot - 1) * 10 + connector` with `slot = board_id % 10`, and
43 * check_board_slots only prevents two boards of the SAME kind sharing a slot. So PT board
44 * 22 and LC board 42 are both slot 2, and PT uid 2201 and LC uid 4201 both land on logical
45 * channel 11. The logical-channel maps below are written from a uid-ordered map, so the
46 * higher uid silently won: on 2026-09-13 a load cell's curve overwrote a 5000 psi
47 * transducer's on disk. The kind namespaces those maps so the two cannot collide.
48 */
49enum class SensorKind { PT, LC };
50
51/** CubicChannel::raw_unit values. */
52inline constexpr const char* kRawUnitAdc = "adc";
53inline constexpr const char* kRawUnitMicrovolts = "uV";
54
55/**
56 * How trustworthy the most recent capture on this channel was.
57 *
58 * A capture is a mean over a short window (CaptureWindow). If the reading was still moving
59 * inside that window — the operator pressed Calibrate while the load was settling — the mean
60 * is between two values and belongs to neither. The point is still recorded, because
61 * discarding an operator's action whose only feedback is a counter that fails to increment is
62 * worse than recording it visibly; `settled` is how the GUI marks it.
63 */
65 bool valid = false; // false = no capture recorded since this channel was loaded
66 double t = 0.0; // unix seconds
67 double adc = 0.0;
68 size_t n = 0;
69 uint64_t window_ms = 0;
70 double spread = 0.0;
71 double drift = 0.0;
72 double drift_z = 0.0;
73 bool settled = true;
74};
75
76/** Per-sensor calibration record: identity, captured points, and the current fit. */
78 uint16_t uid = 0; // board_id*100 + connector (UI key)
79 uint8_t board_id = 0; // uid / 100
80 uint8_t connector = 0; // uid % 100 (1..10)
81 uint8_t logical_ch =
82 0; // pt_logical_calibration_channel(slot, connector) — PTCalibrationManager key
83 std::string role; // human role from config, e.g. "Fuel Upstream"
84 // Which model this uid streams (from config): "cubic" (PT default) | "robust" | "linear" (a
85 // load cell's default) | "physics". Decides capture routing at the service and which display
86 // the frontend renders. For robust uids the cubic `fit` is left invalid (points are
87 // display-only) and `fit_curve` holds the sampled model.
88 std::string active_model = "cubic";
89 // Which logical-channel namespace this channel's fit is serialized into. PT by default so
90 // a legacy file, and any caller that does not say, keeps its historical meaning.
92 /**
93 * True when the RUNNING config declares a board/connector that owns this uid, i.e.
94 * register_channel() was called for it. False for an ORPHAN: an entry load() resumed from
95 * disk whose board is disabled, removed, or whose connector is not in active_connectors.
96 *
97 * An orphan's points stay here — durable, and they re-attach by role when the board comes
98 * back — but its `logical_ch` is NOT its own. logical_ch is (slot - 1) * 10 + connector with
99 * slot = board_id % 10, and nothing stops two boards of different kinds sharing a slot, so a
100 * disabled LC board 41's connectors 1/6/7 name the same logical channels as live PT board
101 * 21's. Anything that pushes a fit into (or clears it from) a live logical-channel map must
102 * therefore ask `live` first — see cal_target() below.
103 */
104 bool live = false;
105 /**
106 * What a point's `adc` means: kRawUnitAdc for an ESP32 board's ADC code, kRawUnitMicrovolts for
107 * a LabJack T7 channel's reading in µV. Calibration is filed by role, so a role that moves from
108 * an ESP32 board to a T7 (or back) would otherwise carry a curve fitted in one unit onto a
109 * channel that reads in the other — a confident, wrong pressure. load() refuses that re-attach.
110 */
111 std::string raw_unit = "adc";
112 std::vector<CubicPoint> points;
113 // The fit this uid streams through: `fit_linear` when active_model is "linear", else
114 // `fit_cubic` (so cubic, and physics/blend as before, are unchanged). It is what the
115 // logical-channel maps carry, so the service, the tare and every reader see one curve.
117 // Both fits over the same points, kept so the calibration page can draw linear beside cubic
118 // without the browser fitting anything. fit_linear needs >= 2 points: a one-point "line" is a
119 // constant, which would read the same kilograms for any load.
122 // Robust display overlay: (adc, psi) samples of the live robust model (predict_pressure_psi),
123 // set by the service on each robust capture. Empty for cubic uids.
124 std::vector<std::pair<double, double>> fit_curve;
126 std::string status = "PENDING"; // PENDING (<2 pts) | OK | ERROR
127 std::string last_error;
128 double updated_at = 0.0;
129};
130
131/**
132 * Which live calibration manager a store entry's fit belongs in — the one rule every caller that
133 * applies a resumed curve to the running stream must go through.
134 *
135 * `None` for an orphan (see CubicChannel::live): it has no sensor streaming, and its logical
136 * channel is shared with whatever live board happens to own that slot. On 2026-11-09 LC board 41
137 * was disabled in config; its three saved entries (connectors 1, 6, 7) resumed as orphans, and
138 * because the service's LC/PT discriminant was built from ENABLED boards they were taken for PTs.
139 * Each held one point, so each fit was invalid, so the service CLEARED PT logical channels 1, 6
140 * and 7 — FT-PT-R, FF-PT and DR-PT-G — right after the PT entries had set them. All three streamed
141 * 0 psi, with raw ADC healthy and a good cubic sitting on disk.
142 */
143enum class CalTarget { None, PT, LC };
144
146 if (!ch.live)
147 return CalTarget::None;
148 return ch.kind == SensorKind::LC ? CalTarget::LC : CalTarget::PT;
149}
150
151/**
152 * Owns the per-channel captured points and the fitted cubic for the calibration service.
153 * Thread-safe. Persists atomically to a single JSON file that is:
154 * - re-loadable by this class on restart (resume points, re-fit), and
155 * - backward-compatible with the existing calibration_polynomials/poly_coeffs loaders
156 * (logical-channel-keyed) so the file doubles as the factory-cubic overlay the service loads.
157 * Those maps are PT-only; load cells get lc_-prefixed equivalents, because the two share a
158 * logical-channel space (see SensorKind).
159 * The service is the sole writer; the Node backend only reads the file to serve the UI.
160 */
162public:
163 explicit CubicCalibrationStore(std::string file_path);
164
165 /** Attach/refresh identity + role + streaming model for a uid (idempotent; call at startup).
166 * `active_model` is the config truth ("cubic"|"robust"|"linear"|"physics"); it takes
167 * precedence over a value restored from disk by load(). A channel that already holds points
168 * is re-fitted when its model changes, so switching cubic <-> linear in config needs no
169 * recapture. */
170 void register_channel(uint16_t uid, uint8_t board_id, uint8_t connector, uint8_t logical_ch,
171 const std::string& role, const std::string& active_model = "cubic",
172 SensorKind kind = SensorKind::PT, const std::string& raw_unit = "adc");
173
174 /** Append a capture point (raw ADC, reference PSI), cap history, and re-fit. Returns the fit.
175 * For a robust uid the point is recorded for display only and an invalid fit is returned
176 * (the robust learner is updated by the service, not here). */
177 CubicFit add_point(uint16_t uid, double adc, double psi);
178
179 /** Record how the last capture on this uid went, for the UI to show. */
180 void note_capture(uint16_t uid, const CaptureQuality& q);
181
182 /** Replace a robust uid's display fit-curve (sampled predict_pressure_psi points). */
183 void set_fit_curve(uint16_t uid, const std::vector<std::pair<double, double>>& curve);
184
185 /** Drop all points, fit, and fit-curve for a uid (channel goes back to PENDING). */
186 void clear_channel(uint16_t uid);
187
188 const CubicFit* fit_for(uint16_t uid) const;
189 const CubicChannel* channel(uint16_t uid) const;
190 std::vector<uint16_t> uids() const;
191
192 /** Atomically write the JSON record (temp + rename). */
193 bool save() const;
194
195 /** Load the JSON record, resume points, and re-fit each channel. Returns channels loaded. */
196 size_t load();
197
198 /** True when load() could not read an existing file, so every curve here is a fallback rather
199 * than the operator's. Callers that DERIVE from a curve (the LC tare recompute) must skip
200 * rather than persist a number computed against one. */
201 bool load_failed() const {
202 std::lock_guard<std::mutex> lock(mutex_);
203 return load_failed_;
204 }
205
206 static constexpr size_t kMaxPoints = 20;
207
208private:
209 mutable std::mutex mutex_;
210 std::string file_path_;
211 /** Set when load() could not read an existing file. Blocks save(), so an unreadable
212 * store is never replaced by the empty one we fell back to. */
213 bool load_failed_ = false;
214 std::map<uint16_t, CubicChannel> channels_;
215
216public:
217 /** Least-squares polynomial of degree min(max_degree, n - 1) over `pts`. Public for tests. */
218 static CubicFit compute_fit(const std::vector<CubicPoint>& pts, int max_degree = 3);
219
220private:
221 /** Recompute fit_cubic / fit_linear / fit / status from ch.points per ch.active_model.
222 * Caller holds mutex_. Robust channels are left to their own branch (no fit). */
223 static void refit(CubicChannel& ch);
224 std::string serialize() const; // caller holds mutex_
225};
226
227} // namespace calibration
228} // namespace fsw
229
230#endif // CUBIC_CALIBRATION_STORE_HPP
CubicCalibrationStore(std::string file_path)
void note_capture(uint16_t uid, const CaptureQuality &q)
Record how the last capture on this uid went, for the UI to show.
std::map< uint16_t, CubicChannel > channels_
size_t load()
Load the JSON record, resume points, and re-fit each channel.
bool load_failed_
Set when load() could not read an existing file.
void clear_channel(uint16_t uid)
Drop all points, fit, and fit-curve for a uid (channel goes back to PENDING).
const CubicFit * fit_for(uint16_t uid) const
void register_channel(uint16_t uid, uint8_t board_id, uint8_t connector, uint8_t logical_ch, const std::string &role, const std::string &active_model="cubic", SensorKind kind=SensorKind::PT, const std::string &raw_unit="adc")
Attach/refresh identity + role + streaming model for a uid (idempotent; call at startup).
static void refit(CubicChannel &ch)
Recompute fit_cubic / fit_linear / fit / status from ch.points per ch.active_model.
static CubicFit compute_fit(const std::vector< CubicPoint > &pts, int max_degree=3)
Least-squares polynomial of degree min(max_degree, n - 1) over pts.
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).
static uint8_t board_id
Definition main.cpp:30
static STAR_MCP3201 adc(MCP3201_CS_PIN, &mcp_spi)
static osMutexId_t lock
Definition mode.c:11
Output smoothing for calibrated PT pressure, per sensor uid (ESP32 and LabJack T7 alike).
constexpr const char * kRawUnitAdc
CubicChannel::raw_unit values.
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...
constexpr const char * kRawUnitMicrovolts
SensorKind
Which family of sensor a channel belongs to.
Motorized ball valve position: raw ADC counts -> % open.
Per-sensor calibration record: identity, captured points, and the current fit.
Result of a least-squares polynomial fit over a channel's captured points.
How trustworthy the most recent capture on this channel was.
Per-sensor calibration record: identity, captured points, and the current fit.
std::string raw_unit
What a point's adc means: kRawUnitAdc for an ESP32 board's ADC code, kRawUnitMicrovolts for a LabJack...
bool live
True when the RUNNING config declares a board/connector that owns this uid, i.e.
std::vector< std::pair< double, double > > fit_curve
Result of a least-squares polynomial fit over a channel's captured points.
One operator-captured calibration point: raw ADC code paired with a known reference PSI.
static std::string ch(int ain, const std::string &kind, const std::string &extra="", const std::string &board="lj")
Definition test_t7.cpp:98