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:
Martino Ferrari
2026-08-28 05:49:01 +02:00
co-authored by Claude Opus 4.6
parent 440b805afd
commit 3add2c42b9
4 changed files with 288 additions and 34 deletions
+73 -1
View File
@@ -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