STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_lc_linear_fit.cpp
Go to the documentation of this file.
1/**
2 * The load-cell "linear" calibration model, and why it is a load cell's default.
3 *
4 * The problem, in the operator's words: calibrate a scale with its readings in the 400-500k ADC
5 * range; the next day its zero has drifted to 200k; press Tare. The tare is an ADC code that is
6 * evaluated through the live curve (LcTareStore), so what the display shows is
7 * curve(live) - curve(tare code). On a straight line that difference depends only on how far the
8 * code moved, so a pure zero shift cancels exactly. A cubic fitted over 400-500k is evaluated two
9 * spans below its data there, where the curvature it picked up from capture noise has grown, and
10 * the tared reading is off by a load-dependent amount.
11 *
12 * 1. linear_model_fits_a_line — degree 1 whatever the point count
13 * 2. linear_needs_two_points — a one-point "line" is a constant; none is kept
14 * 3. cubic_model_is_unchanged — the default PT path fits exactly as before
15 * 4. a_model_change_refits_without_a_capture — config cubic -> linear moves the live curve
16 * 5. tare_cancels_zero_drift_on_linear_not_cubic — the operator's scenario, through LcTareStore
17 * 6. both_fits_are_serialized — the page draws linear beside cubic
18 * 7. raw_coefficients_match_the_normalized_form — the service streams A..D, the browser poly
19 */
20
21#include <cmath>
22#include <cstdio>
23#include <filesystem>
24#include <fstream>
25#include <nlohmann/json.hpp>
26#include <string>
27#include <vector>
28
31
39
40static int g_failures = 0;
41
42#define CHECK(cond, ...) \
43 do { \
44 if (!(cond)) { \
45 g_failures++; \
46 std::printf(" ❌ [%s:%d] ", __func__, __LINE__); \
47 std::printf(__VA_ARGS__); \
48 std::printf("\n"); \
49 } \
50 } while (0)
51
52namespace {
53
54constexpr uint16_t kUid = 4201; // LC board 42, connector 1
55
56std::string scratch(const char* name) {
57 static int seq = 0;
58 auto p = std::filesystem::temp_directory_path() /
59 ("lc_linear_" + std::string(name) + "_" + std::to_string(++seq) + ".json");
60 std::filesystem::remove(p);
61 return p.string();
62}
63
64/** Evaluate a fit the way the browser does (normalized form). */
65double eval_norm(const CubicFit& f, double adc) {
66 const double x = (adc - f.norm_min) / (f.norm_scale != 0.0 ? f.norm_scale : 1.0);
67 double y = 0.0, xp = 1.0;
68 for (double c : f.poly) {
69 y += c * xp;
70 xp *= x;
71 }
72 return y;
73}
74
75/** Evaluate a fit the way calibration_service does (raw A..D, PolynomialCalibration). */
76double eval_raw(const CubicFit& f, double adc) {
77 return ((f.A * adc + f.B) * adc + f.C) * adc + f.D;
78}
79
80/**
81 * A real load cell over 400-500k: 100 kg across 100k counts, linear, with the few-tenths-of-a-kg
82 * scatter an operator's captures carry (a settling load, a mass placed slightly off-centre).
83 */
84std::vector<CubicPoint> drifting_scale_points() {
85 const double codes[] = {400e3, 425e3, 450e3, 475e3, 500e3};
86 const double noise_kg[] = {0.3, -0.2, 0.25, -0.3, 0.2};
87 std::vector<CubicPoint> pts;
88 for (int i = 0; i < 5; ++i)
89 pts.push_back({codes[i], (codes[i] - 400e3) * 0.001 + noise_kg[i], 0.0});
90 return pts;
91}
92
93void fill(CubicCalibrationStore& s, const std::string& model, const std::vector<CubicPoint>& pts) {
94 s.register_channel(kUid, 42, 1, 11, "LOX Scale", model, SensorKind::LC);
95 for (const auto& p : pts)
96 s.add_point(kUid, p.adc, p.psi);
97}
98
99// ── 1 ──────────────────────────────────────────────────────────────────────
102 fill(s, "linear", drifting_scale_points());
103 const CubicFit* f = s.fit_for(kUid);
104 CHECK(f != nullptr, "a linear channel with 5 points must have a fit");
105 if (f == nullptr)
106 return;
107 CHECK(f->degree == 1, "linear must fit degree 1, got %d", f->degree);
108 CHECK(f->A == 0.0 && f->B == 0.0, "a line has no cubic or square term (A=%g B=%g)", f->A, f->B);
109 CHECK(std::fabs(f->C - 0.001) < 2e-5, "slope should be ~0.001 kg/count, got %g", f->C);
110 CHECK(s.channel(kUid)->status == "OK", "status should be OK, got %s",
111 s.channel(kUid)->status.c_str());
112}
113
114// ── 2 ──────────────────────────────────────────────────────────────────────
117 s.register_channel(kUid, 42, 1, 11, "LOX Scale", "linear", SensorKind::LC);
118 s.add_point(kUid, 400e3, 0.0);
119 CHECK(s.fit_for(kUid) == nullptr,
120 "one point is not a line: no fit, so the service streams physics instead");
121 CHECK(s.channel(kUid)->status == "PENDING", "one point should be PENDING, got %s",
122 s.channel(kUid)->status.c_str());
123 s.add_point(kUid, 500e3, 100.0);
124 const CubicFit* f = s.fit_for(kUid);
125 CHECK(f != nullptr && f->degree == 1, "two points make a line");
126}
127
128// ── 3 ──────────────────────────────────────────────────────────────────────
130 const auto pts = drifting_scale_points();
131 CubicCalibrationStore s(scratch("cubic"));
132 fill(s, "cubic", pts);
133 const CubicFit* f = s.fit_for(kUid);
134 const CubicFit ref = CubicCalibrationStore::compute_fit(pts); // the old one-argument call
135 CHECK(f != nullptr && f->degree == 3, "cubic with 5 points stays degree 3");
136 if (f != nullptr)
137 CHECK(f->A == ref.A && f->B == ref.B && f->C == ref.C && f->D == ref.D,
138 "the cubic fit must be bit-identical to the pre-linear fit");
139 // A PT channel registered the old way (no model argument) is a cubic, as it always was.
141 pt.register_channel(2201, 22, 1, 11, "Fuel Upstream");
142 for (const auto& p : pts)
143 pt.add_point(2201, p.adc, p.psi);
144 CHECK(pt.fit_for(2201) != nullptr && pt.fit_for(2201)->degree == 3,
145 "a PT with the default model must still fit a cubic");
146}
147
148// ── 4 ──────────────────────────────────────────────────────────────────────
151 fill(s, "cubic", drifting_scale_points());
152 CHECK(s.fit_for(kUid)->degree == 3, "starts cubic");
153 s.register_channel(kUid, 42, 1, 11, "LOX Scale", "linear", SensorKind::LC);
154 CHECK(s.fit_for(kUid) != nullptr && s.fit_for(kUid)->degree == 1,
155 "switching the config to linear must refit the points as a line");
156 s.register_channel(kUid, 42, 1, 11, "LOX Scale", "cubic", SensorKind::LC);
157 CHECK(s.fit_for(kUid) != nullptr && s.fit_for(kUid)->degree == 3, "and back to cubic");
158}
159
160// ── 5 ──────────────────────────────────────────────────────────────────────
161/**
162 * The scenario. A tank is on the scale; the scale's zero has drifted from 400k to 200k overnight
163 * (the whole curve slid 200k counts down; the sensitivity did not change). The operator tares
164 * with nothing on it, then puts the tank on. What reads?
165 */
166double tared_reading(const CubicFit& f, double zero_code, double tank_kg) {
167 LcTareStore tares(scratch("tare"));
168 const auto eval = [&](double adc) {
169 return eval_raw(f, adc);
170 };
171 tares.set(kUid, lc_tare_entity(42, 1), zero_code, eval);
172 const double live_code = zero_code + tank_kg / 0.001; // 0.001 kg/count, the true slope
173 return eval(live_code) - tares.tare_for(kUid)->offset_kg;
174}
175
177 const auto pts = drifting_scale_points();
179 fill(lin, "linear", pts);
181 fill(cub, "cubic", pts);
182 const CubicFit lf = *lin.fit_for(kUid);
183 const CubicFit cf = *cub.fit_for(kUid);
184
185 for (double tank : {25.0, 50.0, 100.0}) {
186 // In range (zero where it was calibrated), both are fine.
187 const double lin_home = tared_reading(lf, 400e3, tank);
188 const double cub_home = tared_reading(cf, 400e3, tank);
189 // Drifted to 200k: two spans below every captured point.
190 const double lin_drift = tared_reading(lf, 200e3, tank);
191 const double cub_drift = tared_reading(cf, 200e3, tank);
192 std::printf(
193 " tank %5.1f kg | at home: linear %7.2f cubic %7.2f | zero at 200k: "
194 "linear %7.2f cubic %9.2f\n",
195 tank, lin_home, cub_home, lin_drift, cub_drift);
196
197 CHECK(std::fabs(lin_drift - lin_home) < 1e-6,
198 "on a line a zero shift must cancel exactly: %f at home vs %f drifted", lin_home,
199 lin_drift);
200 CHECK(std::fabs(lin_drift - tank) < 0.5, "linear tared reading %f for a %f kg tank",
201 lin_drift, tank);
202 // The defect is not that the cubic is wrong — at home it is as good as the line — but
203 // that what it reads after a tare depends on where the zero has drifted to. With this
204 // fixture's +/-0.3 kg capture scatter that is ~1-2 kg at 200k, growing with the cube of
205 // the distance; a scatterier capture set or a farther drift is worse.
206 CHECK(std::fabs(cub_drift - cub_home) > 0.5,
207 "expected the cubic's tared reading to move with the drift (%f at home, %f at 200k)"
208 " — if this fails the fixture no longer demonstrates the problem",
209 cub_home, cub_drift);
210 }
211}
212
213// ── 6 ──────────────────────────────────────────────────────────────────────
215 const std::string path = scratch("ser");
216 {
218 fill(s, "linear", drifting_scale_points());
219 CHECK(s.save(), "save");
220 }
221 std::ifstream in(path);
222 const auto root = nlohmann::json::parse(in);
223 const auto& ch = root["cubic_state"][std::to_string(kUid)];
224 CHECK(ch["active_model"] == "linear", "active_model should say linear");
225 CHECK(ch["degree"] == 1, "the active fit is the line");
226 CHECK(ch["fits"]["linear"]["valid"] == true && ch["fits"]["linear"]["degree"] == 1,
227 "fits.linear present");
228 CHECK(ch["fits"]["cubic"]["valid"] == true && ch["fits"]["cubic"]["degree"] == 3,
229 "fits.cubic present, for the comparison overlay");
230 // The logical-channel map the service loads at startup carries the ACTIVE fit: the line.
231 const auto& raw = root["lc_calibration_polynomials"]["11"];
232 CHECK(raw.is_array() && raw[0] == 0.0 && raw[1] == 0.0,
233 "lc_calibration_polynomials must carry the line (A = B = 0)");
234
235 // And it survives a restart: load re-fits from the points per the model.
237 again.register_channel(kUid, 42, 1, 11, "LOX Scale", "linear", SensorKind::LC);
238 CHECK(again.load() == 1, "reload one channel");
239 CHECK(again.fit_for(kUid) != nullptr && again.fit_for(kUid)->degree == 1, "reloaded as a line");
240 std::filesystem::remove(path);
241}
242
243// ── 7 ──────────────────────────────────────────────────────────────────────
246 fill(s, "linear", drifting_scale_points());
247 const CubicFit& f = *s.fit_for(kUid);
248 for (double adc : {0.0, 200e3, 450e3, 1e6}) {
249 CHECK(std::fabs(eval_raw(f, adc) - eval_norm(f, adc)) < 1e-6,
250 "raw and normalized disagree at %g: %f vs %f", adc, eval_raw(f, adc),
251 eval_norm(f, adc));
252 }
253}
254
255} // namespace
256
257int main() {
258 std::printf("LC linear fit\n");
266 if (g_failures == 0) {
267 std::printf(" ✅ all passed\n");
268 return 0;
269 }
270 std::printf(" %d failure(s)\n", g_failures);
271 return 1;
272}
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.
Owns the per-channel captured points and the fitted cubic for the calibration service.
size_t load()
Load the JSON record, resume points, and re-fit each channel.
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).
CubicFit add_point(uint16_t uid, double adc, double psi)
Append a capture point (raw ADC, reference PSI), cap history, and re-fit.
const CubicChannel * channel(uint16_t uid) const
bool save() const
Atomically write the JSON record (temp + rename).
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.
const LcTare * tare_for(uint16_t uid) const
static STAR_MCP3201 adc(MCP3201_CS_PIN, &mcp_spi)
int32_t p
Definition flight_e2e.c:78
double eval_raw(const CubicFit &f, double adc)
Evaluate a fit the way calibration_service does (raw A..D, PolynomialCalibration).
std::vector< CubicPoint > drifting_scale_points()
A real load cell over 400-500k: 100 kg across 100k counts, linear, with the few-tenths-of-a-kg scatte...
double tared_reading(const CubicFit &f, double zero_code, double tank_kg)
The scenario.
void fill(CubicCalibrationStore &s, const std::string &model, const std::vector< CubicPoint > &pts)
double eval_norm(const CubicFit &f, double adc)
Evaluate a fit the way the browser does (normalized form).
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 ...
SensorKind
Which family of sensor a channel belongs to.
Per-sensor calibration record: identity, captured points, and the current fit.
Result of a least-squares polynomial fit over a channel's captured points.
One operator-captured calibration point: raw ADC code paired with a known reference PSI.
static int g_failures
static uint8_t scratch[BS]
#define CHECK(cond,...)
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 ...
int main()
static std::string ch(int ain, const std::string &kind, const std::string &extra="", const std::string &board="lj")
Definition test_t7.cpp:98