fix: a crossing is counted with hysteresis, so noise is not counted as cycles

period, frequency and pulse duration counted every sign change about a single
level, which is unusable on any real signal: the mid level is exactly where a
waveform moves fastest, so an interferer faster than the fundamental recrosses
it several times per true crossing. A 5 Hz sine with a 500 Hz tone at a tenth of
the amplitude read 79 crossings for 9, period=0.025622 against 0.2.

MeasureLevel gains a hysteresis band sized once in measure_set_level(): a tenth
of the window's peak-to-peak, clamped to half the room to the nearer extreme so
a threshold set near one stays usable. The band moves the edge decision only --
the sum/count split stays at the bare level, since that split is top/bottom and
has a documented exact reading. The bucket fast path tests the band and the
level separately rather than folding them, or it would refuse exactly the
buckets a crossing passes through: clean cost is unchanged at raw=0.1% and
1.41 ms a recompute.

tools/mock_udpstreamer.py gains --noise, defaulting to 0 so the exact probe
assertions still hold.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-06 10:07:56 +02:00
1 parent b4b26d3e31
commit 13574bfcef
7 files changed
+321 -76

No files matched your search

+63 -15
View File
@@ -28,8 +28,27 @@ void enter_side(MeasureLevel& acc, std::size_t e, double t) {
acc.open[e] = true;
}
/// The hysteresis band's half-width, as a share of the window's peak-to-peak. Measured rather than
/// chosen: 5 % reads the mock's 0.1 interferer correctly and fails at 0.2 (`period=0.075` for a
/// true 0.2), while 10 % holds to an interferer amplitude of 0.3 -- a third of the waveform -- and
/// to white noise at sigma=0.05. Above that the band starts eating a real waveform's own extremes
/// on anything less square than a sine.
constexpr double kHysteresisFrac = 0.10;
} // namespace
void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct) {
const double span = s.hi - s.lo;
acc.level = s.lo + pct * span;
// Half the room to the nearer extreme is the clamp. A threshold at 90 % has a tenth of the span
// above it, so a fixed 10 % band would put `level + h` *above every sample*: the walk then never
// decides the upper side and the measurement reads nothing at all, which is strictly worse than
// a noisy reading. Measured on a 3.3 V logic signal with ringing at thr=90 %: `enters=0` with a
// fixed band, `enters=4 period=0.200000` with the clamp.
const double room = std::min(s.hi - acc.level, acc.level - s.lo);
acc.hysteresis = std::max(0.0, std::min(kHysteresisFrac * span, 0.5 * room));
}
void measure_scan(const MeasureChunk& chunk, MeasureStats& s) {
// The chunk's own extremes first, in a loop that touches nothing but the values: *where* an
// extreme is costs an index, and the caller turns the two that survive into stamps once at the
@@ -130,17 +149,27 @@ void measure_accumulate(const MeasureChunk& chunk,
for (std::size_t i = 0; i < chunk.count; ++i) {
const double v = static_cast<double>(chunk.value[i]);
if (!std::isfinite(v)) continue;
// The *sums* split at the bare level: `Top` and `Bottom` are defined as the mean above and
// below the threshold, and a band here would move a documented exact reading.
const int s = v > acc.level ? 1 : 0;
acc.sum[static_cast<std::size_t>(s)] += v;
++acc.count[static_cast<std::size_t>(s)];
// A sample *at* the level is on neither side, so it decides no crossing and the walk keeps
// the side it last decided. That is what stops a signal which merely touches the level from
// counting every sample as an edge.
if (v == acc.level) continue;
if (acc.have_side && s != (acc.side ? 1 : 0)) {
enter_side(acc, static_cast<std::size_t>(s), time_of(chunk.offset + i));
// The *edge* decision reads the band instead. A sample inside it is on neither side, so it
// decides no crossing and the walk keeps the side it last decided -- which is what stops a
// signal that merely wobbles about the level from counting every wobble as an edge. With
// `hysteresis == 0` this is the single-level test, `v == level` skip included.
int d;
if (v > acc.level + acc.hysteresis) {
d = 1;
} else if (v < acc.level - acc.hysteresis) {
d = 0;
} else {
continue;
}
acc.side = s != 0;
if (acc.have_side && d != (acc.side ? 1 : 0)) {
enter_side(acc, static_cast<std::size_t>(d), time_of(chunk.offset + i));
}
acc.side = d != 0;
acc.have_side = true;
}
}
@@ -153,21 +182,40 @@ bool measure_accumulate_bucket(const MeasureBucket& k,
// A non-finite sample in the bucket would make the edge's position a guess: the raw walk takes
// the first *finite* sample of the new side, and an aggregate cannot say which that is.
if (k.count != k.span) return false;
// Strictly on one side, so every sample decides the same side and none is *at* the level --
// which is what makes this exact rather than an approximation of the walk.
// The *sums* need only the bare level: strictly on one side of it, so every sample decides the
// same bin and none is *at* it, which is what makes the fold exact rather than an approximation.
const bool above = k.lo > acc.level;
const bool below = k.hi < acc.level;
if (!above && !below) return false;
const std::size_t e = above ? 1u : 0u;
// The band is deliberately *not* folded into the test above. Tightening the whole condition to
// `level +/- h` refuses every bucket lying inside the band -- a tenth of the amplitude either side
// of the mid level is where a waveform moves fastest, so those are exactly the buckets a crossing
// passes through -- which hands the raw walk a share of the window rather than a bucket or two per
// crossing, and breaks the "`raw` stays at a fraction of a percent of `covered`" invariant.
// Instead: a bucket on one side of the level can always be *binned*, and only a *side change*
// whose position is inside the bucket is refused.
const double top = acc.level + acc.hysteresis;
const double bot = acc.level - acc.hysteresis;
// Does the bucket reach past the band at all? One wholly inside it decides no side, exactly as a
// raw sample inside it decides none, so the walk state is left alone.
const bool reaches = above ? (k.hi > top) : (k.lo < bot);
const bool change = reaches && acc.have_side && e != (acc.side ? 1u : 0u);
// A bucket that *enters* a side while straddling its own threshold holds the crossing somewhere
// inside itself, and only the raw walk can say where. One wholly past the threshold holds it at
// the bucket's first sample, exactly, which is where the raw walk would have found it.
const bool fully_outside = above ? (k.lo > top) : (k.hi < bot);
// Nothing may be folded before this point: a refusal hands the bucket to `measure_accumulate()`,
// which adds the same samples again. Sums added above the refusal double-counted every opened
// bucket, which read as `top` 0.614304 on a unit sine against the 2/pi it must be.
if (change && !fully_outside) return false;
acc.sum[e] += k.sum;
acc.count[e] += k.count;
if (acc.have_side && e != (acc.side ? 1u : 0u)) {
// The edge is the bucket's *first* sample: everything before it was on the other side and
// everything in the bucket is on this one, so that is where the raw walk would have found it.
enter_side(acc, e, time_of(k.offset));
if (change) enter_side(acc, e, time_of(k.offset));
if (reaches) {
acc.side = e != 0;
acc.have_side = true;
}
acc.side = e != 0;
acc.have_side = true;
return true;
}