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:
1 parent
afd2a6cdc1
commit
e83ad86b8f
10 files changed
+495
-80
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user