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

+219
View File
@@ -2842,6 +2842,225 @@ gboolean measures_probe_step(gpointer user) {
return G_SOURCE_REMOVE;
}
/// The band-average readings, on the shapes whose band means can be computed by hand.
///
/// `above`, `below` and `between` are plain means of the samples on one side of a share -- what
/// `top`/`bottom` reported before a flat level replaced them -- and a mean over a side is a
/// *different number* from the level the signal dwells at, so each reading is asserted where the
/// shape predicts it. Seven measurements are added on the mock's three scalar signals at once,
/// because the trio's point is that a *pair* of levels answers a band no single split can:
/// * the 1 Hz unit sine: `above 50` = 2/pi = 0.636620 and `below 50` = -2/pi, the figures the old
/// `top`/`bottom` documented (and *not* `top`/`bottom`'s 0.957988, the level a sine dwells at
/// near its peak), and `between 50 75` = 3(2-sqrt 3)/pi = 0.255873 -- the mean of the samples in
/// the band (0, 0.5], which is neither 0 (the whole window's mean) nor 0.5 (the band's edge);
/// * the mock's band-limited `square` (30 harmonics, its default): `above 50` = 0.986496, the
/// reading the commit that made `top` a flat level recorded as the *old* `top` -- the edges'
/// ring is inside this mean, and the plateau the signal dwells at is not;
/// * the mock's `ramp`, whose density is uniform: every band's mean is that band's own centre, so
/// `above 50` = 0.5 and `between 50 75` = 0.25 of the range -- a pair of figures no other
/// measurement type on this shape reports (`average` is 0 there, `top`/`bottom` its edges).
/// The dialog leg then edits the sine's band through the real widgets -- the second share is the
/// one field only `between` offers -- and re-reads the value, so a share that is built but not
/// seeded or not read back shows up as a reading that did not move.
gboolean bandavg_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
switch (a->dbg_bandavg_step) {
case 0: {
// The three shapes are enabled *here* rather than assumed: `enable_all_probe` switches a
// signal off two seconds after it runs, and a probe that read another probe's leftovers
// would measure whatever was left. `set_signals_enabled()` is one structural change and a
// no-op when the state already holds, so the usual case costs nothing and the ring keeps
// its history; when it does rebuild, the report below is far enough out for a full window.
std::vector<std::size_t> wanted;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t s = 0; s < signals.size(); ++s) {
if (signals[s].name == "sine" || signals[s].name == "square" ||
signals[s].name == "ramp") {
wanted.push_back(s);
}
}
}
a->model->set_signals_enabled(wanted, true);
struct Leg {
const char* signal;
MeasureType type;
double lo;
double hi;
};
const Leg legs[] = {
{"sine", MeasureType::Above, 50.0, 0.0},
{"sine", MeasureType::Below, 50.0, 0.0},
{"sine", MeasureType::Between, 50.0, 75.0},
{"square", MeasureType::Above, 50.0, 0.0},
// The same band on the square: on the band-limited one it holds the two edges' transitions
// -- a real interior whose mean is ~0, because a transition is odd about its own midpoint --
// and on the ideal `--square-harmonics 0` step it holds *nothing at all*, because every
// sample is exactly +/-1. That pair is the empty-interior rule from both sides.
{"square", MeasureType::Between, 50.0, 75.0},
{"ramp", MeasureType::Between, 50.0, 75.0},
// A share at the top of the range has no sample above it, so this one must read what any
// other measurement with nothing to measure reads -- `invalid`/`--`, never 0. The empty
// *interior* is the same rule from the other side, and the ideal square below is where it
// shows: its samples are all exactly +/-1, so the band (0, 0.5] holds none of them.
{"ramp", MeasureType::Above, 100.0, 0.0},
};
for (const Leg& leg : legs) {
Measure m;
m.signal = leg.signal;
m.type = leg.type;
m.options.threshold_pct = leg.lo;
if (leg.hi > 0.0) m.options.threshold_pct2 = leg.hi;
// The marks are part of the reading for these types too: one ruler at the mean.
m.options.show_on_plot = measure_can_show_on_plot(m.type);
a->measures.push_back(std::move(m));
}
select_side_tab(*a, "measure");
rebuild_measures_pane(*a);
g_timeout_add(250, measure_trace, a);
bandavg_probe_next(*a, 1, 1500);
break;
}
case 1: {
// A *full* window before any reading. The window is what the pane last described -- two seconds
// on the documented run -- and the mock delivers about 0.5 MS/s of the 1.3 MS/s it declares, so
// a ring the probe has just re-enabled needs seconds of wall clock to hold a whole one. A short
// ring reads a *partial* window, which is a different number and keeps moving while it fills,
// and the ring is capacity-bound as well, so the wait is read off the store rather than guessed:
// `stored_slots` is how much history there is and `window / dt` is what the pane is measuring.
double slot_dt = 0.0;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
slot_dt = a->model->partition().slot_dt();
}
const double window_s = a->measure_window_valid
? a->measure_window.max - a->measure_window.min
: 0.0;
const std::uint64_t want =
slot_dt > 0.0 && window_s > 0.0 ? static_cast<std::uint64_t>(window_s / slot_dt) : 0;
const std::uint64_t have = a->model->stored_slots();
if (have < want) {
std::fprintf(stderr, "[bandavg-probe] %-15s stored=%llu of %llu slots for a %.3f s window\n",
"waiting", static_cast<unsigned long long>(have),
static_cast<unsigned long long>(want), window_s);
std::fflush(stderr);
bandavg_probe_next(*a, 1, 500);
break;
}
bandavg_report(*a, "readings");
bandavg_probe_next(*a, 2, 400);
break;
}
case 2: {
// The `between` on the sine, through the ordinary edit path: its dialog is the one that
// offers the second share, and this is where the field is read back out of the widgets.
const int at = bandavg_index(*a, MeasureType::Between, "sine");
if (at < 0) {
std::fprintf(stderr, "[bandavg-probe] no sine/between measurement to edit\n");
std::fflush(stderr);
break;
}
open_measure_dialog(*a, at);
bandavg_probe_next(*a, 3, 400);
break;
}
case 3: {
bandavg_report(*a, "edit opened");
auto* d = static_cast<MeasureDialog*>(
g_object_get_data(G_OBJECT(a->measure_dialog), "udpscope-measure-ctx"));
if (d != nullptr) {
// The two fields as the *stored* parameters came back into them, and the band row's
// visibility: a share that is stored but not seeded, or offered for a type that does not
// read it, is a field the user cannot edit back to what the pane is showing.
std::fprintf(stderr,
"[bandavg-probe] %-15s band_row=%d shares=%.1f/%.1f (as stored)\n",
"edit opened", gtk_widget_get_visible(d->band_row) ? 1 : 0,
gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->threshold)),
gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->band)));
// From the mid split to 90 %: a band whose mean neither of its sides reports (the readings
// above 50 and below 90 are 0.636620 and 0.431362), so the value after the save can only
// come from the pair the two fields now hold.
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->threshold), 50.0);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->band), 90.0);
}
measure_dialog_click(*a, "Save");
bandavg_probe_next(*a, 4, 500);
break;
}
case 4:
bandavg_report(*a, "after band edit");
bandavg_probe_next(*a, 5, 300);
break;
case 5: {
// The delete half of the cycle, through the dialog's own button: a measurement that carries
// two shares is shown to survive add, edit and delete like the ones that carry none.
const int at = bandavg_index(*a, MeasureType::Between, "sine");
if (at >= 0) open_measure_dialog(*a, at);
bandavg_probe_next(*a, 6, 400);
break;
}
default: {
const std::size_t before = a->measures.size();
// `measure_dialog_click()` falls through to the emit when it cannot find its button, so the
// dialog being there is this side's to check, not the helper's.
if (a->measure_dialog != nullptr) measure_dialog_click(*a, "Delete");
std::fprintf(stderr, "[bandavg-probe] %-15s measures=%zu -> %zu (want one fewer)\n",
"after delete", before, a->measures.size());
std::fflush(stderr);
break;
}
}
return G_SOURCE_REMOVE;
}
void bandavg_probe_next(App& a, int step, guint delay_ms) {
a.dbg_bandavg_step = step;
g_timeout_add(delay_ms, bandavg_probe_step, &a);
}
/// The index of one of the probe's measurements, by the pair that names it: a `between` on the sine
/// and a `between` on the ramp are two measurements of one type.
int bandavg_index(App& a, MeasureType type, const char* signal) {
for (std::size_t i = 0; i < a.measures.size(); ++i) {
if (a.measures[i].type == type && a.measures[i].signal == signal) return static_cast<int>(i);
}
return -1;
}
/// Reads the pane out: one line per measurement the probe added, with the shares it was given and
/// the two readings a user could compare (the value and the row's own formatted text). The numbers
/// come from a *forced* recompute over the window on screen now, so they are what the pane says at
/// this instant rather than whatever the last moving frame left behind.
void bandavg_report(App& a, const char* what) {
update_measures(a, /*force=*/true, /*accumulate=*/false);
// The window each reading was taken over, beside it: a mean over a share of a *partial* window is
// a different number from the same share of a full one, and nothing in the value says which it
// was. `samples` against the ring's span is what a short ring looks like from here.
for (const App::MeasureWindow& w : a.measure_samples) {
std::fprintf(stderr,
"[bandavg-probe] %-15s window channel=%d lo=%.6f hi=%.6f samples=%zu levels=%zu\n",
what, w.channel, w.stats.lo, w.stats.hi, w.stats.count, w.level.size());
}
for (std::size_t i = 0; i < a.measures.size(); ++i) {
const Measure& m = a.measures[i];
const MeasureResult& r = i < a.measure_results.size() ? a.measure_results[i] : MeasureResult{};
char shares[64];
if (measure_uses_band(m.type)) {
std::snprintf(shares, sizeof(shares), "%.0f/%.0f%%", m.options.threshold_pct,
m.options.threshold_pct2);
} else {
std::snprintf(shares, sizeof(shares), "%.0f%%", m.options.threshold_pct);
}
std::fprintf(stderr, "[bandavg-probe] %-15s %-7s '%s' shares=%-9s -> %-8s %.9g text=[%s]\n",
what, measure_type_name(m.type), m.signal.c_str(), shares,
r.valid ? "ok" : "invalid", r.valid ? r.primary : 0.0,
i < a.measure_text.size() ? a.measure_text[i].c_str() : "");
}
std::fflush(stderr);
}
gboolean zoomkeys_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->rows->row_count() == 0 || a->rows->row(0).plot == nullptr) return G_SOURCE_REMOVE;