STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
CaptureWindow.hpp
Go to the documentation of this file.
1#ifndef FSW_CAPTURE_WINDOW_HPP
2#define FSW_CAPTURE_WINDOW_HPP
3
4/**
5 * @file CaptureWindow.hpp
6 * @brief The ADC a calibration capture records: a bounded, recent, checked mean.
7 *
8 * A capture must answer "what is this sensor reading RIGHT NOW", and the answer has to
9 * mean the same thing on a 250 Hz pressure transducer and on a 13 Hz load cell.
10 *
11 * It did not. The calibration service kept a plain `deque<int32_t>` of the last 128
12 * samples per sensor and averaged the whole thing, with this comment:
13 *
14 * constexpr size_t kPtAdcRingMax = 128; // ~0.5 s of samples at ~250 Hz
15 *
16 * No board on the rig runs at 250 Hz. Measured pre-downsample ingest: PT 61-68 Hz,
17 * RTD 26 Hz, actuators 10 Hz, and a load cell **13.7 Hz**. So the same 128 samples is
18 * 2 s on a PT and **9.3 s** on a load cell, and every capture averaged the last nine
19 * seconds of the scale. On 2026-09-13 an operator added weight and captured 13 s later;
20 * roughly a quarter of the window still held the previous load, and the point landed 26%
21 * short of the line (steps of 52241, 53737, then 39238 counts per equal 2.268 kg). Three
22 * separate ZERO-load captures spanned 29,087 counts while the live signal's sigma was
23 * 798 — 36 sigma, impossible as noise, ordinary as a moving average over nine seconds.
24 *
25 * So the window is bounded in TIME, not in samples, and two different clocks answer two
26 * different questions:
27 *
28 * - MEMBERSHIP uses the sample timestamp `ts_ns` (the bridge's reconstructed host-epoch
29 * time), measured relative to the NEWEST sample in this sensor's ring rather than to
30 * wall-now. Anchoring on the newest sample cancels any constant offset between the
31 * board's epoch and ours, so transport lag longer than the window can never starve
32 * every capture.
33 * - LIVENESS uses `rx_ns`, our own monotonic arrival stamp, against the capture's
34 * `now_rx_ns`. Clock-domain-pure, so it is immune to board clock skew — and it is what
35 * rejects a dead sensor, or a reconnect burst of samples that are old in our terms.
36 *
37 * SETTLING. A window can be recent and still wrong, if the load changed inside it. The
38 * check compares the mean of the window's first half against its second half, normalised
39 * by the window's own noise, so a ramp or step reads as drift while plain noise does not.
40 * Normalising is what lets one threshold serve a 4-20 mA PT (codes ~1e6) and a load cell
41 * (~5e4) with nothing to tune per sensor. An unsettled capture is reported, never
42 * silently dropped: the caller records the point and flags it.
43 *
44 * Pure and deterministic: no clock is read here — times are inputs, so the tests drive
45 * every edge case (see lib/test/test_capture_window.cpp).
46 *
47 * NOTE on [time_sync] mode = "arrival" (BoardClockSync.hpp): that revert switch flattens
48 * a whole packet onto one stamp, which coarsens `ts_ns` to per-packet resolution. The
49 * newest-sample anchoring keeps membership from failing closed, and kMinCaptureSamples
50 * plus the drift check still catch the pathological case — but expect fewer, chunkier
51 * samples per window in that mode.
52 */
53
54#include <cstddef>
55#include <cstdint>
56#include <deque>
57#include <unordered_map>
58#include <vector>
59
60namespace fsw {
61namespace calibration {
62
63/** How far back a capture averages. ~14 samples on a 13.7 Hz load cell (standard error
64 * ~214 counts against a 53,000-count weight step) and ~61 on a PT — while staying short
65 * enough that a load change two seconds before the button cannot leak in.
66 *
67 * This is the FLOOR. A sensor slower than kMinCaptureSamples per second gets a window of
68 * kMinCaptureSamples of its own sample periods instead (see capture()): the LC boards at
69 * SINC4 / 20 SPS deliver ~1 Hz per channel, and a fixed 1 s window never held three of
70 * their samples, so every LC capture and Zero-All was refused as too_few or stale. */
71constexpr uint64_t kCaptureWindowMs = 1000;
72/** Newest sample older than this (in OUR clock) means the sensor is not live. A floor, like
73 * the window: a slow sensor is allowed kStaleSamplePeriods of its own sample periods. */
74constexpr uint64_t kMaxSampleAgeMs = 1500;
75/** Below this a "mean" is not one, and the settle check has nothing to say. */
76constexpr size_t kMinCaptureSamples = 3;
77/** Liveness allowance for a slow sensor, in its own sample periods: one late sample is
78 * jitter, two missing in a row is a sensor that stopped. */
79constexpr uint64_t kStaleSamplePeriods = 2;
80/** Upper bound on the adaptive window and staleness allowance. A sensor slower than this
81 * allows is not one a capture can honestly describe "right now"; it fails closed. */
82constexpr uint64_t kMaxCaptureWindowMs = 5000;
83/** Ring eviction: by span so it self-sizes across 1-250 Hz, by count so memory is
84 * bounded if a board ever runs fast. Both are enforced on push. Must exceed
85 * kMaxCaptureWindowMs by at least one slow sample period, or a slow sensor's window
86 * could never fill. */
87constexpr uint64_t kRingSpanMs = 8000;
88constexpr size_t kRingMax = 1024;
89/** Half-mean difference, in standard errors, above which the window is not settled. */
90constexpr double kSettleZ = 6.0;
91
92struct AdcSample {
93 uint64_t ts_ns; ///< sample time, board-derived (spacing is what matters)
94 uint64_t rx_ns; ///< arrival time on OUR monotonic clock
95 int32_t adc;
96};
97
98/** Why a capture was refused. Stable strings — they go in the log and in the store. */
99inline constexpr const char* kReasonOk = "";
100inline constexpr const char* kReasonNoSamples = "no_samples";
101inline constexpr const char* kReasonStale = "stale";
102inline constexpr const char* kReasonTooFew = "too_few";
103
105 bool ok = false; ///< false → the caller must NOT create a point
106 double adc_avg = 0.0;
107 size_t n = 0; ///< samples inside the window
108 uint64_t span_ms = 0; ///< ts span actually covered
109 uint64_t age_ms = 0; ///< how old the newest sample is, our clock
110 uint64_t window_ms = kCaptureWindowMs; ///< the window this sensor's rate called for
111 uint64_t max_age_ms = kMaxSampleAgeMs; ///< the staleness limit it was held to
112 double spread = 0.0; ///< max - min inside the window
113 double drift = 0.0; ///< |mean(2nd half) - mean(1st half)|
114 double drift_z = 0.0; ///< drift in standard errors
115 bool settled = true; ///< false → recorded, but flagged
116 const char* reason = kReasonOk;
117};
118
119/**
120 * Per-sensor ring of recent samples, and the capture decision over it.
121 *
122 * Keyed by the calibration uid (board_id * 100 + connector). Nothing here reads a clock:
123 * `push` is told when the sample was taken and when it arrived, `capture` is told what
124 * time it is now.
125 */
127public:
128 /** Record one sample. Evicts by span and by count. */
129 void push(uint16_t uid, int32_t adc, uint64_t ts_ns, uint64_t rx_ns);
130
131 /** What this sensor reads now, or why that cannot be answered. */
132 CaptureResult capture(uint16_t uid, uint64_t now_rx_ns) const;
133
134 /** Forget everything. Used on an Elodin reconnect, where the samples either side of
135 * the gap are not a continuous record and must never be averaged together. */
136 void clear();
137 void clear(uint16_t uid);
138
139 /** Every uid with at least one sample. Zero-All iterates this rather than a
140 * last-value map, so a sensor that has gone quiet is skipped instead of being
141 * zeroed from a stale reading. */
142 std::vector<uint16_t> uids() const;
143
144 /** Samples currently held for a uid (diagnostics and tests). */
145 size_t size(uint16_t uid) const;
146
147private:
148 std::unordered_map<uint16_t, std::deque<AdcSample>> rings_;
149};
150
151} // namespace calibration
152} // namespace fsw
153
154#endif // FSW_CAPTURE_WINDOW_HPP
Per-sensor ring of recent samples, and the capture decision over it.
std::unordered_map< uint16_t, std::deque< AdcSample > > rings_
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 STAR_MCP3201 adc(MCP3201_CS_PIN, &mcp_spi)
Output smoothing for calibrated PT pressure, per sensor uid (ESP32 and LabJack T7 alike).
constexpr const char * kReasonTooFew
constexpr const char * kReasonNoSamples
constexpr uint64_t kCaptureWindowMs
How far back a capture averages.
constexpr const char * kReasonOk
Why a capture was refused.
constexpr const char * kReasonStale
constexpr uint64_t kStaleSamplePeriods
Liveness allowance for a slow sensor, in its own sample periods: one late sample is jitter,...
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.
Motorized ball valve position: raw ADC counts -> % open.
uint64_t ts_ns
sample time, board-derived (spacing is what matters)
uint64_t rx_ns
arrival time on OUR monotonic clock
uint64_t max_age_ms
the staleness limit it was held to
double drift_z
drift in standard errors
uint64_t age_ms
how old the newest sample is, our clock
uint64_t span_ms
ts span actually covered
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