STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
LcTareStore.hpp
Go to the documentation of this file.
1#ifndef LC_TARE_STORE_HPP
2#define LC_TARE_STORE_HPP
3
4#include <cstdint>
5#include <functional>
6#include <map>
7#include <mutex>
8#include <string>
9#include <vector>
10
11namespace fsw {
12namespace calibration {
13
14/**
15 * The publish-path entity name for an LC channel: "LC<slot>_Cal.CH<connector>", where slot is
16 * board_id % 10 with 0 -> 10 — the same rule DatabaseConfig uses to name the calibrated LC
17 * VTables, and the same board_number the LC publish path already holds.
18 *
19 * It exists as one shared function because the failure mode of a second copy is specific and
20 * has already happened: slot is NOT board_id, so PT board 22 and LC board 42 are both slot 2,
21 * and deriving a uid back out of an entity string collides them. Node must key on this string
22 * rather than re-derive it.
23 */
24std::string lc_tare_entity(uint8_t board_id, uint8_t connector);
25
26/**
27 * One load cell's tare: the scale reads zero at the load it was holding when the operator
28 * pressed Tare.
29 *
30 * A tare is NOT a calibration point, and the difference is the reason this store exists
31 * separately from CubicCalibrationStore. A vented PT genuinely is at 0 psig, so capturing a
32 * zero on one is a true reference point and belongs in the shared fit. A load cell holding a
33 * tank is not at 0 kg: capturing that as a zero would inject a false point, and because
34 * add_point re-runs a least-squares fit over every point, it would tilt the whole cubic rather
35 * than shift its intercept. LC therefore gets a display-only tare, and its zero-capture keeps
36 * meaning "unloaded".
37 *
38 * WHAT IS STORED IS THE ADC CODE, NOT THE KILOGRAMS.
39 *
40 * `offset_kg` is a derived cache, re-evaluated from `adc_at_tare` through the current curve on
41 * every re-fit, clear, and profile swap. Persisting kilograms as the truth is the bug this
42 * layout exists to prevent: tare a 20 kg tank against a bad two-point fit that reads it as 18,
43 * improve the fit until the same tank evaluates to 20, and a frozen 18 kg offset displays 2 kg
44 * for a tank that never moved — a plausible-looking wrong number on a pad display. Re-deriving
45 * from the code gives 20 - 20 = 0.
46 *
47 * Subtraction happens in kilograms, never in counts: the curve is a cubic, so offsetting its
48 * input shifts the slope you get instead of translating its output.
49 */
50struct LcTare {
51 uint16_t uid = 0; // board_id*100 + connector
52 std::string entity; // publish-path identity, e.g. "LC2_Cal.CH1" — see set()
53 double adc_at_tare = 0.0; // the truth
54 double offset_kg = 0.0; // derived from adc_at_tare through the live curve; a cache
55 double set_at_ms = 0.0; // unix milliseconds, for "tared 2h ago"
56 uint64_t curve_fp = 0; // fingerprint of the curve offset_kg was computed against
57};
58
59/**
60 * Owns the per-channel tares for the calibration service. Thread-safe, persisted atomically to
61 * one JSON file (temp + rename) that the Node backend reads to apply the subtraction — the
62 * service never sends the value, because [0x46,0x00] is one-way and there is no reply packet.
63 * This mirrors how cubic calibration already reaches the UI.
64 *
65 * Ownership, which is deliberately asymmetric: C++ is the STEADY-STATE writer. Node may remove
66 * the file, but only while sensor-calibration is not running. That is what lets a tare be
67 * cleared at session start and still survive a service restart inside a session — two
68 * requirements that together force a file that is persistent AND externally cleared. Neither
69 * half is redundant; do not "simplify" one away.
70 */
72public:
73 explicit LcTareStore(std::string file_path);
74
75 /** adc -> kg through whichever model this uid streams (select_lc_kg: cubic fit or the
76 * datasheet physics conversion). Taken as a callable so this class never learns which. */
77 using Evaluator = std::function<double(double adc)>;
78
79 /**
80 * Record a tare for `uid` at `adc_at_tare` and derive its offset. `entity` is the
81 * publish-path name (LC<slot>_Cal.CH<connector>) computed by the caller from the same
82 * board_number/channel the LC publish path uses; Node keys on this string and must never
83 * re-derive it, because slot is board_id % 10 and two boards of different kinds share a
84 * slot — the collision that put a load cell's curve on a 5000 psi transducer in Sep 2026.
85 *
86 * Returns false and records nothing when the curve yields a non-finite offset (a degenerate
87 * one-point fit does), because a NaN offset downstream silently kills the whole series.
88 */
89 bool set(uint16_t uid, const std::string& entity, double adc_at_tare, const Evaluator& eval);
90
91 void clear(uint16_t uid);
92 void clear_all();
93
94 /** Re-derive offset_kg for one uid under the current curve. No-op when the uid is untared. */
95 void recompute(uint16_t uid, const Evaluator& eval);
96
97 /** Re-derive every tare; `eval_for` supplies each uid's evaluator. Call after any change to
98 * an LC curve: a capture, a clear, a profile swap, or startup. */
99 void recompute_all(const std::function<Evaluator(uint16_t)>& eval_for);
100
101 /**
102 * Re-derive only the tares whose recorded fingerprint disagrees with the curve now live, and
103 * return how many those were. Zero is the expected answer: every path that moves a curve is
104 * supposed to recompute already.
105 *
106 * A non-zero answer means one didn't, and the caller should say so loudly — it is the audit
107 * that makes a missed recompute hook visible instead of silently subtracting kilograms
108 * derived from a curve that no longer exists. In particular it catches the startup ordering
109 * mistake of reading this file AFTER the live store reload, where the reload's own recompute
110 * runs over an empty map and every restored offset stays stale.
111 */
112 size_t recompute_stale(const std::function<Evaluator(uint16_t)>& eval_for);
113
114 /**
115 * False while the curves cannot be trusted — set when the cubic store failed to load. A
116 * recompute then keeps the last good offset rather than replacing it with one computed
117 * against a zeroed curve, which would read as a confident wrong number.
118 */
119 void set_curves_trusted(bool trusted);
120 bool curves_trusted() const;
121
122 const LcTare* tare_for(uint16_t uid) const;
123 std::vector<uint16_t> uids() const;
124 size_t size() const;
125
126 /** Atomically write the JSON record (temp + rename). Blocked while load_failed(). */
127 bool save() const;
128
129 /** Load the JSON record. Returns tares loaded. */
130 size_t load();
131
132 /** Set when load() could not read an existing file; blocks save() so an unreadable store is
133 * never replaced by the empty one we fell back to. Mirrors CubicCalibrationStore. */
134 bool load_failed() const;
135
136 /**
137 * A fingerprint of what a curve *does*, not of the coefficients that describe it: the
138 * evaluator is sampled at fixed probe codes and the results hashed. Model-agnostic by
139 * construction, so a cubic re-fit, a cleared channel and a flip to the physics conversion
140 * all move it, and a no-op re-save does not. Cheap defence in depth — if some future path
141 * mutates a curve without calling recompute, a mismatched fingerprint makes the stale
142 * offset visible instead of silently subtracted.
143 */
144 static uint64_t fingerprint(const Evaluator& eval);
145
146private:
147 mutable std::mutex mutex_;
148 std::string file_path_;
149 bool load_failed_ = false;
150 bool curves_trusted_ = true;
151 std::map<uint16_t, LcTare> tares_;
152
153 std::string serialize() const; // caller holds mutex_
154 /** Caller holds mutex_. Returns false when the curve yields a non-finite offset. */
155 bool recompute_locked(LcTare& t, const Evaluator& eval);
156};
157
158} // namespace calibration
159} // namespace fsw
160
161#endif // LC_TARE_STORE_HPP
bool load_failed() const
Set when load() could not read an existing file; blocks save() so an unreadable store is never replac...
bool recompute_locked(LcTare &t, const Evaluator &eval)
Caller holds mutex_.
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::map< uint16_t, LcTare > tares_
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,...
LcTareStore(std::string file_path)
bool save() const
Atomically write the JSON record (temp + rename).
void recompute_all(const std::function< Evaluator(uint16_t)> &eval_for)
Re-derive every tare; eval_for supplies each uid's evaluator.
std::function< double(double adc)> Evaluator
adc -> kg through whichever model this uid streams (select_lc_kg: cubic fit or the datasheet physics ...
static uint64_t fingerprint(const Evaluator &eval)
A fingerprint of what a curve does, not of the coefficients that describe it: the evaluator is sample...
std::string serialize() const
static uint8_t board_id
Definition main.cpp:30
static STAR_MCP3201 adc(MCP3201_CS_PIN, &mcp_spi)
Output smoothing for calibrated PT pressure, per sensor uid (ESP32 and LabJack T7 alike).
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 ...
Motorized ball valve position: raw ADC counts -> % open.
One load cell's tare: the scale reads zero at the load it was holding when the operator pressed Tare.