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:
1 parent
b4b26d3e31
commit
13574bfcef
7 files changed
+321
-76
No files matched your search
+30
-4
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user