measure: the split's own means are back as above/below/between

`top`, `bottom` and `amplitude` became the *flat levels* in e83ad86 -- the level the
signal dwells at, read from the density of the window's buckets -- and that change kept
the split's own two means as its internal fallback for a window the pyramid cannot
summarize. Those means are now three types of their own, so the two different questions
about one split can be asked side by side: `above %` and `below %` are the plain mean of
the samples on one side of a share -- the reading the old `top`/`bottom` gave -- and
`between % %` is the mean of the band between two shares, which no single split can
answer, because a band's two edges are two thresholds.

Nothing about the arithmetic is new, and nothing is a new pass. `Above`/`Below` are the
`sum[]`/`count[]` split `measure_accumulate()` already folds -- the very figures
`Top`/`Bottom` fall back to -- and `Between` is the **difference of two levels' upper
sides**, both of which come out of one pass two over one window. It rides the bucket
fast path unchanged: a bucket wholly inside the band is strictly above the lower level
and strictly below the upper one, so both accumulators fold it whole, and only the
buckets that straddle either edge are opened. `measure_level_pcts()` is the one place the
shares are read, and it clamps both and puts `between`'s pair **in order**, so "between
80 % and 20 %" is the band between 20 % and 80 % -- normalized rather than refused,
because the shares name two edges rather than a direction. Nothing to measure reads `--`,
as for every other type: a share at 100 % has no sample above it, and a band can hold
none at all.

Measured, `[bandavg-probe]`, fast mock at a 2 s window (`--rate 1300000 --batch 1000
--freq 1`):

  sine   `above 50` 0.636620 (2/pi), `below 50` -0.636620, `between 50 75` 0.255873
  square `above 50` 0.986496 (30 harmonics) / 1.000000 (--square-harmonics 0),
         `between 50 75` 0.302744 / --
  ramp   `between 50 75` 0.250000 and `above 50` 0.500000, `above 100` --

