fix: a still frame's measurements are the frame's, not the viewport's

A measurement is a statistic of the window the pane is *about*, and on a
pinned view -- a held event, a paused stream, an opened file -- that is the
frame, not the part of it on screen. The pane followed the viewport
instead, so a wheel on a latched event moved `average` and `top`: the
reported "when measuring a still frame, moving the zoom makes measurements
such as average or top change". Nothing about the data had changed; the
user was only looking closer.

The window is `RowSet::pinned_range()` while the pin frames the view and
the viewport otherwise, and the choice is made in `update_measures()` --
the call site -- rather than inside `MeasureWindowReader`, because it is
*booked* as well as read: that window is what the "it moved" test compares
against `App::measure_window`, so a gesture inside the pin spends no
recompute either, and a reader that chose its own would leave a pinned
view recomputing every gesture frame for a number that cannot change.
`pinned_range()` is now the one expression of the pin's extent, shared
with the framing (`hold_pinned_window()`, `fit_x()`), and the reader is
handed the window instead of reading the plot. Live is unchanged: there
the window really moves and the statistic follows it, and the
accumulator's rule about what resets the history is about neither.

`UDPSCOPE_PROBE_MEASUREPIN_MS` is the guard, three legs of before/after a
gesture on the row's own axis. On a latched event zoomed to a quarter
(`view=[-1..1]` -> `[-0.25..0.25]`, `pin` unchanged): `same=4/4
for_same=1 pass_delta=0`, every value bit-identical -- `average=0.000000
top=0.957975 peak to peak=2.000000 amplitude=1.915950` both sides. The
same on a pause taken from it. Since the key `for=` follows `pin=` now,
`[measure-trace]` gained the field, so a held run reads `for==pin` with
`swin` free to sit inside it. On a live view a zoom to an eighth reads
`same=0/4 for_same=0 pass_delta=3 span_after=0.125000 follows=1` with the
values moving -- the case the rule must not swallow.

Before the fix the same probe reads `same=1/4 for_same=0 pass_delta=1
span_after=0.500000` on the held event (`top` `0.957975` -> `0.957966`)
and `same=0/4 span_after=0.125000` on the pause (`top` `0.957944` ->
`0.358614`). Live reads the same either way.

Also fixed, found by the pause leg: pausing while an event is *held*
froze an **empty** picture. `on_pause_toggled()` read `stream_at_x0()`
after setting the pause state, and that accessor follows the display --
`paused` with no pause view published yet answers `-display_epoch()`,
which on a held event is 0, so the cut searched the ring at edge-relative
x, found nothing, and the frame bound an empty snapshot: every
measurement `--`, every ruler gone (`ev=650001` on the probe's line now,
`ev=0` before). The offset and the window are read first, which is the
offset the axis is actually displaying. The live case is unaffected, which
is why the pause probe, which pauses a live view, never saw it.

Docs corrected where they asserted the old half-truth: the invariants now
separate the accumulator's rule (unchanged) from the window a value is
computed over (this change), the trigger/pinning file names the pin as
load-bearing for both the framing and the numbers and records the pause
ordering rule, `verification.md`/`probe-readings.md` describe the new
probe and `pin=`, and the README and the manual say what a still frame
measures.

