The counter is the denominator of the very period lastAccHrt is the numerator of, so any packet that cannot move the tick reference must not move the counter either. Two paths were violating that: a reordered datagram rolled the counter back while the reference correctly held (next burst drawn 0.048x too narrow at distance 20, 83.3% worst spacing error under 2% sustained reordering), and a stray hrt == 0 packet advanced the counter from the warm-up branch without a tick to match (+22.5 ms of future-dating per stray packet). Rules 1 and 2 now record a counter too. The duplicate-datagram guard is keyed on one, so an array rule that recorded none was exempt and plotted every doubly-delivered update twice. Also: rule 2 divides by the count the anchor actually spans and falls back to the last period it derived; the counter-gap test is wrap-safe so 2^32 rollover reads as no information rather than 2e9 lost packets. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1349 lines
58 KiB
C++
1349 lines
58 KiB
C++
#include "FrameDecoder.h"
|
|
|
|
#include <gtest/gtest.h>
|
|
|
|
#include <cmath>
|
|
#include <limits>
|
|
#include <vector>
|
|
|
|
using namespace udpscope;
|
|
|
|
namespace {
|
|
|
|
/** Builds a FrameView over vectors the test owns. */
|
|
struct FrameBuilder {
|
|
std::vector<std::vector<double>> storage;
|
|
std::vector<const double*> ptrs;
|
|
std::vector<uint32_t> counts;
|
|
FrameView view;
|
|
|
|
void addSignal(std::vector<double> vals) {
|
|
storage.push_back(std::move(vals));
|
|
}
|
|
|
|
/* Real frames carry a per-update counter; leaving it at zero would hide
|
|
* whichever rules depend on it, so it must be passed explicitly. */
|
|
const FrameView& build(uint64_t hrt, double recvTime, uint32_t numSamples = 1,
|
|
uint32_t counter = 0) {
|
|
ptrs.clear();
|
|
counts.clear();
|
|
for (const auto& s : storage) {
|
|
ptrs.push_back(s.data());
|
|
counts.push_back(static_cast<uint32_t>(s.size()));
|
|
}
|
|
view.counter = counter;
|
|
view.hrt = hrt;
|
|
view.recvTime = recvTime;
|
|
view.numSamples = numSamples;
|
|
view.numSignals = static_cast<uint32_t>(storage.size());
|
|
view.values = ptrs.data();
|
|
view.counts = counts.data();
|
|
return view;
|
|
}
|
|
};
|
|
|
|
SignalMeta burst(const char* name, uint8_t timeMode, double rate,
|
|
uint32_t elems, uint32_t timeIdx) {
|
|
SignalMeta m;
|
|
m.name = name;
|
|
m.typeCode = 8; /* float32 */
|
|
m.numRows = elems;
|
|
m.numCols = 1;
|
|
m.timeMode = timeMode;
|
|
m.samplingRate = rate;
|
|
m.timeSignalIdx = timeIdx;
|
|
return m;
|
|
}
|
|
|
|
SignalMeta timeSignal(const char* name, uint32_t elems) {
|
|
SignalMeta m;
|
|
m.name = name;
|
|
m.typeCode = 6; /* uint64 -> nanoseconds */
|
|
m.numRows = elems;
|
|
m.numCols = 1;
|
|
return m;
|
|
}
|
|
|
|
} /* namespace */
|
|
|
|
TEST(FrameDecoder, FullArrayTakesOneStampPerElementFromTheTimeSignal) {
|
|
FrameDecoder dec;
|
|
dec.setSignals({burst("Sine", kTimeFullArray, 1000.0, 4, 1),
|
|
timeSignal("Time", 4)});
|
|
|
|
FrameBuilder fb;
|
|
fb.addSignal({1.0, 2.0, 3.0, 4.0});
|
|
/* Nanoseconds: 5.000, 5.001, 5.002, 5.003 s of producer time. */
|
|
fb.addSignal({5.0e9, 5.001e9, 5.002e9, 5.003e9});
|
|
const FrameView& f = fb.build(0, 1000.0);
|
|
|
|
dec.beginFrame(f);
|
|
std::vector<double> ts;
|
|
ASSERT_TRUE(dec.timestamps(f, 0, ts));
|
|
ASSERT_EQ(ts.size(), 4u);
|
|
|
|
/* Element 0 lands on the arrival time; the rest keep the producer spacing. */
|
|
EXPECT_NEAR(ts[0], 1000.000, 1e-9);
|
|
EXPECT_NEAR(ts[1], 1000.001, 1e-9);
|
|
EXPECT_NEAR(ts[2], 1000.002, 1e-9);
|
|
EXPECT_NEAR(ts[3], 1000.003, 1e-9);
|
|
}
|
|
|
|
// A host joined on two interfaces receives every unfragmented update twice, and
|
|
// the second copy is a different signal's problem only if the guard can see it.
|
|
// It is keyed on a counter each rule leaves behind, so an array rule that never
|
|
// records one is silently exempt — and would plot every array twice, at two
|
|
// arrival times, doubling back on the X axis. Rules 1 and 2 join rule 3's
|
|
// counter-keeping for this reason alone; neither reads the value back.
|
|
TEST(FrameDecoder, ArrayRulesDropADuplicatedDatagram) {
|
|
for (uint8_t mode : {kTimeFullArray, kTimeFirstSample}) {
|
|
FrameDecoder dec;
|
|
dec.setSignals({burst("Sine", mode, 1000.0, 4, 1),
|
|
timeSignal("Time", mode == kTimeFullArray ? 4u : 1u)});
|
|
|
|
FrameBuilder fb;
|
|
fb.addSignal({1.0, 2.0, 3.0, 4.0});
|
|
if (mode == kTimeFullArray) {
|
|
fb.addSignal({5.0e9, 5.001e9, 5.002e9, 5.003e9});
|
|
} else {
|
|
fb.addSignal({5.0e9});
|
|
}
|
|
|
|
const FrameView& first = fb.build(0, 1000.0, 4, 77u);
|
|
dec.beginFrame(first);
|
|
std::vector<double> ts;
|
|
ASSERT_TRUE(dec.timestamps(first, 0, ts)) << "mode " << int(mode);
|
|
|
|
/* Same counter, same payload, a fraction of a millisecond later off the
|
|
* second interface. */
|
|
const FrameView& dup = fb.build(0, 1000.0004, 4, 77u);
|
|
dec.beginFrame(dup);
|
|
EXPECT_FALSE(dec.timestamps(dup, 0, ts))
|
|
<< "mode " << int(mode) << " emitted the duplicate array twice";
|
|
}
|
|
}
|
|
|
|
TEST(FrameDecoder, FirstSampleAnchorsElementZeroAndCountsForward) {
|
|
FrameDecoder dec;
|
|
dec.setSignals({burst("Sine", kTimeFirstSample, 1000.0, 4, 1),
|
|
timeSignal("Time", 1)});
|
|
|
|
FrameBuilder fb;
|
|
fb.addSignal({1.0, 2.0, 3.0, 4.0});
|
|
fb.addSignal({7.0e9});
|
|
const FrameView& f = fb.build(0, 2000.0);
|
|
|
|
dec.beginFrame(f);
|
|
std::vector<double> ts;
|
|
ASSERT_TRUE(dec.timestamps(f, 0, ts));
|
|
ASSERT_EQ(ts.size(), 4u);
|
|
EXPECT_NEAR(ts[0], 2000.000, 1e-9);
|
|
EXPECT_NEAR(ts[3], 2000.003, 1e-9);
|
|
}
|
|
|
|
// With no declared rate there is nothing to spread the array by, and
|
|
// UDPSourceSession.cpp:522 leaves the step at zero — every element of the array
|
|
// on one instant. A host-local consumer only stores them; this scope's ring,
|
|
// decimator and trigger all require increasing stamps, and N points at one X is
|
|
// not a trace. Consecutive time-signal anchors carry the burst duration on the
|
|
// PRODUCER'S clock, so the spread is recoverable without a rate.
|
|
TEST(FrameDecoder, FirstSampleWithNoRateSpreadsFromConsecutiveAnchors) {
|
|
FrameDecoder dec;
|
|
dec.setSignals({burst("Sine", kTimeFirstSample, 0.0, 4, 1),
|
|
timeSignal("Time", 1)});
|
|
|
|
/* 4 samples per packet, anchors 4 ms apart: a 1 ms period. Arrivals are
|
|
* jittered so a spread accidentally taken from arrival would be visible. */
|
|
const double jitter[4] = {0.0, 0.0021, -0.0017, 0.0};
|
|
std::vector<double> ts;
|
|
for (int p = 0; p < 5; p++) {
|
|
FrameBuilder fb;
|
|
fb.addSignal({1.0, 2.0, 3.0, 4.0});
|
|
fb.addSignal({7.0e9 + p * 4.0e6}); /* ns, +4 ms per packet */
|
|
const FrameView& f = fb.build(0, 2000.0 + p * 0.004 + jitter[p % 4], 4,
|
|
static_cast<uint32_t>(p + 1));
|
|
dec.beginFrame(f);
|
|
ASSERT_TRUE(dec.timestamps(f, 0, ts));
|
|
ASSERT_EQ(ts.size(), 4u);
|
|
for (size_t i = 1; i < ts.size(); i++) {
|
|
/* The first packet has no predecessor to measure against and legally
|
|
* stacks; from the second on the array must be spread. */
|
|
if (p > 0) { ASSERT_GT(ts[i], ts[i - 1]) << "packet " << p; }
|
|
}
|
|
if (p > 0) { EXPECT_NEAR(ts[1] - ts[0], 0.001, 1e-9) << "packet " << p; }
|
|
}
|
|
|
|
/* A lost datagram doubles the anchor difference; without reading the counter
|
|
* the recovery packet would be spread twice as wide. */
|
|
FrameBuilder fb;
|
|
fb.addSignal({1.0, 2.0, 3.0, 4.0});
|
|
fb.addSignal({7.0e9 + 5 * 4.0e6 + 4.0e6}); /* packet 6 arrives, 5 lost */
|
|
const FrameView& f = fb.build(0, 2000.024, 4, 7u);
|
|
dec.beginFrame(f);
|
|
ASSERT_TRUE(dec.timestamps(f, 0, ts));
|
|
EXPECT_NEAR(ts[1] - ts[0], 0.001, 1e-9) << "loss stretched the array";
|
|
}
|
|
|
|
TEST(FrameDecoder, LastSampleAnchorsTheFinalElementAndCountsBackward) {
|
|
FrameDecoder dec;
|
|
dec.setSignals({burst("Sine", kTimeLastSample, 1000.0, 4, 1),
|
|
timeSignal("Time", 1)});
|
|
|
|
FrameBuilder fb;
|
|
fb.addSignal({1.0, 2.0, 3.0, 4.0});
|
|
fb.addSignal({7.0e9});
|
|
const FrameView& f = fb.build(0, 3000.0);
|
|
|
|
dec.beginFrame(f);
|
|
std::vector<double> ts;
|
|
ASSERT_TRUE(dec.timestamps(f, 0, ts));
|
|
ASSERT_EQ(ts.size(), 4u);
|
|
EXPECT_NEAR(ts[3], 3000.000, 1e-9);
|
|
EXPECT_NEAR(ts[0], 3000.000 - 0.003, 1e-9);
|
|
}
|
|
|
|
TEST(FrameDecoder, PlainScalarUsesArrivalTime) {
|
|
FrameDecoder dec;
|
|
SignalMeta m;
|
|
m.name = "Level";
|
|
m.typeCode = 9;
|
|
dec.setSignals({m});
|
|
|
|
FrameBuilder fb;
|
|
fb.addSignal({42.0});
|
|
const FrameView& f = fb.build(0, 1234.5);
|
|
|
|
dec.beginFrame(f);
|
|
std::vector<double> ts;
|
|
ASSERT_TRUE(dec.timestamps(f, 0, ts));
|
|
ASSERT_EQ(ts.size(), 1u);
|
|
EXPECT_DOUBLE_EQ(ts[0], 1234.5);
|
|
}
|
|
|
|
// This is the failure UDPSourceSession.cpp:560 documents. The kernel delivers
|
|
// two queued datagrams microseconds apart even though each carries 10 ms of
|
|
// signal. Dating from arrival crams the second packet's samples into that gap
|
|
// and the trace becomes a sawtooth; dating from the producer hrt does not.
|
|
TEST(FrameDecoder, AccumulatedScalarSurvivesBurstyDelivery) {
|
|
FrameDecoder dec;
|
|
SignalMeta m;
|
|
m.name = "Acc";
|
|
m.typeCode = 9;
|
|
m.numRows = 1;
|
|
m.samplingRate = 1000.0; /* 1 kHz, 10 samples = 10 ms per packet */
|
|
dec.setSignals({m});
|
|
|
|
const double ticks = 1.0e9;
|
|
std::vector<double> all;
|
|
|
|
for (int p = 0; p < 40; p++) {
|
|
FrameBuilder fb;
|
|
fb.addSignal(std::vector<double>(10, static_cast<double>(p)));
|
|
const double producerSec = 100.0 + p * 0.010;
|
|
/* Packets 20+ arrive in a burst, all within 50 us of each other. */
|
|
const double arrival = (p < 20) ? (500.0 + p * 0.010)
|
|
: (500.2 + (p - 20) * 0.00005);
|
|
const FrameView& f = fb.build(static_cast<uint64_t>(producerSec * ticks),
|
|
arrival, 10, static_cast<uint32_t>(p + 1));
|
|
dec.beginFrame(f);
|
|
std::vector<double> ts;
|
|
if (dec.timestamps(f, 0, ts)) {
|
|
all.insert(all.end(), ts.begin(), ts.end());
|
|
}
|
|
}
|
|
|
|
ASSERT_GT(all.size(), 300u);
|
|
for (size_t i = 1; i < all.size(); i++) {
|
|
EXPECT_GT(all[i], all[i - 1]) << "non-monotonic at " << i;
|
|
EXPECT_NEAR(all[i] - all[i - 1], 0.001, 2e-4)
|
|
<< "spacing collapsed at " << i << " (sawtooth)";
|
|
}
|
|
}
|
|
|
|
namespace {
|
|
|
|
/** Ten contiguous 10-sample bursts at 1 kHz, counters 1..10, ending at 500.090. */
|
|
SignalMeta accSignal() {
|
|
SignalMeta m;
|
|
m.name = "Acc";
|
|
m.typeCode = 9;
|
|
m.numRows = 1;
|
|
m.samplingRate = 1000.0; /* 10 samples = 10 ms per packet */
|
|
return m;
|
|
}
|
|
|
|
void primeTenBursts(FrameDecoder& dec, std::vector<double>& ts, bool withCounter) {
|
|
for (int p = 0; p < 10; p++) {
|
|
FrameBuilder fb;
|
|
fb.addSignal(std::vector<double>(10, 1.0));
|
|
const FrameView& f = fb.build(0, 500.0 + p * 0.010, 10,
|
|
withCounter ? static_cast<uint32_t>(p + 1) : 0u);
|
|
dec.beginFrame(f);
|
|
ASSERT_TRUE(dec.timestamps(f, 0, ts));
|
|
}
|
|
ASSERT_NEAR(ts[9], 500.090, 1e-9);
|
|
}
|
|
|
|
} /* namespace */
|
|
|
|
// The counterweight to the test above. Chaining bursts to suppress arrival
|
|
// jitter is only safe if loss is accounted for: a bare chain closes the hole a
|
|
// dropped datagram left and dates every later sample early for the rest of the
|
|
// run. The wire says exactly how much is missing, so no estimate is needed —
|
|
// and this test deliberately makes arrival time a LIAR (200 ms off) to prove
|
|
// the reconstruction comes from the counter and not from when the packet landed.
|
|
TEST(FrameDecoder, AccumulatedScalarReinstatesLostPacketsFromTheCounterGap) {
|
|
FrameDecoder dec;
|
|
dec.setSignals({accSignal()});
|
|
std::vector<double> ts;
|
|
primeTenBursts(dec, ts, /*withCounter=*/true);
|
|
|
|
/* Counter 111 after 10: 100 packets lost, 1000 samples, exactly 1 s. The
|
|
* packet lands 200 ms later than that truth would predict. */
|
|
FrameBuilder fb;
|
|
fb.addSignal(std::vector<double>(10, 1.0));
|
|
const FrameView& f = fb.build(0, 501.300, 10, 111u);
|
|
dec.beginFrame(f);
|
|
ASSERT_TRUE(dec.timestamps(f, 0, ts));
|
|
|
|
/* Chaining blindly gives 500.091; anchoring on arrival gives 501.291. */
|
|
EXPECT_NEAR(ts[0], 501.091, 1e-9);
|
|
EXPECT_NEAR(ts[9], 501.100, 1e-9);
|
|
}
|
|
|
|
// A producer that never advances the counter, or restarts it, leaves nothing to
|
|
// reconstruct from. Arrival time is then the better of two bad answers, and the
|
|
// chain has to be abandoned rather than left to drift forever.
|
|
TEST(FrameDecoder, AccumulatedScalarResyncsOnArrivalWhenTheCounterSaysNothing) {
|
|
FrameDecoder dec;
|
|
dec.setSignals({accSignal()});
|
|
std::vector<double> ts;
|
|
primeTenBursts(dec, ts, /*withCounter=*/false);
|
|
|
|
FrameBuilder fb;
|
|
fb.addSignal(std::vector<double>(10, 1.0));
|
|
const FrameView& f = fb.build(0, 501.100, 10, 0u);
|
|
dec.beginFrame(f);
|
|
ASSERT_TRUE(dec.timestamps(f, 0, ts));
|
|
|
|
EXPECT_NEAR(ts[0], 501.091, 1e-9);
|
|
EXPECT_NEAR(ts[9], 501.100, 1e-9);
|
|
}
|
|
|
|
// Re-anchoring must never move a signal's timestamps backwards: the ring, the
|
|
// trigger and the exporter all assume they increase, and a backward step is
|
|
// indistinguishable from corruption downstream. Here the counter claims a
|
|
// 20 s hole while the packet arrives 10 ms after the last one, so the
|
|
// prediction and arrival disagree wildly and arrival points into the past.
|
|
TEST(FrameDecoder, AccumulatedScalarNeverStepsBackwardsWhenResyncing) {
|
|
FrameDecoder dec;
|
|
dec.setSignals({accSignal()});
|
|
std::vector<double> ts;
|
|
primeTenBursts(dec, ts, /*withCounter=*/true);
|
|
const double prevEnd = ts[9];
|
|
|
|
FrameBuilder fb;
|
|
fb.addSignal(std::vector<double>(10, 1.0));
|
|
const FrameView& f = fb.build(0, 500.000, 10, 2010u);
|
|
dec.beginFrame(f);
|
|
ASSERT_TRUE(dec.timestamps(f, 0, ts));
|
|
|
|
/* Arrival (500.000) is behind our timeline, so there is nothing to spread
|
|
* into; the burst is squeezed instead, which bleeds the lead off while still
|
|
* moving strictly forwards. The excess (90 ms) is nine nominal burst widths,
|
|
* so the squeeze hits its floor of 0.05 and the step is 50 us. */
|
|
EXPECT_NEAR(ts[0], 500.09005, 1e-9);
|
|
EXPECT_GT(ts[0], prevEnd) << "resync stepped backwards over the previous burst";
|
|
for (size_t i = 1; i < ts.size(); i++) {
|
|
EXPECT_GT(ts[i], ts[i - 1]);
|
|
}
|
|
}
|
|
|
|
// When the chain has to be abandoned but arrival lies just ahead of where the
|
|
// last burst ended, the correction is made by COMPRESSING this one burst rather
|
|
// than by stepping back. Rejecting the correction instead would be one-directional
|
|
// — `predicted` is never below lastEmittedEnd + dt — and a timeline running fast
|
|
// could then never be pulled back.
|
|
TEST(FrameDecoder, AccumulatedScalarCompressesOneBurstRatherThanStepBack) {
|
|
FrameDecoder dec;
|
|
dec.setSignals({accSignal()});
|
|
std::vector<double> ts;
|
|
primeTenBursts(dec, ts, /*withCounter=*/true);
|
|
|
|
/* The compress branch needs the prediction to be rejected while arrival
|
|
* still sits between the previous burst's end and one burst beyond it —
|
|
* which a plain rate mismatch cannot produce, since the prediction is then
|
|
* only a burst away from arrival. It takes a fabricated loss: this gap
|
|
* claims 900000 lost packets, putting the prediction 2.5 hours out, while
|
|
* the packet itself lands 5 ms after the last burst ended so its arrival
|
|
* anchor (500.086) falls just behind that end. */
|
|
FrameBuilder fb;
|
|
fb.addSignal(std::vector<double>(10, 1.0));
|
|
const FrameView& f = fb.build(0, 500.095, 10, 900011u);
|
|
dec.beginFrame(f);
|
|
ASSERT_TRUE(dec.timestamps(f, 0, ts));
|
|
|
|
EXPECT_GT(ts[0], 500.090) << "compressed burst must still start after the last one";
|
|
EXPECT_NEAR(ts[9], 500.095, 1e-9) << "and end exactly on arrival";
|
|
EXPECT_NEAR(ts[1] - ts[0], 0.0005, 1e-9) << "spread over the available room";
|
|
}
|
|
|
|
// The whole point of compressing: a declared SamplingRate is a hand-written
|
|
// config value, and even a correct one is measured against the producer host's
|
|
// crystal, not ours. Tens of ppm of difference is certain over a long session,
|
|
// so the reconstructed timeline WILL run away from the wall clock. It has to be
|
|
// pulled back, and it has to stay monotonic while that happens.
|
|
TEST(FrameDecoder, AccumulatedScalarDoesNotDriftAwayFromTheWallClockForever) {
|
|
FrameDecoder dec;
|
|
dec.setSignals({accSignal()}); /* declares 1 kHz */
|
|
|
|
/* The producer really runs 1 % fast: 10 samples take 9.9 ms of wall time,
|
|
* so a chain stepping the declared 10 ms per packet gains 0.1 ms every
|
|
* packet. This is the direction re-anchoring alone cannot fix: arrival is
|
|
* always BEHIND the chain, so anchoring on it would step backwards and is
|
|
* refused. Only compression pulls the timeline back. */
|
|
double worstLead = 0.0;
|
|
double lastEnd = 0.0;
|
|
for (int p = 0; p < 20000; p++) {
|
|
FrameBuilder fb;
|
|
fb.addSignal(std::vector<double>(10, 1.0));
|
|
const double arrival = 500.0 + p * 0.0099;
|
|
const FrameView& f =
|
|
fb.build(0, arrival, 10, static_cast<uint32_t>(p + 1));
|
|
dec.beginFrame(f);
|
|
std::vector<double> ts;
|
|
ASSERT_TRUE(dec.timestamps(f, 0, ts));
|
|
|
|
for (size_t i = 0; i < ts.size(); i++) {
|
|
ASSERT_GT(ts[i], lastEnd) << "timeline went backwards at packet " << p;
|
|
lastEnd = ts[i];
|
|
}
|
|
worstLead = std::max(worstLead, ts[9] - arrival);
|
|
}
|
|
|
|
/* Unchecked, 20000 packets at 0.1 ms each would put the trace 2 s ahead. */
|
|
EXPECT_LT(worstLead, 0.6) << "timeline drifted " << worstLead << " s ahead";
|
|
}
|
|
|
|
// The test above only exercises a rate that is wrong by ppm, where the squeeze's
|
|
// proportional term does all the work. A rate wrong by a FACTOR is the case the
|
|
// kMinBleedFactor floor cannot handle on its own: at the floor the timeline
|
|
// still advances kMinBleedFactor * nominal per packet, so whenever the nominal
|
|
// burst is wider than 1/kMinBleedFactor packet intervals the lead grows without
|
|
// bound rather than bleeding off (measured: 27 s of lead after 40 s of stream,
|
|
// 667 s after 1000 s). Only capping the advance against the wall time really
|
|
// elapsed since this signal's previous burst converges for every declared rate.
|
|
TEST(FrameDecoder, AccumulatedScalarConvergesWhenTheDeclaredRateIsFarTooLow) {
|
|
FrameDecoder dec;
|
|
SignalMeta m = accSignal();
|
|
m.samplingRate = 30.0; /* config says 30 Hz... */
|
|
dec.setSignals({m});
|
|
|
|
/* ...while the producer really flushes 10 samples at 1 kHz, so a packet is
|
|
* 10 ms of wall time and 333 ms of nominal, declared time. */
|
|
double worstLead = 0.0;
|
|
double last = 0.0;
|
|
for (int p = 0; p < 5000; p++) { /* 50 s of stream */
|
|
FrameBuilder fb;
|
|
fb.addSignal(std::vector<double>(10, 1.0));
|
|
const double arrival = 500.0 + p * 0.010;
|
|
const FrameView& f =
|
|
fb.build(0, arrival, 10, static_cast<uint32_t>(p + 1));
|
|
dec.beginFrame(f);
|
|
std::vector<double> ts;
|
|
ASSERT_TRUE(dec.timestamps(f, 0, ts));
|
|
for (size_t i = 0; i < ts.size(); i++) {
|
|
ASSERT_GT(ts[i], last) << "timeline went backwards at packet " << p;
|
|
last = ts[i];
|
|
}
|
|
worstLead = std::max(worstLead, ts[9] - arrival);
|
|
}
|
|
|
|
/* Bounded, not zero: normal chaining resumes the moment the squeeze stops,
|
|
* so the lead sawtooths up to about kBurstResyncThresholdS and back. */
|
|
EXPECT_LT(worstLead, 1.0) << "timeline ran " << worstLead << " s ahead";
|
|
}
|
|
|
|
// samplingRate is unvalidated wire data. A malformed +inf makes the declared
|
|
// period zero, so a burst's nominal width is zero and the proportional squeeze
|
|
// evaluates 0.0/0.0 — and a NaN factor slips past the floor, because every
|
|
// comparison against NaN is false. The burst, and then every burst after it,
|
|
// comes out NaN. Treating a non-finite rate as no rate at all removes the class.
|
|
TEST(FrameDecoder, AccumulatedScalarWithANonFiniteRateFallsBackToTheHrtPath) {
|
|
FrameDecoder dec;
|
|
SignalMeta m = accSignal();
|
|
m.samplingRate = std::numeric_limits<double>::infinity();
|
|
dec.setSignals({m});
|
|
|
|
const double ticks = 1.0e9;
|
|
std::vector<double> last;
|
|
for (int p = 0; p < 60; p++) {
|
|
FrameBuilder fb;
|
|
fb.addSignal(std::vector<double>(10, 1.0));
|
|
const double producerSec = 100.0 + p * 0.025;
|
|
/* Packet 40 lands at exactly the same instant as packet 39. With the
|
|
* degenerate zero period the previous burst ends precisely on its own
|
|
* arrival, so this makes the squeeze's excess exactly zero — the 0.0/0.0
|
|
* that produces the NaN. */
|
|
const int q = (p == 40) ? 39 : p;
|
|
/* Zero-mean jitter so the answer also identifies WHICH branch replied:
|
|
* a degenerate declared branch spans the jittered arrival gap, the hrt
|
|
* branch returns the producer's exact 2.5 ms whatever delivery did. */
|
|
const double jitter[4] = {0.0, 0.003, 0.0, -0.003};
|
|
const double arrival = 700.0 + q * 0.025 + jitter[q % 4];
|
|
const FrameView& f =
|
|
fb.build(static_cast<uint64_t>(producerSec * ticks), arrival, 10,
|
|
static_cast<uint32_t>(p + 1));
|
|
dec.beginFrame(f);
|
|
std::vector<double> ts;
|
|
if (dec.timestamps(f, 0, ts)) {
|
|
for (size_t i = 0; i < ts.size(); i++) {
|
|
ASSERT_TRUE(std::isfinite(ts[i]))
|
|
<< "packet " << p << " element " << i;
|
|
}
|
|
last = ts;
|
|
}
|
|
}
|
|
|
|
ASSERT_EQ(last.size(), 10u);
|
|
/* The tolerance is bounded from both sides and neither bound is arbitrary.
|
|
* Below: hrtDt divides a tick delta by HrtRateFit's fitted rate, and the fit
|
|
* regresses hrt against arrivals carrying the +/-3 ms jitter above, so ~2 us
|
|
* of residual is inherent — 1e-8 fails. Above: the degenerate declared branch
|
|
* would span those same jittered gaps and answer 2.2 or 2.8 ms, 300 us out.
|
|
* 1e-5 sits two orders below the thing it must reject and five times above
|
|
* the noise it must tolerate. */
|
|
EXPECT_NEAR(last[1] - last[0], 0.0025, 1e-5)
|
|
<< "an unusable declared rate must fall through to the hrt path";
|
|
}
|
|
|
|
// The C client de-duplicates fragments but not whole unfragmented updates, so a
|
|
// host subscribed on two interfaces sees each datagram twice. Emitting the
|
|
// repeat would double the values and advance time by a burst that never was.
|
|
TEST(FrameDecoder, AccumulatedScalarDropsADuplicatedDatagram) {
|
|
FrameDecoder dec;
|
|
dec.setSignals({accSignal()});
|
|
std::vector<double> ts;
|
|
primeTenBursts(dec, ts, /*withCounter=*/true);
|
|
const double endBefore = ts[9];
|
|
|
|
FrameBuilder fb;
|
|
fb.addSignal(std::vector<double>(10, 1.0));
|
|
const FrameView& dup = fb.build(0, 500.1001, 10, 10u); /* counter 10 again */
|
|
dec.beginFrame(dup);
|
|
EXPECT_FALSE(dec.timestamps(dup, 0, ts));
|
|
|
|
/* And the drop must not have disturbed the chain: the genuine next packet
|
|
* still lands one period after burst 10 ended. */
|
|
const FrameView& next = fb.build(0, 500.109, 10, 11u);
|
|
dec.beginFrame(next);
|
|
ASSERT_TRUE(dec.timestamps(next, 0, ts));
|
|
EXPECT_NEAR(ts[0], endBefore + 0.001, 1e-6);
|
|
}
|
|
|
|
// A producer restart returns the counter to zero mid-stream. The unsigned gap
|
|
// then wraps to near 2^32; the loss it implies puts the chained prediction
|
|
// centuries out, the arrival backstop rejects it, and arrival becomes the only
|
|
// usable reference.
|
|
TEST(FrameDecoder, AccumulatedScalarSurvivesAProducerRestart) {
|
|
FrameDecoder dec;
|
|
dec.setSignals({accSignal()});
|
|
std::vector<double> ts;
|
|
primeTenBursts(dec, ts, /*withCounter=*/true);
|
|
const double prevEnd = ts[9];
|
|
|
|
/* Restarted producer: counter 1 again, and the outage lasted 3 s. */
|
|
FrameBuilder fb;
|
|
fb.addSignal(std::vector<double>(10, 1.0));
|
|
const FrameView& f = fb.build(0, 503.100, 10, 1u);
|
|
dec.beginFrame(f);
|
|
ASSERT_TRUE(dec.timestamps(f, 0, ts));
|
|
|
|
/* Reading the wrapped gap as a loss count would claim ~4.3e9 lost packets,
|
|
* some 5e8 seconds of fabricated signal. */
|
|
EXPECT_NEAR(ts[9], 503.100, 1e-9) << "restart must re-anchor on arrival";
|
|
EXPECT_GT(ts[0], prevEnd);
|
|
}
|
|
|
|
// Accumulate mode flushes on a timer, so a short cycle legitimately delivers a
|
|
// single sample between two full bursts. That packet must stay on the chain: if
|
|
// it fell through to the plain-scalar rule it would be dated from arrival while
|
|
// its neighbours are chained, and would leave lastCounter behind so the next
|
|
// real burst read the skip as a lost datagram.
|
|
TEST(FrameDecoder, AccumulatedScalarKeepsShortFlushesOnTheChain) {
|
|
FrameDecoder dec;
|
|
dec.setSignals({accSignal()});
|
|
std::vector<double> ts;
|
|
primeTenBursts(dec, ts, /*withCounter=*/true);
|
|
|
|
double last = ts[9];
|
|
uint32_t counter = 10u;
|
|
double arrival = 500.090;
|
|
for (int p = 0; p < 500; p++) {
|
|
/* Alternating 10-sample and 1-sample flushes, 10 ms and 1 ms of signal. */
|
|
const uint32_t n = (p % 2 == 0) ? 1u : 10u;
|
|
arrival += 0.001 * static_cast<double>(n);
|
|
FrameBuilder fb;
|
|
fb.addSignal(std::vector<double>(n, 1.0));
|
|
const FrameView& f = fb.build(0, arrival, n, ++counter);
|
|
dec.beginFrame(f);
|
|
ASSERT_TRUE(dec.timestamps(f, 0, ts)) << "short flush dropped at " << p;
|
|
ASSERT_EQ(ts.size(), n);
|
|
for (size_t i = 0; i < ts.size(); i++) {
|
|
ASSERT_GT(ts[i], last) << "timeline went backwards at packet " << p;
|
|
/* Contiguous: no phantom loss was ever reinstated. */
|
|
ASSERT_NEAR(ts[i] - last, 0.001, 1e-6) << "gap opened at packet " << p;
|
|
last = ts[i];
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST(FrameDecoder, AccumulatedScalarDerivesDtFromTheHrtGapWhenNoRateIsDeclared) {
|
|
FrameDecoder dec;
|
|
SignalMeta m;
|
|
m.name = "Acc";
|
|
m.typeCode = 9;
|
|
m.samplingRate = 0.0; /* undeclared */
|
|
dec.setSignals({m});
|
|
|
|
const double ticks = 1.0e9;
|
|
std::vector<double> last;
|
|
for (int p = 0; p < 40; p++) {
|
|
FrameBuilder fb;
|
|
fb.addSignal(std::vector<double>(10, 1.0));
|
|
/* 25 ms per packet, deliberately NOT 10: at 10 the expected 1 ms period
|
|
* equals kDefaultDt, so a decoder that never derived anything and just
|
|
* returned the default would pass a test named for the derivation. */
|
|
const double producerSec = 100.0 + p * 0.025;
|
|
/* Zero-mean arrival jitter, so the rate fit still converges but no
|
|
* single arrival GAP is right. Without it, uniform arrivals make
|
|
* packetBurst and the hrt path return the same number by construction
|
|
* and the test cannot tell which branch answered. */
|
|
const double jitter[4] = {0.0, 0.003, 0.0, -0.003};
|
|
const FrameView& f = fb.build(static_cast<uint64_t>(producerSec * ticks),
|
|
700.0 + p * 0.025 + jitter[p % 4], 10,
|
|
static_cast<uint32_t>(p + 1));
|
|
dec.beginFrame(f);
|
|
std::vector<double> ts;
|
|
if (dec.timestamps(f, 0, ts)) { last = ts; }
|
|
}
|
|
|
|
ASSERT_EQ(last.size(), 10u);
|
|
/* 25 ms of producer time across 10 samples is a 2.5 ms period, whatever the
|
|
* datagrams did on the way over. Arrival-spanning the last gap (22 ms)
|
|
* would give 2.2 ms; defaulting would give 1 ms. */
|
|
EXPECT_NEAR(last[1] - last[0], 0.0025, 2e-5);
|
|
}
|
|
|
|
// The trap the hrt path fell into once: positioning each burst at
|
|
// hrt / ticksPerSecond(). hrt counts from the PRODUCER'S BOOT, so it is already
|
|
// ~1e11 ticks for a machine that has been up a day, while the rate is refitted
|
|
// on every packet and wobbles by parts in 1e4 as arrival jitter enters and
|
|
// leaves the window. The wobble arrives multiplied by that whole epoch — tens of
|
|
// milliseconds, in both directions — so bursts land out of order. The producer
|
|
// clock here is EXACT; every timestamp inversion this test can see comes from
|
|
// the client's own arithmetic.
|
|
TEST(FrameDecoder, AccumulatedScalarStaysMonotonicOnALongUndeclaredRunAfterBoot) {
|
|
FrameDecoder dec;
|
|
SignalMeta m;
|
|
m.name = "Acc";
|
|
m.typeCode = 9;
|
|
m.samplingRate = 0.0; /* undeclared: the hrt path */
|
|
dec.setSignals({m});
|
|
|
|
const double ticks = 1.0e9;
|
|
const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks); /* up 1 day */
|
|
double last = 0.0;
|
|
uint32_t seed = 12345u;
|
|
for (int p = 0; p < 20000; p++) { /* 500 s of stream */
|
|
FrameBuilder fb;
|
|
fb.addSignal(std::vector<double>(10, 1.0));
|
|
/* Exact producer clock: 25 ms per packet, 2.5 ms per sample. */
|
|
const uint64_t hrt = bootHrt + static_cast<uint64_t>(p * 0.025 * ticks);
|
|
/* Ordinary scheduling jitter, +/- 1 ms, zero mean. */
|
|
seed = seed * 1103515245u + 12345u;
|
|
const double jitter = (static_cast<double>((seed >> 16) & 0xFFFFu) /
|
|
65535.0 - 0.5) * 0.002;
|
|
const FrameView& f = fb.build(hrt, 700.0 + p * 0.025 + jitter, 10,
|
|
static_cast<uint32_t>(p + 1));
|
|
dec.beginFrame(f);
|
|
std::vector<double> ts;
|
|
if (!dec.timestamps(f, 0, ts)) { continue; }
|
|
for (size_t i = 0; i < ts.size(); i++) {
|
|
ASSERT_GT(ts[i], last) << "timeline went backwards at packet " << p;
|
|
/* Ordering alone is too weak to pin this down: clamping a wrong
|
|
* absolute position to "just after the last one" restores the
|
|
* ordering while leaving the positions wrong, and every forward
|
|
* lurch is still accepted. The producer clock is exact, so the
|
|
* spacing must be exact too. */
|
|
if (p > 100) { /* past the fit warm-up and its packetBurst fallback */
|
|
ASSERT_NEAR(ts[i] - last, 0.0025, 1e-5)
|
|
<< "sample spacing wrong at packet " << p;
|
|
}
|
|
last = ts[i];
|
|
}
|
|
}
|
|
}
|
|
|
|
namespace {
|
|
|
|
/** One delivered datagram of an undeclared-rate accumulated scalar. */
|
|
struct HrtPacket {
|
|
uint64_t hrt;
|
|
double arrival;
|
|
uint32_t counter;
|
|
};
|
|
|
|
SignalMeta undeclaredAcc() {
|
|
SignalMeta m;
|
|
m.name = "Acc";
|
|
m.typeCode = 9;
|
|
m.numRows = 1;
|
|
m.samplingRate = 0.0; /* undeclared: the hrt branch */
|
|
return m;
|
|
}
|
|
|
|
/** Runs a delivery schedule of 10-sample bursts; returns the last stamp emitted. */
|
|
double runUndeclared(const std::vector<HrtPacket>& pkts) {
|
|
FrameDecoder dec;
|
|
dec.setSignals({undeclaredAcc()});
|
|
double lastTs = 0.0;
|
|
for (const HrtPacket& p : pkts) {
|
|
FrameBuilder fb;
|
|
fb.addSignal(std::vector<double>(10, 1.0));
|
|
const FrameView& f = fb.build(p.hrt, p.arrival, 10, p.counter);
|
|
dec.beginFrame(f);
|
|
std::vector<double> ts;
|
|
if (dec.timestamps(f, 0, ts)) { lastTs = ts.back(); }
|
|
}
|
|
return lastTs;
|
|
}
|
|
|
|
/** 300 clean packets, 25 ms apart, from a producer that has been up a day. */
|
|
std::vector<HrtPacket> cleanUndeclaredStream() {
|
|
const double ticks = 1.0e9;
|
|
const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
|
|
std::vector<HrtPacket> pkts;
|
|
for (int p = 0; p < 300; p++) {
|
|
pkts.push_back(HrtPacket{
|
|
bootHrt + static_cast<uint64_t>(p * 0.025 * ticks),
|
|
700.0 + p * 0.025,
|
|
static_cast<uint32_t>(p + 1)});
|
|
}
|
|
return pkts;
|
|
}
|
|
|
|
/** Runs a schedule of 10-sample bursts; returns one entry per DELIVERED
|
|
* datagram, empty where the decoder emitted nothing. Per-packet rather than
|
|
* concatenated because the interesting quantity is the spacing INSIDE a
|
|
* particular burst, and which burst that is depends on the schedule. */
|
|
std::vector<std::vector<double> >
|
|
runUndeclaredPerPacket(const std::vector<HrtPacket>& pkts) {
|
|
FrameDecoder dec;
|
|
dec.setSignals({undeclaredAcc()});
|
|
std::vector<std::vector<double> > out;
|
|
for (const HrtPacket& p : pkts) {
|
|
FrameBuilder fb;
|
|
fb.addSignal(std::vector<double>(10, 1.0));
|
|
const FrameView& f = fb.build(p.hrt, p.arrival, 10, p.counter);
|
|
dec.beginFrame(f);
|
|
std::vector<double> ts;
|
|
if (!dec.timestamps(f, 0, ts)) { ts.clear(); }
|
|
out.push_back(ts);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Delays the datagram at slot @p k by @p dist delivery slots: the payloads
|
|
* behind it each move up one and it lands after them. Only the payload moves —
|
|
* the arrival time belongs to the slot, because delivery order is what the
|
|
* socket actually saw. */
|
|
std::vector<HrtPacket> delayOne(const std::vector<HrtPacket>& clean,
|
|
size_t k, size_t dist) {
|
|
std::vector<HrtPacket> out = clean;
|
|
for (size_t i = 0; i < dist; i++) {
|
|
std::swap(out[k + i].hrt, out[k + i + 1].hrt);
|
|
std::swap(out[k + i].counter, out[k + i + 1].counter);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
} /* namespace */
|
|
|
|
// A datagram that overtakes its neighbour arrives with an hrt BEHIND the one
|
|
// already recorded. It must contribute no producer time — the packet that
|
|
// overtook it already counted the interval — and it must also leave the hrt
|
|
// reference alone. Writing the reference back is what the code used to do, and
|
|
// it makes the NEXT packet's delta span two intervals, fabricating a whole extra
|
|
// packet of producer time per reorder. That error never heals: ClockOffset would
|
|
// correct it but the monotonic clamp discards every backward correction.
|
|
TEST(FrameDecoder, UndeclaredAccumulatedScalarIgnoresReorderedDatagrams) {
|
|
const std::vector<HrtPacket> clean = cleanUndeclaredStream();
|
|
|
|
/* Ten swaps: each pair is delivered in the opposite order, so the arrival
|
|
* times stay increasing (delivery order is what the socket saw) while the
|
|
* hrt and counter they carry are exchanged. */
|
|
std::vector<HrtPacket> reordered = clean;
|
|
for (int k = 100; k < 200; k += 10) {
|
|
std::swap(reordered[k].hrt, reordered[k + 1].hrt);
|
|
std::swap(reordered[k].counter, reordered[k + 1].counter);
|
|
}
|
|
|
|
const double cleanEnd = runUndeclared(clean);
|
|
const double reorderedEnd = runUndeclared(reordered);
|
|
|
|
/* Each swap used to add about one packet of producer time (25 ms); ten of
|
|
* them left the trace a quarter of a second ahead, for good. */
|
|
EXPECT_NEAR(reorderedEnd, cleanEnd, 1.0e-3)
|
|
<< "reordering left " << (reorderedEnd - cleanEnd) << " s of offset";
|
|
}
|
|
|
|
// Leaving the hrt reference alone on a reordered datagram is only half the rule:
|
|
// the packet counter is the DENOMINATOR of the very period that reference is the
|
|
// numerator of, so it has to stay behind too. Rolling lastCounter back while
|
|
// lastAccHrt holds gives the next in-order packet a gap of dist+1 against an
|
|
// elapsed spanning a single interval, and it derives a period dist+1 times too
|
|
// short. The test above cannot see this: it compares end times, and a burst drawn
|
|
// too NARROW ends early rather than late, so the damage hides inside the burst.
|
|
//
|
|
// The bound asserted here is not "the true spacing". A reorder legitimately
|
|
// squeezes bursts, because the late datagram's samples belong in the past and
|
|
// downstream demands increasing stamps, so the monotonic clamp walks them
|
|
// forward instead — and the timeline it leaves ahead of the producer takes a few
|
|
// packets to bleed off, squeezing those too. But that clamp has an exact floor:
|
|
// its cap is kWallBleedFraction * wallElapsed / nElems, and wallElapsed / nElems
|
|
// IS the producer's true period at steady cadence, so no burst it touches can
|
|
// ever be narrower than kWallBleedFraction of true. Anything below that floor
|
|
// did not come from the clamp; it came from a mis-derived period. That is what
|
|
// separates the defect from the design, and it is why the check is a floor
|
|
// rather than a target.
|
|
TEST(FrameDecoder, UndeclaredAccumulatedScalarKeepsItsSpacingAfterAReorder) {
|
|
const double trueDt = 0.0025; /* 10 samples per 25 ms packet */
|
|
const double floorDt = 0.5 * trueDt; /* kWallBleedFraction * trueDt */
|
|
|
|
/* Distance 1 sits exactly ON the floor either way and is here to pin it;
|
|
* 5 and 20 are where the defect drops through it, to 0.167x and 0.048x. */
|
|
for (size_t dist : {size_t(1), size_t(5), size_t(20)}) {
|
|
const std::vector<std::vector<double> > out =
|
|
runUndeclaredPerPacket(delayOne(cleanUndeclaredStream(), 150, dist));
|
|
|
|
/* The late payload lands at slot 150 + dist; the slot after it is the
|
|
* first in-order packet to divide by the poisoned counter. */
|
|
const size_t after = 150u + dist + 1u;
|
|
ASSERT_GE(out[after].size(), 2u) << "distance " << dist;
|
|
const double dt = out[after][1] - out[after][0];
|
|
EXPECT_GE(dt, floorDt - 1.0e-9)
|
|
<< "distance " << dist << " drew its burst at " << dt << " s/sample, "
|
|
<< (dt / trueDt) << "x the true spacing";
|
|
EXPECT_LE(dt, trueDt + 1.0e-9) << "distance " << dist;
|
|
}
|
|
}
|
|
|
|
// The same defect under a network that reorders continuously rather than once.
|
|
// Same floor, applied to every burst in the run including the late datagrams'
|
|
// own — under sustained reordering there is no quiet packet to exempt, and the
|
|
// floor holds for all of them anyway.
|
|
TEST(FrameDecoder, UndeclaredAccumulatedScalarKeepsItsSpacingUnderSustainedReordering) {
|
|
std::vector<HrtPacket> pkts = cleanUndeclaredStream();
|
|
|
|
/* Six of 300 datagrams — 2% — delayed by one to five slots. */
|
|
for (int n = 0; n < 6; n++) {
|
|
pkts = delayOne(pkts, 40u + static_cast<size_t>(n) * 40u,
|
|
1u + static_cast<size_t>(n) % 5u);
|
|
}
|
|
|
|
const std::vector<std::vector<double> > out = runUndeclaredPerPacket(pkts);
|
|
|
|
const double trueDt = 0.0025;
|
|
double narrow = 1.0; /* smallest ratio to true seen */
|
|
size_t narrowAt = 0u;
|
|
for (size_t p = 0; p < out.size(); p++) {
|
|
for (size_t e = 1; e < out[p].size(); e++) {
|
|
const double ratio = (out[p][e] - out[p][e - 1u]) / trueDt;
|
|
if (ratio < narrow) { narrow = ratio; narrowAt = p; }
|
|
}
|
|
}
|
|
/* Bottomed out at 0.167x — an 83.3% spacing error — before the counter moved
|
|
* in lockstep with the reference. The clamp's own floor is 0.5x. */
|
|
EXPECT_GE(narrow, 0.5 - 1.0e-9)
|
|
<< "narrowest burst " << narrow << "x true spacing at packet " << narrowAt;
|
|
}
|
|
|
|
// The counterweight. A producer restart drops hrt from the machine's whole
|
|
// uptime back to near zero, and that is the one case where the hrt reference
|
|
// MUST be allowed to regress: refusing every backward step would leave each
|
|
// later packet below the reference forever, the elapsed producer time
|
|
// permanently zero, and the signal frozen at the fallback period.
|
|
TEST(FrameDecoder, UndeclaredAccumulatedScalarSurvivesAProducerRestart) {
|
|
FrameDecoder dec;
|
|
dec.setSignals({undeclaredAcc()});
|
|
|
|
const double ticks = 1.0e9;
|
|
const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
|
|
double last = 0.0;
|
|
for (int p = 0; p < 120; p++) {
|
|
const bool restarted = (p >= 60);
|
|
/* After the restart hrt counts from one second of uptime, and the
|
|
* outage cost two seconds of wall time. */
|
|
const uint64_t hrt = restarted
|
|
? static_cast<uint64_t>((1.0 + (p - 60) * 0.025) * ticks)
|
|
: bootHrt + static_cast<uint64_t>(p * 0.025 * ticks);
|
|
const double arrival = restarted
|
|
? (700.0 + 59 * 0.025 + 2.0 + (p - 60) * 0.025)
|
|
: (700.0 + p * 0.025);
|
|
|
|
FrameBuilder fb;
|
|
fb.addSignal(std::vector<double>(10, 1.0));
|
|
const FrameView& f =
|
|
fb.build(hrt, arrival, 10, static_cast<uint32_t>(p + 1));
|
|
dec.beginFrame(f);
|
|
std::vector<double> ts;
|
|
if (!dec.timestamps(f, 0, ts)) {
|
|
/* Only the very first packet, which has no previous arrival for the
|
|
* pre-fit fallback to span from. */
|
|
ASSERT_EQ(p, 0) << "packet " << p << " produced nothing";
|
|
continue;
|
|
}
|
|
for (size_t i = 0; i < ts.size(); i++) {
|
|
ASSERT_GT(ts[i], last) << "timeline went backwards at packet " << p;
|
|
last = ts[i];
|
|
}
|
|
/* The restart packet itself has no measurable interval and falls back to
|
|
* the default period; from the next one on the producer's own 2.5 ms
|
|
* must be back. A decoder that could not regress the reference would sit
|
|
* at the 1 ms fallback for the rest of the run. */
|
|
if (p >= 62) {
|
|
EXPECT_NEAR(ts[1] - ts[0], 0.0025, 1e-5)
|
|
<< "spacing not recovered at packet " << p;
|
|
}
|
|
}
|
|
}
|
|
|
|
// The hrt branch's clamp used to be one-directional, which is the same defect
|
|
// the declared branch's squeeze exists to prevent. A wall clock that steps
|
|
// BACKWARDS — an NTP correction, a suspend/resume — leaves the emitted timeline
|
|
// permanently ahead, because the recalibrated position is behind lastEmittedEnd
|
|
// on every later packet too and the clamp keeps discarding it.
|
|
TEST(FrameDecoder, UndeclaredAccumulatedScalarRecoversFromABackwardWallStep) {
|
|
FrameDecoder dec;
|
|
dec.setSignals({undeclaredAcc()});
|
|
|
|
const double ticks = 1.0e9;
|
|
const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
|
|
double last = 0.0;
|
|
double lead = 0.0;
|
|
double worstAfter = 0.0;
|
|
/* 100 ms packets of 10 samples. The step is 0.6 s — just past
|
|
* ClockOffset::kRecalibThresholdS, which is what makes the recalibrated
|
|
* position land behind lastEmittedEnd and the clamp fire at all — and it
|
|
* comes after the rate fit's 256-sample window is full, so the fit
|
|
* redistributes it slowly enough not to be mistaken for this recovery. */
|
|
for (int p = 0; p < 340; p++) {
|
|
const uint64_t hrt = bootHrt + static_cast<uint64_t>(p * 0.1 * ticks);
|
|
const double arrival = 700.0 + p * 0.1 - ((p >= 300) ? 0.6 : 0.0);
|
|
|
|
FrameBuilder fb;
|
|
fb.addSignal(std::vector<double>(10, 1.0));
|
|
const FrameView& f =
|
|
fb.build(hrt, arrival, 10, static_cast<uint32_t>(p + 1));
|
|
dec.beginFrame(f);
|
|
std::vector<double> ts;
|
|
if (!dec.timestamps(f, 0, ts)) {
|
|
ASSERT_EQ(p, 0) << "packet " << p << " produced nothing";
|
|
continue;
|
|
}
|
|
for (size_t i = 0; i < ts.size(); i++) {
|
|
ASSERT_GT(ts[i], last) << "timeline went backwards at packet " << p;
|
|
last = ts[i];
|
|
}
|
|
lead = ts.back() - arrival;
|
|
/* Twenty packets is a generous allowance: the cap bleeds half a packet
|
|
* interval per packet, so the 0.6 s step is gone in twelve. */
|
|
if (p >= 320) { worstAfter = std::max(worstAfter, std::fabs(lead)); }
|
|
}
|
|
|
|
EXPECT_LT(worstAfter, 0.1)
|
|
<< "still " << worstAfter << " s from the wall clock long after the step";
|
|
}
|
|
|
|
// The two branches must place a burst the same way round or two accumulated
|
|
// scalars in one scope, one with a declared rate and one without, sit a whole
|
|
// burst apart on the shared X axis. The declared branch anchors the LAST element
|
|
// on arrival, which is right: the samples were acquired before the packet
|
|
// carrying them landed. The hrt branch used to latch its offset against raw
|
|
// arrival, putting the FIRST element there instead.
|
|
TEST(FrameDecoder, UndeclaredAccumulatedScalarEndsItsBurstOnArrival) {
|
|
FrameDecoder dec;
|
|
dec.setSignals({undeclaredAcc()});
|
|
|
|
const double ticks = 1.0e9;
|
|
const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
|
|
/* 10 ms per packet of 10 samples. The cadence used to matter — the first
|
|
* hrt-branch packet had no measurable interval, latched ClockOffset using
|
|
* kDefaultDt, and only a 1 ms derived period made that harmless — but the
|
|
* warm-up now hands over a real tick reference, so this assertion holds at
|
|
* every cadence. See UndeclaredAccumulatedScalarCrossesTheHrtHandoverCleanly,
|
|
* which is the test that pins that down; this one only fixes the convention
|
|
* that a burst ends, rather than starts, on arrival. */
|
|
double lastArrival = 0.0;
|
|
std::vector<double> last;
|
|
for (int p = 0; p < 60; p++) {
|
|
FrameBuilder fb;
|
|
fb.addSignal(std::vector<double>(10, 1.0));
|
|
const uint64_t hrt = bootHrt + static_cast<uint64_t>(p * 0.010 * ticks);
|
|
const double arrival = 700.0 + p * 0.010;
|
|
const FrameView& f =
|
|
fb.build(hrt, arrival, 10, static_cast<uint32_t>(p + 1));
|
|
dec.beginFrame(f);
|
|
std::vector<double> ts;
|
|
if (dec.timestamps(f, 0, ts)) { last = ts; lastArrival = arrival; }
|
|
}
|
|
|
|
ASSERT_EQ(last.size(), 10u);
|
|
EXPECT_NEAR(last[9], lastArrival, 1e-9) << "burst must END on arrival";
|
|
EXPECT_NEAR(last[0], lastArrival - 0.009, 1e-9);
|
|
}
|
|
|
|
// An undeclared-rate signal is served by TWO different mechanisms in sequence:
|
|
// packetBurst spans arrival gaps until HrtRateFit has collected enough packets,
|
|
// then the hrt branch takes over. They place a burst differently — packetBurst
|
|
// ends it at wallNow, the hrt branch at wallNow - (nElems-1)*hrtDt — so the
|
|
// handover is where a discontinuity hides, and it took two separate blind spots
|
|
// for the other tests to miss it. UndeclaredAccumulatedScalarEndsItsBurstOnArrival
|
|
// runs at 10 samples per 10 ms, the one cadence where the derived period equals
|
|
// the kDefaultDt fallback, so nothing was wrong to see. The two long-run tests
|
|
// run at 10 samples per 25 ms, where the fallback burst is 9 ms against a 25 ms
|
|
// packet interval — too narrow to invert, so their monotonicity assertions held
|
|
// while the trace sat 13.5 ms off the wall clock, which neither of them measures.
|
|
// So sweep cadences either side of the coincidence AND assert absolute position.
|
|
TEST(FrameDecoder, UndeclaredAccumulatedScalarCrossesTheHrtHandoverCleanly) {
|
|
struct Case { uint32_t nElems; double packetSec; };
|
|
const Case cases[] = {
|
|
{10u, 0.0025}, /* 4 kHz: burst wider than the packet interval */
|
|
{100u, 0.010 }, /* 10 kHz */
|
|
{1000u, 0.010 }, /* 100 kHz: a burst is 100x the kDefaultDt guess */
|
|
{10u, 0.050 }, /* 200 Hz: burst narrower than the packet interval */
|
|
};
|
|
|
|
for (const Case& c : cases) {
|
|
FrameDecoder dec;
|
|
dec.setSignals({undeclaredAcc()});
|
|
|
|
const double ticks = 1.0e9;
|
|
const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
|
|
const double sampleDt = c.packetSec / static_cast<double>(c.nElems);
|
|
double last = 0.0;
|
|
bool seen = false;
|
|
double lastArrival = 0.0;
|
|
std::vector<double> lastTs;
|
|
|
|
for (int p = 0; p < 200; p++) {
|
|
FrameBuilder fb;
|
|
fb.addSignal(std::vector<double>(c.nElems, 1.0));
|
|
const uint64_t hrt =
|
|
bootHrt + static_cast<uint64_t>(p * c.packetSec * ticks);
|
|
const double arrival = 700.0 + p * c.packetSec;
|
|
const FrameView& f = fb.build(hrt, arrival, c.nElems,
|
|
static_cast<uint32_t>(p + 1));
|
|
dec.beginFrame(f);
|
|
std::vector<double> ts;
|
|
if (!dec.timestamps(f, 0, ts)) { continue; }
|
|
for (double t : ts) {
|
|
if (seen) {
|
|
ASSERT_GT(t, last)
|
|
<< "handover stepped back " << (last - t) << " s with "
|
|
<< c.nElems << " samples per " << c.packetSec << " s packet";
|
|
}
|
|
last = t;
|
|
seen = true;
|
|
}
|
|
lastTs = ts;
|
|
lastArrival = arrival;
|
|
}
|
|
|
|
/* Monotonic is necessary but not sufficient: a clamp restores ordering
|
|
* while leaving the whole trace parked in the past. The producer clock
|
|
* here is exact, so once settled the burst must still end on arrival and
|
|
* step at the true sample period. */
|
|
ASSERT_EQ(lastTs.size(), c.nElems);
|
|
EXPECT_NEAR(lastTs.back(), lastArrival, 1e-6)
|
|
<< "trace drifted off the wall clock with " << c.nElems
|
|
<< " samples per " << c.packetSec << " s packet";
|
|
EXPECT_NEAR(lastTs[1] - lastTs[0], sampleDt, sampleDt * 1e-3);
|
|
}
|
|
}
|
|
|
|
// Lost datagrams widen the hrt tick gap without widening the sample count that
|
|
// gap is divided by, so a recovery burst is drawn as many times too wide as the
|
|
// counter gap — and because a burst is anchored on its LAST element, too wide
|
|
// means it ends in the FUTURE. The declared branch reads the counter to
|
|
// reinstate the hole exactly; this pins the hrt branch to the same standard.
|
|
// Assert POSITION, not just spacing: a burst can be correctly spaced and still
|
|
// be drawn across the wrong stretch of the axis.
|
|
TEST(FrameDecoder, UndeclaredAccumulatedScalarKeepsItsSpacingThroughPacketLoss) {
|
|
FrameDecoder dec;
|
|
dec.setSignals({undeclaredAcc()});
|
|
|
|
const double ticks = 1.0e9;
|
|
const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
|
|
const double packetSec = 0.025;
|
|
const double sampleDt = 0.0025;
|
|
/* Runs of 1, 4 and 10 consecutive losses, well clear of each other and of
|
|
* the fit warm-up. Ten losses is the interesting one: it used to stretch the
|
|
* recovery burst 11x and date its last sample 225 ms into the future. */
|
|
const int dropFrom[3] = {120, 200, 300};
|
|
const int dropLen[3] = {1, 4, 10};
|
|
|
|
double worstFuture = 0.0;
|
|
double last = 0.0;
|
|
bool seen = false;
|
|
for (int p = 0; p < 500; p++) {
|
|
bool dropped = false;
|
|
for (int k = 0; k < 3; k++) {
|
|
if (p >= dropFrom[k] && p < dropFrom[k] + dropLen[k]) { dropped = true; }
|
|
}
|
|
if (dropped) { continue; }
|
|
|
|
FrameBuilder fb;
|
|
fb.addSignal(std::vector<double>(10, 1.0));
|
|
const uint64_t hrt = bootHrt + static_cast<uint64_t>(p * packetSec * ticks);
|
|
const double arrival = 700.0 + p * packetSec;
|
|
const FrameView& f =
|
|
fb.build(hrt, arrival, 10, static_cast<uint32_t>(p + 1));
|
|
dec.beginFrame(f);
|
|
std::vector<double> ts;
|
|
if (!dec.timestamps(f, 0, ts)) { continue; }
|
|
|
|
for (double t : ts) {
|
|
if (seen) { ASSERT_GT(t, last) << "backwards at packet " << p; }
|
|
last = t;
|
|
seen = true;
|
|
}
|
|
if (p > 100) {
|
|
/* The samples were acquired BEFORE the packet carrying them landed,
|
|
* so none of them may be stamped after its arrival. */
|
|
const double future = ts.back() - arrival;
|
|
if (future > worstFuture) { worstFuture = future; }
|
|
EXPECT_NEAR(ts[1] - ts[0], sampleDt, sampleDt * 1e-3)
|
|
<< "spacing stretched at packet " << p;
|
|
}
|
|
}
|
|
EXPECT_LT(worstFuture, 1e-6)
|
|
<< "a recovery burst ended " << worstFuture << " s in the future";
|
|
}
|
|
|
|
// A restart is the other way kDefaultDt gets latched: hrt goes backwards, so the
|
|
// restart packet measures no interval of its own, and whatever burst width it
|
|
// falls back on is baked into ClockOffset. The displacement that leaves — 13.5 ms
|
|
// at this cadence — is below ClockOffset::kRecalibThresholdS, so it never heals.
|
|
// AccumulatedScalarSurvivesAProducerRestart asserts only order and spacing and
|
|
// passes right through it; this asserts absolute position.
|
|
TEST(FrameDecoder, UndeclaredAccumulatedScalarReturnsToTheWallClockAfterARestart) {
|
|
FrameDecoder dec;
|
|
dec.setSignals({undeclaredAcc()});
|
|
|
|
const double ticks = 1.0e9;
|
|
const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
|
|
const double packetSec = 0.025;
|
|
std::vector<double> lastTs;
|
|
double lastArrival = 0.0;
|
|
|
|
for (int p = 0; p < 400; p++) {
|
|
FrameBuilder fb;
|
|
fb.addSignal(std::vector<double>(10, 1.0));
|
|
/* Packet 200 restarts the producer: hrt returns to a fresh boot and the
|
|
* counter to 1. The wall clock does not restart. */
|
|
const bool after = (p >= 200);
|
|
const uint64_t hrt = after
|
|
? static_cast<uint64_t>((p - 200) * packetSec * ticks)
|
|
: bootHrt + static_cast<uint64_t>(p * packetSec * ticks);
|
|
const uint32_t counter = after ? static_cast<uint32_t>(p - 199)
|
|
: static_cast<uint32_t>(p + 1);
|
|
const double arrival = 700.0 + p * packetSec;
|
|
const FrameView& f = fb.build(hrt, arrival, 10, counter);
|
|
dec.beginFrame(f);
|
|
std::vector<double> ts;
|
|
if (dec.timestamps(f, 0, ts)) { lastTs = ts; lastArrival = arrival; }
|
|
}
|
|
|
|
ASSERT_EQ(lastTs.size(), 10u);
|
|
EXPECT_NEAR(lastTs.back(), lastArrival, 1e-6)
|
|
<< "still displaced from the wall clock 200 packets after the restart";
|
|
EXPECT_NEAR(lastTs[1] - lastTs[0], 0.0025, 2.5e-6);
|
|
}
|
|
|
|
// hrt == 0 sends the packet back to the warm-up branch, which spans from
|
|
// packetBurst's own lastPacketWall. The hrt branch does not otherwise touch that
|
|
// field, so it would be left at whenever this signal last took the warm-up
|
|
// branch — the start of the session — and one stray packet would emit a burst
|
|
// starting seconds in the past, worse the longer the scope has been running.
|
|
//
|
|
// The counter must sit out that detour with it, for the same lockstep reason as
|
|
// the reorder case: a zero-hrt packet advances the counter but cannot advance
|
|
// the tick reference, so the next real packet divides an elapsed spanning one
|
|
// interval by a gap reporting two, drawing that burst twice too wide and — since
|
|
// the burst is anchored on its LAST element — ending it in the future. Measured
|
|
// +22.5 ms for one such packet, +45 ms for two, +112.5 ms for five.
|
|
TEST(FrameDecoder, UndeclaredAccumulatedScalarSurvivesAStrayZeroHrtPacket) {
|
|
const double ticks = 1.0e9;
|
|
const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
|
|
const double packetSec = 0.025;
|
|
|
|
for (int run : {1, 2, 5}) {
|
|
FrameDecoder dec;
|
|
dec.setSignals({undeclaredAcc()});
|
|
double last = 0.0;
|
|
bool seen = false;
|
|
|
|
for (int p = 0; p < 200; p++) {
|
|
FrameBuilder fb;
|
|
fb.addSignal(std::vector<double>(10, 1.0));
|
|
const bool zero = (p >= 153) && (p < 153 + run);
|
|
const uint64_t hrt = zero
|
|
? 0u
|
|
: bootHrt + static_cast<uint64_t>(p * packetSec * ticks);
|
|
const double arrival = 700.0 + p * packetSec;
|
|
const FrameView& f =
|
|
fb.build(hrt, arrival, 10, static_cast<uint32_t>(p + 1));
|
|
dec.beginFrame(f);
|
|
std::vector<double> ts;
|
|
if (!dec.timestamps(f, 0, ts)) { continue; }
|
|
for (double t : ts) {
|
|
if (seen) {
|
|
ASSERT_GT(t, last) << "run of " << run
|
|
<< " zero-hrt packets stepped back "
|
|
<< (last - t) << " s at packet " << p;
|
|
}
|
|
last = t;
|
|
seen = true;
|
|
}
|
|
/* And it must not land far from where the stream already is:
|
|
* spanning from a session-old reference put the burst 2.74 s in the
|
|
* past, and a counter that ran on without the tick reference put the
|
|
* recovery burst 22.5 ms per stray packet into the future. The
|
|
* tolerance is a fifth of a packet period: four and a half times
|
|
* tighter than the smallest error it has to reject, and still far
|
|
* enough above HrtRateFit's residual not to chase regression noise.
|
|
* The 50 ms it replaced admitted every one of those errors. */
|
|
if (p > 100) {
|
|
EXPECT_NEAR(ts.back(), arrival, packetSec / 5.0)
|
|
<< "run of " << run << " at packet " << p;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The same double delivery that the declared branch guards against — a host
|
|
// joined on two interfaces receives every unfragmented update twice — reaches an
|
|
// undeclared-rate signal identically. The guard can only fire if this branch
|
|
// leaves a counter behind for it to compare against.
|
|
TEST(FrameDecoder, UndeclaredAccumulatedScalarDropsADuplicatedDatagram) {
|
|
FrameDecoder dec;
|
|
dec.setSignals({undeclaredAcc()});
|
|
|
|
const double ticks = 1.0e9;
|
|
const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
|
|
std::vector<double> ts;
|
|
for (int p = 0; p < 50; p++) {
|
|
FrameBuilder fb;
|
|
fb.addSignal(std::vector<double>(10, 1.0));
|
|
const FrameView& f =
|
|
fb.build(bootHrt + static_cast<uint64_t>(p * 0.010 * ticks),
|
|
700.0 + p * 0.010, 10, static_cast<uint32_t>(p + 1));
|
|
dec.beginFrame(f);
|
|
const bool ok = dec.timestamps(f, 0, ts);
|
|
ASSERT_EQ(ok, p != 0) << "at packet " << p;
|
|
}
|
|
const double endBefore = ts[9];
|
|
|
|
/* Counter 50 again, the same update off the second interface. */
|
|
FrameBuilder fb;
|
|
fb.addSignal(std::vector<double>(10, 1.0));
|
|
const FrameView& dup =
|
|
fb.build(bootHrt + static_cast<uint64_t>(49 * 0.010 * ticks),
|
|
700.0 + 49 * 0.010 + 0.0001, 10, 50u);
|
|
dec.beginFrame(dup);
|
|
EXPECT_FALSE(dec.timestamps(dup, 0, ts)) << "duplicate was emitted twice";
|
|
|
|
/* And the drop left the chain alone: the genuine next update still lands one
|
|
* period after the last burst ended. */
|
|
const FrameView& next =
|
|
fb.build(bootHrt + static_cast<uint64_t>(50 * 0.010 * ticks),
|
|
700.0 + 50 * 0.010, 10, 51u);
|
|
dec.beginFrame(next);
|
|
ASSERT_TRUE(dec.timestamps(next, 0, ts));
|
|
EXPECT_NEAR(ts[0], endBefore + 0.001, 1e-6);
|
|
}
|
|
|
|
// A PACKET burst has no per-element time at all. Elements span
|
|
// (lastPacket, thisPacket] — backwards from arrival, because the samples were
|
|
// acquired before the packet landed. Forward extrapolation would let a jittered
|
|
// packet overlap the next one and break ring monotonicity.
|
|
TEST(FrameDecoder, PacketBurstDropsTheFirstFrameThenSpansBackwards) {
|
|
FrameDecoder dec;
|
|
dec.setSignals({burst("Raw", kTimePacket, 0.0, 5, kNoTimeSignal)});
|
|
|
|
FrameBuilder fb1;
|
|
fb1.addSignal({1.0, 2.0, 3.0, 4.0, 5.0});
|
|
const FrameView& f1 = fb1.build(0, 10.0);
|
|
dec.beginFrame(f1);
|
|
std::vector<double> ts;
|
|
EXPECT_FALSE(dec.timestamps(f1, 0, ts))
|
|
<< "the first packet has no previous arrival to span from";
|
|
|
|
FrameBuilder fb2;
|
|
fb2.addSignal({6.0, 7.0, 8.0, 9.0, 10.0});
|
|
const FrameView& f2 = fb2.build(0, 10.05);
|
|
dec.beginFrame(f2);
|
|
ASSERT_TRUE(dec.timestamps(f2, 0, ts));
|
|
ASSERT_EQ(ts.size(), 5u);
|
|
EXPECT_GT(ts[0], 10.0);
|
|
EXPECT_NEAR(ts[4], 10.05, 1e-12);
|
|
EXPECT_NEAR(ts[1] - ts[0], 0.01, 1e-12);
|
|
}
|
|
|
|
TEST(FrameDecoder, PacketBurstStaysMonotonicUnderJitteredArrivals) {
|
|
FrameDecoder dec;
|
|
dec.setSignals({burst("Raw", kTimePacket, 0.0, 8, kNoTimeSignal)});
|
|
|
|
const double jitter[] = {0.0, 0.004, -0.003, 0.006, -0.002, 0.0, 0.005, -0.004};
|
|
std::vector<double> all;
|
|
for (int p = 0; p < 8; p++) {
|
|
FrameBuilder fb;
|
|
fb.addSignal(std::vector<double>(8, 1.0));
|
|
const FrameView& f = fb.build(0, 20.0 + p * 0.05 + jitter[p]);
|
|
dec.beginFrame(f);
|
|
std::vector<double> ts;
|
|
if (dec.timestamps(f, 0, ts)) {
|
|
all.insert(all.end(), ts.begin(), ts.end());
|
|
}
|
|
}
|
|
|
|
ASSERT_GT(all.size(), 8u);
|
|
for (size_t i = 1; i < all.size(); i++) {
|
|
EXPECT_GT(all[i], all[i - 1]) << "packets overlapped at " << i;
|
|
}
|
|
}
|
|
|
|
TEST(FrameDecoder, ResetForgetsPerSignalHistory) {
|
|
FrameDecoder dec;
|
|
dec.setSignals({burst("Raw", kTimePacket, 0.0, 4, kNoTimeSignal)});
|
|
|
|
FrameBuilder fb;
|
|
fb.addSignal({1.0, 2.0, 3.0, 4.0});
|
|
const FrameView& f = fb.build(0, 5.0);
|
|
dec.beginFrame(f);
|
|
std::vector<double> ts;
|
|
EXPECT_FALSE(dec.timestamps(f, 0, ts));
|
|
|
|
const FrameView& f2 = fb.build(0, 5.1);
|
|
dec.beginFrame(f2);
|
|
EXPECT_TRUE(dec.timestamps(f2, 0, ts));
|
|
|
|
dec.reset();
|
|
const FrameView& f3 = fb.build(0, 5.2);
|
|
dec.beginFrame(f3);
|
|
EXPECT_FALSE(dec.timestamps(f3, 0, ts))
|
|
<< "after reset the next packet is again the first one";
|
|
}
|