measure: top/bottom/amplitude read the flat levels, not the extremes

A bench scope separates Vtop/Vbase from Vmax/Vmin because a band-limited
edge overshoots and rings: the mock's `square` at 30 harmonics reaches
±1.179 where its plateau is ±1. On the rippled square the old readings were
a property of the edges rather than of the waveform -- `top` 0.986496,
`amplitude` 1.179350 -- and `amplitude` reported the overshoot.

`top`, `bottom` and `amplitude` are now the flat levels and their delta.
A flat level is a density question -- the plateau holds most of the
samples and the ringing a few percent -- so `measure_scan_buckets()` folds
a 64-bin histogram of the whole buckets' midpoints while it has the list in
hand (one observation per bucket, each bin carrying its buckets' exact sum
and finite count), and `measure_flat_levels()` takes, either side of the
split, the densest bin holding at least half the densest one's buckets --
the outermost of those, which is what makes a waveform with no plateau read
its peak -- and the exact mean of the samples of the buckets within 5 % of
the window's peak-to-peak of it. 5 % is deliberately under the ~9 %-of-swing
Gibbs overshoot. `amplitude` is `top - bottom` (a scope's Vamplitude), not
the half-swing it was.

Measured, fast mock, 2 s window, harmonics 0 -> 30: top 1.000000 ->
0.999983, bottom -1.000000 -> -0.999983, amplitude 2.000000 -> 1.999966,
while `peak to peak` keeps the extremes (2.000000 -> 2.358701). On the 1 Hz
sine top is 0.957988 (it was 0.636620, the half-wave mean; a sine has no
plateau, so the densest band is at its peak) with sd 0.0014 over a scrolling
window, and `peak to peak` is still exactly 2.

No raw pass and no new fetch: the histogram is the same fetched bucket list
read a second time, gated on a pane that holds one of the three types.
`raw` is unchanged (0.1% clean, 2.1% with the --noise 0.1 interferer,
against 0.1%/2.1% before) for 0.44 -> 0.50 ms and 0.52 -> 0.65 ms a pass.
A window the pyramid cannot summarize -- an hf-filtered walk, or one shorter
than a bucket -- keeps the old readings: the mean of each side of the split,
and the window's peak to peak for amplitude.

Docs: the flat-level invariant (and the two 2/pi assertions it replaces),
the probe readings, the manual's measurements page and the README.
This commit is contained in:
Martino Ferrari committed 2026-10-15 01:19:33 +02:00
1 parent afd2a6cdc1
commit e83ad86b8f
10 files changed
+495 -80

No files matched your search

+78 -10
View File
@@ -16,9 +16,10 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
frame; the window is materialized in bounded chunks (never two whole-window f32 temporaries)
and the loop holds it **by reference** (copying both vectors per measurement was 300 MB of
memcpy a frame); and `measure_stats()` is one shared pass carrying both extremes with their
first positions, the sum and the count, so average/min/max/peak-to-peak/amplitude need no
pass of their own. **Adding a type must not add a pass** -- that is what `compute_measure()`'s
`stats` argument is for.
first positions, the sum and the count, so average/min/max/peak-to-peak need no pass of their
own -- `top`/`bottom`/`amplitude` take a second read of that same **bucket** list and never of
the window (the flat-level invariant below). **Adding a type must not add a pass** -- that is
what `compute_measure()`'s `stats` argument is for.
The marks' *ink* is set where they are created (`install()` in `plot_host.cpp`): 0.75 px and
dashed 4/4 through `Plot::set_cursor_stroke()`, which is per cursor because the trigger marker
(also a cursor) keeps the solid `line_width_px` stroke. A new measure mark must take that
@@ -57,10 +58,18 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
*finite* sample of the new side and an aggregate cannot say which that is); the caller then opens
that bucket. **Nothing may be folded before a refusal** -- a refused bucket goes to
`measure_accumulate()`, which adds the same samples again, and sums added above the `return false`
double-counted every opened bucket, reading `top=0.614304` on a unit sine against the 2/pi it must
be. Four things this rests on, each of which breaks it silently:
double-counted every opened bucket. That read as `top=0.614304` on a unit sine against the 2/pi a
mean over a half then gave; a *bucketed* window no longer reports `top` from those sums at all
(see the flat-level invariant below), which makes the rule easier to break silently rather than
less binding -- the sums are the fallback a window with no histogram reads, so a wrong one is a
wrong answer waiting for that window. Four things this rests on, each of which breaks it silently:
* **Exactness is the point, so nothing may stride or decimate.** `peak to peak` must still read
exactly `2` on a unit sine and `top` exactly `0.636620` (2/pi). A bucket's sum is a sum.
exactly `2` on a unit sine, and it and `average` still come straight out of the one shared
`measure_stats()` pass. A bucket's sum is a sum. (`top` has no *exact* reading to protect any
more: it is a density estimate and not a mean, and the two places that used to assert
`top == 0.636620` on a sine now assert what the flat level actually reads -- see the flat-level
invariant below. Everything above is about the *bucket* path being a fast path and not an
approximation, and that is unchanged.)
* **The bucket index space must be the window's.** `ChannelBuffer::acquire()` calls
`pyramid_.drop_before(values_->base())`, so a channel's pyramid is in the value ring's
absolute index space -- the same space `live_window_locked()` answers in, since a channel's
@@ -76,9 +85,65 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
the pyramid for nothing.
`measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say
anything.
- **`Top`/`Bottom`/`Amplitude` read a *flat* level, and the density that finds it is the buckets',
not the samples'.** A band-limited edge overshoots and rings -- the mock's `square` at 30
harmonics reaches ±1.179 where its plateau is ±1 -- so a measurement that reads the extremes, or
a mean over a side of a level, reports the ringing rather than the level the signal dwells at:
that is what made this change (`amplitude` read `1.179350` and `top` `0.986496` on the rippled
square, against `1.179350` of overshoot where the plateau is `1`). A flat level is a *density*
question: the plateau holds most of the samples and the ringing a few percent. So
`measure_scan_buckets()` folds a 64-bin histogram of the whole buckets' *midpoints* while it has
the list in hand -- one observation per bucket, each bin carrying its buckets' **exact** sum and
finite count -- and `measure_flat_levels()` takes, either side of the split, the densest bin that
holds at least half the densest one's buckets, **the outermost of those**, and the exact mean of
the samples of the buckets within **5 % of the window's peak-to-peak** of it. 5 % is *under* the
~9 %-of-swing Gibbs overshoot, so the ringing's excursion falls outside the band while a
plateau's own ripple stays inside it; the outermost-of-the-equally-dense rule is what makes a
waveform with *no* plateau -- a sine, a ramp -- read its peak instead of an arbitrary mid-range
bin that a shifted window would move. `Amplitude` is then `top - bottom`, a bench scope's
`Vamplitude`, and **not** the half-swing it used to be, nor the peak-to-peak swing.
The readings, fast mock, 2 s window, `[measure-trace]`, harmonics `0` → `30`: `top` `1.000000` →
`0.999983`, `bottom` `-1.000000` → `-0.999983`, `amplitude` `2.000000` → `1.999966`, against
`peak to peak` `2.000000` → `2.358701` -- the extremes keep the overshoot, and that separation
*is* the measurement. On the probe's 1 Hz ±1 sine: `top` `0.957988` (it was `0.636620`, the
half-wave mean), `amplitude` `1.915983`, and `peak to peak` still **exactly `2`**; `[stats-probe]`
reads `top` `mean=0.957905 sd=0.0014 lo=0.951967 hi=0.962348` over a scrolling window, which is
the stability the value band buys -- the answer does not follow the window's alignment.
Five things this rests on, each of which breaks something silently:
* **Nothing may be read raw for it.** The histogram is the *same* fetched bucket list, walked a
second time inside `measure_scan_buckets()`; a histogram of the window's *samples* would be a
second O(window) walk, which is exactly what the pyramid path exists to avoid. `measure cost:`
is the guard and its `raw` does not move: the probe's pane reads
`covered=59684972 buckets=932446 raw=47640 (0.1%)` against the baseline's
`covered=61183221 buckets=955856 raw=47402 (0.1%)` -- the same `raw`, 0.44 → 0.50 ms a pass
(and `--noise 0.1`, `2.1%` both sides, 0.52 → 0.65 ms).
* **It is gated, and the gate is what keeps an unrelated pane free.** `measure_scan_buckets()`'s
`flat_hist` comes from `App::MeasureWindow::flat_wanted`, set by the first loop over the pane
and only for one of the three types. A pane of means and extremes pays nothing.
* **The bin range is the *buckets'*, not the window's.** The window's partial edge buckets and the
samples produced since `acquire_all()` are read raw and are not in the list, so
`measure_scan_buckets()` tracks its own `blo`/`bhi` over the **non-empty** buckets only -- an
all-non-finite bucket is an empty aggregate whose `lo`/`hi` say nothing, and taking them would
put a bucket at some arbitrary value. A degenerate range (`bhi == blo`) leaves `folded` at 0,
which is the caller's "no histogram at all".
* **The *window's* peak-to-peak is what the band is a share of**, and the *split* is sized exactly
as `measure_set_level()` sizes a level, so "the top" and "the level a period is counted about"
are placed by one rule. The band is a *value* band, which is why the reading does not move when
the window does.
* **The fallback is the split, and it is what a filtered or sub-bucket window reads.** `hf_fc > 0`
disables the pyramid (above) and a window shorter than a bucket has no bucket to fold, so
`top`/`bottom` fall back to the mean of each side of the split -- and `amplitude` to the
window's own peak to peak, which is the same number on a waveform with nothing to exclude.
There is no flat level in that mode to subtract, so `amplitude == top - bottom` is *not* an
invariant to lean on there. Measured on the rippled `square` at `hf_fc=100`:
`top=0.973824 bottom=-0.991731 amplitude=2.354173 peak to peak=2.354173`, `buckets=0
raw=200.0%` -- two raw walks and no density estimate, which is the honest reading of a window
the pyramid cannot summarize.
- **`MeasureOptions::noise_pct` is a percentage, clamped in `measure_set_level()` to half the room to the
nearer extreme**, so a band can never invert its own thresholds. It moves the **edge decision only**: the
sums still split at the bare level, because `Top` on a unit sine must read 2/pi (0.636620).
sums still split at the bare level, which is the *fallback* `Top`/`Bottom` read on a window with no
bucket histogram -- a band that moved that split would change what a filtered or sub-bucket window
reports, for no gain to a crossing count.
- **`measure_accumulate` loops samples outer, levels inner, and that is not backwards.** The filter holds one
state for the whole walk, so levels-outer would step it once per level *per sample* and hand every level but
the last a different filter state.
@@ -105,9 +170,12 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
decides that side and the measurement reads nothing at all, which is worse than reading
something noisy. On a ringing 3.3 V logic signal at a 90 % split: `enters=0` with a fixed band,
`enters=4 period=0.200000` with the clamp.
* **The `sum`/`count` split stays at the bare `level`.** That split *is* `Top`/`Bottom` -- the mean
above and below the threshold -- so a band there changes a documented exact reading. `top` on a
unit sine must still be `0.636620`.
* **The `sum`/`count` split stays at the bare `level`.** Two things read it and neither may be
moved by a band: a crossing's *side* is decided by the sample's own value against the level
(`v == level` is the below side, as it always was), and `Top`/`Bottom` fall back to the mean of
each side on a window with no bucket histogram. The band is about *deciding a crossing*, not
about where a sample lives, so folding it in would change a filtered window's `top` for no gain
to any crossing count.
* **`h = 0` must reduce exactly to the single-level walk**, including the `v == level` skip, so the
band is a parameter of the engine rather than a mode of it.
The cost is the noise's, not the band's: the clean fast mock reads `raw=119412 (0.1%)` of 215 M at
+34 -13
View File
@@ -218,19 +218,32 @@ Two lines of the self-check output are assertions in disguise:
pane_h=H switch_on=X reset_sensitive=Y`
(stderr, `UDPSCOPE_PROBE_MEASURES_MS`) — adds one measurement of every type on the first
plottable signal and lets the tick compute them, so what it prints is what a user reads.
On the test stream's 1 Hz ±1 sine the values must be: average ≈ 0, `top` ≈ 0.64 and
`bottom` ≈ -0.64 (the mean of each half of a sine), max/min ≈ ±1, `pulse duration` ≈ 0.5 s
with `duty` ≈ 0.5, `period` ≈ 1 s and `frequency` ≈ 1 Hz. It needs **`UDPSCOPE_ENABLE_ALL_MS`
ahead of it** or it prints `no acquired signal to measure` and the dump reads `n=0`.
On the test stream's 1 Hz ±1 sine the values must be: average ≈ 0, `top` ≈ 0.96 and
`bottom` ≈ -0.96, `amplitude` ≈ 1.92, max/min ≈ ±1, `peak to peak` **exactly 2**,
`pulse duration` ≈ 0.5 s
with `duty` ≈ 0.5, `period` ≈ 1 s and `frequency` ≈ 1 Hz. **`top` is no longer 2/π**, and that
is the point of it: it is the *flat* level of the upper side, read from the density of the
window rather than from a mean over a side, and a sine has no plateau — so the densest band
sits at its peak and the reading is near it, not the 0.636620 a mean over a half would give.
`amplitude` is that pair's delta (`top - bottom`), which is why it reads about 1.92 rather than
the half-swing it used to: a bench scope's `Vamplitude` is `Vtop - Vbase`. The measured
figures, and the reason they are what they are, are in
[invariants-measurements.md](invariants-measurements.md) — on the mock's `square` the
separation is the visible one (`top`/`bottom` `0.999983`/`-0.999983`, `amplitude` `1.999966`,
while `peak to peak` keeps the edges' ±1.179 overshoot at `2.358701`). It needs
**`UDPSCOPE_ENABLE_ALL_MS` ahead of it** or it prints `no acquired signal to measure` and the
dump reads `n=0`.
**Run it against `--noise 0.1 --freq 1` as well, and the three crossing types must still read
those figures** (measured: `period=1.000100 frequency=0.999900 pulse duration=0.504100`): the
hysteresis band is the only thing holding them there, a single level reads a 500 Hz interferer as
the signal, and the default `--noise 0` mock cannot see it. The band costs `raw`, not accuracy --
the clean fast mock reads `raw=0.1%` at 1.41 ms a recompute and the noisy one `raw=2.0%` at 1.96
ms, so read `measure cost:` on **both**. `marks=` lists each mark with its kind, and the **mix is the point**:
`level:V` alone for average/top/bottom, `level:V,time:T` for min/max (a ruler at the value
`level:V` alone for average/top/bottom; **two** level marks for `peak to peak` (the window's
extremes) and for `amplitude` (the two *flat* levels it subtracts -- a pair of rulers, because
the span between them *is* the measurement); `level:V,time:T` for min/max (a ruler at the value
*and* a marker at the first sample that reached it — two level marks at both extremes was
the bug), `time:A,time:B` for pulse/period/frequency. `text=` is the pane's own string with
the bug); `time:A,time:B` for pulse/period/frequency. `text=` is the pane's own string with
its unit, so `frequency` must read `Hz` (not the signal's unit), `period`/`pulse duration`
seconds, and the rest the channel's unit. `switch_on` and `reset_sensitive` are the panel's *frame* read back: the switch widget against
`measure_stats_on`, which the code drives, and the Reset button that follows it. Either disagreeing with
@@ -1000,18 +1013,26 @@ pane's recompute count since start) and one `| n=… mean=… sd=… lo=… hi=
measurement. Phase 1 adds `dn=` per group.
Measured readings (fast mock, 1 Hz sine, with
`UDPSCOPE_ENABLE_ALL_MS=800 UDPSCOPE_PROBE_MEASURE_MS=1500 UDPSCOPE_PROBE_STATS_MS=2500`):
`UDPSCOPE_ENABLE_ALL_MS=800 UDPSCOPE_PROBE_MEASURE_MS=1500 UDPSCOPE_PROBE_STATS_MS=2500`, the
four measurements the measure probe adds -- average, top, peak to peak, amplitude):
```
phase=0 on=1 passes=36 | n=30 mean=-0.0370313 sd=0.0953509 lo=-0.246367 hi=0.105167 | n=30 mean=0.633511 … | n=30 mean=1.99567 … hi=2 | n=30 mean=0.997834 … hi=1
phase=1 … dn=0 (on all four measurements)
phase=2 … n=1 mean=-4.84467e-14 | n=1 mean=0.63662 | n=1 mean=2 | n=1 mean=1
phase=0 on=1 passes=43 | n=28 mean=0.0826556 sd=0.085427 lo=-0.0314722 hi=0.208469 | n=28 mean=0.957905 sd=0.00142833 lo=0.951967 hi=0.962348 | n=28 mean=2 sd=0 lo=2 hi=2 | n=28 mean=1.91652 sd=0.00300419 lo=1.90993 hi=1.92432
phase=1 … dn=1 (on all four measurements)
phase=2 … n=0 mean=0 sd=0 lo=0 hi=0 (all four)
```
- `phase=0` — the running count. **`n` must be in the low tens**, not thousands: a count in the
thousands means the accumulator is fed per sample, not per recompute. `mean=0.633511` (≈ 2/π)
for the `top` measurement on a unit sine is the check that the threshold types are correct, not
merely plausible. `sd` must be 0 exactly when `n < 2`, and `lo`/`hi` must bracket `mean`.
thousands means the accumulator is fed per sample, not per recompute. `mean=2 sd=0 lo=2 hi=2` for
the `peak to peak` measurement on a unit sine is the assertion on the shared pass: the extremes of
a sine are exactly ±1 whatever the window does, so anything else is the scan, not the reading.
The two flat-level types are the other half and their figure is the **`sd`**, not the mean: `top`
reads `mean=0.957905 sd=0.00142833` and `amplitude` `mean=1.91652 sd=0.0030` over windows that
scroll under them, and a `top` that wanders by tenths is a level that follows the window's
alignment instead of a *value* band (see the flat-level invariant in
[invariants-measurements.md](invariants-measurements.md); the mean itself is window-dependent on a
sine, which has no plateau -- the densest band sits at its peak and moves with how much of the
peak a window holds). `sd` must be 0 exactly when `n < 2`, and `lo`/`hi` must bracket `mean`.
- `phase=1` — after a two-second pause. **`dn` must be 0 or 1**: a pinned window recomputes once
when it settles and then never again. A `dn` near 120 is the second defect — accumulating per
frame, which adds a hundred-odd observations of the identical window and collapses `sd` towards
+17 -3
View File
@@ -84,8 +84,18 @@ precedent, not an exception.
collecting anything -- that is how the first baseline of the stall below read 0.4 ms).
The values are in the exit dump as `measurements: <type>=<value>`; on the fast mock
(`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`) a unit sine must read
`peak to peak=2`, `amplitude=1`, `average=0` and **`top=0.636620`** -- 2/pi, the half-wave
mean of a sine, which is the check that the threshold types are not merely plausible.
`peak to peak=2` **exactly**, `average=0`, and the two flat-level types as their definition
says: **`top=0.957988`** and `amplitude=1.915983`. `top` is no longer 2/pi -- it is the *flat*
level of the upper side, read from the density of the window's buckets, and a sine has no
plateau so the densest band is at its peak; `amplitude` is `top - bottom` (a scope's
`Vamplitude`), not the half-swing it once was. The check that the flat levels are *levels* and
not extremes is the same probe against the mock's `square` (`--square-harmonics 30`, the
control being `0`): `top`/`bottom` `0.999983`/`-0.999983` rippled against `1.000000`/`-1.000000`
ideal, `amplitude` `1.999966` against `2.000000`, while `peak to peak` reads `2.358701` rippled
against `2.000000` ideal -- the ±1.179 edges'
overshoot, kept by the extremes and excluded by the levels. The per-type values, the reason for
each, and the cost of the histogram are in
[invariants-measurements.md](invariants-measurements.md).
`[measure-trace] view=[..] <type>=<value>... marks=N level:V/time:V...` is a repeating line
(250 ms) from the probe: the pane's numbers beside the *current* window and the marks actually
placed, which is the only way a stale result or a mark that should not be drawn shows up as a
@@ -106,7 +116,11 @@ precedent, not an exception.
(`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`), where a window is ~2 M samples.
`--noise 0.1` there reads **2.0%** at 1.96 ms a recompute against the clean 0.1% at 1.41 ms, and
that is the interferer's own cost rather than a regression -- the figure to compare a change
against is the **clean** one.
against is the **clean** one. `raw` is also the guard on the flat-level types, which are the one
thing that reads a second time: `top`/`bottom`/`amplitude` fold a histogram out of the **same**
fetched bucket list, so they add buckets and not samples (measured: `raw=0.1%` and `2.1%` with
the interferer, against `0.1%`/`2.1%` before those types had a flat level, for 0.44→0.50 ms and
0.52→0.65 ms a pass -- the buckets are the budget).
Note the **final** `measurements:` line and `[measure-trace]`'s last lines may read `--`:
`UDPSCOPE_EXIT_AFTER_MS` arms the trigger before it dumps, so the dump can land in `Waiting`
with nothing bound and the axis at its default. The race-free reading is an earlier