STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_board_clock_sync.cpp
Go to the documentation of this file.
1/**
2 * Unit tests for fsw::time::BoardClockSync (pure, deterministic — no sockets,
3 * no real clock). House style: plain main(), return 0 on pass / 1 on failure
4 * (see test_sequencer_elodin.cpp). Registered with CTest in lib/CMakeLists.txt.
5 *
6 * Edge-case matrix (from the time-sync design):
7 * 1. within-packet spreading 6. uint32 wrap mid-packet
8 * 2. min-filter under one-sided jitter 7. safety clamp → arrival fallback
9 * 3. crystal drift (both signs) 8. per-board monotonic guard
10 * 4. reboot → resync → re-lock 9. multi-board independence
11 * 5. uint32 wrap between packets 10. arrival mode (revert switch)
12 */
13
14#include <algorithm>
15#include <cinttypes>
16#include <cmath>
17#include <cstdio>
18#include <string>
19#include <vector>
20
22
25
26static int g_failures = 0;
27
28#define CHECK(cond, ...) \
29 do { \
30 if (!(cond)) { \
31 g_failures++; \
32 std::printf(" ❌ [%s:%d] ", __func__, __LINE__); \
33 std::printf(__VA_ARGS__); \
34 std::printf("\n"); \
35 } \
36 } while (0)
37
38namespace {
39
40constexpr uint64_t NS_MS = 1000000ULL;
41constexpr uint64_t EPOCH = 1800000000000000000ULL; // arbitrary epoch-ns base
42constexpr uint64_t U32_WRAP = 4294967296ULL; // 2^32
43
47 cfg.window_seconds = 10;
48 cfg.max_plausible_gap_s = 60;
49 cfg.max_batch_age_s = 5;
50 cfg.resync_threshold_ms = 1000;
51 return cfg;
52}
53
54/** |a-b| for uint64 without underflow. */
55uint64_t absdiff(uint64_t a, uint64_t b) {
56 return a > b ? a - b : b - a;
57}
58
59/** Deterministic LCG so jitter sequences are reproducible. */
60struct Lcg {
61 uint64_t s;
62 explicit Lcg(uint64_t seed) : s(seed) {
63 }
64 uint64_t next() {
65 s = s * 6364136223846793005ULL + 1442695040888963407ULL;
66 return s >> 33;
67 }
68 uint64_t below(uint64_t n) {
69 return next() % n;
70 }
71};
72
73// ── 1. Within-packet spreading ───────────────────────────────────────────────
76 // First packet: 3 chunks 100 ms apart; anchor = arrival.
77 const uint64_t arrival = EPOCH;
78 auto out = sync.stamp_packet("b1", arrival, {1000, 1100, 1200});
79 CHECK(out.size() == 3, "expected 3 stamps");
80 CHECK(out[2] == arrival, "newest chunk must land on arrival (got delta %" PRIu64 ")",
81 absdiff(out[2], arrival));
82 CHECK(out[1] == arrival - 100 * NS_MS, "middle chunk -100ms");
83 CHECK(out[0] == arrival - 200 * NS_MS, "oldest chunk -200ms");
84 CHECK(sync.stats("b1").locked, "board should be locked after first packet");
85 CHECK(sync.stats("b1").resyncs == 0, "first packet is not a resync");
86}
87
88// ── 2. Min-filter under one-sided jitter (+ congestion immunity) ─────────────
91 // True relationship: send_time = EPOCH + board_ms. Delays one-sided, with a
92 // known minimum of 0 occurring periodically → offset should lock to truth.
93 const uint64_t delays[] = {5, 17, 3, 0, 12, 8, 0, 22, 1, 15};
94 uint32_t board_ms = 1000;
95 uint64_t last = 0;
96 for (int i = 0; i < 100; i++) {
97 const uint64_t true_send = EPOCH + static_cast<uint64_t>(board_ms) * NS_MS;
98 const uint64_t arrival = true_send + delays[i % 10] * NS_MS;
99 auto out = sync.stamp_packet("b1", arrival, {board_ms});
100 last = out[0];
101 board_ms += 100;
102 }
103 // After lock, every stamp should equal the true send time exactly (min=0 seen).
104 const uint64_t true_last = EPOCH + static_cast<uint64_t>(board_ms - 100) * NS_MS;
105 CHECK(absdiff(last, true_last) <= 1 * NS_MS, "locked stamp off by %" PRIu64 " ns",
106 absdiff(last, true_last));
107
108 // Congestion burst: 20 packets all +50 ms late — stamps must stay true
109 // (the min-filter ignores one-sided delay; board time + offset is used).
110 for (int i = 0; i < 20; i++) {
111 const uint64_t true_send = EPOCH + static_cast<uint64_t>(board_ms) * NS_MS;
112 auto out = sync.stamp_packet("b1", true_send + 50 * NS_MS, {board_ms});
113 CHECK(absdiff(out[0], true_send) <= 1 * NS_MS,
114 "congested packet stamped %" PRIu64 " ns off truth", absdiff(out[0], true_send));
115 board_ms += 100;
116 }
117 CHECK(sync.stats("b1").resyncs == 0, "no resyncs expected under jitter/congestion");
118 CHECK(sync.stats("b1").jitter_ms > 1.0, "jitter stat should reflect the 50ms burst");
119}
120
121// ── 3. Crystal drift, both signs ─────────────────────────────────────────────
123 for (const double ppm : {+200.0, -200.0}) {
125 const std::string key = ppm > 0 ? "fast" : "slow";
126 uint64_t worst = 0;
127 // 60 s at 10 Hz; board clock runs at (1+ppm) rate; delivery delay 2 ms.
128 for (int i = 0; i < 600; i++) {
129 const double real_s = 0.1 * i;
130 const uint64_t true_send = EPOCH + static_cast<uint64_t>(real_s * 1e9);
131 const uint32_t board_ms =
132 static_cast<uint32_t>(1000.0 + real_s * 1000.0 * (1.0 + ppm * 1e-6));
133 auto out = sync.stamp_packet(key, true_send + 2 * NS_MS, {board_ms});
134 if (i > 100)
135 worst = std::max(worst, absdiff(out[0], true_send));
136 }
137 // Bound: delivery delay (2ms, constant → indistinguishable from offset)
138 // plus window staleness (10 s × 200 ppm = 2 ms) plus ms quantization.
139 CHECK(worst <= 6 * NS_MS, "%s drift worst error %" PRIu64 " ns (limit 6ms)", key.c_str(),
140 worst);
141 CHECK(sync.stats(key).resyncs == 0, "%s drift must not cause resyncs", key.c_str());
142 }
143}
144
145// ── 4. Reboot → resync → re-lock ─────────────────────────────────────────────
148 uint64_t arrival = EPOCH;
149 uint32_t board_ms = 3000000; // ~50 min uptime
150 uint64_t prev_stamp = 0;
151 for (int i = 0; i < 50; i++) {
152 auto out = sync.stamp_packet("b1", arrival, {board_ms});
153 prev_stamp = out[0];
154 arrival += 100 * NS_MS;
155 board_ms += 100;
156 }
157 CHECK(sync.stats("b1").resyncs == 0, "pre-reboot: no resyncs");
158
159 // Reboot: millis resets to ~0. Forward modular step ≈ 2^32 - uptime → huge.
160 board_ms = 5;
161 auto out = sync.stamp_packet("b1", arrival, {board_ms});
162 CHECK(sync.stats("b1").resyncs == 1, "reboot must count exactly one resync");
163 CHECK(out[0] == arrival, "resync packet anchors at arrival");
164 CHECK(out[0] >= prev_stamp, "monotonicity across reboot");
165
166 // Re-locked: subsequent packets spread normally off the new anchor.
167 arrival += 100 * NS_MS;
168 board_ms += 100;
169 auto out2 = sync.stamp_packet("b1", arrival, {static_cast<uint32_t>(board_ms - 50), board_ms});
170 CHECK(out2[1] == arrival, "post-reboot newest at arrival");
171 CHECK(out2[0] == arrival - 50 * NS_MS, "post-reboot spreading intact");
172 CHECK(sync.stats("b1").resyncs == 1, "re-lock must not add resyncs");
173}
174
175// ── 5. uint32 wrap between packets ───────────────────────────────────────────
178 uint64_t arrival = EPOCH;
179 uint64_t board_ms64 = U32_WRAP - 2000; // 2 s before wrap
180 uint64_t prev = 0;
181 for (int i = 0; i < 50; i++) { // crosses the wrap around i=20
182 const uint32_t raw = static_cast<uint32_t>(board_ms64 & 0xFFFFFFFFULL);
183 auto out = sync.stamp_packet("b1", arrival, {raw});
184 if (i > 0) {
185 CHECK(out[0] - prev == 100 * NS_MS,
186 "spacing must stay exactly 100ms across the wrap (i=%d, got %" PRIu64 ")", i,
187 out[0] - prev);
188 }
189 prev = out[0];
190 arrival += 100 * NS_MS;
191 board_ms64 += 100;
192 }
193 CHECK(sync.stats("b1").resyncs == 0, "a wrap is NOT a reboot — no resync");
194 CHECK(sync.stats("b1").clamp_fallbacks == 0, "no clamps across wrap");
195}
196
197// ── 6. uint32 wrap mid-packet ────────────────────────────────────────────────
200 // Prime the board just before the wrap.
201 sync.stamp_packet("b1", EPOCH, {static_cast<uint32_t>(U32_WRAP - 400)});
202 // Packet whose chunks span the wrap: ...-200, -100, then 0 (=wrap), +100.
203 const uint64_t arrival = EPOCH + 300 * NS_MS;
204 auto out = sync.stamp_packet(
205 "b1", arrival,
206 {static_cast<uint32_t>(U32_WRAP - 200), static_cast<uint32_t>(U32_WRAP - 100), 0, 100});
207 CHECK(out[3] - out[2] == 100 * NS_MS, "post-wrap spacing");
208 CHECK(out[2] - out[1] == 100 * NS_MS, "spacing across the wrap boundary");
209 CHECK(out[1] - out[0] == 100 * NS_MS, "pre-wrap spacing");
210 CHECK(sync.stats("b1").resyncs == 0, "mid-packet wrap is not a resync");
211 CHECK(sync.stats("b1").clamp_fallbacks == 0, "mid-packet wrap must not clamp");
212}
213
214// ── 7. Safety clamp → flat arrival fallback ──────────────────────────────────
217 sync.stamp_packet("b1", EPOCH, {1000});
218 // Chunk claiming to be 10 s older than the newest (> max_batch_age 5 s):
219 // plausible intra-packet step, but the stamp lands before the age floor.
220 const uint64_t arrival = EPOCH + 10100 * NS_MS;
221 auto out = sync.stamp_packet("b1", arrival, {1100, 11100});
222 CHECK(out[0] == arrival, "too-old chunk must fall back to arrival");
223 CHECK(sync.stats("b1").clamp_fallbacks == 1, "fallback must be counted");
224 CHECK(out[1] == arrival, "newest chunk at arrival here");
225
226 // Garbage intra-packet spacing (backwards step > max gap): older chunk clamps.
227 const uint64_t arrival2 = arrival + 100 * NS_MS;
228 auto out2 = sync.stamp_packet("b1", arrival2, {900000000, 11200});
229 CHECK(out2[0] == arrival2, "garbage-spaced chunk must fall back to arrival");
230 CHECK(sync.stats("b1").clamp_fallbacks == 2, "second fallback counted");
231 // Invariant: nothing ever stamps in the future.
232 for (auto t : out2)
233 CHECK(t <= arrival2, "no stamp may exceed arrival");
234}
235
236// ── 8. Per-board monotonic guard ─────────────────────────────────────────────
239 // Packet 1 delayed 10 ms with an empty window → offset absorbs the delay.
240 const uint64_t send1 = EPOCH + 1000 * NS_MS;
241 auto out1 = sync.stamp_packet("b1", send1 + 10 * NS_MS, {1000});
242 // Packet 2: board advances only 5 ms but delivery delay drops to 0 →
243 // naively t2 = t1 - 5ms. Guard must hold the line.
244 const uint64_t send2 = EPOCH + 1005 * NS_MS;
245 auto out2 = sync.stamp_packet("b1", send2, {1005});
246 CHECK(out2[0] >= out1[0], "emitted stamps must be non-decreasing (got -%" PRIu64 " ns)",
247 out1[0] > out2[0] ? out1[0] - out2[0] : 0);
248}
249
250// ── 9. Multi-board independence ──────────────────────────────────────────────
253 // Board A: normal. Board B: absurd absolute clock (offset ~ -1h), which is
254 // fine — offset is per-board. A reboot on B must not disturb A.
255 uint64_t arrival = EPOCH;
256 uint32_t a_ms = 1000, b_ms = 3600000;
257 uint64_t a_prev = 0;
258 for (int i = 0; i < 30; i++) {
259 auto oa = sync.stamp_packet("A", arrival + 1 * NS_MS, {a_ms});
260 sync.stamp_packet("B", arrival + 2 * NS_MS, {b_ms});
261 if (i > 0)
262 CHECK(oa[0] - a_prev == 100 * NS_MS, "board A spacing exact");
263 a_prev = oa[0];
264 arrival += 100 * NS_MS;
265 a_ms += 100;
266 b_ms += 100;
267 }
268 sync.stamp_packet("B", arrival + 2 * NS_MS, {7}); // B reboots
269 CHECK(sync.stats("B").resyncs == 1, "B resynced");
270 CHECK(sync.stats("A").resyncs == 0, "A unaffected by B's reboot");
271 auto oa = sync.stamp_packet("A", arrival + 100 * NS_MS + 1 * NS_MS, {a_ms});
272 CHECK(oa[0] - a_prev == 100 * NS_MS, "A spacing exact after B's reboot");
273}
274
275// ── 10. Arrival mode (revert switch) ─────────────────────────────────────────
279 BoardClockSync sync(cfg);
280 const uint64_t arrival = EPOCH + 42 * NS_MS;
281 auto out = sync.stamp_packet("b1", arrival, {1000, 1100, 1200});
282 for (auto t : out)
283 CHECK(t == arrival, "arrival mode: every chunk at arrival");
284 CHECK(sync.stats("b1").resyncs == 0 && sync.stats("b1").clamp_fallbacks == 0,
285 "arrival mode touches no sync machinery");
286}
287
288// ── Fuzz: random jitter, invariants always hold ──────────────────────────────
291 Lcg rng(0xD1AB70);
292 uint32_t board_ms = 500;
293 uint64_t arrival = EPOCH;
294 uint64_t prev = 0;
295 for (int i = 0; i < 2000; i++) {
296 const uint32_t n_chunks = 1 + static_cast<uint32_t>(rng.below(4));
297 std::vector<uint32_t> chunks;
298 for (uint32_t c = 0; c < n_chunks; c++) {
299 chunks.push_back(board_ms);
300 board_ms += 20 + static_cast<uint32_t>(rng.below(30));
301 }
302 arrival += (50 + rng.below(100)) * NS_MS; // real time advances
303 const uint64_t jittered = arrival + rng.below(40) * NS_MS; // one-sided delay
304 auto out = sync.stamp_packet("fz", jittered, chunks);
305 for (auto t : out) {
306 CHECK(t <= jittered, "fuzz: stamp beyond arrival at i=%d", i);
307 CHECK(t >= prev, "fuzz: non-monotonic stamp at i=%d", i);
308 prev = t;
309 }
310 }
311}
312
313} // namespace
314
315int main() {
316 std::printf("BoardClockSync unit tests\n");
319 test_drift();
320 test_reboot();
328
329 if (g_failures == 0) {
330 std::printf("✅ all BoardClockSync tests passed\n");
331 return 0;
332 }
333 std::printf("❌ %d failure(s)\n", g_failures);
334 return 1;
335}
Per-board clock sync for DAQ sample timestamping.
std::vector< uint64_t > stamp_packet(const std::string &board_key, uint64_t arrival_ns, const std::vector< uint32_t > &chunk_ms)
Stamp one packet's chunks for one board.
BoardStats stats(const std::string &board_key) const
Stats for one board (default-constructed if unknown).
std::vector< daq::SensorDataChunkCollection > chunks
Definition main.cpp:55
*brief create JSON pointer *sa reference_tokens const string_t const string_t & b
Definition json.hpp:13878
*brief create JSON pointer *sa reference_tokens const string_t & a
Definition json.hpp:13877
uint64_t absdiff(uint64_t a, uint64_t b)
|a-b| for uint64 without underflow.
*brief user defined string literal for JSON values *sa std::size_t n
Definition json.hpp:24474
Deterministic LCG so jitter sequences are reproducible.
static int g_failures
#define CHECK(cond,...)