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

+30 -4
View File
@@ -152,6 +152,20 @@ void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, Measu
/// must adding a second measurement of the same type at the same level.
struct MeasureLevel {
double level = 0.0;
/// Half-width of the hysteresis band about `level`: a crossing is counted only once the signal
/// has passed `level + hysteresis` going up or `level - hysteresis` going down, and a sample
/// inside the band decides nothing. It is a Schmitt trigger, and it is what makes an edge count
/// a count of the *waveform's* cycles rather than of its noise: a single level is recrossed
/// several times per true crossing by any interferer faster than the fundamental, because near
/// the mid level the fundamental's own slope is at its smallest. Measured on a 5 Hz sine with a
/// 500 Hz interferer at a tenth of the amplitude: 9 true crossings counted as 79, reading
/// `period=0.025622 freq=39.029` for a true 0.2 s / 5 Hz. With the band, `period=0.200188`.
///
/// It moves the *edge decision* only. The `sum`/`count` split below stays at the bare `level`,
/// because that is what `Top`/`Bottom` are -- the mean of the samples above/below the threshold --
/// and a band there would change a documented exact reading (`top` on a unit sine is 2/pi).
/// `measure_set_level()` is what sizes it; zero reduces the walk exactly to a single level.
double hysteresis = 0.0;
/// Index 0 is the side below the level (a sample exactly *at* it counts here, which is what
/// `bottom` has always reported), index 1 the side above it.
double sum[2] = {0.0, 0.0};
@@ -170,6 +184,14 @@ struct MeasureLevel {
bool open[2] = {false, false};
};
/// Fill a level accumulator's `level` and `hysteresis` from the window's extremes and the share
/// `measure_level_pct()` gave. The one place the band is sized, so every caller gets the same rule:
/// a tenth of the window's peak-to-peak, *clamped* to half the room between the level and the
/// nearer extreme. The clamp is what keeps a threshold near an extreme usable -- `PulseDuration`
/// lets the user put one at 90 %, and a fixed band there puts the upper threshold above every
/// sample in the window, which reads as nothing measured at all rather than as a noisy reading.
void measure_set_level(MeasureLevel& level, const MeasureStats& stats, double pct);
/// Pass two: one chunk into every level at once. The usual case is a single level -- every
/// crossing type reads the mid level and `Top`/`Bottom` default to it too -- so a pane full of
/// measurements on one signal costs two walks, not two per measurement.
@@ -182,10 +204,14 @@ void measure_accumulate(const MeasureChunk& chunk,
/// fold in whole and the walk's side is decided by the bucket rather than by 64 samples. An edge
/// *into* such a bucket lands on its first sample, exactly, which is why that is the stamp taken.
///
/// False means the bucket straddles the level -- or holds a non-finite sample, which would make
/// the edge's position a guess -- and the caller must feed its raw samples to
/// `measure_accumulate()` for this level instead. That is the only place a measurement still
/// reads raw samples in bulk, and on a clean waveform it is a bucket per crossing.
/// False means the caller must feed the bucket's raw samples to `measure_accumulate()` for this
/// level instead. That is the only place a measurement still reads raw samples in bulk, and on a
/// clean waveform it is a bucket or two per crossing. Three things make it happen: the bucket
/// straddles the level (so the sums cannot be binned); it holds a non-finite sample, which would
/// make the edge's position a guess; or it is where the signal passes `level +/- hysteresis` and the
/// *position* of that crossing is inside the bucket. The band is deliberately not folded into the
/// first test -- see the comment in the implementation for why refusing every bucket inside the band
/// costs a share of the window rather than a bucket.
bool measure_accumulate_bucket(const MeasureBucket& bucket,
const std::function<double(std::size_t)>& time_of,
MeasureLevel& level);