fix(udpscope): carry warm-up state across the hrt handover
An undeclared-rate accumulated scalar is served by packetBurst until HrtRateFit is ready, then by the hrt branch. The two place a burst differently -- packetBurst ends it at wallNow, the hrt branch at wallNow - (nElems-1)*hrtDt -- and the warm-up left no state behind, so the handover packet skipped the monotonic clamp and stepped the signal backwards by up to a burst width (-6.5 ms at 10 samples per 2.5 ms packet, -0.99 s at 1000 samples per 10 ms). Seeding lastEmitted* alone would only restore ordering. Without lastAccHrt/prevAccCount the first hrt packet also has no tick delta to measure, falls back to kDefaultDt and latches ClockOffset against a burst width that is wrong whenever the cadence is not 1 kHz -- 89 ms of permanent displacement at 100 samples per 10 ms, below the recalibration threshold that would otherwise heal it. Seed both. Also close the wallElapsed <= 0 bypass in both branches: skipping the bleed cap when the wall has not moved hands back the full proportional advance, letting a run of same-tick arrivals gain lead while no wall time passes at all. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
440b805afd
commit
3add2c42b9
@@ -429,7 +429,14 @@ TEST(FrameDecoder, AccumulatedScalarWithANonFiniteRateFallsBackToTheHrtPath) {
|
||||
}
|
||||
|
||||
ASSERT_EQ(last.size(), 10u);
|
||||
EXPECT_NEAR(last[1] - last[0], 0.0025, 2e-5)
|
||||
/* 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";
|
||||
}
|
||||
|
||||
@@ -811,6 +818,71 @@ TEST(FrameDecoder, UndeclaredAccumulatedScalarEndsItsBurstOnArrival) {
|
||||
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. It is invisible at 10 samples per
|
||||
// 10 ms packet, the one cadence where the derived period equals the kDefaultDt
|
||||
// fallback, which is exactly why the other tests here could not see it. Sweep
|
||||
// cadences either side of that coincidence.
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user