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

+51 -14
View File
@@ -232,18 +232,47 @@ measurement.
| type | what it is | options | shown on the plot as |
|---|---|---|---|
| `average` | the mean of the visible samples | — | a ruler at the mean |
| `top` / `bottom` | the high / low level: the mean of the samples above / below the split | split % | a ruler at the level |
| `top` / `bottom` | the high / low **flat** level: the densest band of the window on that side of the split | split % (which side) | a ruler at the level |
| `max` / `min` | the extremes | — | a ruler at the value **and** a marker where it happened |
| `peak to peak` | the swing, `max - min` — the extremes, ripple and overshoot included | — | a ruler at each extreme |
| `amplitude` | the two flat levels' delta, `top - bottom` | — | a ruler at each flat level |
| `pulse duration` | the mean width of the pulse-side runs | split %, which side is the pulse | markers at the pulse's two edges |
| `period` / `frequency` | the spacing of the threshold crossings / its reciprocal, in Hz | — | markers at two edges of a cycle |
**`top`/`bottom`/`amplitude` read a *flat* level, and that is a different question from the
extremes.** A band-limited edge does not step, it overshoots and rings: the mock's `square` at 30
harmonics reaches **±1.179** where its plateau is ±1, so anything that reads the window's extremes
reports the ringing — `amplitude` used to read `1.179350` there and `top` `0.986496`, both of them
a property of the edges rather than of the waveform. A flat level is a *density* question instead:
the plateau holds most of the samples and the ringing a few percent. Pass one folds a 64-bin
histogram of the whole buckets' midpoints while it has them in hand, and the level is, either side
of the split, the densest bin that still holds half the densest one's buckets — **the outermost of
those**, which is what makes a waveform with no plateau at all (a sine, a ramp) read its *peak*
rather than an arbitrary mid-range bin — with the exact mean of the samples of the buckets within
**5 % of the window's peak-to-peak** of it. Five per cent is deliberately under the ~9 %-of-swing
Gibbs overshoot, so the ringing falls outside the band and a plateau's own ripple stays inside.
`amplitude` is then that pair's delta, which is a bench scope's `Vamplitude` (`Vtop - Vbase`) and
**not** the half-swing it used to be. Measured on the mock, harmonics `0` → `30`: `top`
`1.000000` → `0.999983`, `bottom` `-1.000000` → `-0.999983`, `amplitude` `2.000000` → `1.999966`,
while `peak to peak` goes `2.000000` → `2.358701`. On the probe's 1 Hz ±1 sine `top` reads
`0.957988` — it used to be `0.636620`, the half-wave mean, and a sine's flat level is near its
peak instead — with `sd 0.0014` across recomputes, and `amplitude` `1.915983`; `peak to peak` is
still exactly `2`. The histogram is the *same* fetched bucket list read a second time and never a
sample, so `measure cost:`'s `raw` does not move; a pane with none of the three types does not pay
for it at all. Where the pyramid cannot summarize the window — an `hf`-filtered walk, or one
shorter than a bucket — there is no density estimate, and the types fall back to the old
behaviour: the mean of each side of the split, and `max - min` for `amplitude`.
`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 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.
signal in this window, and a level in volts would have to be typed for every signal. For
`top`/`bottom` it says **which side** the level is read from, so any split between the two
plateaus reads the same number — it is no longer a mean *over* the side, which is the reading a
ringing edge used to drag.
Two more options ride on it, both at their old behaviour by default:
@@ -283,9 +312,9 @@ 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/π.
level, and nothing that band touches is the flat level: a band there would change what a crossing
counts as an edge for no gain to anything, and the split's two means are now only the fallback
`top`/`bottom` take on a window with no bucket histogram.
- **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
@@ -353,7 +382,8 @@ Both walks go through it:
and one raw scan of that bucket alone then pins it to a sample — two buckets at most, whatever
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`)
- **Pass two** (the crossing types — `pulse duration`, `period`, `frequency` — and, as a fallback
for a window with no histogram, `top`/`bottom`)
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
@@ -364,11 +394,14 @@ Both walks go through it:
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 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
exactly `2` on a unit sine, and the shared pass that carries it is untouched — `top` has no
*exact* reading to protect any more, because it is a flat level read from a density rather than
the half-wave mean it used to be (`0.636620` on a unit sine; it is `0.957988` now, near the peak,
because a sine has no plateau). A window shorter than a bucket, or a signal
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.
this: `raw` near `covered` means the pyramid bought nothing — and `raw` is unchanged by the flat
levels, which read the *same* bucket list a second time and never a sample.
### What the math channels cost
@@ -1528,11 +1561,15 @@ a relative tolerance (clamping to *exactly* `window_s_` can leave the group's sp
and a clamp that fires every frame is a re-frame loop — the dump counts it as `clamp fires:`), and
the sample-window walk it does is unchanged.
**Amplitude and peak to peak** are the two new types: `max - min`, and half of it. Deliberately
`(max - min) / 2` rather than `max(|value|)` — the latter is a distance from zero, which for a
signal riding on an offset is a different and less useful number. Both come out of the shared
pass, so they are free, and both read exactly what a unit sine should: `amplitude=1`,
`peak to peak=2`.
**Amplitude and peak to peak** were the two types added then: `max - min`, and half of it.
Deliberately `(max - min) / 2` rather than `max(|value|)` — the latter is a distance from zero,
which for a signal riding on an offset is a different and less useful number. Both came out of the
shared pass, so they were free, and both read exactly what a unit sine should: `amplitude=1`,
`peak to peak=2`. **`amplitude` no longer means that.** It is `top - bottom`, the delta between the
two *flat* levels — a bench scope's `Vamplitude` — and it is the extremes that stay with
`peak to peak`, which is why it reads `2.000000` on the rippled square where the swing is
`2.358701`: a ringing edge's overshoot belongs to the swing and not to the amplitude (see
*Measurements*).
The second is subtler and needs a saturated ring. A `ChannelBuffer` sample is a (value,
stamp) pair split across two rings of equal capacity, and the published window is pinned