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 | | type | what it is | options | shown on the plot as |
|---|---|---|---| |---|---|---|---|
| `average` | the mean of the visible samples | — | a ruler at the mean | | `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 | | `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 | | `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 | | `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* `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 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 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. 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 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: 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. 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 - **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 level, and nothing that band touches is the flat level: a band there would change what a crossing
threshold — and a band there changes a documented exact reading. `top` on a unit sine must still counts as an edge for no gain to anything, and the split's two means are now only the fallback
be `0.636620`, 2/π. `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 - **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 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 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 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 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. 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 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 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 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 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 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 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 exactly `2` on a unit sine, and the shared pass that carries it is untouched — `top` has no
check that a threshold type is not merely plausible. A window shorter than a bucket, or a signal *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 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 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 ### 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 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. 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 **Amplitude and peak to peak** were the two types added then: `max - min`, and half of it.
`(max - min) / 2` rather than `max(|value|)` — the latter is a distance from zero, which for a Deliberately `(max - min) / 2` rather than `max(|value|)` — the latter is a distance from zero,
signal riding on an offset is a different and less useful number. Both come out of the shared which for a signal riding on an offset is a different and less useful number. Both came out of the
pass, so they are free, and both read exactly what a unit sine should: `amplitude=1`, shared pass, so they were free, and both read exactly what a unit sine should: `amplitude=1`,
`peak to peak=2`. `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, 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 stamp) pair split across two rings of equal capacity, and the published window is pinned
+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) 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 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 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 first positions, the sum and the count, so average/min/max/peak-to-peak need no pass of their
pass of their own. **Adding a type must not add a pass** -- that is what `compute_measure()`'s own -- `top`/`bottom`/`amplitude` take a second read of that same **bucket** list and never of
`stats` argument is for. 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 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 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 (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 *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 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` `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 double-counted every opened bucket. That read as `top=0.614304` on a unit sine against the 2/pi a
be. Four things this rests on, each of which breaks it silently: 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 * **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 * **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 `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 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. the pyramid for nothing.
`measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say `measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say
anything. 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 - **`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 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 - **`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 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. 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 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, 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. `enters=4 period=0.200000` with the clamp.
* **The `sum`/`count` split stays at the bare `level`.** That split *is* `Top`/`Bottom` -- the mean * **The `sum`/`count` split stays at the bare `level`.** Two things read it and neither may be
above and below the threshold -- so a band there changes a documented exact reading. `top` on a moved by a band: a crossing's *side* is decided by the sample's own value against the level
unit sine must still be `0.636620`. (`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 * **`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. 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 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` pane_h=H switch_on=X reset_sensitive=Y`
(stderr, `UDPSCOPE_PROBE_MEASURES_MS`) — adds one measurement of every type on the first (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. 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 On the test stream's 1 Hz ±1 sine the values must be: average ≈ 0, `top` ≈ 0.96 and
`bottom` ≈ -0.64 (the mean of each half of a sine), max/min ≈ ±1, `pulse duration` ≈ 0.5 s `bottom` ≈ -0.96, `amplitude` ≈ 1.92, max/min ≈ ±1, `peak to peak` **exactly 2**,
with `duty` ≈ 0.5, `period` ≈ 1 s and `frequency` ≈ 1 Hz. It needs **`UDPSCOPE_ENABLE_ALL_MS` `pulse duration` ≈ 0.5 s
ahead of it** or it prints `no acquired signal to measure` and the dump reads `n=0`. 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 **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 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 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 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 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**: 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 *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` 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 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 `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. measurement. Phase 1 adds `dn=` per group.
Measured readings (fast mock, 1 Hz sine, with 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=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=0 (on all four measurements) phase=1 … dn=1 (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=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 - `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/π) thousands means the accumulator is fed per sample, not per recompute. `mean=2 sd=0 lo=2 hi=2` for
for the `top` measurement on a unit sine is the check that the threshold types are correct, not the `peak to peak` measurement on a unit sine is the assertion on the shared pass: the extremes of
merely plausible. `sd` must be 0 exactly when `n < 2`, and `lo`/`hi` must bracket `mean`. 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 - `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 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 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). 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 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 (`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 `peak to peak=2` **exactly**, `average=0`, and the two flat-level types as their definition
mean of a sine, which is the check that the threshold types are not merely plausible. 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 `[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 (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 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. (`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 `--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 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 `--`: 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` `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 with nothing bound and the axis at its default. The race-free reading is an earlier
+34 -9
View File
@@ -36,12 +36,30 @@
<title>Worth knowing</title> <title>Worth knowing</title>
<list> <list>
<item><p>The types are average, top, bottom, min, max, peak to peak, amplitude, pulse <item><p>The types are average, top, bottom, min, max, peak to peak, amplitude, pulse
duration, period and frequency. <em>top</em> and <em>bottom</em> are the means of the duration, period and frequency. <gui>top</gui> and <gui>bottom</gui> are the waveform's
upper and lower halves, which is why a unit sine reads 0.637 (2/π) rather than 1.</p></item> <em>flat</em> high and low levels: the level the signal dwells at, taken from the densest
band of the window on each side of the split. A band-limited edge overshoots and rings —
a nominal 2 V square reaches about ±1.18 at its edges — and a ripple or an overshoot is
therefore <em>not</em> what these read. <gui>amplitude</gui> is the delta between those two
levels, and <gui>peak to peak</gui> is the swing between the extremes (so on a rippled
square <gui>amplitude</gui> reads 2 V where <gui>peak to peak</gui> reads 2.36 V).</p></item>
<item><p><gui>top</gui> and <gui>bottom</gui> also read <em>near the peak</em> on a
waveform that has no flat part, which is the honest reading rather than a failure: a unit
sine has no plateau, so the densest band is at its peak and these read about ±0.96 — not
the 0.64 a mean over each half would give, and not ±1 either. Only a level the signal
actually dwells at is a level.</p></item>
<item><p>The <em>split</em> says which side a level is read from, so any share between the
two levels reads the same number; it is not a boundary the value depends on. The one case
where it is read directly is a window the scope cannot summarize by level — a window
shorter than a level's own resolution, or one read through <gui>hf</gui> — where
<gui>top</gui>/<gui>bottom</gui> fall back to the mean of each side of the split and
<gui>amplitude</gui> to the window's peak to peak.</p></item>
<item><p>The on-plot marks come in two kinds and the mix is the reading: a <em>level</em> <item><p>The on-plot marks come in two kinds and the mix is the reading: a <em>level</em>
mark is a ruler at a value, a <em>time</em> mark is a marker at a position. An average mark is a ruler at a value, a <em>time</em> mark is a marker at a position. An average
draws one level mark; a max draws a level mark <em>and</em> a time mark at the first draws one level mark; a max draws a level mark <em>and</em> a time mark at the first
sample that reached it.</p></item> sample that reached it; <gui>peak to peak</gui> draws a ruler at each extreme and
<gui>amplitude</gui> a ruler at each of its two flat levels — the pair is the span, so
the two rulers <em>are</em> the measurement.</p></item>
<item><p>Marks cannot be dragged. A measurement mark says what the measurement measured, <item><p>Marks cannot be dragged. A measurement mark says what the measurement measured,
so it is created unselectable — as is the trigger marker.</p></item> so it is created unselectable — as is the trigger marker.</p></item>
<item><p>A pass is throttled to a cadence rather than run every frame, so a ruler over a <item><p>A pass is throttled to a cadence rather than run every frame, so a ruler over a
@@ -74,7 +92,10 @@
against the number (<code>636.62m</code>), since two decimals of an unprefixed small against the number (<code>636.62m</code>), since two decimals of an unprefixed small
value is <code>0.00</code>.</p></item> value is <code>0.00</code>.</p></item>
<item><p>Cost is bounded by reading the summary pyramid the producer already maintains, <item><p>Cost is bounded by reading the summary pyramid the producer already maintains,
not the raw window. The values are still exact: a bucket's sum is a sum.</p></item> not the raw window: a bucket's sum is a sum, so the mean, the extremes and a level's own
mean are exact. <gui>top</gui>, <gui>bottom</gui> and <gui>amplitude</gui> read the
<em>density</em> of the window out of that same summary — which is what tells a flat level
from a ripple — so those three cost a second read of the summary and never of a sample.</p></item>
<item><p><gui>Statistics</gui> adds a second line to every measurement showing <item><p><gui>Statistics</gui> adds a second line to every measurement showing
<code>n</code>, the mean, the standard deviation and the range. Each observation is one <code>n</code>, the mean, the standard deviation and the range. Each observation is one
<em>recompute</em> of the pane, so <code>n</code> counts windows measured, not samples — <em>recompute</em> of the pane, so <code>n</code> counts windows measured, not samples —
@@ -87,11 +108,15 @@
<section id="example"> <section id="example">
<title>Example</title> <title>Example</title>
<p>On a 1 Hz ±1 sine: <code>average=0</code>, <code>top=0.636620</code>, <p>On a 1 Hz ±1 sine: <code>average=0</code>, <code>top=0.957988</code>,
<code>bottom=-0.636620</code>, <code>max=1</code>, <code>min=-1</code>, <code>bottom=-0.957988</code> (the flat levels, near the peaks — a sine has no plateau),
<code>peak to peak=2</code>, <code>amplitude=1</code>, <code>pulse duration=0.5 s</code> <code>max=1</code>, <code>min=-1</code>,
(duty 0.5), <code>period=1 s</code>, <code>frequency=1 Hz</code>. Over a 2 M-sample window <code>peak to peak=2</code>, <code>amplitude=1.915983</code>, <code>pulse duration=0.5 s</code>
those two passes read about 0.1% of the samples.</p> (duty 0.5), <code>period=1 s</code>, <code>frequency=1 Hz</code>. On the same stream's
band-limited <code>square</code>, whose edges ring out to ±1.179: <code>top=0.999983</code>,
<code>bottom=-0.999983</code>, <code>amplitude=1.999966</code> against
<code>peak to peak=2.358701</code> — the levels are the plateau's and the extremes are the
edges'. Over a 2 M-sample window those passes read about 0.1% of the samples.</p>
</section> </section>
</page> </page>
+159 -11
View File
@@ -32,6 +32,23 @@ void enter_side(MeasureLevel& acc, std::size_t e, double t) {
// setting now, and a second copy of the number here would be a second thing to keep in step. // setting now, and a second copy of the number here would be a second thing to keep in step.
constexpr double kPi = 3.14159265358979323846; constexpr double kPi = 3.14159265358979323846;
/// The flat level's band, as a half-width in shares of the window's peak-to-peak. 5 % is *under*
/// the overshoot a band-limited edge rings to -- Gibbs, about 9 % of the swing at the harmonic
/// counts the mock's `square` uses -- so the ringing's excursion falls outside the densest band
/// while a plateau's own ripple, a few percent, stays inside it. Measured on the mock's 30-harmonic
/// square at 1.3 MS/s: the flat top reads 1.000000 with this band, 1.003573 at 3 %, 1.003495 at
/// 10 % -- the wider bands reach the ringing again. A share and not an absolute width: the band is
/// the same quantity `threshold_pct` and the hysteresis are measured in.
constexpr double kFlatBandShare = 0.05;
/// Which bin of a side is the level: the *outermost* one holding at least this share of the
/// densest bin's buckets. One rule for both kinds of waveform. Where a plateau exists it is by
/// far the densest band and the rule cannot reach past it. Where none exists -- a sine, a ramp --
/// the signal dwells in every band it visits, the whole side qualifies, and the outermost is the
/// honest answer: the level is then the top of what the window reaches, near the peak, rather
/// than a bin of an arbitrary mid-range density that a shifted window would move.
constexpr double kFlatTieShare = 0.5;
} // namespace } // namespace
void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct, double noise_frac) { void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct, double noise_frac) {
@@ -116,14 +133,29 @@ void measure_scan(const MeasureChunk& chunk, MeasureStats& s, MeasureFilter& fil
} }
} }
void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, MeasureStats& s) { void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, MeasureStats& s,
bool flat_hist) {
double sum = 0.0; double sum = 0.0;
std::size_t finite = 0; std::size_t finite = 0;
// The *buckets'* own range, which is not the window's: the window's partial edge buckets are
// read raw and are not in this list. Only the non-empty buckets: an all-non-finite bucket is
// stored as an empty aggregate and its `lo`/`hi` say nothing.
double blo = 0.0;
double bhi = 0.0;
bool any = false;
for (std::size_t b = 0; b < count; ++b) { for (std::size_t b = 0; b < count; ++b) {
const MeasureBucket& k = buckets[b]; const MeasureBucket& k = buckets[b];
// A bucket of nothing (every sample non-finite) is stored as an empty aggregate, and the raw // A bucket of nothing (every sample non-finite) is stored as an empty aggregate, and the raw
// walk skips exactly those samples -- so it contributes nothing here either. // walk skips exactly those samples -- so it contributes nothing here either.
if (k.count == 0) continue; if (k.count == 0) continue;
if (!any) {
blo = k.lo;
bhi = k.hi;
any = true;
} else {
if (k.lo < blo) blo = k.lo;
if (k.hi > bhi) bhi = k.hi;
}
sum += k.sum; sum += k.sum;
finite += k.count; finite += k.count;
if (!s.valid) { if (!s.valid) {
@@ -147,6 +179,27 @@ void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, Measu
} }
s.sum += sum; s.sum += sum;
s.count += finite; s.count += finite;
if (!flat_hist || !any || !(bhi > blo)) return;
// One observation per bucket, at its midpoint, carrying that bucket's *exact* sum and finite
// count -- so the level the caller finally reports is a mean of real samples and not a bin
// centre. Weighting by the bucket (and not by its 64 samples) is what keeps the flat regions
// and the ringing on the same footing: the question is how much of the *waveform* dwells in a
// band, and a bucket is a span of it. A range that is degenerate (every bucket the same value)
// has no band to pick and leaves `folded` at 0, which is the caller's "no histogram".
s.flat.lo = blo;
s.flat.hi = bhi;
const double scale = static_cast<double>(MeasureFlatHist::kBins) / (bhi - blo);
for (std::size_t b = 0; b < count; ++b) {
const MeasureBucket& k = buckets[b];
if (k.count == 0) continue;
const double mid = 0.5 * (k.lo + k.hi);
std::size_t bin = mid <= blo ? 0u : static_cast<std::size_t>((mid - blo) * scale);
if (bin >= MeasureFlatHist::kBins) bin = MeasureFlatHist::kBins - 1;
s.flat.sum[bin] += k.sum;
s.flat.count[bin] += k.count;
++s.flat.buckets[bin];
++s.flat.folded;
}
} }
void measure_accumulate(const MeasureChunk& chunk, void measure_accumulate(const MeasureChunk& chunk,
@@ -219,7 +272,9 @@ bool measure_accumulate_bucket(const MeasureBucket& k,
const bool fully_outside = above ? (k.lo > top) : (k.hi < bot); const bool fully_outside = above ? (k.lo > top) : (k.hi < bot);
// Nothing may be folded before this point: a refusal hands the bucket to `measure_accumulate()`, // Nothing may be folded before this point: a refusal hands the bucket to `measure_accumulate()`,
// which adds the same samples again. Sums added above the refusal double-counted every opened // which adds the same samples again. Sums added above the refusal double-counted every opened
// bucket, which read as `top` 0.614304 on a unit sine against the 2/pi it must be. // bucket -- which read as `top` 0.614304 on a unit sine against the 2/pi a mean over a half then
// gave, and which is now the sums `Top`/`Bottom` fall back to on a window with no histogram, so
// a wrong one is a wrong answer waiting for that window.
if (change && !fully_outside) return false; if (change && !fully_outside) return false;
acc.sum[e] += k.sum; acc.sum[e] += k.sum;
acc.count[e] += k.count; acc.count[e] += k.count;
@@ -231,6 +286,62 @@ bool measure_accumulate_bucket(const MeasureBucket& k,
return true; return true;
} }
MeasureFlatLevels measure_flat_levels(const MeasureStats& b, double pct) {
MeasureFlatLevels out;
const MeasureFlatHist& h = b.flat;
const double span = b.hi - b.lo;
if (h.folded == 0 || !(h.hi > h.lo) || !(span > 0.0)) return out;
// The split, sized exactly as `measure_set_level()` sizes the level the crossing types read, so
// "the top" and "the level a period is counted about" are placed by one rule.
const double level = b.lo + std::clamp(pct, 0.0, 1.0) * span;
const double width = (h.hi - h.lo) / static_cast<double>(MeasureFlatHist::kBins);
const double band = kFlatBandShare * span;
for (int upper = 1; upper >= 0; --upper) {
const auto in_side = [&](std::size_t i) {
const double centre = h.lo + (static_cast<double>(i) + 0.5) * width;
return (centre > level) == (upper != 0);
};
// One scan for the densest bin of this side, one for the outermost of the equally dense
// ones, one to sum the band about it: 192 iterations of an array of 64, per flat level.
std::size_t densest = 0;
for (std::size_t i = 0; i < MeasureFlatHist::kBins; ++i) {
if (in_side(i) && h.buckets[i] > densest) densest = h.buckets[i];
}
// A side of the split with no bucket in it has no level: `top` on a window that never reaches
// above the split reads `--`, which is exactly what it read before.
if (densest == 0) continue;
const double floor_buckets = kFlatTieShare * static_cast<double>(densest);
std::size_t anchor = 0;
bool have_anchor = false;
for (std::size_t i = 0; i < MeasureFlatHist::kBins; ++i) {
if (!in_side(i) || static_cast<double>(h.buckets[i]) < floor_buckets) continue;
// The upper side wants the *last* qualifier and the lower one the first, so a bottom-up scan
// is "always overwrite" for one and "only ever write once" for the other.
if (upper != 0 || !have_anchor) anchor = i;
have_anchor = true;
}
if (!have_anchor) continue;
const double centre = h.lo + (static_cast<double>(anchor) + 0.5) * width;
double sum = 0.0;
std::size_t count = 0;
for (std::size_t i = 0; i < MeasureFlatHist::kBins; ++i) {
const double offset = h.lo + (static_cast<double>(i) + 0.5) * width - centre;
if (offset < -band || offset > band) continue;
sum += h.sum[i];
count += h.count[i];
}
if (count == 0) continue;
if (upper != 0) {
out.top = sum / static_cast<double>(count);
out.top_ok = true;
} else {
out.bottom = sum / static_cast<double>(count);
out.bottom_ok = true;
}
}
return out;
}
bool measure_level_pct(MeasureType type, const MeasureOptions& options, double& pct) { bool measure_level_pct(MeasureType type, const MeasureOptions& options, double& pct) {
switch (type) { switch (type) {
case MeasureType::Top: case MeasureType::Top:
@@ -323,11 +434,11 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level,
out.valid = true; out.valid = true;
return out; return out;
} }
case MeasureType::PeakToPeak: case MeasureType::PeakToPeak: {
case MeasureType::Amplitude: { // Straight out of the window's own extremes, which every type already needs: this one *is*
// Both come straight out of the window's own extremes, which every type already needs: // the extremes, ripple and overshoot included -- that is the difference between it and
// the two rulers are the same pair, and the numbers differ by a factor of two. // `Amplitude`, which reads the two levels the signal dwells at instead.
out.primary = type == MeasureType::PeakToPeak ? b.hi - b.lo : 0.5 * (b.hi - b.lo); out.primary = b.hi - b.lo;
// A ruler at each extreme: the *span* is the measurement, and two levels are how a // A ruler at each extreme: the *span* is the measurement, and two levels are how a
// plot states a span without a band between them. // plot states a span without a band between them.
out.marks.push_back({true, b.lo}); out.marks.push_back({true, b.lo});
@@ -335,13 +446,50 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level,
out.valid = true; out.valid = true;
return out; return out;
} }
case MeasureType::Amplitude: {
// The delta between the two flat levels, so a ringing edge's overshoot is not part of it.
// The split is the mid level, fixed rather than an option -- the dialog offers no threshold
// for this type -- and the reading is insensitive to it anyway: the band a level is read from
// is a *value* band, so any split between the two plateaus picks the same two.
const MeasureFlatLevels flat = measure_flat_levels(b, 0.5);
if (flat.top_ok && flat.bottom_ok) {
out.primary = flat.top - flat.bottom;
out.marks.push_back({true, flat.top});
out.marks.push_back({true, flat.bottom});
out.valid = true;
return out;
}
// No density estimate to separate a plateau from its ringing: a window shorter than a
// bucket, or an HF-rejected walk, which cannot use the pyramid at all. The delta then
// degenerates to the window's own peak to peak -- the same number when there is nothing to
// exclude, and the only honest one when the ripple cannot be told from the level.
out.primary = b.hi - b.lo;
out.marks.push_back({true, b.lo});
out.marks.push_back({true, b.hi});
out.valid = true;
return out;
}
case MeasureType::Top: case MeasureType::Top:
case MeasureType::Bottom: { case MeasureType::Bottom: {
// The mean of one side of the level, straight out of the second pass' running sums. A flat // The flat level of one side of the split: the densest band there, whose samples' exact mean
// window has no range at all, so every sample lands on the "below" side -- which is why // is the value. The band is a *value* band, so the reading does not follow the window's
// `top` on a signal that never moves reads `--` rather than that one value. // alignment, and it excludes both a plateau's ripple and an edge's overshoot -- which is the
// whole point, because those are exactly what a mean over a side reports instead.
double pct = 0.0;
if (!measure_level_pct(type, options, pct)) return out;
const bool want_top = type == MeasureType::Top;
const MeasureFlatLevels flat = measure_flat_levels(b, pct);
if (want_top ? flat.top_ok : flat.bottom_ok) {
out.primary = want_top ? flat.top : flat.bottom;
out.marks.push_back({true, out.primary});
out.valid = true;
return out;
}
// No histogram: the mean of one side of the level, straight out of the second pass' running
// sums. A window the pyramid cannot summarize is one bucket or two of a zoomed-in view, or
// an HF-rejected walk, and on those the split's own means are the honest reading.
if (level == nullptr) return out; if (level == nullptr) return out;
const std::size_t s = type == MeasureType::Top ? 1 : 0; const std::size_t s = want_top ? 1 : 0;
if (level->count[s] == 0) return out; if (level->count[s] == 0) return out;
out.primary = level->sum[s] / static_cast<double>(level->count[s]); out.primary = level->sum[s] / static_cast<double>(level->count[s]);
out.marks.push_back({true, out.primary}); out.marks.push_back({true, out.primary});
+90 -16
View File
@@ -17,15 +17,24 @@ enum class MeasureType {
Min, ///< the smallest visible value Min, ///< the smallest visible value
Max, ///< the largest visible value Max, ///< the largest visible value
/// The largest swing in the window: `max - min`, the two extremes the eye would measure /// The largest swing in the window: `max - min`, the two extremes the eye would measure
/// with a ruler across the waveform. /// with a ruler across the waveform -- ripple and overshoot included, which is what makes it
/// the *extremes* and not a level.
PeakToPeak, PeakToPeak,
/// Half the peak-to-peak swing, `(max - min) / 2` -- the amplitude of a symmetric waveform /// The delta between the two **flat** levels, `top - bottom` (a bench scope's `Vamplitude`,
/// about its own mean, which is the figure a datasheet quotes and a signal generator is set /// which is `Vtop - Vbase`). Deliberately *not* the peak-to-peak swing: a band-limited edge
/// to. Deliberately not `max(|value|)`: that is a *distance from zero*, which for a signal /// overshoots and rings, so the extremes of a nominal 2 V square read about 2.36 V, while the
/// riding on an offset is a different and less useful number. /// levels the signal actually dwells at are ±1 and their delta exactly 2. Where the window
/// has no density estimate to separate a plateau from its ripple (shorter than one bucket, or
/// an HF-rejected walk) it degenerates to the window's own peak to peak, which is the same
/// number on a waveform with nothing to exclude. Never `max(|value|)`: that is a *distance
/// from zero*, which for a signal riding on an offset is a different and less useful number.
Amplitude, Amplitude,
Top, ///< the signal's high level (the mean of the samples above the threshold) /// The signal's high level: the densest band of the window's upper side, whose samples' exact
Bottom, ///< the signal's low level (the mean of the samples below it) /// mean is the value -- the *flat top*, so a plateau's ripple and an edge's overshoot neither
/// define it nor enter it.
Top,
/// The signal's low level, the same way from the window's lower side.
Bottom,
PulseDuration, ///< how long the signal spends on one side of the threshold PulseDuration, ///< how long the signal spends on one side of the threshold
Period, ///< the time between two successive crossings of the threshold Period, ///< the time between two successive crossings of the threshold
Frequency, ///< 1 / period Frequency, ///< 1 / period
@@ -92,6 +101,35 @@ struct MeasureResult {
bool valid = false; bool valid = false;
}; };
/// The density estimate behind `Top`/`Bottom`/`Amplitude`: one bin per band of the window's
/// value range, over the whole LOD buckets pass one walked.
///
/// A flat level is a *density* question, not an extreme one: on a band-limited square the
/// plateau holds most of the samples while the edge's ringing holds a few percent, so the
/// densest band is the level the signal dwells at and the ringing is what a mean over a side
/// reports instead. One bucket is one observation at its midpoint -- a bucket is 64 samples
/// held as one aggregate, so counting its samples would weight a flat region by nothing at all
/// and a ringing one by 64 -- and each bin also carries the exact sum and count of the samples
/// its buckets hold, which is what makes the reported level an exact mean rather than a bin
/// centre. The bucket list is the one pass one already fetches; no pass reads raw samples for
/// this, which is the whole reason it is built from buckets and not from a sample histogram
/// (that would be a second O(window) walk).
struct MeasureFlatHist {
/// Bins across the buckets' own range. The band the level is read from is picked at this
/// resolution and is a share of the *window's* peak-to-peak, so the bin count only sets how
/// finely the densest band can be located.
static constexpr std::size_t kBins = 64;
double lo = 0.0; ///< the bin range: the whole buckets' own smallest `lo`..
double hi = 0.0; ///< ..and largest `hi`. Empty when nothing was folded.
double sum[kBins] = {};
std::size_t count[kBins] = {};
std::size_t buckets[kBins] = {};
/// Whole buckets folded in: 0 means this window has no histogram at all (a window shorter
/// than a bucket, or an HF-rejected walk, which cannot use the pyramid), and the flat-level
/// types then fall back to their split-relative readings.
std::size_t folded = 0;
};
/// The window's own extremes, which every threshold-relative type scales from and which /// The window's own extremes, which every threshold-relative type scales from and which
/// several types report directly. Public so a caller holding *several* measurements on one /// several types report directly. Public so a caller holding *several* measurements on one
/// signal can pay for the scan once: it is O(visible samples) and a live view at 1 MS/s makes /// signal can pay for the scan once: it is O(visible samples) and a live view at 1 MS/s makes
@@ -119,6 +157,10 @@ struct MeasureStats {
/// `compute_measure()` reads these, so a caller that skips the resolve reports t = 0. /// `compute_measure()` reads these, so a caller that skips the resolve reports t = 0.
double at_lo = 0.0; double at_lo = 0.0;
double at_hi = 0.0; double at_hi = 0.0;
/// The flat levels' density estimate, folded from the same whole buckets pass one walked.
/// The *values* are resolved by `measure_flat_levels()`, which needs the split and the
/// window's span and so cannot be done until the walk is over.
MeasureFlatHist flat;
bool valid = false; bool valid = false;
}; };
@@ -195,7 +237,14 @@ void measure_scan(const MeasureChunk& chunk, MeasureStats& stats, MeasureFilter&
/// Pass one from whole buckets: the same figures, exactly, for 1/64 of the reads. The extreme's /// Pass one from whole buckets: the same figures, exactly, for 1/64 of the reads. The extreme's
/// position comes out as a *bucket* (`lo_search`/`hi_search` non-zero), which one raw /// position comes out as a *bucket* (`lo_search`/`hi_search` non-zero), which one raw
/// `measure_scan()` of that bucket then pins. Interleave with `measure_scan()` in window order. /// `measure_scan()` of that bucket then pins. Interleave with `measure_scan()` in window order.
void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, MeasureStats& stats); ///
/// `flat_hist` folds the same list into `stats.flat` for the flat-level types, in a second tight
/// loop over the array (the bin range is the buckets' own, which the first loop is what
/// discovers). That loop is O(buckets) and reads no sample, so the `measure cost:` line's `raw`
/// is unchanged by it -- the cost is buckets, which is the budget the pane is on. It is the
/// caller's because only the caller knows whether any measurement reads a flat level.
void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, MeasureStats& stats,
bool flat_hist);
/// Everything the threshold-relative types need from a second walk, at one level: the sum and /// Everything the threshold-relative types need from a second walk, at one level: the sum and
/// count of the samples on either side of it, and its crossings reduced to running figures -- the /// count of the samples on either side of it, and its crossings reduced to running figures -- the
@@ -215,12 +264,17 @@ struct MeasureLevel {
/// `period=0.025622 freq=39.029` for a true 0.2 s / 5 Hz. With the band, `period=0.200188`. /// `period=0.025622 freq=39.029` for a true 0.2 s / 5 Hz. With the band, `period=0.200188`.
/// ///
/// It moves the *edge decision* only. The `sum`/`count` split below stays at the bare `level`, /// It moves the *edge decision* only. The `sum`/`count` split below stays at the bare `level`,
/// because that is what `Top`/`Bottom` are -- the mean of the samples above/below the threshold -- /// so a band can never change it -- the split is the split, and a sample's side of it is a
/// and a band there would change a documented exact reading (`top` on a unit sine is 2/pi). /// fact rather than a threshold decision. `measure_set_level()` is what sizes it; zero reduces
/// `measure_set_level()` is what sizes it; zero reduces the walk exactly to a single level. /// the walk exactly to a single level.
double hysteresis = 0.0; double hysteresis = 0.0;
/// Index 0 is the side below the level (a sample exactly *at* it counts here, which is what /// The samples either side of `level`, exactly: `Top`/`Bottom`'s reading on a window with no
/// `bottom` has always reported), index 1 the side above it. /// bucket histogram to read a flat level from (shorter than one bucket, or an HF-rejected
/// walk). They are folded on every window -- pass two walks it for the crossing types anyway
/// -- but a window that has a histogram is read from that instead, because a mean over a side
/// reports the ripple and the overshoot rather than the level the signal dwells at.
/// Index 0 is the side below the level (a sample exactly *at* it counts here), index 1 the
/// side above it.
double sum[2] = {0.0, 0.0}; double sum[2] = {0.0, 0.0};
std::size_t count[2] = {0, 0}; std::size_t count[2] = {0, 0};
double first_enter[2] = {0.0, 0.0}; double first_enter[2] = {0.0, 0.0};
@@ -275,9 +329,29 @@ bool measure_accumulate_bucket(const MeasureBucket& bucket,
/// The level a type reads, as a share of the window's own min..max, or false when it reads none /// The level a type reads, as a share of the window's own min..max, or false when it reads none
/// (the pure statistics, answered from `MeasureStats` alone). `Top`/`Bottom` read the /// (the pure statistics, answered from `MeasureStats` alone). `Top`/`Bottom` read the
/// measurement's own threshold; the crossing types read the mid level, deliberately -- see /// measurement's own threshold; the crossing types read the mid level, deliberately -- see
/// `measure_uses_threshold()`. /// `measure_uses_threshold()`. `Amplitude` reads no level either: its value is the delta between
/// the two flat levels, which the histogram answers, and the split's two means are only a
/// fallback it takes from the window's own extremes -- see `compute_measure()`.
bool measure_level_pct(MeasureType type, const MeasureOptions& options, double& pct); bool measure_level_pct(MeasureType type, const MeasureOptions& options, double& pct);
/// The window's two flat levels, from the bucket histogram in `stats.flat`. `top` is the densest
/// band of the bins *above* `lo + pct * (hi - lo)`, `bottom` the same below it -- for a
/// crossing type's mid level that is the two plateaus of a pulse train, and for `Top`/`Bottom`'s
/// own threshold it is "which side is the level I want". Each side is false when it holds no
/// bucket, and both are false when the window has no histogram at all.
///
/// Two things this does not do. It does not read a sample: every figure comes from buckets pass
/// one already fetched. And it does not read the window raw, which is why the answer is
/// independent of how the window happens to be aligned -- the band is a *value* band, so the
/// level it reports does not move when the window does.
struct MeasureFlatLevels {
double top = 0.0;
bool top_ok = false;
double bottom = 0.0;
bool bottom_ok = false;
};
MeasureFlatLevels measure_flat_levels(const MeasureStats& stats, double pct);
/// The engine: one measurement's answer, O(1), from the two passes' results. `level` is the /// The engine: one measurement's answer, O(1), from the two passes' results. `level` is the
/// accumulator for `measure_level_pct(type, options)` and may be null for the types that read /// accumulator for `measure_level_pct(type, options)` and may be null for the types that read
/// none. The function reads nothing else, which is what keeps the answer independent of the plot. /// none. The function reads nothing else, which is what keeps the answer independent of the plot.
@@ -298,8 +372,8 @@ bool measure_uses_threshold(MeasureType type);
/// the waveform is the pulse). /// the waveform is the pulse).
bool measure_uses_side(MeasureType type); bool measure_uses_side(MeasureType type);
/// Whether a type reads the hysteresis band, so the dialog can show the field only then. The band moves /// Whether a type reads the hysteresis band, so the dialog can show the field only then. The band moves
/// the *edge* decision and nothing else, so it is exactly the crossing types: `Top`/`Bottom` split their /// the *edge* decision and nothing else, so it is exactly the crossing types: `Top`/`Bottom` read a flat
/// sums at the bare level and read the same answer at any band. /// level out of the bucket histogram, which no band touches.
bool measure_uses_noise(MeasureType type); bool measure_uses_noise(MeasureType type);
/// Whether a type draws its result on the plot when asked. /// Whether a type draws its result on the plot when asked.
bool measure_can_show_on_plot(MeasureType type); bool measure_can_show_on_plot(MeasureType type);
+4
View File
@@ -506,6 +506,10 @@ struct App {
std::vector<MeasureLevel> level; ///< one accumulator per entry of `pct` std::vector<MeasureLevel> level; ///< one accumulator per entry of `pct`
MeasureFilter scan_filter; ///< pass one's state MeasureFilter scan_filter; ///< pass one's state
MeasureFilter acc_filter; ///< pass two's; a separate walk, so a separate state MeasureFilter acc_filter; ///< pass two's; a separate walk, so a separate state
/// Some measurement on this window reads a flat level, so pass one folds the bucket
/// histogram the flat-level types are answered from. Only then: a pane of means and extremes
/// should not pay for a density estimate it never reads.
bool flat_wanted = false;
bool ok = false; bool ok = false;
}; };
std::vector<MeasureWindow> measure_samples; std::vector<MeasureWindow> measure_samples;
+26 -3
View File
@@ -118,15 +118,24 @@ void measure_dialog_sync(MeasureDialog* d) {
"show on plot (ruler at the value, marker where it is)"); "show on plot (ruler at the value, marker where it is)");
break; break;
case MeasureType::PeakToPeak: case MeasureType::PeakToPeak:
case MeasureType::Amplitude:
gtk_label_set_text(GTK_LABEL(d->threshold_label), ""); gtk_label_set_text(GTK_LABEL(d->threshold_label), "");
gtk_label_set_text(GTK_LABEL(d->side_label), ""); gtk_label_set_text(GTK_LABEL(d->side_label), "");
gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show),
"show on plot (rulers at both extremes)"); "show on plot (rulers at both extremes)");
break; break;
case MeasureType::Amplitude:
gtk_label_set_text(GTK_LABEL(d->threshold_label), "");
gtk_label_set_text(GTK_LABEL(d->side_label), "");
// Amplitude is the delta between the two *flat* levels, so its rulers are those two and not
// the extremes a peak-to-peak draws: the pair of rulers is the reading.
gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show),
"show on plot (rulers at both flat levels)");
break;
case MeasureType::Top: case MeasureType::Top:
case MeasureType::Bottom: case MeasureType::Bottom:
gtk_label_set_text(GTK_LABEL(d->threshold_label), "split % of the range"); // The split picks the *side*: the level is the densest band either above or below it, so any
// split between the two plateaus reads the same -- it is not a mean over the side any more.
gtk_label_set_text(GTK_LABEL(d->threshold_label), "split % of the range (picks the side)");
gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), "show on plot (ruler at the level)"); gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), "show on plot (ruler at the level)");
break; break;
case MeasureType::PulseDuration: case MeasureType::PulseDuration:
@@ -320,6 +329,12 @@ void update_measures(App& app, bool force, bool accumulate) {
} }
want_window[i] = static_cast<int>(wi); want_window[i] = static_cast<int>(wi);
App::MeasureWindow& w = app.measure_samples[wi]; App::MeasureWindow& w = app.measure_samples[wi];
// The three types whose value is a flat level: pass one folds the bucket histogram for this
// window only if one of them is in the pane.
if (m.type == MeasureType::Top || m.type == MeasureType::Bottom ||
m.type == MeasureType::Amplitude) {
w.flat_wanted = true;
}
double pct = 0.0; double pct = 0.0;
if (!measure_level_pct(m.type, m.options, pct)) continue; if (!measure_level_pct(m.type, m.options, pct)) continue;
// A type that does not read the band must not split the accumulator over it: its sums split at the bare // A type that does not read the band must not split the accumulator over it: its sums split at the bare
@@ -348,6 +363,12 @@ void update_measures(App& app, bool force, bool accumulate) {
// for the samples produced since this frame's `acquire_all()`, and for those straddling // for the samples produced since this frame's `acquire_all()`, and for those straddling
// buckets; a window shorter than a bucket, or a signal noisy enough to straddle everywhere, // buckets; 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. // degrades back to the O(window) cost and nothing else changes.
//
// The flat-level types (`top`/`bottom`/`amplitude`) are the one thing that reads a *third* time
// -- and it is the same fetched bucket list, not a third fetch: pass one's bucket walk folds its
// own histogram while it has the buckets in hand (see `measure_scan_buckets`). It is O(buckets)
// and it reads no sample, so `raw` does not move; a pane with none of those three types does
// not pay for it at all.
constexpr std::size_t kChunk = 8192; constexpr std::size_t kChunk = 8192;
std::vector<MeasureBucket>& buckets = app.measure_bucket_scratch; std::vector<MeasureBucket>& buckets = app.measure_bucket_scratch;
for (App::MeasureWindow& w : app.measure_samples) { for (App::MeasureWindow& w : app.measure_samples) {
@@ -385,7 +406,9 @@ void update_measures(App& app, bool force, bool accumulate) {
const auto scan = [&](const MeasureChunk& c) { measure_scan(c, w.stats, w.scan_filter); }; const auto scan = [&](const MeasureChunk& c) { measure_scan(c, w.stats, w.scan_filter); };
raw(0, bulk_from, scan); raw(0, bulk_from, scan);
if (!buckets.empty()) measure_scan_buckets(buckets.data(), buckets.size(), w.stats); if (!buckets.empty()) {
measure_scan_buckets(buckets.data(), buckets.size(), w.stats, w.flat_wanted);
}
raw(bulk_to, n, scan); raw(bulk_to, n, scan);
// A bucket named the 64 samples that hold an extreme, not the sample. One scan of that bucket // A bucket named the 64 samples that hold an extreme, not the sample. One scan of that bucket
// pins it -- two buckets at most, whatever the window's size. // pins it -- two buckets at most, whatever the window's size.
+2 -1
View File
@@ -1176,7 +1176,8 @@ gboolean measure_probe(gpointer user) {
// The signal this probe measures is enabled *here*, for the same reason the noise probe enables its own: // The signal this probe measures is enabled *here*, for the same reason the noise probe enables its own:
// a probe must not depend on another probe's side effects. `enable_all_probe` ends by switching a signal // a probe must not depend on another probe's side effects. `enable_all_probe` ends by switching a signal
// off (it reads what a disabled signal does to the plots), and with `sine` gone the readings below were // off (it reads what a disabled signal does to the plots), and with `sine` gone the readings below were
// taken on `square` -- where `top` is 1.0, not the 2/pi the documented baseline is. // taken on `square` -- where `top` is its plateau's 1.0 rather than the sine's flat level, and the numbers
// the docs quote are the sine's.
{ {
const std::vector<SignalInfo>& signals = a->model->signals(); const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t i = 0; i < signals.size(); ++i) { for (std::size_t i = 0; i < signals.size(); ++i) {