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

+54 -12
View File
@@ -201,13 +201,53 @@ measurement.
`period` and `frequency` deliberately take **no options**: the engine counts *rising*
crossings of the *mid* level, one edge per cycle, and a split or a side to choose from could
only make that count wrong — a split near either extreme counts noise as crossings, and
counting both edges halves the period.
only make that count wrong — a split near either extreme leaves the hysteresis band below no room
to reject noise with, and counting both edges halves the period.
The split is a share of the **window's own** min..max, not volts: a measurement is about this
signal in this window, and a level in volts would have to be typed for every signal. A
crossing has to be *strict* — a sample exactly at the level is on neither side — so a signal
that merely touches the level does not count every sample as an event.
signal in this window, and a level in volts would have to be typed for every signal.
### Why a crossing is counted with hysteresis
A crossing is not read off a single level. It is read off a **band**: two thresholds a tenth of
the window's peak-to-peak either side of the split, and an edge is counted only once the signal
has passed the far one. Inside the band a sample decides nothing. That is a Schmitt trigger, and
it is the difference between counting the waveform's cycles and counting its noise.
Counting on one level was the first implementation and it fails on anything real, badly and
silently. The mid level is exactly where a waveform moves *fastest* — on a sine the slope there
is 2πf and the signal only clears a disturbance of amplitude *a* after a/2πf seconds — so any
interferer faster than the fundamental recrosses the level several times per true crossing, and
each recrossing is counted. Measured on a 5 Hz sine with a 500 Hz tone at a tenth of the
amplitude (`tools/mock_udpstreamer.py --noise 0.1`): 9 true crossings read as **79**, giving
`period=0.025622` and `frequency=39.029` for a true 0.2 s and 5 Hz. The number is not merely
noisy, it is a different order. With the band the same window reads `period=0.200188`. The band
holds to an interferer at **0.3** — a third of the waveform — where it reads `period=0.200688`,
and to white noise at σ=0.05; at 5 % of peak-to-peak it already fails at 0.2.
Three things about it are deliberate, and each was wrong first.
- **The band moves the edge decision only.** The side the `sum`/`count` split uses stays the bare
level, because that split *is* `top` and `bottom` — the mean of the samples above and below the
threshold — and a band there changes a documented exact reading. `top` on a unit sine must still
be `0.636620`, 2/π.
- **The band is clamped to stay inside the data**, to half the room between the level and the
nearer extreme. `pulse duration` lets the user put the split near an extreme (a 3.3 V logic
signal with ringing wants it nearer the top), and a fixed tenth of the span there puts the upper
threshold *above every sample in the window*: the walk never decides the upper side and the
measurement reads nothing at all, which is strictly worse than reading something noisy. Measured
on such a signal at a 90 % split: `enters=0` with a fixed band, `enters=4 period=0.200000` with
the clamp.
- **The LOD fast path tests the band and the level separately.** Tightening the bucket-fold
condition to `level ± h` would refuse every bucket lying inside the band — and those are
precisely the buckets a crossing passes through, so the raw walk would get a *share of the
window* instead of a bucket or two per crossing. A bucket on one side of the **level** can always
be binned; only a side *change* whose position is inside the bucket is refused. The clean fast
mock still reads `raw=119412 (0.1%)` of a 215 M-sample total at 1.41 ms a recompute; with the
0.1 interferer it is `raw=2.0%` at 1.96 ms, which is the noise's own cost and not the band's.
A sample exactly *at* a threshold is still on neither side, so a signal that merely touches one
does not count every sample as an event.
Each measurement's row carries **two badges**: its own colour (the same one its marks use on
the plot) and the signal's chip, in the palette colour every other view names that signal
@@ -258,18 +298,20 @@ Both walks go through it:
the window's size. The two stamps the scan needed are read at the end rather than at every
chunk that moved an extreme, since a stamp is a ring read and an extreme moves many times.
- **Pass two** (the threshold types: `top`, `bottom`, `pulse duration`, `period`, `frequency`)
folds a bucket whole whenever it lies *strictly* on one side of its level — then every sample
in it decides the same side, none is *at* the level, and an entering edge is exactly the
bucket's first sample, which is where the raw walk would have found it. Only the buckets that
**straddle** a level are opened, which on a clean waveform is one per crossing.
folds a bucket's sums whenever it lies *strictly* on one side of its level — then every sample
in it falls in the same bin and none is *at* the level, so the fold is exact. It then asks
separately whether the bucket *enters* a side of the hysteresis band: one wholly past a threshold
holds that crossing at its first sample, exactly, which is where the raw walk would have found
it. Only a bucket that straddles the level, or one that enters a side somewhere inside itself, is
opened — on a clean waveform a bucket or two per crossing.
The raw path is still there and still exact, for the window's partial edge buckets, for the
samples produced since this frame's `acquire_all()` (bounded by one frame of production), and
for those straddling buckets. Nothing is decimated or strided: `peak to peak` still reads
for those opened buckets. Nothing is decimated or strided: `peak to peak` still reads
exactly `2` on a unit sine and `top` exactly `0.636620` — 2/π, the half-wave mean, which is the
check that a threshold type is not merely plausible. A window shorter than a bucket, or a signal
noisy enough to straddle everywhere, degrades back to the O(window) cost and nothing else
changes. The dump's `measure cost:` line reports `covered`, `buckets` and `raw` for exactly
noisy enough to cross a threshold everywhere, degrades back towards the O(window) cost and nothing
else changes. The dump's `measure cost:` line reports `covered`, `buckets` and `raw` for exactly
this: `raw` near `covered` means the pyramid bought nothing.
### What the math channels cost