diff --git a/README.md b/README.md
index e8112c3..1e3465d 100644
--- a/README.md
+++ b/README.md
@@ -377,23 +377,37 @@ range") only for `between`, which is the one type whose two edges are both param
words on the split row name its side ("above % of the range" for `above`) — and the "show on plot"
line says what would be drawn.
-The values are **window statistics over exactly the visible range, at the stored rate**: the
-same cut the export makes, so a measurement describes the data on screen rather than the ring
-behind it — and the marks it draws belong to that window, so they stay in the viewport.
+The values are **window statistics over the window the pane is about, at the stored rate**: the
+same cut the export makes, so a measurement describes data rather than the ring behind it — and
+the marks it draws belong to that window.
-They are recomputed when the **window moves**, not on a timer: a timer decouples the two, and
+**Which** window, when the view is pinned, is the frame and not the viewport. A held event or a
+paused stream is a *still frame*: the data behind it cannot change, so a pan or a zoom there is
+only looking closer, and the pane goes on describing the **pin's own extent**
+(`RowSet::pinned_range()` — the same statement the framing itself is clamped into, so "what this
+view is a view of" has one expression). Zoom into a latched event and the numbers do not move:
+that is what a still picture means, and it is the reported *"when measuring a still frame, moving
+the zoom makes measurements such as average or top change"*. A live view is the other case and is
+unchanged: there the window genuinely moves and the statistic follows it.
+
+They are recomputed when that **window moves**, not on a timer: a timer decouples the two, and
with a scrolling window the stored marks then describe a range the plot has already left (the
min/max marker ends up outside the viewport, and a dragged marker survives until the next
tick). The trigger is 2 % of the span, so the numbers track the window within a few pixels
while the cost stays bounded by the window's motion rather than by the frame rate; one pass
copies the visible samples of each *channel* once, however many measurements read it.
-"The window moved" is asked in **every** mode, including a held trigger event — which is the
-one mode where a zoom or a pan is the only thing that can move it. Held views were once
-exempted, on the grounds that a pinned window must not recompute every frame, and the result
-was a pane that kept describing the whole captured event while you zoomed into a slice of it.
-A pinned window needs no exemption: it lands on exactly the range it already measured and
-fails the 2 % test by itself.
+"The window moved" is asked in **every** mode, but it is asked about the window the pane
+*describes*. Suppressing it while a view is held left the pane describing the whole captured
+event while you zoomed into a slice of it; dropping the exemption made the opposite mistake — the
+numbers followed the zoom, so a still frame's `average` moved whenever the wheel did. On a pinned
+view neither can happen: the pin is what the pane is about, so a gesture moves no number and
+spends no recompute, while a fresh latch, a re-stated pin or a new snapshot still recomputes it.
+The one path that is *not* the pin is the window control, which unpins as it re-frames
+(`apply_time_window()`), so for that frame the view really is live-framed and measuring the
+viewport is right. `UDPSCOPE_PROBE_MEASUREPIN_MS` is the guard: a gesture on a held event and on a
+pause must leave every value bit-identical (`same=4/4`, `pass_delta=0`), and on a live view the
+results must still narrow with the axis (`follows=1`).
**Show on plot** marks the result on the row that plots the signal: a ruler at the level
(`top`/`bottom`), both rulers at the extremes (`min`/`max`), the pulse's or the cycle's two
@@ -534,9 +548,11 @@ loses that spread entirely.
**Reset** clears every accumulator. So does editing, adding or deleting a measurement, a CONFIG
that renames the signals, and a change of the display window — each of those changes *what is
being measured*, and a mean across the change describes nothing. A pause, a resume, a trigger
-latch, a pan and a zoom do **not** reset: they change which samples are in view, which is
-exactly what a statistic over many windows is meant to average. The toggle itself does not
-reset either, so turning the line off to read the plot and back on again keeps the history.
+latch, a pan and a zoom do **not** reset: on a live view they change which samples are in view,
+which is exactly what a statistic over many windows is meant to average — and on a pinned one they
+change nothing at all, values included (above), so the history has nothing to see either way. The
+toggle itself does not reset either, so turning the line off to read the plot and back on again
+keeps the history.
## Resequencing and relative timing
@@ -1556,6 +1572,17 @@ event pin already worked this way, and the pause agrees with it. `[pause-probe]`
beside `frozen_x=`, and while paused the axis must lie *inside* `frozen_x` — equal at the pause, a
subset after a zoom-in.
+**The pause reads its display offset *before* it sets the pause state**, and pausing while an event
+is held is the case that depends on the order. `stream_at_x0()` follows the *display*: the moment
+`paused` is true with no pause view published yet it answers `-display_epoch()`, which on a held
+event is `0` — so the cut searched the ring at the event's edge-relative x, found no samples, and
+froze an **empty** picture: no trace, every measurement `--`, every ruler gone. Read first, the
+offset is the one the axis is actually displaying, which is what hands the snapshot stamps back on
+the coordinates the axis is already showing. The live case is unaffected (its epoch does not depend
+on `paused()`), which is why the pause probe, which pauses a live view, never caught it;
+`[measurepin-probe]`'s `paused/before` leg pauses a *held* event and prints the bound frame's own
+sample count (`ev=650001` for a quarter-of-the-event zoom), which is `ev=0` when this regresses.
+
**Zooming into a held event clipped it further and further from the left**, and the cause was the
one the library's own margin fix is about, in the half of the code the fix could not reach.
`EventSeries::neighbours()` — the call the renderer maps the visible range onto sample indices with
diff --git a/docs/claude/invariants-measurements.md b/docs/claude/invariants-measurements.md
index ef60113..6dc1947 100644
--- a/docs/claude/invariants-measurements.md
+++ b/docs/claude/invariants-measurements.md
@@ -20,17 +20,38 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
own -- `top`/`bottom`/`amplitude` take a second read of that same **bucket** list and never of
the window (the flat-level invariant below). **Adding a type must not add a pass** -- that is
what `compute_measure()`'s `stats` argument is for.
- **The "it moved" test is asked in every mode, a held event included.** Suppressing it while
- held (`moved = ... && !held`) is the shape the pinned-window fix first took, and it is wrong
- for the one mode where a *gesture* is the only thing that can move the window: a latched event
- zoomed forty times over left the pane describing the whole 2 s event with `passes` frozen,
- which is the reported "measurements move when zooming or panning on data" -- the numbers stand
- still while the data under them moves. Nothing is needed for the pinned case anyway, because a
- window that is merely re-pinned satisfies `swin == measure_window` exactly and fails the move
- test on its own; a pause is the standing proof, since `display_mode()` reports `Paused` rather
- than `Held` for one and the statistics probe already requires `n` to advance by at most one
- across a two-second pause. The held/live state is still a recompute of its own, so a mode
- change is not missed.
+ **The window a value is computed over is the *pin* while the view is pinned, and the viewport
+ otherwise.** A pinned view -- a held event, a paused stream, an opened file -- is a *still
+ frame*: the data behind it does not change, so a pan or a zoom only looks closer. Measuring the
+ pin (`RowSet::pinned_range()`, the same accessor `hold_pinned_window()` clamps into, so "what
+ frames this view" has one expression) is what makes a gesture change **no value at all** --
+ and it is decided in `update_measures()`, at the call site, rather than inside
+ `MeasureWindowReader`, because the decision is also *booked*: the window
+ `MeasureWindowReader` is handed is the same one the "it moved" test compares against
+ `App::measure_window`, so a gesture inside the pin spends no recompute either. A reader that
+ chose its own window would leave a pinned view recomputing on every gesture frame for a number
+ that cannot change (457 passes in one run is the shape that mistake took).
+ This test has been wrong in both directions and the reasoning is worth keeping. Suppressing it
+ while held (`moved = ... && !held`) left the pane describing the whole 2 s event
+ (`for=[0.47..2.47]`) while a 50 ms slice of it was on screen; *removing* the exemption was the
+ opposite mistake, and it is the reported "when measuring a still frame (a triggered event, or a
+ paused stream) moving the zoom makes measurements such as average or top change, which should
+ never happen". The two are one question asked of two different windows: **which window is this
+ pane about** (the pin, while pinned) and **has it moved** (asked of that window in every mode).
+ Answers: a pinned view still recomputes when it should -- a fresh latch, a re-stated pin, a new
+ snapshot, the held/live change -- and a settled one satisfies `swin == measure_window` exactly
+ and so reads `moved == false` on its own.
+ `[measurepin-probe]` is the reading: on a latched event zoomed to a quarter of itself,
+ `same=4/4 for_same=1 pass_delta=0` with the values bit-identical and `for=` still at the pin;
+ the same on a pause taken from a held event. Live is the case that must **not** be swallowed,
+ and it reads `same=0/4 for_same=0` with a positive `pass_delta`, `span_before=2.000000`,
+ `span_after=0.125000` and `follows=1` -- the results window narrowed with the viewport and the
+ values moved with it. The
+ one path that is *not* this rule is the window control: `apply_time_window()` unpins
+ (`set_time_window()` clears `pinned_`), so for the frames before the held branch re-states the
+ pin the view really is live-framed and the pane measures the viewport -- `[measure-trace]`
+ prints `pin=off` for exactly those frames, which is what makes them legible rather than looking
+ like the defect again.
The marks' *ink* is set where they are created (`install()` in `plot_host.cpp`): 0.75 px and
dashed 4/4 through `Plot::set_cursor_stroke()`, which is per cursor because the trigger marker
(also a cursor) keeps the solid `line_width_px` stroke. A new measure mark must take that
@@ -268,6 +289,10 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
changed".** An explicit Reset, an add/edit/delete (all three funnel through
`rebuild_measures_pane()`), a CONFIG, and a change of display window each change the
*question*, so the history is cleared. A pause, a resume, a trigger latch, a pan and a zoom do
- not: they change which samples answer it, which is precisely what averaging over many windows
- is for. The pane-wide toggle does not reset either — it only shows and hides labels that are
- always created and always fed, so reading the plot for a moment does not cost the history.
+ not: on a **live** view they change which samples answer it, which is precisely what averaging
+ over many windows is for -- and on a **pinned** one they change *nothing*, not even a value
+ (the window invariant above), so there is nothing for the history to see either way. The rule
+ is about the *accumulator* only; it is not a claim that a gesture leaves a value alone, which is
+ a property of the window a value is computed over and is the other half of this change. The
+ pane-wide toggle does not reset either — it only shows and hides labels that are always created
+ and always fed, so reading the plot for a moment does not cost the history.
diff --git a/docs/claude/invariants-trigger-and-pinning.md b/docs/claude/invariants-trigger-and-pinning.md
index 4e779b0..8a22ec4 100644
--- a/docs/claude/invariants-trigger-and-pinning.md
+++ b/docs/claude/invariants-trigger-and-pinning.md
@@ -81,13 +81,20 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
the far threshold of the side it is leaving. Seeding the previous side from `0` -- the undecided code --
fires on a signal that starts *inside* the band and leaves it without ever reaching the level, an event the
trigger is not supposed to see.
-- **A pinned window is *clamped* into the event every frame, and a zoom in is allowed.**
+ **A pinned window is *clamped* into the event every frame, and a zoom in is allowed.**
While an event is held, a capture is filling, or the view is paused, `tick()` calls
`RowSet::hold_pinned_window()`, which caps the span at `[-pinned_pre_, +pinned_post_]`'s and
clamps the position into it (and states `Off`, since a following group would re-derive from a
live edge). **A zoom in must keep working** -- "not outside the event" is not "not readable",
and this function's first version *re-stated* the pin every frame, which made the wheel a no-op
in single trigger mode: an acquired event could not be examined at all.
+ **What a gesture inside the pin may *not* move is a measurement**, and it is not this function
+ that holds that: `pinned_range()` is the one expression of the pin's extent, and
+ `update_measures()` measures *it* rather than the viewport while the view is pinned, so a pan
+ or a zoom on a still frame changes no value and spends no recompute (see
+ [invariants-measurements.md](invariants-measurements.md); `[measurepin-probe]` is the reading,
+ `pin=`/`for=` in `[measure-trace]` the field to watch). The pin is therefore load-bearing twice
+ over, and a change to what `pinned_range()` says changes both the framing and the numbers.
**The request is read from the row's own axis, not from the group.** A member's `set_x_range`
-- what the controller writes for a gesture -- is *not* adopted into the group while the follow
is off, so a clamp that read the group only ever saw its own previous answer (the probe showed
@@ -148,6 +155,18 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
afterwards if the probe zoomed in. Before the fix the two disagreed outright:
`axis=[-1.44..0.56]` over data that started at `0.0`. (The event pin already worked this way; the
pause now agrees.)
+- **The pause reads the display offset *before* it sets the pause state, and a pause taken on a
+ held event depends on that order.** `on_pause_toggled()` reads the row's axis and
+ `stream_at_x0()`, cuts `view + offset` out of the ring, and hands the same offset to the
+ snapshot as its `x_offset`. `stream_at_x0()` follows the *display*: the moment `paused` is true
+ with no pause view published yet it answers `-display_epoch()`, which on a `Held` view is **0**
+ -- so the cut searched the ring at the event's edge-relative x, found nothing, and froze an
+ **empty** picture: `ev=0` on the probe's line, every measurement `--` and every ruler gone,
+ which is a much louder symptom than the empty margin above and reaches a user the same way
+ (pause while an event is on screen). Read first, it is the offset the axis is actually
+ displaying, which is what the whole scheme requires. The live case is unchanged -- there the
+ epoch does not depend on `paused()`, which is why the pause probe, which pauses a *live* view,
+ never saw it.
- **The event axis is pinned from the snapshot's own stamps, not from the request.** A
capture may legitimately be shorter than asked for — the ring need not reach back a
full pre-trigger share on the first latch after arming, so `snap=[-0.339..1.000]`
diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md
index d08a2ef..79fef9b 100644
--- a/docs/claude/probe-readings.md
+++ b/docs/claude/probe-readings.md
@@ -253,9 +253,10 @@ Two lines of the self-check output are assertions in disguise:
seconds, and the rest the channel's unit. `switch_on` and `reset_sensitive` are the panel's *frame* read back: the switch widget against
`measure_stats_on`, which the code drives, and the Reset button that follows it. Either disagreeing with
`[stats-probe] on=` is a `.ui` that lost the widget, or lost the wiring to it; `pane_rows=R` is what
- proves the readout rows landed in the frame's `measures_list` rather than nowhere. `marks_shown` counts two per shown measurement
- (a min/max showing both its marks counts two of its own). `measures window:` is the
- timeliness assertion -- the window the numbers were computed over against the one on screen.
+ `marks_shown` counts two per shown measurement
+ (a min/max showing both marks counts two of its own). `measures window:` is the
+ timeliness assertion -- the window the numbers were computed over against the one that *should*
+ be on screen.
**The reading that must hold is `marks_in_window=K/K`**: every *time* mark inside the window
on screen. Both ranges are on the **stream's** clock, which is the space `measure_window` and
the time marks are stored in -- a live axis is a fixed `[-window, 0]`, so comparing display x
@@ -263,15 +264,22 @@ Two lines of the self-check output are assertions in disguise:
(`App::measure_view_window`) and is deliberately not read back here: the dump runs from the
destroy handler, by which point the exit probe has switched the trigger on, and
`stream_at_x0()` then answers for a mode no frame was ever drawn in -- which read as a 45000 %
- drift on a run whose every frame was exact. And the count is against `win`, not against
- `measure_window`, which contains every mark by construction: read that way it was a tautology
+ drift on a run whose every frame was exact. `pin` is the same tick's pin (`App::measure_pin_window`,
+ `off` when nothing was pinned) and it is the **target** while the view is pinned: a measurement
+ on a still frame describes the frame, so `drift` there is `results_for` against the *pin*, and a
+ zoom that leaves the screen showing a slice of that frame is not drift. Read against `win` -- the
+ old rule -- a zoomed held event would report the intended behaviour as the defect it was once
+ reported as. The count is still against `win`, the window **on screen**, not against
+ `measure_window`/`pin`, which contain every mark by construction: read that way it was a tautology
that printed `2/2` while the pane described a window a forty-fold zoom away from the one
- displayed. On a window **shorter than the refresh interval** the count rides the same sawtooth
+ displayed. A *time* mark may legitimately sit outside `win` on a pinned view that has been zoomed
+ into the pin (the mark is a position in the window the result describes, and that window is the
+ frame), so the strict `K/K` reading belongs to a live view and to a pinned view left at its pin.
+ On a window **shorter than the refresh interval** the count rides the same sawtooth
`drift` does -- the 50 ms window the zoom sweep leaves behind can lag far enough that *every*
- time mark is off screen, so `0/K` there is read beside `drift` and not alone. The strict
- reading belongs to the documented 2 s run, and to a **held** window in any run: a latched
- event does not scroll, so `[measure-trace]` must show `for=` equal to `swin=` exactly for as
- long as the mode stays `held`, zoom and pan included.
+ time mark is off screen, so `0/K` there is read beside `drift` and not alone. `0/0` is a pane
+ whose types carry no time marks at all (`average`, `top`, `amplitude` and `peak to peak` draw
+ rulers only), and is not a reading about marks.
`drift` itself is a **single sample of a sawtooth** and is not an assertion on its
own: a live view scrolls between recomputes, so it rises to one `kMeasureRefreshUs` of scroll
and drops to zero at each pass. The figure is `(|Δmin| + |Δmax|) / span`, so a pure scroll
@@ -284,9 +292,11 @@ Two lines of the self-check output are assertions in disguise:
is a dump that landed just after a pass, not a better state. Read it against the recompute count:
*more* recomputes with a *higher* drift sample is the sawtooth, while a drift that exceeds one
refresh interval of scroll, or marks outside the window, is the defect. A measurement is
- recomputed when the window moves (2 % of its span) or the held/live state changes, never on a
- wall-clock timer: a timer is what let a marker sit outside the viewport and a dragged one
- survive a frame. **`marks_selectable` must be 0**: the
+ recomputed when the window **it describes** moves (2 % of its span) or the held/live state
+ changes, never on a wall-clock timer: a timer is what let a marker sit outside the viewport and a
+ dragged one survive a frame, and it is the window it describes -- the viewport live, the pin while
+ pinned -- so that a gesture on a still frame spends no recompute at all. **`marks_selectable`
+ must be 0**: the
measurement marks are created unselectable (`Plot::set_cursor_selectable()`, which the
library gained for this), read back out of the plot here -- re-placing a marker every frame
does **not** stop a drag, because the controller holds it for the gesture's life and the
@@ -1152,6 +1162,58 @@ Four line kinds, and the reading is the third:
most and `0.986495` on the rest. **A line's `--` on a *lagging* trace is the trace's, not the
reading's** — the pane is refreshed on a cadence, so quote an earlier line beside its own `view=`.
+## `[measurepin-probe]`
+
+`UDPSCOPE_PROBE_MEASUREPIN_MS`. The reading for **what window a measurement describes**: on a still
+frame the pane is about the *frame*, so a pan or a zoom changes no number, and on a live view the
+numbers still follow the window. It installs `measure_probe`'s own four types on the first *channel*
+(`average`, `top`, `peak to peak`, `amplitude`), arms the trigger in **single** mode, and runs three
+legs — each one reading the pane before and after a **gesture** written on the row's own axis, which
+is what a drag, a wheel or a box zoom writes. Nothing here takes the window-control path: that one
+*calls off* the pin (`set_time_window()` clears `pinned_`), so measuring it would test a different
+question.
+
+Run it against the fast mock, and mind the datagram limit: `--batch 2000` is 80 kB of payload for the
+mock's five signals and dies with `OSError: [Errno 90] Message too long`, so the command here is
+`--rate 1300000 --batch 1500 --freq 1`. Its sequence is about 12 s: it waits for a latch, then
+~1.5 s per leg, so `UDPSCOPE_EXIT_AFTER_MS=20000` covers it with room.
+
+```
+[measurepin-probe] 4 measurements on 'sine', trigger single
+[measurepin-probe] wait mode=waiting pinned=0 at_pin=0 for=[-1.419616..0.580384] pin=[0.000000..1.000000] passes=14
+[measurepin-probe] wait mode=capturing pinned=1 at_pin=0 for=[-1.419616..0.580384] pin=[0.000000..1.000000] passes=14
+[measurepin-probe] wait mode=capturing pinned=1 at_pin=1 for=[0.881155..2.881155] pin=[0.881155..2.881155] passes=15
+[measurepin-probe] wait mode=held pinned=1 at_pin=1 for=[0.881155..2.881155] pin=[0.881155..2.881155] passes=15
+[measurepin-probe] held/before pinned=1 mode=held view=[-1.000000..1.000000] pin=[-1.000000..1.000000] for=[0.881155..2.881155] passes=16 ev=2600001 evx=[-1.000000..1.000000] average=0.000000 top=0.957975 peak to peak=2.000000 amplitude=1.915950
+[measurepin-probe] held/after pinned=1 mode=held view=[-0.250000..0.250000] pin=[-1.000000..1.000000] for=[0.881155..2.881155] passes=16 ev=2600001 evx=[-1.000000..1.000000] average=0.000000 top=0.957975 peak to peak=2.000000 amplitude=1.915950 same=4/4 for_same=1 pass_delta=0 span_before=2.000000 span_after=2.000000 follows=0
+[measurepin-probe] paused/before pinned=1 mode=paused view=[-0.250000..0.250000] pin=[-0.250000..0.250000] for=[1.631155..2.131155] passes=17 ev=650001 evx=[-0.250000..0.250000] average=0.000003 top=0.957944 peak to peak=2.000000 amplitude=1.915906
+[measurepin-probe] paused/after pinned=1 mode=paused view=[-0.062500..0.062500] pin=[-0.250000..0.250000] for=[1.631155..2.131155] passes=17 ev=650001 evx=[-0.250000..0.250000] average=0.000003 top=0.957944 peak to peak=2.000000 amplitude=1.915906 same=4/4 for_same=1 pass_delta=0 span_before=0.500000 span_after=0.500000 follows=0
+[measurepin-probe] live/before pinned=0 mode=live view=[-2.000000..0.000000] pin=[off] for=[1.666922..3.666922] passes=22 ev=0 evx=[0.000000..0.000000] average=-0.000000 top=0.957975 peak to peak=2.000000 amplitude=1.915948
+[measurepin-probe] live/after pinned=0 mode=live view=[-1.062500..-0.937500] pin=[off] for=[2.746345..2.871345] passes=24 ev=0 evx=[0.000000..0.000000] average=-0.427665 top=-0.083224 peak to peak=0.687725 amplitude=0.644726 same=0/4 for_same=0 pass_delta=2 span_before=2.000000 span_after=0.125000 follows=1
+```
+
+- `view`/`pin`/`for`/`passes`/`ev`/`evx` are the state: the axis, the pin's own extent, the window
+ the printed values were computed over, the pane's pass count, and the **bound event series'** own
+ sample count and extent — the frame the numbers are about. `ev=0` while a view is pinned is an
+ *empty* frozen frame (every value `--`), which is the reading that named the pause ordering defect
+ (`on_pause_toggled()` read its display offset after setting the pause state).
+- `same=K/4` — the values that are **bit-identical** before and after the gesture. On a pinned view
+ it must be `4/4` with `for_same=1` and `pass_delta=0`: the frame's samples did not change, so
+ neither may a number, and the pane may not even spend a recompute on the gesture. `for_same` is
+ the app's own 2 %-of-span criterion (the rule that decides a recompute is not owed), not equality:
+ the pin is re-derived from the snapshot's stamps every tick and the pane keeps the window its last
+ pass used, so the two differ in the last digits without anything being owed.
+- `follows` — the live leg's assertion, and the case the still-frame rule must *not* swallow:
+ `for_same=0`, a positive `pass_delta` and the results window's span narrowed from the axis'
+ 2.000000 to 0.125000, with `average`, `top` and `peak to peak` all moving. `follows=1` with `same=4/4` on a
+ pinned leg, or `follows=1` while pinned at all, is the defect in one direction or the other.
+- The `before`/`after` pair is taken only once the pinned frame has **settled** (a `wait` line per
+ *change* of the state being waited on until then, then nothing): the frames that bind a snapshot or freeze a pause re-state the
+ pin from the request and then from the snapshot's own stamps, and a gesture made in one of those
+ frames is reverted by that re-statement — the `[zoom-probe]`'s own gesture sweep loses its first
+ two steps to it, which is visible in its `ges/x1`, `ges/x2` lines. A `gave up waiting after N
+ polls` line means no latch (or no pin) was ever reached, and is a reading about the stream.
+
## `[search-probe]`
`UDPSCOPE_PROBE_SEARCH_MS`. The handler calls `condition_probe()` directly — the condition is
diff --git a/docs/claude/verification.md b/docs/claude/verification.md
index 2d2ed4b..c179a81 100644
--- a/docs/claude/verification.md
+++ b/docs/claude/verification.md
@@ -96,20 +96,25 @@ precedent, not an exception.
overshoot, kept by the extremes and excluded by the levels. The per-type values, the reason for
each, and the cost of the histogram are in
[invariants-measurements.md](invariants-measurements.md).
- `[measure-trace] mode=M view=[..] s0=S swin=[..] for=[..] passes=N =... marks=N
- level:V/time:V...` is a repeating line (250 ms) from the probe: the pane's numbers beside the
- *current* window and the marks actually placed, which is the only way a stale result or a mark
- that should not be drawn shows up as a number. `swin` is `view` moved onto the stream's clock by
- `s0` (`stream_at_x0()`), and `for` is the window the printed numbers were computed over, in the
- same clock -- so a reading that moved while `view` stood still is a change of `s0` (a new
- snapshot under the same mode) and not a stale pass, and only these three fields tell the two
- apart. `passes` beside them says whether a recompute answered the move. **While `mode=held`,
- `for` must equal `swin` exactly**, through any number of zooms and pans: a latched event does
- not scroll, so there is nothing for a recompute to be late for. That reading is where
- the measurements standing still under a gesture was found -- `for=[0.47..2.47]` against a
- `swin=[1.417..1.467]` with `passes` frozen, the pane describing the whole 2 s event while a 50 ms
- slice of it was on screen. The line must also never print `marks=0` while measurements with
- `show_on_plot` are on and the data is there -- that was the rulers blinking off at a mode change.
+ `[measure-trace] mode=M view=[..] s0=S swin=[..] pin=[..|off] for=[..] passes=N
+ =... marks=N level:V/time:V...` is a repeating line (250 ms) from the probe: the
+ pane's numbers beside the *current* window and the marks actually placed, which is the only way a
+ stale result or a mark that should not be drawn shows up as a number. `swin` is `view` moved onto
+ the stream's clock by `s0` (`stream_at_x0()`), `pin` is the pin's own extent in that clock (`off`
+ when nothing is pinned) and `for` is the window the printed numbers were computed over -- so a
+ reading that moved while `view` stood still is a change of `s0` (a new snapshot under the same
+ mode) and not a stale pass, and only these fields tell those apart. `passes` beside them says
+ whether a recompute answered the move. **While the view is pinned, `for` must equal `pin` -- and
+ `swin` is free to sit anywhere inside it** -- through any number of zooms and pans: a latched
+ event's numbers are the *frame's*, so a gesture inside the pin changes neither them nor `passes`.
+ Read the other way round it is the defect: `for` following `swin` into the zoom while the values
+ move is the reported "on a still frame the measurements change when I zoom". Neither reading is
+ the old "measurements stand still while the data moves", which was `for=[0.47..2.47]` against a
+ `swin=[1.417..1.467]` with `passes` frozen -- the pane describing a window the plot had left.
+ The line must also never print `marks=0` while measurements with `show_on_plot` are on and the
+ data is there -- that was the rulers blinking off at a mode change. `pin=off` while the mode is
+ `held` is not a stale reading but the window control: `apply_time_window()` unpins, and for those
+ frames the view is live-framed and measuring the viewport is right.
`measure cost: N recomputes, X ms total, Y ms each, data D ms (P%), covered=C buckets=B
raw=R (F%), N ns/covered sample` follows it: **read it against a run with
`UDPSCOPE_PROBE_MEASURE_MS` unset**, because a frame mean cannot attribute a cost. The last
@@ -168,6 +173,21 @@ precedent, not an exception.
`--square-harmonics 30` for the `0.986496` and the `0.302744`, `--square-harmonics 0` for the
`1.000000` and the empty band.
+- `[measurepin-probe]` (stderr, `UDPSCOPE_PROBE_MEASUREPIN_MS`) — **what window a measurement
+ describes**: on a still frame the pane is about the *frame*, so a pan or a zoom changes no
+ number, and on a live view the numbers still follow the window. Three legs — a latched event, a
+ pause taken from it, and a live view after the trigger is switched off — each read the pane
+ before and after a *gesture* written on the row's own axis (never the window control, which
+ calls off the pin). The assertions, in order: the pinned legs read **`same=4/4 for_same=1
+ pass_delta=0`** with `for=` still at the pin (the values bit-identical and no recompute spent on
+ the gesture), and the live leg reads **`follows=1`** with `for_same=0`, `pass_delta>0` and the
+ results window's span narrowed to the axis'. Its sequence is about 12 s, so
+ `UDPSCOPE_EXIT_AFTER_MS=20000`; run it on the fast mock with a batch the datagram limit allows
+ (`--rate 1300000 --batch 1500 --freq 1`, **not** `--batch 2000`, which is 80 kB of payload and
+ dies with `Message too long`). The lines, the fields and the settle wait are in
+ [probe-readings.md](probe-readings.md); the window rule itself is in
+ [invariants-measurements.md](invariants-measurements.md).
+
- `[pan-probe] asked=[..] was=[..]`, `[pan-probe] after=[..] held=D asked=[..]`, then
`[pan-probe] zoom asked=[..] after=[..] span=S window=W limited=L`
(stderr, `UDPSCOPE_PROBE_PAN_MS`) — **`held` must be 1**: a gesture must not move a window
diff --git a/help/C/org.udpscope.app/measurements.page b/help/C/org.udpscope.app/measurements.page
index bbf5719..9293d8d 100644
--- a/help/C/org.udpscope.app/measurements.page
+++ b/help/C/org.udpscope.app/measurements.page
@@ -5,7 +5,8 @@
- A measurement is a statistic of the visible window of one signal, with optional
+ A measurement is a statistic of one signal over the window the pane is about — the
+ visible range, or the frozen frame while a view is pinned — with optional
on-plot marks and a per-measurement statistics line.Martino Ferrari
@@ -14,10 +15,15 @@
Measurements
-
A measurement is a statistic of the visible window of one signal, recomputed as
+
A measurement is a statistic of one signal over the window the pane is about, recomputed as
that window moves. The pane lists them with their values; each can also draw its result on
the plot.
+
That window is the visible range — so on a live view the numbers follow what you
+ pan and zoom to. On a still frame — a held event, a paused stream, an opened file — it
+ is the frame's own extent instead, so panning or zooming inside it changes nothing: the data
+ being measured has not changed and you are only looking closer.
+
How to reach it
@@ -122,7 +128,9 @@
a mean frequency over many windows is what that claims to be. Reset clears
every accumulator and starts fresh. A change of display window, a CONFIG or a
measurement edit also resets; a pause, a resume, a zoom, a pan and the toggle itself do
- not, so turning the line off to read the plot and back on again keeps the history.
+ not, so turning the line off to read the plot and back on again keeps the history. On a
+ still frame a pan or a zoom changes nothing for the history to record in the first place —
+ the values do not move there at all.
diff --git a/help/C/org.udpscope.app/pause.page b/help/C/org.udpscope.app/pause.page
index e3b239f..9f8dbe3 100644
--- a/help/C/org.udpscope.app/pause.page
+++ b/help/C/org.udpscope.app/pause.page
@@ -41,7 +41,9 @@
snapshot, and the pinned axis is that shorter span — so you see a narrower view rather
than an empty margin.
A paused view is pinned exactly as a held trigger event is: zoom in freely, but
- a pan or a zoom out is clamped to the snapshot's data.
+ a pan or a zoom out is clamped to the snapshot's data. A measurement goes on describing
+ that frozen frame rather than the part of it you are looking at, so a zoom on a pause
+ changes no reading — see .
Pausing is the easiest way to measure a layout or read a cursor precisely,
because nothing under the pointer moves between frames.
The status bar says how long you have been paused — wall time, since the stream
diff --git a/src/plot_host.cpp b/src/plot_host.cpp
index 1bdc4b3..efef5e5 100644
--- a/src/plot_host.cpp
+++ b/src/plot_host.cpp
@@ -745,7 +745,7 @@ void RowSet::hold_pinned_window() {
// and a following group would re-derive the window from a live edge on the next layout --
// ignoring the pin altogether.
figure_->set_x_link_follow(x_group_, plot::XFollow::Off);
- const plot::Range pin{-pinned_pre_, pinned_post_};
+ const plot::Range pin = pinned_range();
// The *request*: what the user's gesture left on the row's own axis. A member's `set_x_range`
// -- what the controller writes -- is not adopted into the group while the group is not
// following, so reading the group here only ever saw the previous clamp's answer and the wheel
@@ -857,7 +857,7 @@ void RowSet::fit_x() {
// *members* have to be written as well as the group, because `hold_pinned_window()` prefers a
// member that has moved since our own last statement -- that is how it tells a gesture from its
// own echo -- so writing the group alone would be reverted to the zoomed-in member next frame.
- const plot::Range pin{-pinned_pre_, pinned_post_};
+ const plot::Range pin = pinned_range();
if (!(pin.span() > 0.0) || !std::isfinite(pin.min) || !std::isfinite(pin.max)) return;
for (int i = 0; i < rows_; ++i) {
Row& r = row(i);
diff --git a/src/plot_host.hpp b/src/plot_host.hpp
index 65c6582..e4b99f5 100644
--- a/src/plot_host.hpp
+++ b/src/plot_host.hpp
@@ -385,6 +385,12 @@ class RowSet {
/// Whether a window is pinned: a held event, a filling capture or a paused view. The condition
/// `fit_x()` branches on, which is why the fit probe reports it.
bool window_pinned() const noexcept { return pinned_; }
+
+ /// The pin's own extent, in the x the rows' axes read -- what a pinned view is a view *of*.
+ /// The one expression of it, shared by the framing (`hold_pinned_window()`, `fit_x()`) and by
+ /// the measurement pane, which measures *this* while the view is pinned so that a gesture
+ /// changes no number (see `update_measures()`); meaningless when `window_pinned()` is false.
+ plot::Range pinned_range() const noexcept { return {-pinned_pre_, pinned_post_}; }
/// True when the pin already *is* this window. A caller that re-states a pin every frame -- the file
/// view does, from the ring's own extent -- uses this to update the pin's numbers with
/// `set_data_pin()` instead of writing the group's range again with `pin_event_window()`, which would
diff --git a/src/ui.cpp b/src/ui.cpp
index cf095e4..c20847c 100644
--- a/src/ui.cpp
+++ b/src/ui.cpp
@@ -1136,22 +1136,38 @@ void on_window_step(GtkButton* button, gpointer user) {
void on_pause_toggled(GtkToggleButton* button, gpointer user) {
App* app = static_cast(user);
const bool paused = gtk_toggle_button_get_active(button);
+ // Freeze the *view*: snapshot exactly the window on screen, and pin the axes there. The
+ // acquisition thread keeps writing the live ring behind it, so resuming shows the data that
+ // arrived while paused instead of a hole.
+ //
+ // The window and its offset are read **before** the pause is set, and that order is the whole
+ // of what makes a pause taken while an event is *held* work. `stream_at_x0()` follows the
+ // *display*: the moment `paused` is true with no pause view published yet, it answers
+ // `-display_epoch()`, which on a held event is 0 -- so the cut searched the ring at
+ // edge-relative x, found no samples, and froze an *empty* picture (`ev=0` on the probe's own
+ // line, every measurement `--`). What it has to hand the snapshot is the offset the axis is
+ // reading at the instant of the click, which is exactly what it says below.
+ plot::Range view{0.0, 0.0};
+ double s0 = 0.0;
+ bool have_view = false;
+ if (RowSet::Row& r0 = app->rows->row(0); r0.plot != nullptr) {
+ view = r0.plot->x_range(r0.axis_x);
+ have_view = true;
+ }
+ if (have_view) {
+ // The axis is in display x and the ring is on the stream's clock, so the window has to be
+ // put back on that clock to be cut -- `stream_at_x0()` is the one accessor that is right in
+ // every mode, including a pause taken while an event is *held*, where the axis is
+ // edge-relative and the old `- display_epoch()` form searched the ring with event
+ // coordinates. The same offset is handed to the snapshot as its `x_offset`, so its stamps
+ // come back rebased onto the x the axis is already showing and the frozen picture does not
+ // move at the instant of the pause.
+ s0 = app->model->stream_at_x0();
+ }
app->acq->set_paused(paused);
app->model->set_paused(paused);
if (paused) {
- // Freeze the *view*: snapshot exactly the window on screen, and pin the axes there. The
- // acquisition thread keeps writing the live ring behind it, so resuming shows the data that
- // arrived while paused instead of a hole.
- if (RowSet::Row& r0 = app->rows->row(0); r0.plot != nullptr) {
- const plot::Range view = r0.plot->x_range(r0.axis_x);
- // The axis is in display x and the ring is on the stream's clock, so the window has to be
- // put back on that clock to be cut -- `stream_at_x0()` is the one accessor that is right in
- // every mode, including a pause taken while an event is *held*, where the axis is
- // edge-relative and the old `- display_epoch()` form searched the ring with event
- // coordinates. The same offset is handed to the snapshot as its `x_offset`, so its stamps
- // come back rebased onto the x the axis is already showing and the frozen picture does not
- // move at the instant of the pause.
- const double s0 = app->model->stream_at_x0();
+ if (have_view) {
app->model->publish_pause_view(view.min + s0, view.max + s0, s0);
app->pause_started_us = g_get_monotonic_time();
// Pin on what the snapshot *got*: the cut is clamped to the ring, so it can be narrower than
@@ -5815,6 +5831,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASURE_MS")) {
g_timeout_add(static_cast(std::atoi(env)), measure_probe, &app);
}
+ if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASUREPIN_MS")) {
+ g_timeout_add(static_cast(std::atoi(env)), measurepin_probe, &app);
+ }
if (const char* env = g_getenv("UDPSCOPE_PROBE_EVENTZOOM_MS")) {
g_timeout_add(static_cast(std::atoi(env)), eventzoom_probe, &app);
}
@@ -6474,24 +6493,34 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
{
std::lock_guard lock(model.mutex());
// `results_for` is the window the numbers describe, `win` the one on screen now: the
- // drift between them is the assertion that a measurement is a statistic of the *current*
- // window -- a timer-decoupled recomputation is what used to leave a marker outside it.
- // Both on the **stream's** clock, which is the space `measure_window` and the time marks
- // are stored in: a live axis is a fixed `[-window, 0]`, so comparing display x would read
- // no drift whatever the data did (see `App::measure_window`).
+ // drift between them is the assertion that a measurement is a statistic of the window it
+ // *should* describe -- a timer-decoupled recomputation is what used to leave a marker
+ // outside it. Both on the **stream's** clock, which is the space `measure_window` and
+ // the time marks are stored in: a live axis is a fixed `[-window, 0]`, so comparing display x
+ // would read no drift whatever the data did (see `App::measure_window`).
//
- // `win` comes from the last **tick** (`App::measure_view_window`) and is not read back here:
- // this runs from the destroy handler, where the exit probe has already switched the trigger
- // on, and `stream_at_x0()` then answers for a mode no frame was drawn in.
+ // Which window that should be depends on the view, so `drift` is measured against the
+ // **target**: `win` live, and the **pin** (`pin=`, the same stream seconds) while the view
+ // is pinned. On a pinned view the pane is about the frame, and a zoom inside it is allowed
+ // to leave the screen showing a slice of that frame -- reading that as drift would report
+ // the intended behaviour as the defect it was already reported as once.
+ //
+ // `win` and `pin` come from the last **tick** (`App::measure_view_window`,
+ // `App::measure_pin_window`) and are not read back here: this runs from the destroy
+ // handler, where the exit probe has already switched the trigger on, and `stream_at_x0()`
+ // then answers for a mode no frame was drawn in.
const plot::Range win = app.measure_view_window;
- const double span = app.measure_window.span();
+ const plot::Range target = app.measure_pin_valid ? app.measure_pin_window : win;
+ const double span = target.span();
int in_window = 0;
int marks = 0;
- // Against `win`, the window **on screen** -- not against `measure_window`, which is the
- // window the mark was computed over and so contains it by construction. Read that way the
- // count was a tautology: it printed `2/2` while the pane's numbers described a window 11 s
- // and a forty-fold zoom away from the one displayed, which is how a held event that never
- // recomputed under a gesture stayed invisible to every run of this check.
+ // Against `win`, the window **on screen** -- not against `measure_window`/`target`, which
+ // contain every mark by construction: read that way the count was a tautology, and it
+ // printed `2/2` while the pane's numbers described a window 11 s and a forty-fold zoom
+ // away from the one displayed. A *time* mark may legitimately sit outside `win` on a
+ // pinned view that has been zoomed into the pin -- the mark is a position in the window the
+ // result describes, and that window is the frame, not the slice -- so the strict `K/K`
+ // reading belongs to a live view and to a pinned view left at its pin.
for (const RowSet::MeasureMark& mk : app.measure_marks) {
if (mk.level) continue;
++marks;
@@ -6510,11 +6539,16 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (r.plot->cursor(r.measure_v[mi]).selectable) ++selectable;
}
}
- std::printf("[udpscope] measures window: win=[%.6f..%.6f] results_for=[%.6f..%.6f] "
+ const std::string pin_text =
+ app.measure_pin_valid ? std::to_string(app.measure_pin_window.min) + ".." +
+ std::to_string(app.measure_pin_window.max)
+ : std::string("off");
+ std::printf("[udpscope] measures window: win=[%.6f..%.6f] pin=[%s] results_for=[%.6f..%.6f] "
"drift=%.4f%% marks_in_window=%d/%d marks_selectable=%d\n",
- win.min, win.max, app.measure_window.min, app.measure_window.max,
- span > 0.0 ? 100.0 * (std::fabs(win.min - app.measure_window.min) +
- std::fabs(win.max - app.measure_window.max)) /
+ win.min, win.max, pin_text.c_str(), app.measure_window.min,
+ app.measure_window.max,
+ span > 0.0 ? 100.0 * (std::fabs(target.min - app.measure_window.min) +
+ std::fabs(target.max - app.measure_window.max)) /
span
: 0.0,
in_window, marks, selectable);
diff --git a/src/ui_app.hpp b/src/ui_app.hpp
index 969e593..55c898e 100644
--- a/src/ui_app.hpp
+++ b/src/ui_app.hpp
@@ -484,6 +484,11 @@ struct App {
/// `[-window, 0]` whatever arrives, so "the window moved" is invisible in display coordinates
/// and the pane would compute once and then never again -- the readout frozen on the samples
/// that happened to be on screen when the pane opened.
+ ///
+ /// **Which** window that is depends on the view: the viewport while the view is live, and the
+ /// **pin** (`RowSet::pinned_range()`, captured in `measure_pin_window` below) while it is
+ /// pinned. A still frame is measured as a frame, so a gesture inside the pin moves neither this
+ /// nor any number.
plot::Range measure_window{};
bool measure_window_valid = false;
bool measure_was_held = false;
@@ -493,7 +498,16 @@ struct App {
/// destroy handler, after the exit probe has switched the trigger on, and `stream_at_x0()` by
/// then answers for a mode no frame was ever drawn in -- which read as a 45000 % drift on a run
/// whose every frame was exact.
+ ///
+ /// While the view is pinned this and `measure_window` are *allowed* to differ: the pane
+ /// describes the pin, and a gesture may have narrowed what is on screen to a slice of it.
plot::Range measure_view_window{};
+ /// The pin's own extent in the same stream seconds, and whether the view was pinned when the
+ /// tick read it. This is the window a pinned view's results describe, so the dump measures its
+ /// drift against it -- otherwise a zoomed held event would read as a pane describing a window
+ /// the plot has left, which is precisely what it must do there.
+ plot::Range measure_pin_window{};
+ bool measure_pin_valid = false;
/// What the two walks over one channel's visible window produced, shared by every measurement
/// on that signal: the window's statistics, and one accumulator per *distinct level* any of
/// them asked for. Several measurements on one signal must cost two walks, not two each -- and
@@ -1076,16 +1090,22 @@ struct ColorPick {
};
class MeasureWindowReader {
public:
- MeasureWindowReader(App& app, std::size_t channel) {
+ /// `window` is the x range to read, **in the coordinates the axes read** -- the viewport for a
+ /// live view, the pin for a pinned one -- and it is handed in rather than read off the plot
+ /// here, because *which* window a measurement describes is a decision (`update_measures()`)
+ /// that also has to be booked: the pane compares the window it last measured against this one
+ /// to decide whether a recompute is owed, and a reader that chose its own would leave the two
+ /// disagreeing -- a pinned view recomputing on every gesture frame for a number that cannot
+ /// change.
+ MeasureWindowReader(App& app, std::size_t channel, plot::Range window) {
const RowSet::Row& r0 = app.rows->row(0);
if (r0.plot == nullptr) return;
- const plot::Range view = r0.plot->x_range(r0.axis_x);
if ((snap_ = app.model->display_snapshot()) != nullptr) {
if (channel >= snap_->values.size()) return;
const std::vector& st = snap_->stamps;
if (snap_->values[channel].size() != st.size()) return;
- const auto lo = std::lower_bound(st.begin(), st.end(), view.min);
- const auto hi = std::upper_bound(st.begin(), st.end(), view.max);
+ const auto lo = std::lower_bound(st.begin(), st.end(), window.min);
+ const auto hi = std::upper_bound(st.begin(), st.end(), window.max);
if (hi <= lo) return;
off_ = static_cast(lo - st.begin());
count_ = static_cast(hi - lo);
@@ -1101,7 +1121,7 @@ class MeasureWindowReader {
epoch_ = app.model->display_epoch();
u64 first = 0;
u64 last = 0;
- app.model->live_window_locked(view.min - epoch_, view.max - epoch_, first, last);
+ app.model->live_window_locked(window.min - epoch_, window.max - epoch_, first, last);
if (last <= first) return;
store_ = &app.model->live();
channel_ = channel;
@@ -1710,6 +1730,10 @@ gboolean mathwrap_probe(gpointer user);
gboolean mathwrap_probe_wait(gpointer user);
gboolean mathwrap_probe_report(gpointer user);
gboolean measure_probe(gpointer user);
+gboolean measurepin_probe(gpointer user);
+gboolean measurepin_held_wait(gpointer user);
+gboolean measurepin_pause_wait(gpointer user);
+gboolean measurepin_live_wait(gpointer user);
gboolean measure_trace(gpointer user);
gboolean stats_probe_report(gpointer user);
gboolean stats_probe(gpointer user);
diff --git a/src/ui_measure.cpp b/src/ui_measure.cpp
index b0bfc58..465a983 100644
--- a/src/ui_measure.cpp
+++ b/src/ui_measure.cpp
@@ -264,10 +264,13 @@ void place_measure_marks(App& app) {
app.rows->set_measure_marks(app.measure_marks_display);
}
-/// Recomputes every measurement over the visible window and writes the results into the pane,
-/// collecting the marks the ones asking to be shown need.
+/// Recomputes every measurement over the window it describes and writes the results into the
+/// pane, collecting the marks the ones asking to be shown need.
///
-/// A measurement is a statistic of the window, so it is recomputed when the *window* moves --
+/// **The window is the viewport live and the pin while the view is pinned** (see below): a still
+/// frame is measured as a frame, so on a held event or a pause a pan or a zoom changes no value.
+///
+/// A measurement is a statistic of that window, so it is recomputed when the *window* moves --
/// not on a timer. A timer decouples the two: with the window scrolling under a 5 Hz cadence
/// the stored marks describe a range the plot has already left, which is how a min/max marker
/// ended up outside the viewport, and how a dragged marker survived until the next tick. The
@@ -283,19 +286,46 @@ void update_measures(App& app, bool force, bool accumulate) {
// `[-window, 0]` that never changes while the data under it does.
const double s0 = app.model->stream_at_x0();
const plot::Range view = r0.plot->x_range(r0.axis_x);
- const plot::Range swin{view.min + s0, view.max + s0};
- app.measure_view_window = swin;
+ // The window a statistic describes. A **pinned** view -- a held event, a paused view, an opened
+ // file -- is a *still frame*: the data behind it does not change, so a pan or a zoom only looks
+ // closer and no number may move because of one. What the pane describes there is the **pin's own
+ // extent**, which is the same accessor the framing itself uses (`hold_pinned_window()` clamps a
+ // pinned view into exactly this, so the pin *is* what frames it), and it is decided here rather
+ // than inside `MeasureWindowReader` because the decision is booked as well as read: `swin` below
+ // is what the "it moved" test compares against the last result's window, and a reader that chose
+ // its own window would leave a pinned view recomputing on every gesture frame for a number that
+ // cannot change -- the 457-pass run this pane's cadence was fixed for.
+ //
+ // A live view is the other case and is unchanged: there the window genuinely moves and the
+ // statistic follows it, and the accumulator's rule about what resets the history (`measure_*`
+ // stats) is about neither -- a pan or a zoom still changes *which samples* answer a live
+ // measurement, which is what averaging over many windows is for.
+ const plot::Range pin = app.rows->pinned_range();
+ const bool pinned_view = app.rows->window_pinned() && pin.span() > 0.0 &&
+ std::isfinite(pin.min) && std::isfinite(pin.max);
+ const plot::Range window = pinned_view ? pin : view;
+ const plot::Range swin{window.min + s0, window.max + s0};
+ // Both windows of the tick, on the stream's clock: what is **on screen**, and (while pinned)
+ // what the pin *is*. The dump reads them instead of calling `stream_at_x0()` itself, which by
+ // the time it runs answers for a mode no frame was drawn in.
+ app.measure_view_window = plot::Range{view.min + s0, view.max + s0};
+ app.measure_pin_valid = pinned_view;
+ if (pinned_view) app.measure_pin_window = swin;
const bool held = app.model->display_mode() == DisplayMode::Held;
- // "It moved" is asked in **every** mode, a held event included. This test used to carry a
- // `&& !held`, and a held event is precisely the mode where a *gesture* is the only thing that
- // can move the window: with it, zooming a latched event forty times over left the pane
- // describing the whole 2 s event (`for=[0.47..2.47]` against a `swin=[1.417..1.467]`, `passes`
- // frozen), which is what "the measurements move when zooming or panning" was -- the numbers and
- // the time marks belonged to a window the plot had left. The term was aimed at a *pinned*
- // window recomputing every frame, and nothing is needed for that: a settled window satisfies
- // `swin == measure_window` exactly and so reads `moved == false` on its own. A pause is the
- // standing proof, since `display_mode()` reports `Paused` rather than `Held` for one and the
- // statistics probe already requires `n` to advance by at most one across a two-second pause.
+ // "It moved" is asked in **every** mode, a held event included -- but it is asked about the
+ // window the measurement *describes* (`swin`, the viewport live and the pin when pinned), never
+ // about the viewport. That distinction is the whole of a reported defect in both directions.
+ // This test used to compare the viewport, with a `&& !held` on it: exempting a held event left
+ // the pane describing the whole 2 s event (`for=[0.47..2.47]`) while a 50 ms slice of it was on
+ // screen, and *removing* the exemption made the opposite mistake -- the numbers followed the
+ // zoom into the slice, so an average or a top moved whenever the user moved the wheel on a
+ // frozen event, which is the report this pins down. Neither is a staleness question: on a
+ // pinned view the pin is what the pane is about, so a gesture cannot move it at all, and the
+ // numbers a gesture would have disturbed are the frame's and stay put. A pinned view still
+ // recomputes when it should -- a fresh latch, a re-stated pin, a new snapshot and the mode
+ // change below all move `swin` -- and a settled one satisfies `swin == measure_window` exactly
+ // and so reads `moved == false` on its own, which is what keeps it from recomputing every
+ // frame (457 passes in one run is the shape that mistake took).
const bool moved = app.measure_window_valid &&
std::fabs(swin.min - app.measure_window.min) +
std::fabs(swin.max - app.measure_window.max) >
@@ -445,7 +475,7 @@ void update_measures(App& app, bool force, bool accumulate) {
std::vector& buckets = app.measure_bucket_scratch;
for (App::MeasureWindow& w : app.measure_samples) {
const gint64 fetch_start_us = g_get_monotonic_time();
- MeasureWindowReader reader(app, static_cast(w.channel));
+ MeasureWindowReader reader(app, static_cast(w.channel), window);
const std::size_t n = reader.size();
const std::function time_of = [&reader](std::size_t i) {
return reader.time_of(i);
@@ -653,12 +683,21 @@ gboolean measure_trace(gpointer user) {
// the offset that converts between them: a reading that moved without the view moving is a
// change of `s0` (a new snapshot under the same mode), and only these three fields tell the two
// apart. `passes` beside them says whether a recompute answered it.
+ //
+ // `pin` is the fourth, and it is where a *pinned* view's `for` comes from: while the pin is what
+ // frames the plot the numbers describe it and not `swin`, so a gesture inside the pin leaves
+ // `for`, `passes` and every value exactly where they were. Both are captured on the tick, which
+ // is why `for` and `pin` are readable together; `view`/`s0` are read here, as they always were.
const double s0 = a->model->stream_at_x0();
std::string line = "[measure-trace] mode=" +
std::string(display_mode_name(a->model->display_mode())) + " view=[" +
std::to_string(view.min) + ".." + std::to_string(view.max) + "] s0=" +
std::to_string(s0) + " swin=[" + std::to_string(view.min + s0) + ".." +
- std::to_string(view.max + s0) + "] for=[" +
+ std::to_string(view.max + s0) + "] pin=[" +
+ (a->measure_pin_valid ? std::to_string(a->measure_pin_window.min) + ".." +
+ std::to_string(a->measure_pin_window.max)
+ : std::string("off")) +
+ "] for=[" +
std::to_string(a->measure_window.min) + ".." +
std::to_string(a->measure_window.max) + "] passes=" +
std::to_string(a->measure_passes);
diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp
index 99777e9..a2e6a41 100644
--- a/src/ui_probe.cpp
+++ b/src/ui_probe.cpp
@@ -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 before;
+ std::vector 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& values, std::vector& 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& values,
+ const std::vector& 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(view.min), static_cast(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 after;
+ std::vector 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(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(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(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(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(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(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(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(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& 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 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(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;