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

+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.
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<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,
@@ -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<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) {
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<double>(level->count[s]);
out.marks.push_back({true, out.primary});