The first three are the figures the old `top`/`bottom` documented; the ramp's are what a
uniform density predicts (a band's mean is its own centre); the ideal square's empty band
is the nothing-to-measure rule. The pane offers the types and edits their percentages
through the dialog's second share -- which only `between` shows -- and a measurement's
pair survives add/edit/delete and the session file (`threshold2`), where a file written
before it existed loads at the default.

No raw pass: with the measurements probe adding one of every type at a 2 s window, `raw`
went from 113966 of 240719031 covered (0.0%) to 134978 of 255372033 (0.1%), 0.8 -> 1.1
ns a covered sample.

Docs: the band-average invariant (and the two assertions the flat-level change moved),
the probe readings for the new probe, the verification recipe, the manual's measurements
page and the README.
This commit is contained in:
Martino Ferrari committed 2026-10-15 07:56:37 +02:00
1 parent e83ad86b8f
commit 22e7a8efcc
14 files changed
+769 -98

No files matched your search

+92 -28
View File
@@ -342,21 +342,36 @@ MeasureFlatLevels measure_flat_levels(const MeasureStats& b, double pct) {
return out;
}
bool measure_level_pct(MeasureType type, const MeasureOptions& options, double& pct) {
int measure_level_pcts(MeasureType type, const MeasureOptions& options, double pct[2]) {
const auto share = [](double v) { return std::clamp(v, 0.0, 100.0) / 100.0; };
switch (type) {
case MeasureType::Top:
case MeasureType::Bottom:
pct = std::clamp(options.threshold_pct, 0.0, 100.0) / 100.0;
return true;
case MeasureType::Above:
case MeasureType::Below:
pct[0] = share(options.threshold_pct);
return 1;
case MeasureType::Between: {
// The band's two edges, clamped and *ordered*: the shares name a lower and an upper edge, so
// "between 80 % and 20 %" is the band between 20 % and 80 %. Normalizing rather than refusing
// is the honest reading of a band -- the set of samples does not depend on which edge the user
// typed first -- and the two accumulators the pair is answered from are keyed on these ordered
// values, so the same band is one walk however it was written.
const double a = share(options.threshold_pct);
const double b = share(options.threshold_pct2);
pct[0] = std::min(a, b);
pct[1] = std::max(a, b);
return 2;
}
case MeasureType::PulseDuration:
case MeasureType::Period:
case MeasureType::Frequency:
// The *mid* level, for all three. No option here on purpose: a period is the spacing of
// one crossing per cycle, and a threshold near either extreme counts noise as crossings.
pct = 0.5;
return true;
pct[0] = 0.5;
return 1;
default:
return false;
return 0;
}
}
@@ -373,11 +388,14 @@ const char* measure_type_name(MeasureType type) {
case MeasureType::PulseDuration: return "pulse duration";
case MeasureType::Period: return "period";
case MeasureType::Frequency: return "frequency";
case MeasureType::Above: return "above";
case MeasureType::Below: return "below";
case MeasureType::Between: return "between";
}
return "?";
}
int measure_type_count() { return 10; }
int measure_type_count() { return 13; }
MeasureType measure_type_at(int index) {
switch (index) {
@@ -390,15 +408,24 @@ MeasureType measure_type_at(int index) {
case 6: return MeasureType::Amplitude;
case 7: return MeasureType::PulseDuration;
case 8: return MeasureType::Frequency;
default: return MeasureType::Period;
case 9: return MeasureType::Period;
// Appended rather than filed beside `Top`/`Bottom`, where they read best in the dropdown:
// `layout_store` writes *this* index into the session file, so renumbering would turn a saved
// `max` into an `above` on the next start -- silently, and only for users who had one.
case 10: return MeasureType::Above;
case 11: return MeasureType::Below;
default: return MeasureType::Between;
}
}
bool measure_uses_threshold(MeasureType type) {
return type == MeasureType::Top || type == MeasureType::Bottom ||
type == MeasureType::PulseDuration;
type == MeasureType::PulseDuration || type == MeasureType::Above ||
type == MeasureType::Below || type == MeasureType::Between;
}
bool measure_uses_band(MeasureType type) { return type == MeasureType::Between; }
bool measure_uses_side(MeasureType type) { return type == MeasureType::PulseDuration; }
bool measure_uses_noise(MeasureType type) {
@@ -408,8 +435,8 @@ bool measure_uses_noise(MeasureType type) {
bool measure_can_show_on_plot(MeasureType) { return true; }
MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level, MeasureType type,
const MeasureOptions& options) {
MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* const levels[2],
MeasureType type, const MeasureOptions& options) {
MeasureResult out;
if (!b.valid) return out;
@@ -475,10 +502,10 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level,
// 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;
double pct[2] = {0.0, 0.0};
if (measure_level_pcts(type, options, pct) == 0) return out;
const bool want_top = type == MeasureType::Top;
const MeasureFlatLevels flat = measure_flat_levels(b, pct);
const MeasureFlatLevels flat = measure_flat_levels(b, pct[0]);
if (want_top ? flat.top_ok : flat.bottom_ok) {
out.primary = want_top ? flat.top : flat.bottom;
out.marks.push_back({true, out.primary});
@@ -487,11 +514,47 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level,
}
// 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;
// an HF-rejected walk, and on those the split's own means are the honest reading -- and they
// are what `Above`/`Below` report on *every* window.
const MeasureLevel* lv = levels != nullptr ? levels[0] : nullptr;
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]);
if (lv == nullptr || lv->count[s] == 0) return out;
out.primary = lv->sum[s] / static_cast<double>(lv->count[s]);
out.marks.push_back({true, out.primary});
out.valid = true;
return out;
}
case MeasureType::Above:
case MeasureType::Below: {
// The plain mean of the samples on one side of the share -- what `Top`/`Bottom` reported
// before they became flat levels, and a different question from them: a mean over a side
// carries the ripple and the edge's overshoot (0.986496 on the rippled square where the
// plateau is 1), which is exactly why the flat-level types exist beside these.
const MeasureLevel* lv = levels != nullptr ? levels[0] : nullptr;
const std::size_t s = type == MeasureType::Above ? 1 : 0;
if (lv == nullptr || lv->count[s] == 0) return out;
out.primary = lv->sum[s] / static_cast<double>(lv->count[s]);
out.marks.push_back({true, out.primary});
out.valid = true;
return out;
}
case MeasureType::Between: {
// The band's interior: what lies above the lower edge and *not* above the upper one. Both
// accumulators split the same samples the same way -- pass two walks one bucket list and one
// filter for the whole window -- so the band is the *difference* of the two upper sides, and
// it is exact rather than approximate. It needs no third walk of its own, and the bucket
// fast path serves it unchanged: a bucket wholly inside the band is strictly above the lower
// level and strictly below the upper, so both accumulators fold it whole.
const MeasureLevel* lower = levels != nullptr ? levels[0] : nullptr;
const MeasureLevel* upper = levels != nullptr ? levels[1] : nullptr;
if (lower == nullptr || upper == nullptr) return out;
// Equal counts mean an empty interior: nothing lies in the band, so there is nothing to
// average -- the pane reads `--`, as it does for any other type with no samples on the side
// it was asked about. `lower` may not count *fewer*, which would say the two accumulators
// saw different samples.
if (lower->count[1] <= upper->count[1]) return out;
out.primary = (lower->sum[1] - upper->sum[1]) /
static_cast<double>(lower->count[1] - upper->count[1]);
out.marks.push_back({true, out.primary});
out.valid = true;
return out;
@@ -499,18 +562,19 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level,
case MeasureType::PulseDuration:
case MeasureType::Period:
case MeasureType::Frequency: {
if (level == nullptr) return out;
if (levels == nullptr || levels[0] == nullptr) return out;
const MeasureLevel& level = *levels[0];
// The pulse is one side of the mid level: its widths run from the edge that enters it to
// the edge that leaves it, and a period is the spacing of the edges that *enter* it -- one
// per cycle, whatever else the waveform does in between.
const std::size_t s = options.above ? 1 : 0;
const double enter_span = level->last_enter[s] - level->first_enter[s];
const double enter_period = level->enters[s] >= 2
? enter_span / static_cast<double>(level->enters[s] - 1)
const double enter_span = level.last_enter[s] - level.first_enter[s];
const double enter_period = level.enters[s] >= 2
? enter_span / static_cast<double>(level.enters[s] - 1)
: 0.0;
if (type == MeasureType::PulseDuration) {
if (level->widths[s] == 0) return out;
out.primary = level->width_sum[s] / static_cast<double>(level->widths[s]);
if (level.widths[s] == 0) return out;
out.primary = level.width_sum[s] / static_cast<double>(level.widths[s]);
// Duty as width/period, not as pulse-time over the window: a window that starts or
// ends mid-pulse holds partial cycles, and counting only the complete ones against
// the whole span would report a duty that depends on where the window happened to
@@ -518,15 +582,15 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level,
// them -- a window holding one pulse reports the width alone.
out.secondary = enter_period > 0.0 ? out.primary / enter_period : 0.0;
out.has_secondary = enter_period > 0.0;
out.marks.push_back({false, level->first_enter[s]});
out.marks.push_back({false, level->first_enter[s] + out.primary});
out.marks.push_back({false, level.first_enter[s]});
out.marks.push_back({false, level.first_enter[s] + out.primary});
out.valid = true;
return out;
}
if (!(enter_period > 0.0)) return out;
out.primary = type == MeasureType::Period ? enter_period : 1.0 / enter_period;
out.marks.push_back({false, level->first_enter[s]});
out.marks.push_back({false, level->first_enter[s] + enter_period});
out.marks.push_back({false, level.first_enter[s]});
out.marks.push_back({false, level.first_enter[s] + enter_period});
out.valid = true;
return out;
}