From e83ad86b8fbf244b0016f256a14963dc37d93e48 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Thu, 15 Oct 2026 01:19:33 +0200 Subject: [PATCH] measure: top/bottom/amplitude read the flat levels, not the extremes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 65 +++++++-- docs/claude/invariants-measurements.md | 88 +++++++++-- docs/claude/probe-readings.md | 47 ++++-- docs/claude/verification.md | 20 ++- help/C/org.udpscope.app/measurements.page | 43 ++++-- src/measure.cpp | 170 ++++++++++++++++++++-- src/measure.hpp | 106 ++++++++++++-- src/ui_app.hpp | 4 + src/ui_measure.cpp | 29 +++- src/ui_probe.cpp | 3 +- 10 files changed, 495 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index 98281d7..6b8578c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/claude/invariants-measurements.md b/docs/claude/invariants-measurements.md index a1c0b0b..9358f82 100644 --- a/docs/claude/invariants-measurements.md +++ b/docs/claude/invariants-measurements.md @@ -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 diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index a4f4bca..1b8a4d8 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -218,19 +218,32 @@ Two lines of the self-check output are assertions in disguise: pane_h=H switch_on=X reset_sensitive=Y` (stderr, `UDPSCOPE_PROBE_MEASURES_MS`) — adds one measurement of every type on the first plottable signal and lets the tick compute them, so what it prints is what a user reads. - On the test stream's 1 Hz ±1 sine the values must be: average ≈ 0, `top` ≈ 0.64 and - `bottom` ≈ -0.64 (the mean of each half of a sine), max/min ≈ ±1, `pulse duration` ≈ 0.5 s - with `duty` ≈ 0.5, `period` ≈ 1 s and `frequency` ≈ 1 Hz. It needs **`UDPSCOPE_ENABLE_ALL_MS` - ahead of it** or it prints `no acquired signal to measure` and the dump reads `n=0`. + On the test stream's 1 Hz ±1 sine the values must be: average ≈ 0, `top` ≈ 0.96 and + `bottom` ≈ -0.96, `amplitude` ≈ 1.92, max/min ≈ ±1, `peak to peak` **exactly 2**, + `pulse duration` ≈ 0.5 s + with `duty` ≈ 0.5, `period` ≈ 1 s and `frequency` ≈ 1 Hz. **`top` is no longer 2/π**, and that + is the point of it: it is the *flat* level of the upper side, read from the density of the + window rather than from a mean over a side, and a sine has no plateau — so the densest band + sits at its peak and the reading is near it, not the 0.636620 a mean over a half would give. + `amplitude` is that pair's delta (`top - bottom`), which is why it reads about 1.92 rather than + the half-swing it used to: a bench scope's `Vamplitude` is `Vtop - Vbase`. The measured + figures, and the reason they are what they are, are in + [invariants-measurements.md](invariants-measurements.md) — on the mock's `square` the + separation is the visible one (`top`/`bottom` `0.999983`/`-0.999983`, `amplitude` `1.999966`, + while `peak to peak` keeps the edges' ±1.179 overshoot at `2.358701`). It needs + **`UDPSCOPE_ENABLE_ALL_MS` ahead of it** or it prints `no acquired signal to measure` and the + dump reads `n=0`. **Run it against `--noise 0.1 --freq 1` as well, and the three crossing types must still read those figures** (measured: `period=1.000100 frequency=0.999900 pulse duration=0.504100`): the hysteresis band is the only thing holding them there, a single level reads a 500 Hz interferer as the signal, and the default `--noise 0` mock cannot see it. The band costs `raw`, not accuracy -- the clean fast mock reads `raw=0.1%` at 1.41 ms a recompute and the noisy one `raw=2.0%` at 1.96 ms, so read `measure cost:` on **both**. `marks=` lists each mark with its kind, and the **mix is the point**: - `level:V` alone for average/top/bottom, `level:V,time:T` for min/max (a ruler at the value + `level:V` alone for average/top/bottom; **two** level marks for `peak to peak` (the window's + extremes) and for `amplitude` (the two *flat* levels it subtracts -- a pair of rulers, because + the span between them *is* the measurement); `level:V,time:T` for min/max (a ruler at the value *and* a marker at the first sample that reached it — two level marks at both extremes was - the bug), `time:A,time:B` for pulse/period/frequency. `text=` is the pane's own string with + the bug); `time:A,time:B` for pulse/period/frequency. `text=` is the pane's own string with its unit, so `frequency` must read `Hz` (not the signal's unit), `period`/`pulse duration` seconds, and the rest the channel's unit. `switch_on` and `reset_sensitive` are the panel's *frame* read back: the switch widget against `measure_stats_on`, which the code drives, and the Reset button that follows it. Either disagreeing with @@ -1000,18 +1013,26 @@ pane's recompute count since start) and one `| n=… mean=… sd=… lo=… hi= measurement. Phase 1 adds `dn=` per group. Measured readings (fast mock, 1 Hz sine, with -`UDPSCOPE_ENABLE_ALL_MS=800 UDPSCOPE_PROBE_MEASURE_MS=1500 UDPSCOPE_PROBE_STATS_MS=2500`): +`UDPSCOPE_ENABLE_ALL_MS=800 UDPSCOPE_PROBE_MEASURE_MS=1500 UDPSCOPE_PROBE_STATS_MS=2500`, the +four measurements the measure probe adds -- average, top, peak to peak, amplitude): ``` -phase=0 on=1 passes=36 | n=30 mean=-0.0370313 sd=0.0953509 lo=-0.246367 hi=0.105167 | n=30 mean=0.633511 … | n=30 mean=1.99567 … hi=2 | n=30 mean=0.997834 … hi=1 -phase=1 … dn=0 (on all four measurements) -phase=2 … n=1 mean=-4.84467e-14 | n=1 mean=0.63662 | n=1 mean=2 | n=1 mean=1 +phase=0 on=1 passes=43 | n=28 mean=0.0826556 sd=0.085427 lo=-0.0314722 hi=0.208469 | n=28 mean=0.957905 sd=0.00142833 lo=0.951967 hi=0.962348 | n=28 mean=2 sd=0 lo=2 hi=2 | n=28 mean=1.91652 sd=0.00300419 lo=1.90993 hi=1.92432 +phase=1 … dn=1 (on all four measurements) +phase=2 … n=0 mean=0 sd=0 lo=0 hi=0 (all four) ``` - `phase=0` — the running count. **`n` must be in the low tens**, not thousands: a count in the - thousands means the accumulator is fed per sample, not per recompute. `mean=0.633511` (≈ 2/π) - for the `top` measurement on a unit sine is the check that the threshold types are correct, not - merely plausible. `sd` must be 0 exactly when `n < 2`, and `lo`/`hi` must bracket `mean`. + thousands means the accumulator is fed per sample, not per recompute. `mean=2 sd=0 lo=2 hi=2` for + the `peak to peak` measurement on a unit sine is the assertion on the shared pass: the extremes of + a sine are exactly ±1 whatever the window does, so anything else is the scan, not the reading. + The two flat-level types are the other half and their figure is the **`sd`**, not the mean: `top` + reads `mean=0.957905 sd=0.00142833` and `amplitude` `mean=1.91652 sd=0.0030` over windows that + scroll under them, and a `top` that wanders by tenths is a level that follows the window's + alignment instead of a *value* band (see the flat-level invariant in + [invariants-measurements.md](invariants-measurements.md); the mean itself is window-dependent on a + sine, which has no plateau -- the densest band sits at its peak and moves with how much of the + peak a window holds). `sd` must be 0 exactly when `n < 2`, and `lo`/`hi` must bracket `mean`. - `phase=1` — after a two-second pause. **`dn` must be 0 or 1**: a pinned window recomputes once when it settles and then never again. A `dn` near 120 is the second defect — accumulating per frame, which adds a hundred-odd observations of the identical window and collapses `sd` towards diff --git a/docs/claude/verification.md b/docs/claude/verification.md index ff029c6..ad9f5fc 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -84,8 +84,18 @@ precedent, not an exception. collecting anything -- that is how the first baseline of the stall below read 0.4 ms). The values are in the exit dump as `measurements: =`; on the fast mock (`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`) a unit sine must read - `peak to peak=2`, `amplitude=1`, `average=0` and **`top=0.636620`** -- 2/pi, the half-wave - mean of a sine, which is the check that the threshold types are not merely plausible. + `peak to peak=2` **exactly**, `average=0`, and the two flat-level types as their definition + says: **`top=0.957988`** and `amplitude=1.915983`. `top` is no longer 2/pi -- it is the *flat* + level of the upper side, read from the density of the window's buckets, and a sine has no + plateau so the densest band is at its peak; `amplitude` is `top - bottom` (a scope's + `Vamplitude`), not the half-swing it once was. The check that the flat levels are *levels* and + not extremes is the same probe against the mock's `square` (`--square-harmonics 30`, the + control being `0`): `top`/`bottom` `0.999983`/`-0.999983` rippled against `1.000000`/`-1.000000` + ideal, `amplitude` `1.999966` against `2.000000`, while `peak to peak` reads `2.358701` rippled + against `2.000000` ideal -- the ±1.179 edges' + overshoot, kept by the extremes and excluded by the levels. The per-type values, the reason for + each, and the cost of the histogram are in + [invariants-measurements.md](invariants-measurements.md). `[measure-trace] view=[..] =... marks=N level:V/time:V...` is a repeating line (250 ms) from the probe: the pane's numbers beside the *current* window and the marks actually placed, which is the only way a stale result or a mark that should not be drawn shows up as a @@ -106,7 +116,11 @@ precedent, not an exception. (`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`), where a window is ~2 M samples. `--noise 0.1` there reads **2.0%** at 1.96 ms a recompute against the clean 0.1% at 1.41 ms, and that is the interferer's own cost rather than a regression -- the figure to compare a change - against is the **clean** one. + against is the **clean** one. `raw` is also the guard on the flat-level types, which are the one + thing that reads a second time: `top`/`bottom`/`amplitude` fold a histogram out of the **same** + fetched bucket list, so they add buckets and not samples (measured: `raw=0.1%` and `2.1%` with + the interferer, against `0.1%`/`2.1%` before those types had a flat level, for 0.44→0.50 ms and + 0.52→0.65 ms a pass -- the buckets are the budget). Note the **final** `measurements:` line and `[measure-trace]`'s last lines may read `--`: `UDPSCOPE_EXIT_AFTER_MS` arms the trigger before it dumps, so the dump can land in `Waiting` with nothing bound and the axis at its default. The race-free reading is an earlier diff --git a/help/C/org.udpscope.app/measurements.page b/help/C/org.udpscope.app/measurements.page index f31b069..5ae36c9 100644 --- a/help/C/org.udpscope.app/measurements.page +++ b/help/C/org.udpscope.app/measurements.page @@ -36,12 +36,30 @@ Worth knowing

The types are average, top, bottom, min, max, peak to peak, amplitude, pulse - duration, period and frequency. top and bottom are the means of the - upper and lower halves, which is why a unit sine reads 0.637 (2/π) rather than 1.

+ duration, period and frequency. top and bottom are the waveform's + flat 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 not what these read. amplitude is the delta between those two + levels, and peak to peak is the swing between the extremes (so on a rippled + square amplitude reads 2 V where peak to peak reads 2.36 V).

+

top and bottom also read near the peak 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.

+

The split 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 hf — where + top/bottom fall back to the mean of each side of the split and + amplitude to the window's peak to peak.

The on-plot marks come in two kinds and the mix is the reading: a level mark is a ruler at a value, a time mark is a marker at a position. An average draws one level mark; a max draws a level mark and a time mark at the first - sample that reached it.

+ sample that reached it; peak to peak draws a ruler at each extreme and + amplitude a ruler at each of its two flat levels — the pair is the span, so + the two rulers are the measurement.

Marks cannot be dragged. A measurement mark says what the measurement measured, so it is created unselectable — as is the trigger marker.

A pass is throttled to a cadence rather than run every frame, so a ruler over a @@ -74,7 +92,10 @@ against the number (636.62m), since two decimals of an unprefixed small value is 0.00.

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.

+ not the raw window: a bucket's sum is a sum, so the mean, the extremes and a level's own + mean are exact. top, bottom and amplitude read the + density 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.

Statistics adds a second line to every measurement showing n, the mean, the standard deviation and the range. Each observation is one recompute of the pane, so n counts windows measured, not samples — @@ -87,11 +108,15 @@

Example -

On a 1 Hz ±1 sine: average=0, top=0.636620, - bottom=-0.636620, max=1, min=-1, - peak to peak=2, amplitude=1, pulse duration=0.5 s - (duty 0.5), period=1 s, frequency=1 Hz. Over a 2 M-sample window - those two passes read about 0.1% of the samples.

+

On a 1 Hz ±1 sine: average=0, top=0.957988, + bottom=-0.957988 (the flat levels, near the peaks — a sine has no plateau), + max=1, min=-1, + peak to peak=2, amplitude=1.915983, pulse duration=0.5 s + (duty 0.5), period=1 s, frequency=1 Hz. On the same stream's + band-limited square, whose edges ring out to ±1.179: top=0.999983, + bottom=-0.999983, amplitude=1.999966 against + peak to peak=2.358701 — 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.

diff --git a/src/measure.cpp b/src/measure.cpp index e1e05a7..db7810a 100644 --- a/src/measure.cpp +++ b/src/measure.cpp @@ -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. 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 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; 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) { const MeasureBucket& k = buckets[b]; // 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. 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; finite += k.count; if (!s.valid) { @@ -147,6 +179,27 @@ void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, Measu } s.sum += sum; 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(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((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, @@ -219,7 +272,9 @@ bool measure_accumulate_bucket(const MeasureBucket& k, 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()`, // 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; acc.sum[e] += k.sum; acc.count[e] += k.count; @@ -231,6 +286,62 @@ bool measure_accumulate_bucket(const MeasureBucket& k, 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(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(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(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(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(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(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(count); + out.top_ok = true; + } else { + out.bottom = sum / static_cast(count); + out.bottom_ok = true; + } + } + return out; +} + bool measure_level_pct(MeasureType type, const MeasureOptions& options, double& pct) { switch (type) { case MeasureType::Top: @@ -323,11 +434,11 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level, out.valid = true; return out; } - case MeasureType::PeakToPeak: - case MeasureType::Amplitude: { - // Both come straight out of the window's own extremes, which every type already needs: - // the two rulers are the same pair, and the numbers differ by a factor of two. - out.primary = type == MeasureType::PeakToPeak ? b.hi - b.lo : 0.5 * (b.hi - b.lo); + case MeasureType::PeakToPeak: { + // Straight out of the window's own extremes, which every type already needs: this one *is* + // the extremes, ripple and overshoot included -- that is the difference between it and + // `Amplitude`, which reads the two levels the signal dwells at instead. + out.primary = b.hi - b.lo; // 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. out.marks.push_back({true, b.lo}); @@ -335,13 +446,50 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level, out.valid = true; 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::Bottom: { - // The mean of one side of the level, straight out of the second pass' running sums. A flat - // window has no range at all, so every sample lands on the "below" side -- which is why - // `top` on a signal that never moves reads `--` rather than that one value. + // The flat level of one side of the split: the densest band there, whose samples' exact mean + // is the value. The band is a *value* band, so the reading does not follow the window's + // 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; - 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; out.primary = level->sum[s] / static_cast(level->count[s]); out.marks.push_back({true, out.primary}); diff --git a/src/measure.hpp b/src/measure.hpp index 9a1ae88..a786e9d 100644 --- a/src/measure.hpp +++ b/src/measure.hpp @@ -17,15 +17,24 @@ enum class MeasureType { Min, ///< the smallest visible value Max, ///< the largest visible value /// 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, - /// Half the peak-to-peak swing, `(max - min) / 2` -- the amplitude of a symmetric waveform - /// about its own mean, which is the figure a datasheet quotes and a signal generator is set - /// to. Deliberately not `max(|value|)`: that is a *distance from zero*, which for a signal - /// riding on an offset is a different and less useful number. + /// The delta between the two **flat** levels, `top - bottom` (a bench scope's `Vamplitude`, + /// which is `Vtop - Vbase`). Deliberately *not* the peak-to-peak swing: a band-limited edge + /// overshoots and rings, so the extremes of a nominal 2 V square read about 2.36 V, while the + /// 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, - Top, ///< the signal's high level (the mean of the samples above the threshold) - Bottom, ///< the signal's low level (the mean of the samples below it) + /// The signal's high level: the densest band of the window's upper side, whose samples' exact + /// 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 Period, ///< the time between two successive crossings of the threshold Frequency, ///< 1 / period @@ -92,6 +101,35 @@ struct MeasureResult { 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 /// 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 @@ -119,6 +157,10 @@ struct MeasureStats { /// `compute_measure()` reads these, so a caller that skips the resolve reports t = 0. double at_lo = 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; }; @@ -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 /// 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. -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 /// 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`. /// /// 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 -- - /// and a band there would change a documented exact reading (`top` on a unit sine is 2/pi). - /// `measure_set_level()` is what sizes it; zero reduces the walk exactly to a single level. + /// so a band can never change it -- the split is the split, and a sample's side of it is a + /// fact rather than a threshold decision. `measure_set_level()` is what sizes it; zero reduces + /// the walk exactly to a single level. double hysteresis = 0.0; - /// Index 0 is the side below the level (a sample exactly *at* it counts here, which is what - /// `bottom` has always reported), index 1 the side above it. + /// The samples either side of `level`, exactly: `Top`/`Bottom`'s reading on a window with no + /// 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}; std::size_t count[2] = {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 pure statistics, answered from `MeasureStats` alone). `Top`/`Bottom` read the /// 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); +/// 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 /// 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. @@ -298,8 +372,8 @@ bool measure_uses_threshold(MeasureType type); /// the waveform is the pulse). 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 -/// the *edge* decision and nothing else, so it is exactly the crossing types: `Top`/`Bottom` split their -/// sums at the bare level and read the same answer at any band. +/// the *edge* decision and nothing else, so it is exactly the crossing types: `Top`/`Bottom` read a flat +/// level out of the bucket histogram, which no band touches. bool measure_uses_noise(MeasureType type); /// Whether a type draws its result on the plot when asked. bool measure_can_show_on_plot(MeasureType type); diff --git a/src/ui_app.hpp b/src/ui_app.hpp index c5b4f6a..4ef119e 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -506,6 +506,10 @@ struct App { std::vector level; ///< one accumulator per entry of `pct` MeasureFilter scan_filter; ///< pass one's 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; }; std::vector measure_samples; diff --git a/src/ui_measure.cpp b/src/ui_measure.cpp index 7dbab0c..56eb393 100644 --- a/src/ui_measure.cpp +++ b/src/ui_measure.cpp @@ -118,15 +118,24 @@ void measure_dialog_sync(MeasureDialog* d) { "show on plot (ruler at the value, marker where it is)"); break; case MeasureType::PeakToPeak: - case MeasureType::Amplitude: gtk_label_set_text(GTK_LABEL(d->threshold_label), ""); gtk_label_set_text(GTK_LABEL(d->side_label), ""); gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), "show on plot (rulers at both extremes)"); 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::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)"); break; case MeasureType::PulseDuration: @@ -320,6 +329,12 @@ void update_measures(App& app, bool force, bool accumulate) { } want_window[i] = static_cast(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; 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 @@ -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 // 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. + // + // 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; std::vector& buckets = app.measure_bucket_scratch; 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); }; 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); // 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. diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index dfdb0f7..3fa29b4 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -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: // 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 - // 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& signals = a->model->signals(); for (std::size_t i = 0; i < signals.size(); ++i) {