STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_capture_window.cpp
Go to the documentation of this file.
1/**
2 * Unit tests for fsw::calibration::CaptureWindow (pure, deterministic — no clock).
3 * House style: plain main(), return 0 on pass / 1 on failure (see
4 * test_board_clock_sync.cpp). Registered with CTest in lib/CMakeLists.txt.
5 *
6 * The headline test is low_rate_step_after_settle: it replays the 2026-09-13 load-cell
7 * failure at the measured 13.7 Hz and asserts both that the bounded window is right AND
8 * that the old unbounded mean was wrong by the margin actually seen on the stand.
9 *
10 * 1. low-rate step, captured after settling 5. clear() on reconnect
11 * 2. window bound at high rate 6. settle detection (4 cases)
12 * 3. stale sensor rejected, no fallback 7. ring eviction bounds
13 * 4. no samples / too few 8. uids() skips empty
14 * 9. ~1 Hz load cell captures (adaptive) 10. fast sensors keep the fixed bounds
15 */
16
17#include <cinttypes>
18#include <cmath>
19#include <cstdio>
20#include <string>
21#include <vector>
22
24
27
28static int g_failures = 0;
29
30#define CHECK(cond, ...) \
31 do { \
32 if (!(cond)) { \
33 g_failures++; \
34 std::printf(" ❌ [%s:%d] ", __func__, __LINE__); \
35 std::printf(__VA_ARGS__); \
36 std::printf("\n"); \
37 } \
38 } while (0)
39
40namespace {
41
42constexpr uint64_t NS_MS = 1000000ULL;
43constexpr uint64_t EPOCH = 1800000000000000000ULL;
44constexpr uint16_t UID = 4101; // the Fuel Scale, LC board 41 connector 1
45
46/** The measured load-cell cadence: 13.7 Hz is one sample every ~73 ms. */
47constexpr uint64_t LC_PERIOD_MS = 73;
48/** A fast transducer, for contrast. */
49constexpr uint64_t PT_PERIOD_MS = 4;
50
51/** Deterministic noise, same shape as test_board_clock_sync.cpp's Lcg. */
52struct Lcg {
53 uint64_t s = 0x2026091300000001ULL;
54 int32_t next(int32_t amplitude) {
55 s = s * 6364136223846793005ULL + 1442695040888963407ULL;
56 return static_cast<int32_t>((s >> 33) % (2 * amplitude + 1)) - amplitude;
57 }
58};
59
60/** Feed `count` samples at `period_ms`, returning the time just after the last one. */
61uint64_t feed(CaptureWindow& w, uint64_t t_ms, size_t count, uint64_t period_ms, int32_t value,
62 Lcg* noise = nullptr, int32_t amplitude = 0) {
63 for (size_t i = 0; i < count; i++) {
64 const int32_t v = noise ? value + noise->next(amplitude) : value;
65 const uint64_t ns = EPOCH + t_ms * NS_MS;
66 w.push(UID, v, ns, ns);
67 t_ms += period_ms;
68 }
69 return t_ms;
70}
71
72uint64_t now_ns(uint64_t t_ms) {
73 return EPOCH + t_ms * NS_MS;
74}
75
76// ── 1. The field failure, replayed ───────────────────────────────────────────────────
77//
78// 30 s at one load, step, then 13 s at the new load — the exact gap between the operator's
79// third and fourth Fuel Scale captures. The bounded window must see only the new load.
82 Lcg noise;
83 constexpr int32_t kBefore = 100000;
84 constexpr int32_t kAfter = 150000; // a 50,000-count step, like a 2.268 kg weight
85
86 uint64_t t = 0;
87 t = feed(w, t, 411, LC_PERIOD_MS, kBefore, &noise, 800); // ~30 s at 13.7 Hz
88 t = feed(w, t, 178, LC_PERIOD_MS, kAfter, &noise, 800); // ~13 s after the step
89
90 const CaptureResult r = w.capture(UID, now_ns(t));
91 CHECK(r.ok, "capture should succeed, reason=%s", r.reason);
92 CHECK(std::fabs(r.adc_avg - kAfter) < 0.01 * (kAfter - kBefore),
93 "bounded mean %.1f should be within 1%% of the step of %d", r.adc_avg, kAfter);
94 CHECK(r.settled, "a step that finished 13 s ago must read as settled (z=%.1f)", r.drift_z);
96 "span %" PRIu64 " ms should not exceed the window", r.span_ms);
97
98 // And the regression itself: the OLD behaviour averaged the last 128 samples whatever
99 // their age. At 13.7 Hz that is 9.3 s, so it still holds the previous load — which is
100 // how a 2.268 kg step recorded 39238 counts instead of ~53000, 26% short.
101 //
102 // Reproduce that arithmetic here so the test pins the real defect, not a tolerance:
103 // replay only 3 s past the step (what the operator's 13 s gap left after handling the
104 // weight) and confirm the unbounded 128-sample mean falls well short of the true value.
105 constexpr size_t kOldRingMax = 128; // the constant this class replaced
106 std::vector<int32_t> hist;
107 Lcg n2;
108 for (size_t i = 0; i < 411; i++)
109 hist.push_back(kBefore + n2.next(800));
110 for (size_t i = 0; i < 41; i++)
111 hist.push_back(kAfter + n2.next(800)); // ~3 s past the step
112
113 double old_sum = 0.0;
114 for (size_t i = hist.size() - kOldRingMax; i < hist.size(); i++)
115 old_sum += hist[i];
116 const double old_step = old_sum / kOldRingMax - kBefore;
117 const double true_step = kAfter - kBefore;
118 CHECK(old_step < 0.8 * true_step,
119 "the old 128-sample mean should fall >20%% short (got %.0f of %.0f)", old_step,
120 true_step);
121
122 // The bounded window, on that same history, does not.
123 CaptureWindow bounded;
124 uint64_t t2 = 0;
125 for (size_t i = 0; i < hist.size(); i++) {
126 const uint64_t ns = EPOCH + t2 * NS_MS;
127 bounded.push(UID, hist[i], ns, ns);
128 t2 += LC_PERIOD_MS;
129 }
130 const CaptureResult fixed = bounded.capture(UID, now_ns(t2));
131 CHECK(fixed.ok && std::fabs(fixed.adc_avg - kAfter) < 0.01 * true_step,
132 "bounded mean %.1f should still be the new load", fixed.adc_avg);
133}
134
135// ── 2. The window is a time bound, not a sample bound ────────────────────────────────
138 uint64_t t = 0;
139 t = feed(w, t, 1000, PT_PERIOD_MS, 20000); // 4 s at 250 Hz
140 t = feed(w, t, 625, PT_PERIOD_MS, 30000); // 2.5 s after the step
141
142 const CaptureResult r = w.capture(UID, now_ns(t));
143 CHECK(r.ok, "capture should succeed, reason=%s", r.reason);
144 CHECK(std::fabs(r.adc_avg - 30000.0) < 1.0, "mean %.1f should be the post-step value",
145 r.adc_avg);
147 "span %" PRIu64 " ms should be the window, not the ring", r.span_ms);
148 // ~250 samples in 1 s, versus ~14 for the load cell — same window, same meaning.
149 CHECK(r.n > 200 && r.n < 300, "expected ~250 samples in the window, got %zu", r.n);
150}
151
152// ── 3. A sensor that stopped publishing has no reading ───────────────────────────────
155 uint64_t t = feed(w, 0, 100, LC_PERIOD_MS, 100000);
156
157 const CaptureResult r = w.capture(UID, now_ns(t + 60000)); // 60 s later
158 CHECK(!r.ok, "a 60 s old sensor must not yield a capture");
159 CHECK(std::string(r.reason) == "stale", "reason should be stale, got %s", r.reason);
160 CHECK(r.age_ms >= 60000, "age %" PRIu64 " ms should reflect the gap", r.age_ms);
161 // There is deliberately no last-value fallback to rescue this.
162}
163
164// ── 4. Nothing to average ────────────────────────────────────────────────────────────
167 const CaptureResult none = w.capture(UID, now_ns(0));
168 CHECK(!none.ok && std::string(none.reason) == "no_samples", "unknown uid → no_samples, got %s",
169 none.reason);
170
171 uint64_t t = feed(w, 0, 2, LC_PERIOD_MS, 100000);
172 const CaptureResult few = w.capture(UID, now_ns(t));
173 CHECK(!few.ok && std::string(few.reason) == "too_few", "2 samples → too_few, got %s",
174 few.reason);
175}
176
177// ── 5. Samples either side of a reconnect are not one record ─────────────────────────
180 uint64_t t = feed(w, 0, 200, LC_PERIOD_MS, 100000);
181 CHECK(w.capture(UID, now_ns(t)).ok, "sanity: capture works before the drop");
182
183 w.clear();
184 const CaptureResult r = w.capture(UID, now_ns(t));
185 CHECK(!r.ok && std::string(r.reason) == "no_samples",
186 "after clear() the pre-drop samples must be gone, got %s", r.reason);
187 CHECK(w.uids().empty(), "clear() should empty uids()");
188}
189
190// ── 6. Settling ──────────────────────────────────────────────────────────────────────
192 { // (a) steady, with noise
194 Lcg noise;
195 uint64_t t = feed(w, 0, 200, LC_PERIOD_MS, 100000, &noise, 800);
196 const CaptureResult r = w.capture(UID, now_ns(t));
197 CHECK(r.ok && r.settled, "steady noisy signal should be settled (z=%.2f)", r.drift_z);
198 }
199 { // (b) a step INSIDE the window
201 Lcg noise;
202 uint64_t t = feed(w, 0, 200, LC_PERIOD_MS, 100000, &noise, 800);
203 t = feed(w, t, 7, LC_PERIOD_MS, 150000, &noise, 800); // ~0.5 s, half the window
204 const CaptureResult r = w.capture(UID, now_ns(t));
205 CHECK(r.ok, "a mid-step capture is still recorded");
206 CHECK(!r.settled, "a step inside the window must read unsettled (z=%.2f)", r.drift_z);
207 CHECK(r.drift_z > fsw::calibration::kSettleZ, "drift_z %.2f should exceed the threshold",
208 r.drift_z);
209 }
210 { // (c) a step that COMPLETED before the window — settled, and at the new value
212 Lcg noise;
213 uint64_t t = feed(w, 0, 200, LC_PERIOD_MS, 100000, &noise, 800);
214 t = feed(w, t, 42, LC_PERIOD_MS, 150000, &noise, 800); // ~3 s at the new load
215 const CaptureResult r = w.capture(UID, now_ns(t));
216 CHECK(r.ok && r.settled, "a step 3 s ago should be settled (z=%.2f)", r.drift_z);
217 CHECK(std::fabs(r.adc_avg - 150000.0) < 500.0, "mean %.1f should be the new load",
218 r.adc_avg);
219 }
220 { // (d) a perfectly quiet channel — sd 0 must not make drift infinitely significant
222 uint64_t t = feed(w, 0, 200, LC_PERIOD_MS, 100000);
223 const CaptureResult r = w.capture(UID, now_ns(t));
224 CHECK(r.ok && r.settled, "a constant signal is settled (z=%.2f)", r.drift_z);
225 CHECK(std::isfinite(r.drift_z), "drift_z must stay finite when sd is 0");
226 CHECK(r.spread == 0.0, "spread should be 0, got %.1f", r.spread);
227 }
228}
229
230// ── 7. The ring cannot grow without bound ────────────────────────────────────────────
233 feed(w, 0, 15000, PT_PERIOD_MS, 20000); // 60 s at 250 Hz
234 CHECK(w.size(UID) <= fsw::calibration::kRingMax, "ring %zu should be capped at %zu",
236 // At 250 Hz the count cap bites before the 8 s span (which would be ~2000 samples).
237 CHECK(w.size(UID) == fsw::calibration::kRingMax, "ring should sit at the count cap, got %zu",
238 w.size(UID));
239
240 // A slow sensor is bounded by span instead, and the span must outlast the largest window.
241 CaptureWindow slow;
242 uint64_t t = 0;
243 for (size_t i = 0; i < 60; i++, t += 1000) {
244 const uint64_t ns = EPOCH + t * NS_MS;
245 slow.push(UID, 100000, ns, ns);
246 }
247 CHECK(slow.size(UID) <= fsw::calibration::kRingSpanMs / 1000 + 1,
248 "1 Hz ring should hold ~8 s, got %zu", slow.size(UID));
250 "1 Hz ring %zu must outlast the largest window", slow.size(UID));
251}
252
253// ── 9. A ~1 Hz load cell can be captured ─────────────────────────────────────────────
254//
255// The LC boards at SINC4 / 20 SPS give ~1.2 Hz per scale (board 42) and ~0.76 Hz per load
256// cell (board 41). With a fixed 1 s window and 1.5 s staleness limit every capture was
257// refused: on 2026-11-07 Zero All logged "REJECTED (stale): newest sample 7525ms old, 0 in
258// last 1000ms" for both scales. The window now scales to the sensor's own period.
260 for (uint64_t period : {800ULL, 1300ULL}) {
262 Lcg noise;
263 uint64_t t = feed(w, 0, 10, period, 100000, &noise, 800);
264 // Captured half a period after the newest sample arrived: not stale.
265 const CaptureResult r = w.capture(UID, now_ns(t - period + period / 2));
266 CHECK(r.ok, "%" PRIu64 " ms load cell should capture, reason=%s", period, r.reason);
267 CHECK(r.n >= fsw::calibration::kMinCaptureSamples, "got %zu samples", r.n);
268 CHECK(r.window_ms == 3 * period, "window %" PRIu64 " should be 3 periods", r.window_ms);
269 CHECK(std::fabs(r.adc_avg - 100000.0) < 800.0, "mean %.1f", r.adc_avg);
270
271 // ...and still goes stale once it really stops: three periods with nothing.
272 const CaptureResult dead = w.capture(UID, now_ns(t - period + 3 * period));
273 CHECK(!dead.ok && std::string(dead.reason) == "stale",
274 "%" PRIu64 " ms sensor silent 3 periods must be stale, got %s", period, dead.reason);
275 }
276}
277
278// ── 10. Fast sensors keep the fixed bounds exactly ───────────────────────────────────
281 uint64_t t = feed(w, 0, 500, 15, 20000); // a 66 Hz PT
282 const CaptureResult r = w.capture(UID, now_ns(t));
283 CHECK(r.ok && r.window_ms == fsw::calibration::kCaptureWindowMs,
284 "PT window %" PRIu64 " should stay at the floor", r.window_ms);
286 "PT staleness %" PRIu64 " should stay at the floor", r.max_age_ms);
287 const CaptureResult late = w.capture(UID, now_ns(t - 15 + 1600));
288 CHECK(!late.ok && std::string(late.reason) == "stale",
289 "a PT silent 1.6 s is stale as before, got %s", late.reason);
290}
291
292// ── 8. uids() is the live set ────────────────────────────────────────────────────────
295 const uint64_t ns = EPOCH;
296 w.push(2101, 10, ns, ns);
297 w.push(4101, 20, ns, ns);
298 w.push(2201, 30, ns, ns);
299 const std::vector<uint16_t> ids = w.uids();
300 CHECK(ids.size() == 3, "expected 3 uids, got %zu", ids.size());
301 CHECK(ids[0] == 2101 && ids[1] == 2201 && ids[2] == 4101, "uids should come back sorted");
302
303 w.clear(4101);
304 CHECK(w.uids().size() == 2, "clear(uid) should drop just that one");
305}
306
307} // namespace
308
309int main() {
310 std::printf("CaptureWindow tests\n");
317 ring_bounds();
321
322 if (g_failures == 0)
323 std::printf(" ✅ all passed\n");
324 else
325 std::printf(" %d failure(s)\n", g_failures);
326 return g_failures ? 1 : 0;
327}
The ADC a calibration capture records: a bounded, recent, checked mean.
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.
size_t size(uint16_t uid) const
Samples currently held for a uid (diagnostics and tests).
static bool ok
Definition flight_e2e.c:435
size_type count(const key_type &key) const
Definition json.hpp:19264
constexpr uint64_t PT_PERIOD_MS
A fast transducer, for contrast.
constexpr uint64_t LC_PERIOD_MS
The measured load-cell cadence: 13.7 Hz is one sample every ~73 ms.
uint64_t feed(CaptureWindow &w, uint64_t t_ms, size_t count, uint64_t period_ms, int32_t value, Lcg *noise=nullptr, int32_t amplitude=0)
Feed count samples at period_ms, returning the time just after the last one.
constexpr uint64_t kCaptureWindowMs
How far back a capture averages.
constexpr size_t kMinCaptureSamples
Below this a "mean" is not one, and the settle check has nothing to say.
constexpr uint64_t kMaxCaptureWindowMs
Upper bound on the adaptive window and staleness allowance.
constexpr size_t kRingMax
constexpr double kSettleZ
Half-mean difference, in standard errors, above which the window is not settled.
constexpr uint64_t kRingSpanMs
Ring eviction: by span so it self-sizes across 1-250 Hz, by count so memory is bounded if a board eve...
constexpr uint64_t kMaxSampleAgeMs
Newest sample older than this (in OUR clock) means the sensor is not live.
Deterministic noise, same shape as test_board_clock_sync.cpp's Lcg.
bool ok
false → the caller must NOT create a point
static int g_failures
static boot_report_t r
#define CHECK(cond,...)
int main()