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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user