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
@@ -328,15 +328,25 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
|
|||||||
if (factor < kMinBleedFactor) { factor = kMinBleedFactor; }
|
if (factor < kMinBleedFactor) { factor = kMinBleedFactor; }
|
||||||
double advance = nominal * factor;
|
double advance = nominal * factor;
|
||||||
|
|
||||||
|
/* A non-positive elapsed means the wall has not moved
|
||||||
|
* since this signal's previous burst — a coarse arrival
|
||||||
|
* clock, or two packets stamped within one tick of it.
|
||||||
|
* There is no wall time to spend, so the cap is zero.
|
||||||
|
* Skipping the cap in that case (which is what this code
|
||||||
|
* used to do) hands back the full proportional advance,
|
||||||
|
* so a run of same-tick arrivals gains lead while no wall
|
||||||
|
* time passes at all — the divergence the cap exists to
|
||||||
|
* stop, in its purest form. */
|
||||||
const double wallElapsed = wallNow - st.lastEmittedWall;
|
const double wallElapsed = wallNow - st.lastEmittedWall;
|
||||||
if (wallElapsed > 0.0) {
|
const double cap = (wallElapsed > 0.0)
|
||||||
const double cap = kWallBleedFraction * wallElapsed;
|
? (kWallBleedFraction * wallElapsed)
|
||||||
|
: 0.0;
|
||||||
if (cap < advance) { advance = cap; }
|
if (cap < advance) { advance = cap; }
|
||||||
}
|
|
||||||
step = advance / static_cast<double>(nElems);
|
step = advance / static_cast<double>(nElems);
|
||||||
/* Unreachable with a finite positive dt — kept because
|
/* Reached whenever the cap is zero, and a backstop
|
||||||
* downstream monotonicity must not depend on that
|
* against a nonsensical dt off the wire: downstream
|
||||||
* argument holding for every value off the wire. */
|
* requires strictly increasing stamps, so the burst must
|
||||||
|
* still advance by something. */
|
||||||
if (!(step > 0.0)) { step = dt * kMinBleedFactor; }
|
if (!(step > 0.0)) { step = dt * kMinBleedFactor; }
|
||||||
base = st.lastEmittedEnd + step;
|
base = st.lastEmittedEnd + step;
|
||||||
}
|
}
|
||||||
@@ -356,7 +366,37 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
|
|||||||
|
|
||||||
/* No declared rate: need hrt-derived dt. */
|
/* No declared rate: need hrt-derived dt. */
|
||||||
if (!hrtFit_.ready() || f.hrt == 0u) {
|
if (!hrtFit_.ready() || f.hrt == 0u) {
|
||||||
return packetBurst(idx, nElems, wallNow, tsOut);
|
const bool ok = packetBurst(idx, nElems, wallNow, tsOut);
|
||||||
|
/* Carry the warm-up's state into the hrt branch, or the handover
|
||||||
|
* from one to the other is a discontinuity in both directions.
|
||||||
|
*
|
||||||
|
* The producer-clock reference (lastAccHrt, prevAccCount) matters
|
||||||
|
* most. Without it the first hrt packet has no previous tick to
|
||||||
|
* subtract, falls back to kDefaultDt for its inter-element step and
|
||||||
|
* latches ClockOffset against wallNow - (nElems-1)*kDefaultDt.
|
||||||
|
* kDefaultDt is only right when the burst happens to run at 1 kHz;
|
||||||
|
* at 100 samples per 10 ms packet it is ten times too wide and the
|
||||||
|
* latch lands 89 ms in the past — permanently, since it is below
|
||||||
|
* ClockOffset's recalibration threshold. Seeding here means the
|
||||||
|
* first hrt packet measures a real tick delta and latches correctly.
|
||||||
|
*
|
||||||
|
* The emitted-timeline reference (lastEmitted*) then only has to
|
||||||
|
* cover residual disagreement, but it is what keeps the handover
|
||||||
|
* MONOTONIC: packetBurst ends its burst at wallNow while the hrt
|
||||||
|
* branch ends at wallNow - (nElems-1)*hrtDt, and without a previous
|
||||||
|
* end to clamp against the first hrt packet steps the signal
|
||||||
|
* backwards by up to a whole burst width. */
|
||||||
|
if (f.hrt != 0u) {
|
||||||
|
st.lastAccHrt = f.hrt;
|
||||||
|
st.lastAccValid = true;
|
||||||
|
}
|
||||||
|
st.prevAccCount = nElems;
|
||||||
|
if (!ok) { return false; }
|
||||||
|
st.lastEmittedEnd = tsOut[nElems - 1u];
|
||||||
|
st.lastEmittedWall = wallNow;
|
||||||
|
st.lastCounter = f.counter;
|
||||||
|
st.lastEmittedValid = true;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
const double rate = hrtFit_.ticksPerSecond();
|
const double rate = hrtFit_.ticksPerSecond();
|
||||||
|
|
||||||
@@ -455,11 +495,16 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
|
|||||||
* at kWallBleedFraction: only that makes the lead bleed off. */
|
* at kWallBleedFraction: only that makes the lead bleed off. */
|
||||||
if (st.lastEmittedValid && base <= st.lastEmittedEnd) {
|
if (st.lastEmittedValid && base <= st.lastEmittedEnd) {
|
||||||
const double wallElapsed = wallNow - st.lastEmittedWall;
|
const double wallElapsed = wallNow - st.lastEmittedWall;
|
||||||
if (wallElapsed > 0.0) {
|
/* No wall movement, no wall time to spend: see the same cap in the
|
||||||
const double cap = kWallBleedFraction * wallElapsed /
|
* declared branch. Zero rather than "skip the cap", so a run of
|
||||||
static_cast<double>(nElems);
|
* same-tick arrivals cannot advance a full hrtDt per sample while
|
||||||
|
* the wall stands still. */
|
||||||
|
const double cap = (wallElapsed > 0.0)
|
||||||
|
? (kWallBleedFraction * wallElapsed /
|
||||||
|
static_cast<double>(nElems))
|
||||||
|
: 0.0;
|
||||||
if (cap < step) { step = cap; }
|
if (cap < step) { step = cap; }
|
||||||
}
|
if (!(step > 0.0)) { step = hrtDt * kMinBleedFactor; }
|
||||||
base = st.lastEmittedEnd + step;
|
base = st.lastEmittedEnd + step;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* Source/Applications/StreamHub/UDPSourceSession.cpp documents this failure and
|
* Source/Applications/StreamHub/UDPSourceSession.cpp documents this failure and
|
||||||
* solves it; these are the same rules, computed from udps_frame_t's own fields.
|
* solves it; these are the same rules, computed from udps_frame_t's own fields.
|
||||||
*
|
*
|
||||||
* Two rules deliberately differ, both in the accumulated-scalar case (rule 3).
|
* Three rules deliberately differ, all in the accumulated-scalar case (rule 3).
|
||||||
*
|
*
|
||||||
* First, the anchor. StreamHub anchors every accumulated-scalar burst on the
|
* First, the anchor. StreamHub anchors every accumulated-scalar burst on the
|
||||||
* packet's own hrt, converted with the LOCAL MARTe HighResolutionTimer
|
* packet's own hrt, converted with the LOCAL MARTe HighResolutionTimer
|
||||||
@@ -24,7 +24,15 @@
|
|||||||
* reconstructed timeline drift, and drift that only arrival time can observe
|
* reconstructed timeline drift, and drift that only arrival time can observe
|
||||||
* must be corrected against arrival time — see rule 3.
|
* must be corrected against arrival time — see rule 3.
|
||||||
*
|
*
|
||||||
* Second, the entry condition. UDPSourceSession.cpp:554 routes any update
|
* Second, which end of the burst is anchored. StreamHub converts the packet's
|
||||||
|
* hrt into the position of sample 0 and steps forward, so the burst STARTS at
|
||||||
|
* the anchor. Here the anchor is arrival time, and the samples were acquired
|
||||||
|
* before the packet carrying them landed — so the burst must END there instead.
|
||||||
|
* Both branches of rule 3 do this, or two accumulated scalars in one scope, one
|
||||||
|
* with a declared rate and one without, would sit a whole burst apart on the
|
||||||
|
* shared X axis.
|
||||||
|
*
|
||||||
|
* Third, the entry condition. UDPSourceSession.cpp:554 routes any update
|
||||||
* carrying nElems <= 1 to plain arrival time. That is safe for a host-local
|
* carrying nElems <= 1 to plain arrival time. That is safe for a host-local
|
||||||
* consumer whose arrival time is the producer's own clock, but wrong here:
|
* consumer whose arrival time is the producer's own clock, but wrong here:
|
||||||
* Accumulate mode flushes on a TIMER, so a short RT cycle legitimately delivers
|
* Accumulate mode flushes on a TIMER, so a short RT cycle legitimately delivers
|
||||||
|
|||||||
@@ -429,7 +429,14 @@ TEST(FrameDecoder, AccumulatedScalarWithANonFiniteRateFallsBackToTheHrtPath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ASSERT_EQ(last.size(), 10u);
|
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";
|
<< "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);
|
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
|
// The same double delivery that the declared branch guards against — a host
|
||||||
// joined on two interfaces receives every unfragmented update twice — reaches an
|
// joined on two interfaces receives every unfragmented update twice — reaches an
|
||||||
// undeclared-rate signal identically. The guard can only fire if this branch
|
// undeclared-rate signal identically. The guard can only fire if this branch
|
||||||
|
|||||||
@@ -1861,7 +1861,14 @@ TEST(FrameDecoder, AccumulatedScalarWithANonFiniteRateFallsBackToTheHrtPath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ASSERT_EQ(last.size(), 10u);
|
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";
|
<< "an unusable declared rate must fall through to the hrt path";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2243,6 +2250,71 @@ TEST(FrameDecoder, UndeclaredAccumulatedScalarEndsItsBurstOnArrival) {
|
|||||||
EXPECT_NEAR(last[0], lastArrival - 0.009, 1e-9);
|
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
|
// The same double delivery that the declared branch guards against — a host
|
||||||
// joined on two interfaces receives every unfragmented update twice — reaches an
|
// joined on two interfaces receives every unfragmented update twice — reaches an
|
||||||
// undeclared-rate signal identically. The guard can only fire if this branch
|
// undeclared-rate signal identically. The guard can only fire if this branch
|
||||||
@@ -2383,7 +2455,7 @@ Create `Client/udpscope/FrameDecoder.h`:
|
|||||||
* Source/Applications/StreamHub/UDPSourceSession.cpp documents this failure and
|
* Source/Applications/StreamHub/UDPSourceSession.cpp documents this failure and
|
||||||
* solves it; these are the same rules, computed from udps_frame_t's own fields.
|
* solves it; these are the same rules, computed from udps_frame_t's own fields.
|
||||||
*
|
*
|
||||||
* Two rules deliberately differ, both in the accumulated-scalar case (rule 3).
|
* Three rules deliberately differ, all in the accumulated-scalar case (rule 3).
|
||||||
*
|
*
|
||||||
* First, the anchor. StreamHub anchors every accumulated-scalar burst on the
|
* First, the anchor. StreamHub anchors every accumulated-scalar burst on the
|
||||||
* packet's own hrt, converted with the LOCAL MARTe HighResolutionTimer
|
* packet's own hrt, converted with the LOCAL MARTe HighResolutionTimer
|
||||||
@@ -2397,7 +2469,15 @@ Create `Client/udpscope/FrameDecoder.h`:
|
|||||||
* reconstructed timeline drift, and drift that only arrival time can observe
|
* reconstructed timeline drift, and drift that only arrival time can observe
|
||||||
* must be corrected against arrival time — see rule 3.
|
* must be corrected against arrival time — see rule 3.
|
||||||
*
|
*
|
||||||
* Second, the entry condition. UDPSourceSession.cpp:554 routes any update
|
* Second, which end of the burst is anchored. StreamHub converts the packet's
|
||||||
|
* hrt into the position of sample 0 and steps forward, so the burst STARTS at
|
||||||
|
* the anchor. Here the anchor is arrival time, and the samples were acquired
|
||||||
|
* before the packet carrying them landed — so the burst must END there instead.
|
||||||
|
* Both branches of rule 3 do this, or two accumulated scalars in one scope, one
|
||||||
|
* with a declared rate and one without, would sit a whole burst apart on the
|
||||||
|
* shared X axis.
|
||||||
|
*
|
||||||
|
* Third, the entry condition. UDPSourceSession.cpp:554 routes any update
|
||||||
* carrying nElems <= 1 to plain arrival time. That is safe for a host-local
|
* carrying nElems <= 1 to plain arrival time. That is safe for a host-local
|
||||||
* consumer whose arrival time is the producer's own clock, but wrong here:
|
* consumer whose arrival time is the producer's own clock, but wrong here:
|
||||||
* Accumulate mode flushes on a TIMER, so a short RT cycle legitimately delivers
|
* Accumulate mode flushes on a TIMER, so a short RT cycle legitimately delivers
|
||||||
@@ -2819,15 +2899,25 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
|
|||||||
if (factor < kMinBleedFactor) { factor = kMinBleedFactor; }
|
if (factor < kMinBleedFactor) { factor = kMinBleedFactor; }
|
||||||
double advance = nominal * factor;
|
double advance = nominal * factor;
|
||||||
|
|
||||||
|
/* A non-positive elapsed means the wall has not moved
|
||||||
|
* since this signal's previous burst — a coarse arrival
|
||||||
|
* clock, or two packets stamped within one tick of it.
|
||||||
|
* There is no wall time to spend, so the cap is zero.
|
||||||
|
* Skipping the cap in that case (which is what this code
|
||||||
|
* used to do) hands back the full proportional advance,
|
||||||
|
* so a run of same-tick arrivals gains lead while no wall
|
||||||
|
* time passes at all — the divergence the cap exists to
|
||||||
|
* stop, in its purest form. */
|
||||||
const double wallElapsed = wallNow - st.lastEmittedWall;
|
const double wallElapsed = wallNow - st.lastEmittedWall;
|
||||||
if (wallElapsed > 0.0) {
|
const double cap = (wallElapsed > 0.0)
|
||||||
const double cap = kWallBleedFraction * wallElapsed;
|
? (kWallBleedFraction * wallElapsed)
|
||||||
|
: 0.0;
|
||||||
if (cap < advance) { advance = cap; }
|
if (cap < advance) { advance = cap; }
|
||||||
}
|
|
||||||
step = advance / static_cast<double>(nElems);
|
step = advance / static_cast<double>(nElems);
|
||||||
/* Unreachable with a finite positive dt — kept because
|
/* Reached whenever the cap is zero, and a backstop
|
||||||
* downstream monotonicity must not depend on that
|
* against a nonsensical dt off the wire: downstream
|
||||||
* argument holding for every value off the wire. */
|
* requires strictly increasing stamps, so the burst must
|
||||||
|
* still advance by something. */
|
||||||
if (!(step > 0.0)) { step = dt * kMinBleedFactor; }
|
if (!(step > 0.0)) { step = dt * kMinBleedFactor; }
|
||||||
base = st.lastEmittedEnd + step;
|
base = st.lastEmittedEnd + step;
|
||||||
}
|
}
|
||||||
@@ -2847,7 +2937,37 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
|
|||||||
|
|
||||||
/* No declared rate: need hrt-derived dt. */
|
/* No declared rate: need hrt-derived dt. */
|
||||||
if (!hrtFit_.ready() || f.hrt == 0u) {
|
if (!hrtFit_.ready() || f.hrt == 0u) {
|
||||||
return packetBurst(idx, nElems, wallNow, tsOut);
|
const bool ok = packetBurst(idx, nElems, wallNow, tsOut);
|
||||||
|
/* Carry the warm-up's state into the hrt branch, or the handover
|
||||||
|
* from one to the other is a discontinuity in both directions.
|
||||||
|
*
|
||||||
|
* The producer-clock reference (lastAccHrt, prevAccCount) matters
|
||||||
|
* most. Without it the first hrt packet has no previous tick to
|
||||||
|
* subtract, falls back to kDefaultDt for its inter-element step and
|
||||||
|
* latches ClockOffset against wallNow - (nElems-1)*kDefaultDt.
|
||||||
|
* kDefaultDt is only right when the burst happens to run at 1 kHz;
|
||||||
|
* at 100 samples per 10 ms packet it is ten times too wide and the
|
||||||
|
* latch lands 89 ms in the past — permanently, since it is below
|
||||||
|
* ClockOffset's recalibration threshold. Seeding here means the
|
||||||
|
* first hrt packet measures a real tick delta and latches correctly.
|
||||||
|
*
|
||||||
|
* The emitted-timeline reference (lastEmitted*) then only has to
|
||||||
|
* cover residual disagreement, but it is what keeps the handover
|
||||||
|
* MONOTONIC: packetBurst ends its burst at wallNow while the hrt
|
||||||
|
* branch ends at wallNow - (nElems-1)*hrtDt, and without a previous
|
||||||
|
* end to clamp against the first hrt packet steps the signal
|
||||||
|
* backwards by up to a whole burst width. */
|
||||||
|
if (f.hrt != 0u) {
|
||||||
|
st.lastAccHrt = f.hrt;
|
||||||
|
st.lastAccValid = true;
|
||||||
|
}
|
||||||
|
st.prevAccCount = nElems;
|
||||||
|
if (!ok) { return false; }
|
||||||
|
st.lastEmittedEnd = tsOut[nElems - 1u];
|
||||||
|
st.lastEmittedWall = wallNow;
|
||||||
|
st.lastCounter = f.counter;
|
||||||
|
st.lastEmittedValid = true;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
const double rate = hrtFit_.ticksPerSecond();
|
const double rate = hrtFit_.ticksPerSecond();
|
||||||
|
|
||||||
@@ -2946,11 +3066,16 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
|
|||||||
* at kWallBleedFraction: only that makes the lead bleed off. */
|
* at kWallBleedFraction: only that makes the lead bleed off. */
|
||||||
if (st.lastEmittedValid && base <= st.lastEmittedEnd) {
|
if (st.lastEmittedValid && base <= st.lastEmittedEnd) {
|
||||||
const double wallElapsed = wallNow - st.lastEmittedWall;
|
const double wallElapsed = wallNow - st.lastEmittedWall;
|
||||||
if (wallElapsed > 0.0) {
|
/* No wall movement, no wall time to spend: see the same cap in the
|
||||||
const double cap = kWallBleedFraction * wallElapsed /
|
* declared branch. Zero rather than "skip the cap", so a run of
|
||||||
static_cast<double>(nElems);
|
* same-tick arrivals cannot advance a full hrtDt per sample while
|
||||||
|
* the wall stands still. */
|
||||||
|
const double cap = (wallElapsed > 0.0)
|
||||||
|
? (kWallBleedFraction * wallElapsed /
|
||||||
|
static_cast<double>(nElems))
|
||||||
|
: 0.0;
|
||||||
if (cap < step) { step = cap; }
|
if (cap < step) { step = cap; }
|
||||||
}
|
if (!(step > 0.0)) { step = hrtDt * kMinBleedFactor; }
|
||||||
base = st.lastEmittedEnd + step;
|
base = st.lastEmittedEnd + step;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3002,7 +3127,7 @@ set(CORE_SOURCES
|
|||||||
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='FrameDecoder*'
|
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='FrameDecoder*'
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected: PASS, 25 `FrameDecoder` tests — 54 across the whole `udpscope_tests` binary.
|
Expected: PASS, 26 `FrameDecoder` tests — 55 across the whole `udpscope_tests` binary.
|
||||||
|
|
||||||
If `AccumulatedScalarSurvivesBurstyDelivery` fails, do NOT reach for the hrt fit: with a declared `samplingRate` rule 3 never consults it, precisely because the fit is not ready for the first 32 packets and — since `HrtRateFit` regresses `hrt` against ARRIVAL time — is itself corrupted by the very bursts it would be asked to survive. Check instead that `lastEmittedEnd`, `lastCounter`, `prevAccCount` and `lastEmittedValid` are updated on every emitted burst.
|
If `AccumulatedScalarSurvivesBurstyDelivery` fails, do NOT reach for the hrt fit: with a declared `samplingRate` rule 3 never consults it, precisely because the fit is not ready for the first 32 packets and — since `HrtRateFit` regresses `hrt` against ARRIVAL time — is itself corrupted by the very bursts it would be asked to survive. Check instead that `lastEmittedEnd`, `lastCounter`, `prevAccCount` and `lastEmittedValid` are updated on every emitted burst.
|
||||||
|
|
||||||
@@ -3022,7 +3147,11 @@ If `UndeclaredAccumulatedScalarSurvivesAProducerRestart` fails with the spacing
|
|||||||
|
|
||||||
If `UndeclaredAccumulatedScalarRecoversFromABackwardWallStep` fails, the hrt branch's `base <= lastEmittedEnd` clamp has gone back to a bare `lastEmittedEnd + hrtDt`, which is one-directional and leaves an NTP correction or a suspend/resume as a permanent lead. Note the test's geometry is deliberate: the step is 0.6 s (it must exceed `ClockOffset::kRecalibThresholdS` or the recalibration that puts `base` behind `lastEmittedEnd` never happens at all), and it lands after the 256-sample rate-fit window is full. `HrtRateFit` regresses `hrt` against ARRIVAL, so it eventually absorbs the step too, at roughly `step / kWindow` per packet — a much slower second correction that would swamp the measurement if the step were placed early or the run continued for hundreds of packets past it.
|
If `UndeclaredAccumulatedScalarRecoversFromABackwardWallStep` fails, the hrt branch's `base <= lastEmittedEnd` clamp has gone back to a bare `lastEmittedEnd + hrtDt`, which is one-directional and leaves an NTP correction or a suspend/resume as a permanent lead. Note the test's geometry is deliberate: the step is 0.6 s (it must exceed `ClockOffset::kRecalibThresholdS` or the recalibration that puts `base` behind `lastEmittedEnd` never happens at all), and it lands after the 256-sample rate-fit window is full. `HrtRateFit` regresses `hrt` against ARRIVAL, so it eventually absorbs the step too, at roughly `step / kWindow` per packet — a much slower second correction that would swamp the measurement if the step were placed early or the run continued for hundreds of packets past it.
|
||||||
|
|
||||||
If `UndeclaredAccumulatedScalarEndsItsBurstOnArrival` fails by exactly `(nElems - 1) * hrtDt`, `ClockOffset::map()` is being latched against raw `wallNow` again, which puts the burst's FIRST element on arrival while the declared branch puts its LAST one there — two accumulated scalars in one scope, one with a declared rate and one without, then sit a whole burst apart on the shared X axis. The test's 10 ms packet of 10 samples is chosen so the derived period equals `kDefaultDt`: the very first hrt-branch packet has no measurable interval and latches the offset using that fallback, and any other period would bake the difference into the offset for the rest of the run.
|
If `UndeclaredAccumulatedScalarEndsItsBurstOnArrival` fails by exactly `(nElems - 1) * hrtDt`, `ClockOffset::map()` is being latched against raw `wallNow` again, which puts the burst's FIRST element on arrival while the declared branch puts its LAST one there — two accumulated scalars in one scope, one with a declared rate and one without, then sit a whole burst apart on the shared X axis.
|
||||||
|
|
||||||
|
That test alone is NOT sufficient cover for the hrt branch's anchoring, which is why `UndeclaredAccumulatedScalarCrossesTheHrtHandoverCleanly` exists beside it. Its 10 samples per 10 ms packet make the derived period exactly `kDefaultDt`, the one cadence at which the warm-up-to-hrt handover cannot misbehave, so it passes against a decoder that steps backwards by up to a whole burst at that handover. The handover is the sharp edge here: an undeclared-rate signal is served by `packetBurst` until `HrtRateFit` is ready and by the hrt branch afterwards, and the two place a burst differently — `packetBurst` ends it at `wallNow`, the hrt branch at `wallNow - (nElems - 1) * hrtDt`.
|
||||||
|
|
||||||
|
If `UndeclaredAccumulatedScalarCrossesTheHrtHandoverCleanly` fails on the monotonicity assertion, the warm-up branch is returning `packetBurst()`'s result directly without recording state. Two different fields matter and they fix two different symptoms. `lastEmittedEnd`/`lastEmittedWall`/`lastEmittedValid` are what the monotonic clamp needs; without them the first hrt packet skips the clamp entirely and steps back by up to a burst width (-6.5 ms at 10 samples per 2.5 ms, -0.99 s at 1000 samples per 10 ms). `lastAccHrt`/`prevAccCount` are what stops the failure being merely hidden: without a previous tick to subtract, the first hrt packet has no measurable interval, falls back to `kDefaultDt` and latches `ClockOffset` against `wallNow - (nElems - 1) * kDefaultDt`. At 100 samples per 10 ms packet that is ten times too wide and parks the trace 89 ms in the past permanently, since 89 ms is below `ClockOffset::kRecalibThresholdS`. That is why the test asserts the settled burst still ends on arrival and steps at the true sample period, not just that it never goes backwards — the clamp on its own would satisfy monotonicity while leaving the trace displaced.
|
||||||
|
|
||||||
- [ ] **Step 8: Commit**
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user