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

+90 -16
View File
@@ -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);