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

+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)");
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<int>(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<MeasureBucket>& 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.