Checked: `cmake --build build --clean-first -j4` 0 errors and the
documented three warnings; `make -C build test-ui` 13 ok; `make -C build
test-help` green; an exit-dump run 0 Gtk-WARNING/Gtk-CRITICAL and `gtk
warnings: 0`; `[measurepin-probe]`, `[pan-probe]` (`held=1`,
`allowed=1`, `limited=1`), `[pause-probe]` (`event_size` non-zero exactly
while paused, `axis` inside `frozen_x`, `skips` 0 live / 30 paused),
`[stats-probe]` (`dn=1` across the pause), `[zoom-probe]` (`gap_px=0.0`
at every step) and `[measure-probe]` + the dump's `measures
window:` (all-types run `marks_in_window=4/4`, `pin=off` live) on the fast
mock. `measure cost:` is unchanged in kind: `raw=37298 (0.0%)` of
`covered=121704537`, 1.04 ms a pass -- the runs that read higher are the
probe's own zoomed-to-a-slice windows, where the window is legitimately
shorter than a bucket.
This commit is contained in:
Martino Ferrari committed 2026-10-16 06:10:24 +02:00
1 parent d91957a2fe
commit 0a209becb1
13 files changed
+719 -114

No files matched your search

+339
View File
@@ -1326,6 +1326,345 @@ gboolean measure_probe(gpointer user) {
return G_SOURCE_REMOVE;
}
/// `UDPSCOPE_PROBE_MEASUREPIN_MS`: on a **still frame** the pane describes the frame and not the
/// viewport, so a pan or a zoom changes no number -- and on a live view the numbers still follow
/// their window.
///
/// Three legs, each one reading the pane's own results before and after a **gesture**: the range
/// is written on the row's own axis, which is what a drag, a wheel or a box zoom writes and what
/// `Figure::layout()` adopts. Nothing here takes the window-control path, which *unpins* on the
/// way and would be testing something else.
///
/// * `held`: arms the trigger in single mode, polls for a latched event, reads, zooms to a
/// quarter of the event, reads again. **`same=4/4`, `for_same=1` and `pass_delta=0` are the
/// assertion**: the frame's samples did not change, so no number may, and the pane is not
/// even allowed to spend a recompute on the gesture.
/// * `paused`: presses the pause button and does the same, with `for=` at the *snapshot's* own
/// extent.
/// * `live`: resumes and zooms to a sixteenth of the window. The results must move with it --
/// `follows=1`, `for=`'s span narrowed to the axis' -- and the values move with them, which
/// is the case the still-frame rule must not swallow.
struct MeasurePinProbe {
App* app = nullptr;
/// Which leg is running: 0 held, 1 paused, 2 live. It only names the `before` line, since each
/// step is scheduled from the last.
int stage = 0;
std::vector<double> before;
std::vector<bool> before_valid;
plot::Range for_before{};
double span_before = 0.0;
/// `App::measure_passes` is an `int`, and the delta below is a difference of two of them.
int passes_before = 0;
/// The settle guard's own state: the pass count the last poll saw, and how many polls in a row
/// have been quiet.
int passes_seen = -1;
int settled = 0;
int polls = 0;
/// The last state the wait line was printed for: mode, pinned and at-pin in one number.
int last_state = -1;
};
/// Whether two windows are the same window **by the app's own criterion** -- the 2 %-of-span
/// rule `update_measures()` uses to decide that a recompute is not owed. Equality is not that
/// criterion and would never hold: the pin is recomputed from the snapshot's stamps every tick
/// while the pane keeps the window its last pass used, and the two differ in the last decimals
/// without a recompute being owed for it.
bool measurepin_same_window(const plot::Range& a, const plot::Range& b) {
const double span = std::max(1.0e-12, std::max(a.span(), b.span()));
return std::fabs(a.min - b.min) + std::fabs(a.max - b.max) <= 0.02 * span;
}
/// Whether the pane's results describe the pin.
bool measurepin_at_pin(App& a) {
return a.measure_pin_valid && measurepin_same_window(a.measure_window, a.measure_pin_window);
}
/// The pane's values, its window and its pass count, as the pane itself holds them.
void measurepin_read(App& a, std::vector<double>& values, std::vector<bool>& valid,
plot::Range& window, int& passes) {
values.clear();
valid.clear();
for (const MeasureResult& r : a.measure_results) {
values.push_back(r.primary);
valid.push_back(r.valid);
}
window = a.measure_window;
passes = a.measure_passes;
}
/// One leg's line: the state, the window, every value the pane is showing, and -- the frame the
/// measurement is *about* -- the bound event series' own sample count and extent. A pinned view
/// with no event bound, or one bound to an empty snapshot, is the defect that reads as `--` in
/// every column, and nothing else in the line would say which of the two windows it was.
void measurepin_line(App& a, const char* leg, const std::vector<double>& values,
const std::vector<bool>& valid) {
const RowSet::Row& r0 = a.rows->row(0);
const plot::Range view =
r0.plot != nullptr ? r0.plot->x_range(r0.axis_x) : plot::Range{0.0, 0.0};
// The pin only means something while it is what frames the plot: a *stale* pin (one that a
// previous mode left behind) printed as a window would read as the answer.
const bool pinned = a.rows->window_pinned();
const plot::Range pin = a.rows->pinned_range();
const EventSeries* ev = r0.slots[0].bound_event();
const plot::Range ev_x = ev != nullptr ? ev->x_bounds() : plot::Range{0.0, 0.0};
std::fprintf(stderr,
"[measurepin-probe] %-13s pinned=%d mode=%-7s view=[%.6f..%.6f] pin=[%s] "
"for=[%.6f..%.6f] passes=%d ev=%zu evx=[%.6f..%.6f]",
leg, pinned ? 1 : 0, display_mode_name(a.model->display_mode()),
static_cast<double>(view.min), static_cast<double>(view.max),
pinned ? (std::to_string(pin.min) + ".." + std::to_string(pin.max)).c_str() : "off",
a.measure_window.min, a.measure_window.max, a.measure_passes,
ev != nullptr ? ev->size() : 0, ev_x.min, ev_x.max);
for (std::size_t i = 0; i < a.measures.size() && i < values.size(); ++i) {
std::fprintf(stderr, " %s=", measure_type_name(a.measures[i].type));
if (valid[i]) {
std::fprintf(stderr, "%.6f", values[i]);
} else {
std::fprintf(stderr, "--");
}
}
}
/// A gesture zoom: `keep` of the span on screen now, about its centre, written where the pointer
/// puts it. The next frame's `hold_pinned_window()` adopts it -- a zoom *in* is allowed, which is
/// what makes this the case that used to move the numbers.
void measurepin_zoom(App& a, double keep) {
RowSet::Row& r0 = a.rows->row(0);
if (r0.plot == nullptr) return;
const plot::Range now = r0.plot->x_range(r0.axis_x);
const double centre = 0.5 * (now.min + now.max);
const double span = now.span() * keep;
r0.plot->set_x_range(r0.axis_x, plot::Range{centre - 0.5 * span, centre + 0.5 * span});
}
/// Reads the leg's second half and compares it with the first. `same` counts the values that are
/// bit-identical, which is what "the measurement did not change" means for a statistic over the
/// same samples; `for_same` says the window is the same, and `pass_delta` that no recompute was
/// spent.
void measurepin_compare(MeasurePinProbe& p, const char* leg) {
App& a = *p.app;
std::vector<double> after;
std::vector<bool> after_valid;
plot::Range window{};
int passes = 0;
measurepin_read(a, after, after_valid, window, passes);
int same = 0;
for (std::size_t i = 0; i < after.size() && i < p.before.size(); ++i) {
if (after_valid[i] && p.before_valid[i] && after[i] == p.before[i]) ++same;
}
measurepin_line(a, leg, after, after_valid);
const bool for_same = measurepin_same_window(window, p.for_before);
const double swin_span = window.span();
std::fprintf(stderr,
" same=%d/%zu for_same=%d pass_delta=%d"
" span_before=%.6f span_after=%.6f follows=%d\n",
same, after.size(), for_same ? 1 : 0, passes - p.passes_before,
p.span_before, swin_span,
// Live, the results *are* the viewport: the pass that follows a gesture must be
// over the narrowed window, which is where "the numbers follow the window" is a
// reading rather than a claim. Pinned, `for` must not have moved at all.
(for_same == false && swin_span > 0.0 && swin_span <= 0.75 * p.span_before) ? 1 : 0);
std::fflush(stderr);
}
/// Starts a leg: what the pane says now, and the gesture that must not move it.
void measurepin_begin(MeasurePinProbe& p, double keep) {
App& a = *p.app;
p.before_valid.clear();
measurepin_read(a, p.before, p.before_valid, p.for_before, p.passes_before);
p.span_before = p.for_before.span();
measurepin_line(a, p.stage == 0 ? "held/before" : (p.stage == 1 ? "paused/before" : "live/before"),
p.before, p.before_valid);
std::fprintf(stderr, "\n");
std::fflush(stderr);
measurepin_zoom(a, keep);
}
/// The wait budget every leg shares: every step is scheduled from the last, so a step that is
/// never reached has to *say* so -- a probe that quietly waits until the window closes reads as a
/// passing run with no lines, which is the worst of both.
bool measurepin_polls(MeasurePinProbe& p, const char* what, App& a) {
if (++p.polls <= 600) return true;
std::fprintf(stderr, "[measurepin-probe] gave up waiting for %s after %d polls (mode=%s pinned=%d)\n",
what, p.polls, display_mode_name(a.model->display_mode()),
a.rows->window_pinned() ? 1 : 0);
std::fflush(stderr);
return false;
}
/// The pause leg: presses the button, waits for the frozen frame to settle, then reads and zooms
/// it like the held leg.
gboolean measurepin_pause_wait(gpointer user);
/// The live leg: resumes, waits for the follow to re-frame, then reads and zooms.
gboolean measurepin_live_wait(gpointer user);
/// Whether a **pinned** pane has settled on the pin: the results describe it, and no pass has
/// landed since the last poll. Both halves are needed, and the second is what makes the reading
/// below about the gesture: a pass landing between the two reads moves a value for a reason that
/// is not the zoom.
bool measurepin_pinned_settled(MeasurePinProbe& p) {
App& a = *p.app;
if (!measurepin_at_pin(a) || a.measure_passes != p.passes_seen) {
p.passes_seen = a.measure_passes;
p.settled = 0;
return false;
}
// Three polls of quiet: one is the pass, the next is the frame it landed in being re-clamped.
return ++p.settled >= 3;
}
gboolean measurepin_pause_wait(gpointer user) {
MeasurePinProbe& p = *static_cast<MeasurePinProbe*>(user);
App& a = *p.app;
if (a.window == nullptr || !measurepin_polls(p, "the pause to settle", a)) {
delete &p;
return G_SOURCE_REMOVE;
}
if (a.model->display_mode() != DisplayMode::Paused ||
!measurepin_pinned_settled(p)) {
return G_SOURCE_CONTINUE;
}
p.stage = 1;
measurepin_begin(p, 0.25);
g_timeout_add(400, +[](gpointer user) {
MeasurePinProbe& probe = *static_cast<MeasurePinProbe*>(user);
measurepin_compare(probe, "paused/after");
// The live leg: the display has to *leave* the event, and a resumed view does not -- a
// `Held` model holds its snapshot with the trigger on, so resuming only un-pins it. Switching
// the trigger off is the user's own way back to the stream, and it is what makes the last leg
// a live view rather than the same event under another name.
App& a = *probe.app;
if (a.pause_button != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a.pause_button), FALSE);
}
if (a.trigger_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a.trigger_toggle), FALSE);
}
probe.settled = 0;
probe.polls = 0;
g_timeout_add(50, measurepin_live_wait, &probe);
return G_SOURCE_REMOVE;
}, &p);
return G_SOURCE_REMOVE;
}
gboolean measurepin_live_wait(gpointer user) {
MeasurePinProbe& p = *static_cast<MeasurePinProbe*>(user);
App& a = *p.app;
if (a.window == nullptr || !measurepin_polls(p, "the stream to come back", a)) {
delete &p;
return G_SOURCE_REMOVE;
}
// A resumed view is live again, and there "settled" is not a state: the window scrolls and a
// pass lands every `kMeasureRefreshUs`. What is waited for here is the *resume* itself -- the
// follow re-armed and the pin gone -- and then a few frames for the results to be over the new
// window rather than the frozen one.
const bool live = a.model->display_mode() == DisplayMode::Live && !a.rows->window_pinned();
if (!live || ++p.settled < 8) return G_SOURCE_CONTINUE;
p.stage = 2;
measurepin_begin(p, 0.0625);
g_timeout_add(400, +[](gpointer user) {
MeasurePinProbe& probe = *static_cast<MeasurePinProbe*>(user);
measurepin_compare(probe, "live/after");
delete &probe;
return G_SOURCE_REMOVE;
}, &p);
return G_SOURCE_REMOVE;
}
/// Polls for the held event the first leg needs: a latch happens when the stream says so, and a
/// sweep on a timer has nothing to zoom into. Then for the *settled* pin, which is the state the
/// gesture is read against.
gboolean measurepin_held_wait(gpointer user) {
MeasurePinProbe& p = *static_cast<MeasurePinProbe*>(user);
App& a = *p.app;
if (a.window == nullptr) {
delete &p;
return G_SOURCE_REMOVE;
}
if (!measurepin_polls(p, "a latched event to settle on its pin", a)) {
// A wait that never ends is a *reading* about the state, not a hung probe: say what it was
// waiting for rather than printing nothing at all.
delete &p;
return G_SOURCE_REMOVE;
}
// One line per *change* of the state being waited on, which is a handful in a normal run and the
// whole reading in a stuck one: `mode=held at_pin=0` for ever is a pane whose results never land
// on the pin, and it is invisible in the leg lines because there are none.
const int state = static_cast<int>(a.model->display_mode()) * 4 +
(a.rows->window_pinned() ? 2 : 0) + (measurepin_at_pin(a) ? 1 : 0);
if (state != p.last_state) {
p.last_state = state;
std::fprintf(stderr,
"[measurepin-probe] wait mode=%s pinned=%d at_pin=%d for=[%.6f..%.6f] "
"pin=[%.6f..%.6f] passes=%d\n",
display_mode_name(a.model->display_mode()), a.rows->window_pinned() ? 1 : 0,
measurepin_at_pin(a) ? 1 : 0, a.measure_window.min, a.measure_window.max,
a.measure_pin_window.min, a.measure_pin_window.max, a.measure_passes);
std::fflush(stderr);
}
if (a.model->display_mode() != DisplayMode::Held || !measurepin_pinned_settled(p)) {
if (a.model->display_mode() != DisplayMode::Held) p.settled = 0;
return G_SOURCE_CONTINUE;
}
p.stage = 0;
measurepin_begin(p, 0.25);
g_timeout_add(400, +[](gpointer user) {
MeasurePinProbe& probe = *static_cast<MeasurePinProbe*>(user);
measurepin_compare(probe, "held/after");
App& a = *probe.app;
if (a.pause_button != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a.pause_button), TRUE);
}
probe.settled = 0;
probe.polls = 0;
g_timeout_add(50, measurepin_pause_wait, &probe);
return G_SOURCE_REMOVE;
}, &p);
return G_SOURCE_REMOVE;
}
gboolean measurepin_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
// The same four types `measure_probe` adds, on a *channel* -- the first signal of a stream is
// its clock, which is not acquired, so naming it would measure nothing at all and every
// comparison below would be a comparison of two `--`.
{
const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t i = 0; i < signals.size(); ++i) {
if (signals[i].name == "sine") a->model->set_signal_enabled(i, true);
}
}
std::string name;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
if (a->model->channels().empty()) return G_SOURCE_REMOVE;
name = a->model->channels().front().name;
}
for (const MeasureType t : {MeasureType::Average, MeasureType::Top, MeasureType::PeakToPeak,
MeasureType::Amplitude}) {
MeasureOptions options;
options.show_on_plot = measure_can_show_on_plot(t);
a->measures.push_back({name, t, options});
}
rebuild_measures_pane(*a);
// Single trigger mode, so one event stays on screen for the whole gesture.
if (a->trigger_mode_drop != nullptr) {
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_mode_drop), 1);
}
if (a->trigger_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
}
std::fprintf(stderr, "[measurepin-probe] %zu measurements on '%s', trigger single\n",
a->measures.size(), name.c_str());
std::fflush(stderr);
auto* probe = new MeasurePinProbe;
probe->app = a;
g_timeout_add(50, measurepin_held_wait, probe);
return G_SOURCE_REMOVE;
}
gboolean stats_probe_report(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;