diff --git a/CLAUDE.md b/CLAUDE.md index 8977d0b..5fa7c1b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,9 +72,14 @@ it, and `LineProgram::set_snap()` / `u_snap` rounds an axis-aligned segment's in whole pixels for the chrome that frames a plot (grid and axis lines), never for anything drawn at a data position. `generate_ticks()` also stores the tick at the origin as exactly `0.0` — the walk starts at `ceil(min/stride)*stride` and so never lands on zero -when the stride is not a power of two, which labelled it `-139a`. All three carry +when the stride is not a power of two, which labelled it `-139a`. `format_time()` picks its +unit rung by the **rounded** mantissa (`t < u.scale * 0.9995`, and the same slack on the +whole-second branch) — the `>= 999.5` spill guard `format_si()` in the same file always had — +because a tick a hair under a rung's top rounds to 1000 at three significant figures and +`%.3g` writes that `1e+03`, which is how a one-second tick read `1e+3ms`. All four carry upstream tests — `the_atlas_is_filtered_not_point_sampled`, -`grid_hairlines_land_on_whole_pixels` and `the_tick_at_the_origin_reads_zero`. The minor +`grid_hairlines_land_on_whole_pixels`, `the_tick_at_the_origin_reads_zero` and +`format_time_never_lets_a_field_reach_its_radix`. The minor grid's look is set there too, not here: `grid_minor` carries 50% alpha, `grid_minor_width` is 0.75 (sub-pixel, expressed as coverage at 1x) and `grid_minor_dash_on`/`_off` are 3/3 — a fragment discard, no extra geometry — so the subdivisions read as texture under the labelled @@ -208,8 +213,8 @@ finding. since `set_time_window()` frames the newest span before the pin clamps it — both are expected. - `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are measured on a still picture. It now also asserts what a pause *is* - (`[pause-probe] paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..]` on - stderr): + (`[pause-probe] paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..] + glance=[..]` on stderr): it presses the **button**, not `Acquisition::set_paused()`, because pausing is a display state as well as an acquisition one and the view snapshot and axis pin live in the handler. Across the three lines **`ring_produced` must grow** (the producer never @@ -218,6 +223,17 @@ finding. the ring) and **`frozen_x` must not move between the pause and the resume**; after the resume `event_size` is 0 and the window is back on the newest data. Pausing used to stop the producer, which is why the counts grew by nothing and the resume left a hole. + `frozen_x` is the *rebased* snapshot — the pause view's own stamps are relative to the freeze + instant now, so the range reads `[-window..0]`-ish rather than stream seconds, and a + `frozen_x` in the thousands is the rebase gone. + **`glance=` is the assertion for the paused status field**, and this is the only line that can + make it: the exit dump's `status bar:` is read live and its two `mode …` lines are the live and + triggered states. While paused it must read `paused · · · for `, with + the elapsed figure **growing between the two paused lines** and **absent on the resume line**. + It is **wall** time (`g_get_monotonic_time()`), not stream time, because the producer never + stops while paused and "how long have I been looking at this" is a question about the user, not + about the stream; it is written through `format_elapsed()` so the field steps to a coarser unit + instead of growing digits (see the layout rule). - `UDPSCOPE_EXPORT_MS` (+ optional `UDPSCOPE_EXPORT_PATH`, default `/tmp/udpscope-export`) — collect the export table and write it both ways, skipping only the file chooser. @@ -258,7 +274,11 @@ Two lines of the self-check output are assertions in disguise: edge and `axis=` must stay at `[-pre..+post]` across consecutive probes while `data=`'s upper bound climbs towards `+post` — an axis that moves instead means the capture is being treated as a live view (`clamp_to_data()` reaching a pinned window). - `epoch=` must be back to `0.0000` in every other state. Note the probe reads + `epoch=` is **minus the newest acquired stamp** in `live` and `nothing` — it is what makes the + live axis `[-window, 0]`, so there it grows (in magnitude) with the stream and a `0.0000` is + the defect, not the healthy reading. It is back to `0.0000` only in `event(frozen)`, where the + snapshot carries its own origin (`EventSnapshot::x_offset`) and the display epoch has nothing + left to do. Note the probe reads `pending=` fresher than `display=` (which is last tick's decision), so a single `pending=0 display=live(capturing)` line is one frame of sampling skew, not a defect. And `snap=[a..b]` must equal `axis=[a..b]`, with `b` positive by roughly @@ -305,19 +325,24 @@ Two lines of the self-check output are assertions in disguise: `events` does not move**: no event fired, so the partial publish deliberately leaves `trigger_latches()` alone — which is also what keeps the `trigger events: N total, M at the disarm` line in the dump meaning what it says. -- `[export-probe] mode=.. axis=[a..b] rows=N cols=M time=[c..d] stream=[..]` (stderr, +- `[export-probe] mode=.. axis=[a..b] rows=N cols=M time=[c..d] abs=[..]` (stderr, `UDPSCOPE_EXPORT_MS`) — **`time=` must equal `axis=`** and **`rows=` must be the span times the stream's real rate, plus one** (2 s at 10 kS/s is 20001). The export is the *visible* range at the *stored* rate, so a count far short of that is a table cut from the LOD pyramid, and a `time=` narrower than `axis=` is a window searched on - the wrong clock (the live branch subtracts `display_epoch()` before searching the - ring). In `mode=event`, `stream=` must be the edge ± the window; in `mode=live` it - equals `time=`. - The one exception to both: an export taken **mid-capture** reports `mode=live` with - `time=[-pre..e]` where `e` is how far the post window has filled, and `stream=` offset - from it by the edge (so `stream - time` reads the edge exactly) — the export is what is - on screen, and during a capture that is a partly filled window. Pin the probe outside a - capture when checking the `rows=` count. + the wrong clock (the live branch puts the window back on the stream's clock with + `stream_at_x0()` before searching the ring, and that offset is a whole window while + streaming — not the 0 it once was). + **`abs - time` must be a single constant across both ends of a line**, and that constant is + where the display's origin sits in the stream's own clock: `epoch()` plus the newest stamp in + `mode=live`, `epoch()` plus the accepted edge in `mode=event`. That is the one assertion on + the second column, and it replaces the old "`stream=` must be the edge ± the window" reading, + which only ever held because the display clock *was* the stream clock. A `mode=live` line + whose `abs - time` differs between the two ends is the two columns computed from different + epochs — the defect `abs_base` exists as a single `const` to prevent. + An export taken **mid-capture** reports `mode=live` with `time=[-pre..e]` where `e` is how far + the post window has filled — the export is what is on screen, and during a capture that is a + partly filled window. Pin the probe outside a capture when checking the `rows=` count. The two `bytes=` lines are the writers: the `.npy` is `64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)`, so a size that is not that is a mispacked record. Read it back with `pd.DataFrame(np.load(path))` after changing @@ -401,11 +426,17 @@ Two lines of the self-check output are assertions in disguise: (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. **The reading that must hold is `marks_in_window=K/K`**: every *time* mark inside the window - on screen. `drift` itself is a **single sample of a sawtooth** and is not an assertion on its + 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 + would read no drift whatever the data did. + `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. On a 2 s window at the 100 ms cadence the top of that sawtooth - is `drift=5%`, which is what a dump landing just before a scheduled pass reads -- a `0.0000%` - is a dump that landed just after one, not a better state. Read it against the recompute count: + and drops to zero at each pass. The figure is `(|Δmin| + |Δmax|) / span`, so a pure scroll + counts **twice** and the top of the sawtooth is `2 × refresh / window`: ~10 % on a 2 s window + at the 100 ms cadence, and a few *hundred* percent on a window shorter than the interval (a + 50 ms window reads 254 % for 63.6 ms of scroll, and its ceiling is 400 %). Compute the ceiling + for the window the run actually used before calling a figure large. A `0.0000%` + 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 @@ -443,12 +474,17 @@ Two lines of the self-check output are assertions in disguise: row-level `set_time_window()` does nothing** (a probe that changes the window must use the app's path). **`distinct` must be 1 on every line, and that is the assertion** — it says no two major labels on the axis are the same string, which is the one thing the relative form exists to - buy and the only reading that holds whatever the stream's uptime is. `relative=D` deliberately - is *not* fixed per window: the form is decided by the tick step against the label resolution at - the axis' **magnitude** (see the invariant below), so the same 50 ms window reads absolute at - 2 s of uptime (`2.13s, 2.14s, 2.15s`) and relative at 17 s (`100µs, 10.1ms, 20.1ms`, with the - caption) — both correct, and a probe pinned to one of them would assert the stream's clock - rather than the app. The **caption must be non-empty exactly when `relative=1`**. Run it after + buy and the only reading that holds whatever the stream's uptime is. `relative=D` is **expected + to read 0 on every line now**, and that is a consequence of the newest-relative axis rather than + a change to the rule: the form is decided by the tick step against the label resolution at the + axis' **magnitude** (see the invariant below), and no display mode carries a large magnitude any + more — live and waiting are `[-window, 0]`, a capture and a held event are edge-relative, a pause + is freeze-relative. The 50 ms window that used to read absolute at 2 s of uptime (`2.13s, 2.14s, + 2.15s`) and relative at 17 s (`100µs, 10.1ms, 20.1ms`, with the caption) now reads + `-40ms, -30ms, -20ms, -10ms` at any uptime. The rule and the caption are kept because they are + the answer for *any* axis magnitude and nothing in them is mode-specific — do not delete them on + the strength of this reading — but what the probe asserts is `distinct`, not the form. The + **caption must be non-empty exactly when `relative=1`**, which here means empty throughout. Run it after `UDPSCOPE_ARM_TRIGGER_MS` as well, which is the case the span rule got wrong: a triggered 50 ms window must read `relative=0 note=[] ticks=[-20ms,-10ms,0,10ms]` — a `t0` caption on an axis that is already edge-relative re-anchors the ticks away from the trigger, which is the one @@ -571,7 +607,7 @@ Two lines of the self-check output are assertions in disguise: reconnects; `config=0`), and check it does **not** fire against a live source. The status line carries ` | N reconnects` only when N > 0, which is the same counter without the prose. -- **The export's `time`/`stream_time` columns must be strictly increasing and evenly +- **The export's `time`/`abs_seconds` columns must be strictly increasing and evenly spaced.** Check the written file, not just the probe: `np.diff(t)` with no `<= 0` and `rows / (t[-1] - t[0])` equal to the stored rate (the `ch` line's figure). Duplicate or backwards stamps mean the recorded timeline holds two clocks or a collapsed cadence — @@ -579,11 +615,17 @@ Two lines of the self-check output are assertions in disguise: README) and a per-sample step derived from the client's `lost` counter (now the stream's measured arrival cadence). A `rows=` count far below the span times the rate is the other signature, that of a table cut from the LOD pyramid. - **`set(np.diff(t))` must hold exactly one value**, and this is the check that needs a long - run at a high rate: a *plateau* of equal stamps, or a diff taking two or three values a few - hundred nanoseconds apart, is the time ring losing precision with uptime (see the `f64` - time-ring invariant below — the figure to compute is the ULP of `t[0]`, since the defect - grows with how long the app has run, not with the export's own length). + **The step must hold to a few ULP of the column's own magnitude**, and this is the check that + needs a long run at a high rate: the old form of this assertion was "`set(np.diff(t))` holds + exactly one value", and that was only true while `time` *was* the stored stamp. Both columns + are derived now — `time` is `stamp - stream_at_x0()` and `abs_seconds` is that plus a base of a + wholly different magnitude — so each subtraction costs a rounding and `set()` legitimately + holds a handful of neighbouring doubles. Measured: four values spanning ~1.5 ULP, 6.8e-16 s. + The thing that must not come back is the **plateau**: equal stamps, or a diff taking two or + three values a few *hundred nanoseconds* apart, is the time ring losing precision with uptime + (see the `f64` time-ring invariant below). The figure to compute is the ULP of `t[0]` and of + `abs_seconds[0]` — the defect that invariant guards is microseconds, nine orders of magnitude + above the arithmetic noise, so the two readings cannot be confused. - `badge frames: lit=L dim=D idle=I` — the activity badge's phase per frame. Over a run that acquired anything **both `lit` and `dim` must be non-zero**: that is what "it blinks" means, and a badge stuck lit is indistinguishable from a working one in a @@ -685,7 +727,7 @@ Two lines of the self-check output are assertions in disguise: it afterwards. - `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `: : ->` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values). - `[zoomkeys] ctrl+plus x=[..]->[..] y_held=1 | alt+plus y=[..]->[..] x_held=1` (stderr, `UDPSCOPE_PROBE_ZOOMKEYS_MS`) — **both `_held` must be 1 and both zooms must still happen.** Run it inside a pinned state, where a frame cannot re-frame the other axis for its own reasons: `UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200`. A `_held=0` is the modifier not reaching the plotter's controller; an unchanged range on the *zoomed* axis is the opposite failure, the key doing nothing. Ctrl restricts to x and Alt to y; Ctrl+Alt zooms neither (no special case needed — neither flag alone fires). -- `status bar: glance=[● live · 1.09 MS/s · 2 s window] max_glance_chars=53 detail_fields=11 popover_rebuilds=0` (exit dump) — **`max_glance_chars` must stay small** (under about 60) and **`popover_rebuilds` must be 0**. The status row is directly above the plot, so this is the "layout must not depend on content" rule read from the bar's own side: the state field is one word from a fixed set, the rate field is `%.3g` (so `10 kS/s`, never `10.0 kS/s`), and the only field trigger mode adds is the elapsed time, which steps units rather than growing digits. `detail_fields` is the popover's label count, a literal, so adding a label without thinking about layout shows up here. The glance rate format matters: `%.3g` drops the trailing zero that a `%.4g` or a fixed-precision format would keep — a rate that reads differently in the dump and in the help page is a format disagreement, and `14-status.txt`'s example must match it. +- `status bar: glance=[● live · 1.09 MS/s · 2 s window] max_glance_chars=53 detail_fields=11 popover_rebuilds=0` (exit dump) — **`max_glance_chars` must stay small** (under about 60) and **`popover_rebuilds` must be 0**. The status row is directly above the plot, so this is the "layout must not depend on content" rule read from the bar's own side: the state field is one word from a fixed set, the rate field is `%.3g` (so `10 kS/s`, never `10.0 kS/s`), and the two fields a *state* adds are both elapsed times — trigger mode's `last event … ago` and a pause's `for ` — which step units rather than growing digits (`format_elapsed()`). A **paused** glance is not in the dump at all: it is read by `[pause-probe]`'s `glance=` field, since the dump's own line is taken live. `detail_fields` is the popover's label count, a literal, so adding a label without thinking about layout shows up here. The glance rate format matters: `%.3g` drops the trailing zero that a `%.4g` or a fixed-precision format would keep — a rate that reads differently in the dump and in the help page is a format disagreement, and `14-status.txt`'s example must match it. The probes are the regression net — when changing UI or plot-host behaviour, extend the probe/dump in `src/ui.cpp` rather than adding a separate harness. @@ -721,13 +763,16 @@ immutable copy of the captured window; see README for why two live rings were dr What a trace binds to is `ScopeModel::display_mode()`, one of four states, and `RowSet::bind()` takes it as an argument rather than recomputing it: -- `Live` — no trigger: the live ring on the stream clock. +- `Live` — no trigger: the live ring, on the clock of the **newest sample** + (`display_epoch() == -newest`), so the axis is a fixed `[-window, 0]` and x reads as an age. - `Capturing` — an edge has been accepted (so the pre window was full) and the post window is still filling: the live ring, on the coming event's own clock (`display_epoch() == -edge`), inside an axis already pinned to `[-pre, +post]`. The post side fills in view and the marker sits at x = 0 throughout. -- `Held` — the frozen `EventSnapshot` of the last event. -- `Waiting` — armed, nothing captured yet: blank. +- `Held` — the frozen `EventSnapshot` of the last event, whose stamps are already edge-relative, + so the display epoch is 0. +- `Waiting` — armed, nothing captured yet: blank, with the live epoch (there is nothing bound, + but the axis is still a live one). **Disarm during a capture publishes what the capture got to**, then disarms: the producer cuts the window at the newest sample and that partial snapshot becomes the held event, so @@ -750,6 +795,35 @@ and hands it to `bind()`). The producer can open or close a capture between any reads, and two reads in one frame put the bind, the epoch, the pin and the status line on different clocks. +**The display clock is not the stream clock, and `ScopeModel::stream_at_x0()` is the only +conversion.** `display_x = stream_t - stream_at_x0()` in **every** mode, and the offset lives in +one of two places: a snapshot carries its own (`EventSnapshot::x_offset`, frozen with the +picture) and a live view carries it as minus `display_epoch()`. Anything crossing between the +two clocks goes through that one accessor rather than picking the branch itself — the export's +`abs_seconds` column, the pause handler putting a display window back on the ring to cut it, the +measurement marks — because there are four modes and picking the branch by hand gets one of them +wrong. It reads atomics only, so it is safe to call inside an existing `mutex()` guard. +Three things about the live epoch specifically: +- **It is written after `bind()` and `acquire_all()`, not before.** It must be minus the newest + sample the *traces* can report, and asking before the acquire is one frame of production + stale — at 1 MS/s over a 50 ms window that is a third of the screen to the right of the axis. +- **There is no feedback loop**, even though the newest stamp is read through the epoch that is + about to change: `RowSet::live_edge()` reports in the epoch currently in force, so + `display_epoch() - live_edge()` is minus the *raw* stamp and the fixed point is reached in one + frame rather than converged on. +- **`Waiting` takes the live epoch too.** Nothing is bound, but the axis is still a live one, and + an epoch of 0 there would make the axis jump on the frame a capture closes. + +**A measurement mark is glued to its sample; a cursor stays on screen.** The two instruments move +in opposite directions under a rolling axis and that is deliberate — a bench scope's cursors sit +where the user put them while the waveform rolls under, and a measurement's mark names the sample +it measured. The cursors need no code: their x *is* a display coordinate. The marks are stored in +**stream** seconds and reconverted every frame by `place_measure_marks()`, since a mark left in +display coordinates would crawl forward at one window per window. `App::measure_window` is +stream-clocked for the same reason, and that one is load-bearing: the recompute trigger is "the +window moved by 2 % of its span", and in display coordinates a live window is a fixed +`[-window, 0]` — it never moves, so the measurements would never be recomputed again. + Layers to keep straight: - `SignalInfo` — one signal as the CONFIG described it. The unit of enable/disable. @@ -1017,12 +1091,15 @@ far the largest file. the doubling against overflow); `live_window_locked()` compares its bounds in the ring's own type with **no rounding step**, the old `f32` round-trip having existed only so that a sample sitting exactly on a bound was kept whatever the stream's absolute time was; and - `EventSnapshot::stamps` is `f64` because a **pause** view is deliberately *not* rebased and - therefore carries full stream time (an event's own stamps are edge-relative and small — the - symmetry is not the reason). `RingBuffer` stores slots as `std::atomic` behind a - `static_assert(is_always_lock_free)`, which `double` satisfies on every supported target. - The guard is the **export**: `np.diff(t)` over a multi-million-row `.npy` must hold exactly - one distinct step. Three steps at 2, 2.5 and 3 ULP of the stamps' magnitude is this defect. + `EventSnapshot::stamps` is `f64` because it is cut from this ring and is therefore in the same + units, whatever a given snapshot's own origin is (both kinds are rebased now — an event's + stamps are edge-relative, a pause's freeze-relative — so neither is magnitude-bound the way the + ring is, and the width is for the cut rather than for the coordinate). `RingBuffer` stores + slots as `std::atomic` behind a `static_assert(is_always_lock_free)`, which `double` + satisfies on every supported target. The guard is the **export**: over a multi-million-row + `.npy`, `np.diff(t)` must hold one step to within a few ULP of the column's magnitude (see + the export guard above for why it is no longer exactly one value). Three steps at 2, 2.5 and + 3 ULP of *the stream's uptime* — microseconds, not femtoseconds — is this defect. - **A dropped time slot is clamped, never answered with a sentinel.** `acquire()` pins the published window to the value ring's base and both rings keep sliding afterwards — their capacities are equal, so there is no slack — which means the oldest few samples diff --git a/README.md b/README.md index bee3423..abfd71c 100644 --- a/README.md +++ b/README.md @@ -63,16 +63,66 @@ frame and the GUI thread takes once per frame around binding the traces. Samples cross the boundary through `plot::data::RingBuffer` (single producer, single consumer), never through the lock. -## Measurements +## The x axis is relative to now + +**A live axis is fixed at `[-window, 0]`, with the newest sample at 0.** That is what a bench +scope in roll mode shows and what the window control's figure means: a 2 s window reads `-2s` to +`0` and keeps reading that for as long as the stream runs, with the trace flowing right to left +under it. The axis used to carry the stream's own clock, so the labels counted the app's uptime +upwards (`20:00`, `20:01`, …) and the one number a user actually wants — how long ago a feature +happened — had to be worked out by subtraction. + +Every mode now expresses x as *seconds from the thing the view is about*, and the rule is one +line: `display_x = stream_t - ScopeModel::stream_at_x0()`, where `stream_at_x0()` is the stream +time of display x = 0. + +| display mode | x = 0 is | `stream_at_x0()` | +| --- | --- | --- | +| `Live`, `Waiting` | the newest acquired sample | the newest stamp | +| `Capturing` | the accepted edge | the edge | +| `Held` | the event's edge | the snapshot's `x_offset` | +| `Paused` | the instant of the freeze | the snapshot's `x_offset` | + +The live answer is written by the frame tick **after** `bind()` and `acquire()`, because it has to +be minus the newest sample the *traces* can report: asking the model before they are bound puts +the newest data a frame's production to the right of the axis, which at 1 MS/s over a 50 ms window +is a third of the screen. It converges in exactly one frame and cannot oscillate — `live_edge()` +reports in the epoch currently in force, so `new = old - live_edge()` is `-raw_newest` whatever the +old value was. + +A snapshot carries its own offset (`EventSnapshot::x_offset`) rather than leaning on the model's +epoch, because its coordinates outlive the epoch that produced them. One consequence is a reversal +of an earlier deliberate choice: **a pause snapshot is now rebased** like an event's. Leaving its +stamps on the stream's clock would have thrown the frozen picture a whole uptime to the right of an +axis that is now newest-relative. `x_offset` keeps the stream clock recoverable, which is what the +export's second column reads — and it fixed a latent bug on the way, the export's snapshot branch +having added `trigger_time()` to a *paused* view's stamps: an unrelated edge, or none at all before +the first latch. + +Two things move in **opposite** directions under a rolling axis, and the asymmetry is deliberate. +A **cursor** stays on screen: placed at -1.5 s it keeps meaning "1.5 s ago" and the data flows +under it, which is a bench scope's roll behaviour and is free here, the cursors already being +stored in display coordinates. A **measurement mark** is glued to the sample it measured, because +a mark is a *result* rather than an instrument — so the marks are stored in stream seconds and +reconverted every frame (`place_measure_marks()`), which is what makes them drift left with their +own data between recomputes. `App::measure_window` is on the stream's clock for a related reason: +"has the window moved" is the question that triggers a recompute, and a fixed `[-window, 0]` makes +it invisible in display coordinates, so the pane would have computed once and never again. **Pause freezes the picture, not the acquisition.** The producer keeps writing the live ring while paused — the same thing it does when the trigger is off — and the plot binds a *snapshot* of the window that was on screen at the pause, with the axes pinned there. That is the same -immutable-series path a held trigger event uses (`DisplayMode::Paused` sits beside `Held`); the -difference is the anchoring: an event's stamps are edge-relative, a pause's are the stream's own, -so the times on the axis do not change when the pause begins. Resuming drops the snapshot and +immutable-series path a held trigger event uses (`DisplayMode::Paused` sits beside `Held`). +Resuming drops the snapshot and re-frames on the newest data, so the seconds that passed while paused are *there*, not a hole — -which is what pausing used to leave, because it stopped the producer outright. +which is what pausing used to leave, because it stopped the producer outright. The status bar +says how long it has been frozen (`paused · 10 kS/s · 2 s window · for 12 s`), in **wall** +seconds: the producer never stopped, so the stream's own clock says nothing about how long the +user has been looking at a still picture. It is written through `format_elapsed()`, which steps to +a coarser unit instead of growing digits, because the status row sits directly above the plot and +a field whose width tracks the wait would relayout it. + +## Measurements **The x ticks read relative when, and only when, they could not be written absolutely.** A `TickFormat::Time` label carries three significant digits, so the test is the tick **step** @@ -103,8 +153,11 @@ annotation's anchor is a *data* coordinate, so the caption crawled across the pl drifted under a zoom, which is what it was reported as. The library test is `the_x_note_is_chrome_that_a_pan_or_a_zoom_does_not_move`: the footprint is measured as the pixels a frame with the caption differs from the same frame without one, and a pan to another decade plus -a 64× zoom must produce the same box to the pixel. Everything a user copies out — -the cursors' `T0`/`T1`/`dT`, a measurement, an export — stays in absolute stream seconds. +a 64× zoom must produce the same box to the pixel. Everything a user reads off the screen is in +the *display* coordinate the labels name — the cursors' `T0`/`T1`/`dT`, a measurement's time +marks, the crosshair — so live they are negative ages and in trigger mode they are offsets from +the edge. Only the **export** carries the stream's own clock as well, in a second column +(`abs_seconds`); see *Export*. The anchor is taken by the *axis* on every layout (`Axis::origin_follows_range`), not written by the app: the window's edge is derived from the newest sample during the layout, so a value @@ -1107,6 +1160,16 @@ A third, found the same way, is in the tick generator rather than the shaders: — where the same residue read `3.19e-13ms`. A range whose ticks are *legitimately* that small keeps them. +4. *One second was labelled `1e+3ms`.* `format_time()` picked its rung by comparing the raw + value against each unit's scale and then wrote the mantissa with `%.3g` — so a tick a hair + under a rung's top (a 1 s tick computed as 0.9999999 s, which is what differencing a live + axis gives) stayed on the rung below and its mantissa *rounded up to 1000*, which `%.3g` + writes in exponential form. The fix is to pick the rung by the **rounded** mantissa + (`t < u.scale * 0.9995`), which is exactly the `>= 999.5` spill guard `format_si()` in the + same file already had; the whole-second branch takes the same slack. The test is + `format_time_never_lets_a_field_reach_its_radix`, extended with the boundary of every rung + from seconds down to nanoseconds and its negative. + **The minor grid is texture, the labelled grid is structure.** The subdivisions used to be drawn exactly like the labelled lines — same width, same opacity — so a plot dense enough to need them read as two competing sets of ticks. They are now three things the style states @@ -1133,6 +1196,7 @@ so it is one path, and it costs what the crosshair's own lookup costs. Each carries a test upstream (`the_atlas_is_filtered_not_point_sampled`, `grid_hairlines_land_on_whole_pixels`, `the_tick_at_the_origin_reads_zero`, +`format_time_never_lets_a_field_reach_its_radix`, `a_sparse_window_between_samples_still_inks_both_margins`), and each was verified to fail with its own fix reverted. @@ -1409,16 +1473,30 @@ division. over the shared x window, at the **stored** sample rate — never the LOD pyramid's buckets, which are the envelope the renderer drew rather than the acquisition. A signal on several rows is one column; the columns come out in channel order, so the -file reads in the same order as the signal panel. The table has two time columns: -`time` is the axis as displayed (stream seconds live, `[-pre, +post]` while an event -is held) and `stream_time` is always the stream's own clock, so two exports taken in -different modes line up. +file reads in the same order as the signal panel. + +The table has **two time columns**, which is the whole answer to "relative or absolute": + +- **`time`** is the axis exactly as it was displayed — negative ages ending at 0 live, offsets + from the edge in trigger mode — so a row's `time` is the x the user read a value at. +- **`abs_seconds`** is the stream's own clock for the same sample: `epoch() + stream_at_x0() + + time[i]`, one expression correct in all four display modes (see *The x axis is relative to + now*). Two exports taken in different modes therefore line up on this column, and + `abs - time` is a constant per file naming where the display's origin sat in the stream. + +Both are `f64` seconds and not the wire's integer, because the wire's integer never exists in +this process: `udps_frame_t::values` is a `const double*`, so the time signal has already been +widened by the client before udpscope sees it. One caveat follows from that and from `time` +being a *derived* coordinate: `abs_seconds` is a sum of two numbers of very different +magnitudes, so consecutive differences wobble by a few ULP of the stream's uptime +(femtoseconds at any plausible uptime — note that the `f32` defect this used to guard against +was *microseconds*, four orders of magnitude up). Two formats, picked by the extension the save dialog ends up with: | file | what it is | |---|---| -| `.npy` | one NumPy structured array, `time`/`stream_time` as ` stamps; std::vector> values; ///< one array per channel, same length + /// The stream time of this snapshot's x = 0, so `x_offset + stamps[i]` is the clock the + /// data arrived on: the edge for a trigger event, the newest sample at the moment of the + /// freeze for a pause view. + /// + /// It exists because a snapshot is the one thing on screen whose coordinates outlive the + /// `display_epoch()` that produced them, and `ScopeModel::stream_at_x0()` is what reads + /// it: an export has to name the stream's own clock whatever is on screen, and the pause + /// handler has to put a *display* window back on the ring to cut it. Before it, the + /// export added `trigger_time()` to a pause view's stamps -- an unrelated edge, or none + /// at all -- for want of anywhere to record which clock a snapshot was on. + double x_offset = 0.0; double pre_s = 0.0; ///< seconds of pre-trigger data kept double post_s = 0.0; ///< seconds of post-trigger data kept std::vector y; ///< per-channel value bounds, precomputed diff --git a/src/export.cpp b/src/export.cpp index d9c279a..1fa269c 100644 --- a/src/export.cpp +++ b/src/export.cpp @@ -38,7 +38,7 @@ std::string sanitise(const std::string& name) { /// two time columns, with a `_2`, `_3` suffix. Two elements of one vector signal can /// easily sanitise to the same string. std::vector column_labels(const ExportTable& table) { - std::vector taken{"time", "stream_time"}; + std::vector taken{"time", "abs_seconds"}; std::vector out; out.reserve(table.columns.size()); for (const ExportTable::Column& col : table.columns) { @@ -106,6 +106,14 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) { // What is on screen: a pause's frozen view, or the trigger's held event. Both are immutable // snapshots, so the export is the picture the user is looking at either way. std::shared_ptr snap = model.display_snapshot(); + // The absolute clock of display x = 0, so `abs_base + time[i]` is the number the stream + // stamped sample i with -- **one expression, correct in all four display modes**, which is the + // whole reason `stream_at_x0()` exists. `epoch()` is the stream time of the first sample ever + // seen (the rings store stamps relative to it) and `stream_at_x0()` is the offset of the + // display's origin within that, whether it comes from a snapshot or from the live epoch. The + // snapshot branch used to add `trigger_time()` here, which named an unrelated edge on a + // *paused* export, or none at all before the first latch. + const f64 abs_base = model.epoch() + model.stream_at_x0(); if (snap != nullptr) { // The snapshot is immutable and already index-aligned, so the visible window is two // binary searches over its stamps and the same index range for every channel. @@ -115,15 +123,11 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) { if (hi <= lo) return table; const std::size_t first = static_cast(lo - st.begin()); const std::size_t n = static_cast(hi - lo); - // The event's x = 0 is the edge, so the stream clock has to be added back for the - // second column -- the one case where the visible x is not the stream's own. - const f64 edge = model.trigger_time().load(std::memory_order_relaxed); - table.time.resize(n); - table.stream_time.resize(n); + table.abs_seconds.resize(n); for (std::size_t i = 0; i < n; ++i) { table.time[i] = st[first + i]; - table.stream_time[i] = edge + table.time[i]; + table.abs_seconds[i] = abs_base + table.time[i]; } for (const int c : chans) { const std::size_t ch = static_cast(c); @@ -141,12 +145,12 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) { // the same window hold the same samples. The lock is against a structural change // dropping the rings under the copy, which is what the UI thread has to fear here. std::lock_guard lock(model.mutex()); - // The axis reads `display_epoch + stamp`, so the window has to be put back on the - // stream's clock before the ring is searched. It is 0 in live mode, but reading it is - // what keeps this correct rather than merely true today. - const f64 epoch = model.display_epoch(); + // The axis reads `stamp - stream_at_x0()`, so the window has to be put back on the stream's + // clock before the ring is searched -- and that offset is a whole window while streaming now, + // not the 0 it once was: the live axis is `[-window, 0]` about the newest sample. + const f64 s0 = model.stream_at_x0(); u64 first = 0, last = 0; - model.live_window_locked(view.min - epoch, view.max - epoch, first, last); + model.live_window_locked(view.min + s0, view.max + s0, first, last); const u64 n = last - first; if (n == 0) return table; @@ -154,10 +158,10 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) { std::vector stamps(static_cast(n)); store.time().copy_from(first, n, stamps.data()); table.time.resize(static_cast(n)); - table.stream_time.resize(static_cast(n)); + table.abs_seconds.resize(static_cast(n)); for (std::size_t i = 0; i < stamps.size(); ++i) { - table.stream_time[i] = stamps[i]; - table.time[i] = epoch + table.stream_time[i]; + table.time[i] = stamps[i] - s0; + table.abs_seconds[i] = abs_base + table.time[i]; } for (const int c : chans) { const std::size_t ch = static_cast(c); @@ -179,7 +183,7 @@ bool write_npy(const std::string& path, const ExportTable& table, std::string& e // `descr` as a list of (name, format) pairs is a *packed* structured dtype -- NumPy // only inserts alignment padding when asked to (`align=True`) -- so the records below // are field widths back to back with no gaps. - std::string descr = "[('time', ' labels = column_labels(table); - std::string line = "time,stream_time"; + std::string line = "time,abs_seconds"; for (const std::string& label : labels) line += "," + label; line += "\n"; std::fwrite(line.data(), 1, line.size(), out.f); @@ -252,7 +256,7 @@ bool write_csv(const std::string& path, const ExportTable& table, std::string& e line.clear(); std::snprintf(buf, sizeof(buf), "%.17g", table.time[i]); line += buf; - std::snprintf(buf, sizeof(buf), "%.17g", table.stream_time[i]); + std::snprintf(buf, sizeof(buf), "%.17g", table.abs_seconds[i]); line += ","; line += buf; for (const ExportTable::Column& col : table.columns) { diff --git a/src/export.hpp b/src/export.hpp index 2cb2b37..8708510 100644 --- a/src/export.hpp +++ b/src/export.hpp @@ -24,13 +24,22 @@ struct ExportTable { std::vector values; ///< `rows()` long, the ring's own `f32` }; - /// The x the plot shows and the crosshair reads: stream seconds in live mode, - /// edge-relative seconds in `[-pre, +post]` while an event is held. + /// The x the plot shows and the crosshair reads, which is **relative** in every mode: seconds + /// before now while streaming (so the newest sample is 0 and the window is `[-window, 0]`), + /// seconds from the edge in `[-pre, +post]` while an event is held, and seconds from the + /// instant of the freeze on a paused view. std::vector time; - /// The stream's own clock, so two exports taken in different modes line up. Equal to - /// `time` in live mode; `time + trigger_time` while an event is held, which is the one - /// case where the visible x is not the stream's. - std::vector stream_time; + /// The stream's own absolute clock -- the number the time signal carried, which is what the + /// source stamped the samples with -- so two exports taken in different modes, or from + /// different runs, line up on one timeline. + /// + /// `f64 seconds` rather than the wire's integer, because the integer does not exist in this + /// process: `udps_frame_t::values` is a `const double*`, so the client has already widened + /// every value by the time acquisition sees it. At Unix-epoch magnitudes an `f64` resolves + /// about 240 ns, which is coarser than a 1 MS/s sample period -- so this column is the + /// *provenance* of a row and `time` is the column to difference. Its own `np.diff` is + /// legitimately not single-valued for that reason. + std::vector abs_seconds; std::vector columns; std::size_t rows() const noexcept { return time.size(); } @@ -45,7 +54,7 @@ struct ExportTable { /// model access. In trigger mode nothing is locked: the `EventSnapshot` is immutable. ExportTable collect_export(ScopeModel& model, RowSet& rows); -/// Writes a NumPy `.npy` file holding one structured array: `time` and `stream_time` as +/// Writes a NumPy `.npy` file holding one structured array: `time` and `abs_seconds` as /// `--buf the drawing thread reads whatever that ring holds when a frame starts, which is why a trace never makes the window stutter. -The stream is the clock. Every time you read on screen is a timestamp the source sent, never -your machine's, so a stalled source freezes the view instead of scrolling it -- and a source -whose clock steps still gets the window you asked for. +The stream is the clock. Every interval you read on screen is measured between timestamps the +source sent, never your machine's, so a stalled source freezes the view instead of scrolling it +-- and a source whose clock steps still gets the window you asked for. + +The time axis is relative to now. While streaming it is a fixed [-window .. 0] +with the newest sample at 0, so x reads as an age: -1.5s is a sample a second and a half +old. In trigger mode 0 is the event's edge instead, and a paused view keeps the ages it was +frozen with. The source's own clock is still there when you need it -- an export writes it as a +second column. How to reach it diff --git a/src/help/06-navigate.txt b/src/help/06-navigate.txt index 6f0c8a0..81f8f0b 100644 --- a/src/help/06-navigate.txt +++ b/src/help/06-navigate.txt @@ -24,6 +24,11 @@ The full table is in the shortcut overview (Ctrl+/). zoom, so a 2 s window zoomed into reads 2 s again. The window is more than the axis: it is also the trigger's event span and how the samples are stored, so a wheel notch that redefined it would re-plan the buffer and drop your history. +• The axis does not scroll — the data does. A live axis is a fixed [-window .. 0] + with the newest sample at 0, so the labels stand still and the trace rolls under them. The + cursors stand still with the labels (they are positions on screen, so a cursor you placed stays + where you put it, over newer data each frame), while a measurement's marks travel with the + sample they measured and leave the view when it does. • A live view is clamped to the window you asked for, not to what the ring happens to hold. That is why a buffer that has only just started filling leaves its left-hand side empty instead of squeezing the window down to the first few samples. diff --git a/src/help/07-pause.txt b/src/help/07-pause.txt index 41942f4..d73ebeb 100644 --- a/src/help/07-pause.txt +++ b/src/help/07-pause.txt @@ -20,6 +20,8 @@ live ring and puts the window back on the newest data. zoom out is clamped to the snapshot's data. • 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 itself never + stopped. It is there so a frozen picture cannot be mistaken for a live one that went quiet. Example diff --git a/src/help/09-cursors.txt b/src/help/09-cursors.txt index 300e25a..10bb69f 100644 --- a/src/help/09-cursors.txt +++ b/src/help/09-cursors.txt @@ -37,6 +37,11 @@ owns a Reset button. Drag a marker to move it. The mode dropdown offers f is drawn larger than a trace's sample dots so it does not read as one more sample. • The time row exists only in the modes that show the vertical pair. horizontal has no time row, by design. +• A cursor stays where you put it while a live view rolls under it, which is what a bench + scope does: its T0 is an age, so a marker left at -1.5s keeps reading the + sample a second and a half old as the stream advances. A measurement's marks do the opposite — + they travel with the sample they measured — because a cursor is a question you asked and a mark + is an answer about one particular sample. Example diff --git a/src/help/12-export.txt b/src/help/12-export.txt index 2b7c8a0..822ddec 100644 --- a/src/help/12-export.txt +++ b/src/help/12-export.txt @@ -17,15 +17,17 @@ the format follows the path's extension. • The stored rate is not always the wire rate. A window long enough to need decimation is stored averaged at a lower rate, and the export carries that — read the status bar's ⓘ popover for the figure before trusting a sample count. -• Two time columns. time is the display timeline (relative to the trigger edge while an - event is held) and stream_time is the source's own clock. Both are strictly increasing - and evenly spaced; duplicate or backwards stamps would mean the file held two clocks. +• Two time columns. time is the display timeline — negative ages ending at 0 while + streaming, offsets from the trigger edge while an event is held — so it is the x you read a + value at on screen. abs_seconds is the source's own clock for the same sample, which + is what lets two exports taken in different modes line up. Both are strictly increasing and + evenly spaced; duplicate or backwards stamps would mean the file held two clocks. • An export taken mid-capture writes the partly filled window that is on screen, which is shorter than the event span. Export outside a capture if you want the whole event. Example A 2 s window at 10 kS/s over 3 traces writes 20001 rows and 5 columns -(time, stream_time, three signals). The .npy is +(time, abs_seconds, three signals). The .npy is 64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols) bytes. Read it back with pd.DataFrame(np.load(path)). diff --git a/src/help/14-status.txt b/src/help/14-status.txt index 7cdbc5f..43dc32a 100644 --- a/src/help/14-status.txt +++ b/src/help/14-status.txt @@ -12,8 +12,10 @@ The bar is always visible under the plot. Click ⓘ for the detail popove • The badge is one glyph in three colours: lit and dim alternating means data is arriving, and a steady idle colour means it is not. A badge that never blinks is not a working badge. • The state field is one word from a fixed set: live, paused, armed, - capturing, event held, disarmed. In trigger mode the bar adds - exactly one more field, how long ago the last event was. + capturing, event held, disarmed. Two states add one field each: in + trigger mode, how long ago the last event was; while paused, how long you have been paused. +• The paused figure is wall time, not stream time — the producer never stops while paused, + so it answers "how long have I been looking at this picture". • Nothing on the bar may change width with the stream, because the bar sits directly above the plot and a wider label would relayout it. That is why the rate collapses to one figure or a range rather than listing per channel, and why an elapsed time steps to a coarser unit instead diff --git a/src/model.cpp b/src/model.cpp index ae4993f..41f8f0d 100644 --- a/src/model.cpp +++ b/src/model.cpp @@ -515,8 +515,9 @@ void ScopeModel::reset_live_history_locked() { } } -void ScopeModel::publish_pause_view(double lo_s, double hi_s) { +void ScopeModel::publish_pause_view(double lo_s, double hi_s, double x_offset) { auto snap = std::make_shared(); + snap->x_offset = x_offset; std::lock_guard lock(mutex_); BufferStore& src = live(); u64 first = 0; @@ -525,10 +526,14 @@ void ScopeModel::publish_pause_view(double lo_s, double hi_s) { const u64 n = last - first; snap->stamps.resize(static_cast(n)); if (n > 0) { - // The stamps go in *as the ring holds them*: the stream's own seconds, which is the - // coordinate the axis was already reading. Nothing is rebased, so a pause does not move the - // dates under the reader the way a trigger event's edge-relative axis deliberately does. + // The stamps come out of the ring on the stream's own clock and are rebased onto + // `x_offset`, which is the coordinate the axis was *already* reading: a live view is + // newest-relative, so leaving them absolute would throw the frozen picture a whole uptime + // to the right of the axis at the instant the user pressed pause. Nothing is lost by the + // shift -- `x_offset` is exactly what makes the stream's time recoverable, which is what + // `stream_at_x0()` reads it for. src.time().copy_from(first, n, snap->stamps.data()); + for (f64& t : snap->stamps) t -= x_offset; } snap->values.resize(channels_.size()); snap->y.resize(channels_.size(), plot::Range{0.0, 0.0}); @@ -551,6 +556,7 @@ void ScopeModel::publish_pause_view(double lo_s, double hi_s) { void ScopeModel::publish_event(double pre_s, double post_s, double edge) { auto snap = std::make_shared(); + snap->x_offset = edge; // the stream time x = 0 means, for everything that has to cross back BufferStore& src = live(); plot::data::RingBuffer& tsrc = src.time(); diff --git a/src/model.hpp b/src/model.hpp index 77a4b8f..a77fa56 100644 --- a/src/model.hpp +++ b/src/model.hpp @@ -290,16 +290,17 @@ class ScopeModel { void live_window_locked(double lo_s, double hi_s, u64& first, u64& last); /// The frozen view taken when the user paused, or null while running. A pause is a /// snapshot of the *visible window* in exactly the sense a trigger event is -- an immutable - /// series the plot binds to -- and the difference is the anchoring: an event's stamps are - /// edge-relative, a pause's are the stream's own, so the axis does not move when the pause - /// begins and the reader keeps the times they were already reading. + /// series the plot binds to -- and both are anchored the same way: the stamps are the + /// coordinates the axis was already reading, with `x_offset` recording which instant that + /// x = 0 was. So the axis does not move when the pause begins. std::shared_ptr pause_view() const noexcept { return pause_view_.load(std::memory_order_acquire); } - /// Snapshots the live ring over `[lo_s, hi_s]` (stream seconds, the display epoch already - /// removed) as the pause view. The acquisition thread keeps writing, so this is a copy: what - /// is frozen is the picture, not the data. - void publish_pause_view(double lo_s, double hi_s); + /// Snapshots the live ring over `[lo_s, hi_s]` (stream seconds, i.e. the display window put + /// back on the stream's clock) as the pause view, rebased onto `x_offset` so its stamps are + /// the x the axis is already showing. The acquisition thread keeps writing, so this is a + /// copy: what is frozen is the picture, not the data. + void publish_pause_view(double lo_s, double hi_s, double x_offset); void clear_pause_view() noexcept { pause_view_.store(nullptr, std::memory_order_release); } /// Whether the user paused acquisition. Lives here rather than on `Acquisition` because the @@ -378,12 +379,30 @@ class ScopeModel { return event() != nullptr ? DisplayMode::Held : DisplayMode::Waiting; } - /// The x origin the plot uses: 0 for "seconds since the stream started", or minus - /// the trigger instant while in trigger mode, so the axis reads seconds from the - /// event. Applied to the live store's channels. + /// The offset the **live** channels report their x with: `display_x = stream_t + epoch`. + /// Minus the newest stored sample's stamp while streaming, so the axis reads + /// `[-window, 0]` and the newest data is at 0; minus the trigger instant while a capture + /// is filling, so the axis reads seconds from the event. It is 0 while a *snapshot* is on + /// screen (a held event, a paused view), because then the live channels are bound to + /// nothing and the snapshot's own `x_offset` is what says which clock x = 0 is -- + /// `stream_at_x0()` is the accessor that does not have to know which of the two it is. f64 display_epoch() const noexcept { return display_epoch_.load(std::memory_order_relaxed); } void set_display_epoch(f64 e) noexcept; + /// The stream time of the display's x = 0, so `stream_t = display_x + stream_at_x0()` and + /// `display_x = stream_t - stream_at_x0()` in **every** display mode. + /// + /// One accessor for the two places the offset can live: a snapshot carries its own + /// (`EventSnapshot::x_offset`, frozen with the picture), and a live view carries it as + /// minus the display epoch. Everything that has to cross between the two clocks goes + /// through here -- the export's absolute column, and the pause handler putting a display + /// window back on the ring to cut it -- rather than picking the branch itself and getting + /// one of the four modes wrong. + f64 stream_at_x0() const noexcept { + if (std::shared_ptr snap = display_snapshot()) return snap->x_offset; + return -display_epoch(); + } + // --- stream state --------------------------------------------------------- StreamStats& stats() noexcept { return stats_; } const StreamStats& stats() const noexcept { return stats_; } diff --git a/src/plot_host.hpp b/src/plot_host.hpp index f9f77fa..fe92a30 100644 --- a/src/plot_host.hpp +++ b/src/plot_host.hpp @@ -178,6 +178,14 @@ class RowSet { void set_time_window(double seconds); double time_window() const noexcept { return window_s_; } + /// The newest x any row's live traces hold, which is what the link group follows -- and, since + /// it is reported in the display epoch currently in force, also what the frame tick subtracts + /// from that epoch to park the newest sample at x = 0. It must be read **after** `bind()` and + /// `acquire()`: it asks the bound series, so before them it answers for the previous frame's + /// window, and a frame's production at 1 MS/s over a 50 ms view is a third of the screen. + /// False when no row holds a streaming trace. + bool live_edge(double& newest) const; + void set_y_auto(int row, bool on); void set_y_range(int row, double lo, double hi); @@ -191,7 +199,13 @@ class RowSet { void place_crossings(); CursorMode cursor_mode() const noexcept { return cursor_mode_; } - /// The two vertical cursors' stream times; shared by every row. + /// The two vertical cursors' positions, in the **display** x the axis reads -- not stream + /// seconds, which they coincided with only while `display_epoch()` was always 0. A live view + /// is newest-relative now, so a cursor left at -1.5 s keeps meaning "1.5 s ago" and the data + /// flows under it, which is what a bench scope's roll mode does and is the behaviour that was + /// asked for. They are read back out of the plots and written to them unchanged, so there is + /// nothing to convert; anything that needs the stream's own clock goes through + /// `ScopeModel::stream_at_x0()`. Shared by every row. double cursor_a_x() const noexcept { return cursor_a_x_; } double cursor_b_x() const noexcept { return cursor_b_x_; } /// One row's ruler values, or 0 for a row that does not exist. @@ -399,8 +413,6 @@ class RowSet { /// at the end of the gesture (`Controller::end_drag()`), and a value read out of the plot /// mid-drag has already been through that act. void place_cursors(Row& r, plot::CursorPlacement placement = plot::CursorPlacement::Snapped); - /// The newest x any row's live traces hold, which is what the group follows. - bool live_edge(double& newest) const; std::unique_ptr figure_; /// The one x link group every visible row belongs to. Groups are numbered from 1. diff --git a/src/status_text.cpp b/src/status_text.cpp index bf178e7..1b2bc5b 100644 --- a/src/status_text.cpp +++ b/src/status_text.cpp @@ -48,6 +48,11 @@ std::string format_glance(const StatusSnapshot& s) { out += " · "; out += s.window_text; out += " window"; + if (s.paused) { + // Directly after the state word's own field rather than at the end, because it qualifies + // *that* word: "paused · 10 kS/s · 2 s window · for 12 s" reads as one statement. + out += " · for " + format_elapsed(s.paused_for_s); + } if (s.trigger_on) { out += " · "; if (s.have_event) { @@ -74,6 +79,7 @@ std::string format_detail(const StatusSnapshot& s) { s.plot1_traces, s.plot1_samples, s.buffer_mb, s.span_s, static_cast(s.time_stride)); std::string out = buf; + if (s.paused) out += " | paused for " + format_elapsed(s.paused_for_s); if (s.trigger_on) { out += s.have_event ? " | last event " + format_elapsed(s.since_event_s) + " ago" : " | no event yet"; diff --git a/src/status_text.hpp b/src/status_text.hpp index 8c6426a..cfa31af 100644 --- a/src/status_text.hpp +++ b/src/status_text.hpp @@ -44,10 +44,20 @@ struct StatusSnapshot { bool have_event = false; double since_event_s = 0.0; int reconnects = 0; + + /// How long the view has been frozen, in **wall** seconds, and whether it is. The producer + /// never stops while paused -- it keeps filling the live ring, which is what makes the resume + /// seamless -- so the stream's own clock says nothing about how long the user has been looking + /// at a still picture. Written through `format_elapsed()` like the event age, for the same + /// reason: it steps to a coarser unit instead of growing digits, and the bar is directly above + /// the plot. + bool paused = false; + double paused_for_s = 0.0; }; -/// The bar: "live · 10 kS/s · 2 s window", plus " · event 4.2 s ago" in trigger mode and -/// nothing else ever. The badge glyph is a separate widget and is not part of this string. +/// The bar: "live · 10 kS/s · 2 s window", plus " · event 4.2 s ago" in trigger mode, " · for +/// 12 s" while paused, and nothing else ever. The badge glyph is a separate widget and is not +/// part of this string. std::string format_glance(const StatusSnapshot& s); /// The popover's prose and the exit dump's line, field for field as the old single-line bar diff --git a/src/ui.cpp b/src/ui.cpp index e3fddf2..26679ba 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -137,6 +137,12 @@ struct App { GtkWidget* trigger_holdoff_spin = nullptr; GtkWidget* signal_list = nullptr; GtkWidget* pause_button = nullptr; + /// When the pause began, on the **monotonic wall clock**, or 0 while running. The elapsed time + /// the status bar shows has to be wall time rather than stream time: the producer never stops + /// while paused (it keeps filling the live ring, which is what makes the resume seamless), so + /// the stream's own clock advances whether the view is frozen or not and would say nothing + /// about how long the user has been looking at a still picture. + gint64 pause_started_us = 0; GtkWidget* menu_button = nullptr; GtkWidget* cursors_toggle = nullptr; /// Where the panel controls ended up, decided once at build time: the dump runs after the window @@ -198,6 +204,12 @@ struct App { std::vector measure_text; /// The window the current results were computed over, and whether a capture was held then: /// a recomputation is owed when either moves (see `update_measures`). + /// + /// It is kept in **stream** seconds (through `ScopeModel::stream_at_x0()`), not in the display x + /// it was read as, because a live view is newest-relative: the axis there is a fixed + /// `[-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. plot::Range measure_window{}; bool measure_window_valid = false; bool measure_was_held = false; @@ -245,7 +257,17 @@ struct App { std::vector measure_last_valid; /// The marks the last computation produced: re-placed every frame, which is what makes the /// measurement cursors read-only. + /// + /// A **time** mark's `value` is held in stream seconds, like `measure_window`, and converted to + /// the axis' own x by `place_measure_marks()` on the frame it is placed. That is what keeps a + /// mark glued to the sample it measured while a live view rolls the data leftwards under it -- + /// a mark is a *result*, so it travels with its datum, which is the opposite of a cursor (an + /// instrument the user put at a position and that stays there). A level mark's `value` is a y + /// and has nothing to do with either clock. std::vector measure_marks; + /// `measure_marks` with the time marks converted to display x, rebuilt in place each frame + /// rather than allocated: this is on the frame tick's path. + std::vector measure_marks_display; /// What the dump prints for the header and the panels. Captured while the window is alive: /// the dump runs from the destroy handler, where every widget call is a GTK-CRITICAL and /// reads nothing. @@ -1978,8 +2000,16 @@ void on_pause_toggled(GtkToggleButton* button, gpointer user) { // 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); - const double epoch = app->model->display_epoch(); - app->model->publish_pause_view(view.min - epoch, view.max - epoch); + // 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(); + 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 // the view, and pinning the request left the shortfall as an empty left margin. plot::Range pin = view; @@ -1994,6 +2024,7 @@ void on_pause_toggled(GtkToggleButton* button, gpointer user) { } else { app->model->clear_pause_view(); app->rows->unpin_pause_window(); + app->pause_started_us = 0; } gtk_button_set_icon_name(GTK_BUTTON(button), paused ? "media-playback-start-symbolic" : "media-playback-pause-symbolic"); @@ -2016,7 +2047,12 @@ void on_trigger_toggled(GtkToggleButton* button, gpointer user) { app->capturing = false; if (!on) { app->model->trigger_time_valid().store(false, std::memory_order_relaxed); - app->model->set_display_epoch(0.0); // back to seconds since the stream started + // The epoch is *not* written here: the frame tick owns it and states the live answer -- minus + // the newest acquired sample -- after it has bound and acquired, which is the only moment the + // right number is knowable. This used to set it to 0 ("seconds since the stream started"), + // which is no longer a display coordinate anything uses. Nothing is left stale in the + // meantime: the group follows the live edge again from here, and `Figure::layout()` re-derives + // the range from it on every frame. app->rows->set_time_window(app->rows->time_window()); // and follow the live edge } if (on && signal >= 0) { @@ -2876,6 +2912,19 @@ void open_measure_dialog(App& app, int edit_index) { gtk_window_present(GTK_WINDOW(window)); } +/// Puts `App::measure_marks` on the plots, converting each **time** mark from the stream seconds +/// it is stored in to the x the axis is reading this frame. Called every frame -- that is what +/// makes the marks read-only (a gesture moves one, and this puts it back) and, since a live axis +/// is newest-relative, also what rolls each mark leftwards with the sample it measured. +void place_measure_marks(App& app) { + const double s0 = app.model->stream_at_x0(); + app.measure_marks_display = app.measure_marks; + for (RowSet::MeasureMark& mk : app.measure_marks_display) { + if (!mk.level) mk.value -= s0; + } + 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. /// @@ -2890,12 +2939,17 @@ void update_measures(App& app, bool force) { if (app.measure_labels.size() != app.measures.size()) return; // mid-rebuild const RowSet::Row& r0 = app.rows->row(0); if (r0.plot == nullptr) return; + // The window on the **stream's** clock, which is the only space in which "it moved" is a + // question with an answer: a live axis is newest-relative, so `view` itself is a fixed + // `[-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}; const bool held = app.model->display_mode() == DisplayMode::Held; const bool moved = app.measure_window_valid && !held && - std::fabs(view.min - app.measure_window.min) + - std::fabs(view.max - app.measure_window.max) > - 0.02 * std::max(1.0e-12, view.span()); + std::fabs(swin.min - app.measure_window.min) + + std::fabs(swin.max - app.measure_window.max) > + 0.02 * std::max(1.0e-12, swin.span()); // A measurement is a statistic of the visible window, and *a live view moves that window every // frame* -- so "recompute when the window moves" is "recompute every frame", at O(visible // samples) per measurement. That is what stalled the frame tick (0.4 ms -> 13.7 ms, a third of @@ -2918,16 +2972,16 @@ void update_measures(App& app, bool force) { // lag on it is not readable. const bool changed = !app.measure_window_valid || held != app.measure_was_held || moved; if (!force && !changed) { - app.rows->set_measure_marks(app.measure_marks); + place_measure_marks(app); return; } if (!force && moved && held == app.measure_was_held && !due) { // Nothing to recompute -- but the marks are still re-placed below, which is what makes // them read-only: a gesture moves one on the plot, and this is what puts it back. - app.rows->set_measure_marks(app.measure_marks); + place_measure_marks(app); return; } - app.measure_window = view; + app.measure_window = swin; app.measure_window_valid = true; app.measure_was_held = held; app.measure_stamp_us = now_us; @@ -3131,11 +3185,15 @@ void update_measures(App& app, bool force) { const plot::u32 color = measure_color(i); for (const MeasureMarkSpec& spec : mk->marks) { if (!spec.level && !r.valid) continue; - marks.push_back({row, spec.level, spec.value, color}); + // A time mark is stored on the **stream's** clock -- the walks report display x, so `s0` + // puts it back -- and `place_measure_marks()` converts it again on every frame it draws. + // That is what keeps the mark on its own sample as a live view rolls underneath it; a level + // mark is a y and is stored as it comes. + marks.push_back({row, spec.level, spec.level ? spec.value : spec.value + s0, color}); } } app.measure_marks = std::move(marks); - app.rows->set_measure_marks(app.measure_marks); + place_measure_marks(app); } /// The measurements panel: a header with the `[+]`, and one row per measurement. @@ -4865,16 +4923,37 @@ gboolean tick(gpointer user) { // UI's own structural changes: the bind takes shared handles into the live // store, and acquire() reads it. std::lock_guard lock(model.mutex()); - // Mid-capture the live ring is drawn on the coming event's clock, so its edge sits at - // x = 0 exactly as a snapshot's does and the pinned [-pre, +post] axis means the same - // thing in both. Every other mode reads stream seconds. The epoch is a consumer-side - // offset on the live channels, so it has to be in place before the bind that hands - // them to the traces -- and it is written here, under the lock, because a rebuild can - // replace the store. - const double want_epoch = display == DisplayMode::Capturing ? -cap_edge : 0.0; - if (model.display_epoch() != want_epoch) model.set_display_epoch(want_epoch); app->rows->bind(model, display); app->rows->acquire(model); + // What x = 0 means, written *after* the bind and the acquire on purpose: the live answer + // is minus the newest sample the traces can now report, and asking before they were bound + // would put the newest data a frame's production to the right of the axis -- a third of + // the screen at 1 MS/s over a 50 ms window. The epoch is only read on the consumer side + // (it offsets the live channels' x), so nothing between here and the next frame's bind + // depends on it having been set earlier. It is written under the lock because a rebuild + // can replace the store it is pushed into. + // + // Three cases, one rule -- `display_x = stream_t + epoch`: + // * streaming, the newest stored sample sits at x = 0, so the axis is a fixed + // [-window, 0] and the data flows leftwards through it. A scope's time base is "how + // long ago", and the stream's own clock is an uptime nobody reads off an axis. + // * mid-capture, the coming event's edge sits at x = 0 exactly as the published + // snapshot's will, so the pinned [-pre, +post] means the same thing in both and the + // picture does not jump at the latch. + // * with a *snapshot* on screen (a held event, a paused view) the live channels are + // bound to nothing and the snapshot carries its own `x_offset`, so the epoch is 0 and + // `ScopeModel::stream_at_x0()` is what crosses between the clocks. + double want_epoch = 0.0; + if (display == DisplayMode::Capturing) { + want_epoch = -cap_edge; + } else if (model.display_snapshot() == nullptr) { + // `live_edge()` reports in the epoch currently in force, so subtracting it is minus the + // raw stamp: the fixed point is reached in one frame rather than converged on. + double newest = 0.0; + want_epoch = app->rows->live_edge(newest) ? model.display_epoch() - newest + : model.display_epoch(); + } + if (model.display_epoch() != want_epoch) model.set_display_epoch(want_epoch); } // The gesture probe's verdicts, one frame after the ask and after this frame's clamp: a @@ -5468,6 +5547,12 @@ gboolean tick(gpointer user) { ? double(g_get_monotonic_time() - app->last_latch_us) / 1.0e6 : 0.0; snap.reconnects = static_cast(st.reconnects.load(std::memory_order_relaxed)); + // Paused *and* with a recorded start: the button's handler is what stamps it, so a pause + // arriving any other way (there is none today) reports the state without a duration rather + // than counting from the epoch. + snap.paused = paused && app->pause_started_us != 0; + snap.paused_for_s = + snap.paused ? double(g_get_monotonic_time() - app->pause_started_us) / 1.0e6 : 0.0; const std::string glance = udpscope::format_glance(snap); app->dbg_glance_last = glance; @@ -6625,8 +6710,11 @@ gboolean measure_trace(gpointer user) { line += " " + std::string(measure_type_name(a->measures[i].type)) + "=" + (r.valid ? std::to_string(r.primary) : std::string("--")); } - line += " marks=" + std::to_string(a->measure_marks.size()); - for (const RowSet::MeasureMark& m : a->measure_marks) { + // The marks as *placed*, not as stored: a time mark is kept on the stream's clock and converted + // for the axis each frame (see `place_measure_marks()`), and it is the converted value that is + // comparable with the `view=` printed beside it. + line += " marks=" + std::to_string(a->measure_marks_display.size()); + for (const RowSet::MeasureMark& m : a->measure_marks_display) { line += " " + std::string(m.level ? "level" : "time") + ":" + std::to_string(m.value); } std::fprintf(stderr, "%s\n", line.c_str()); @@ -7039,11 +7127,14 @@ void pause_probe_report(App& a, const char* what) { const RowSet::Row& r0 = a.rows->row(0); axis = r0.plot->x_range(r0.axis_x); } + // The bar's own string, because "how long have I been paused" is a *status* field and the + // elapsed figure is the one part of it that no other line can show: the exit dump's + // `status bar:` is read live and the two `mode ...` lines are the live and triggered states. std::fprintf(stderr, "[pause-probe] %-9s paused=%d ring_produced=%zu event_size=%zu " - "frozen_x=[%.6f..%.6f] axis=[%.6f..%.6f]\n", + "frozen_x=[%.6f..%.6f] axis=[%.6f..%.6f] glance=[%s]\n", what, a.model->paused() ? 1 : 0, ring_produced, event_size, bounds_lo, bounds_hi, - double(axis.min), double(axis.max)); + double(axis.min), double(axis.max), a.dbg_glance_last.c_str()); std::fflush(stderr); } @@ -7115,13 +7206,13 @@ gboolean export_probe(gpointer user) { const ExportTable table = collect_export(*a->model, *a->rows); std::fprintf(stderr, "[export-probe] mode=%s axis=[%.6f..%.6f] rows=%zu cols=%zu time=[%.6f..%.6f] " - "stream=[%.6f..%.6f]\n", + "abs=[%.6f..%.6f]\n", a->model->trigger_enabled() && a->model->event() != nullptr ? "event" : "live", axis.min, axis.max, table.rows(), table.columns.size(), table.rows() != 0 ? table.time.front() : 0.0, table.rows() != 0 ? table.time.back() : 0.0, - table.rows() != 0 ? table.stream_time.front() : 0.0, - table.rows() != 0 ? table.stream_time.back() : 0.0); + table.rows() != 0 ? table.abs_seconds.front() : 0.0, + table.rows() != 0 ? table.abs_seconds.back() : 0.0); for (const ExportTable::Column& col : table.columns) { std::fprintf(stderr, "[export-probe] column '%s' n=%zu\n", col.name.c_str(), col.values.size()); } @@ -7587,10 +7678,13 @@ gboolean measures_probe_step(gpointer user) { // A gesture on a measurement's own cursor, placed through the plot exactly as the // controller would: the next frame has to put it back, which is what "read-only" means // here (the library has no flag the hit test would respect). - if (!a->measure_marks.empty()) { + // The marks as *placed* throughout: the drag writes a plot cursor and the check below reads + // one back, so both sides have to be in the plot's own coordinates -- and a time mark is + // stored on the stream's clock (see `place_measure_marks()`). + if (!a->measure_marks_display.empty()) { RowSet::Row& r0 = a->rows->row(0); if (r0.plot != nullptr) { - const RowSet::MeasureMark& mk = a->measure_marks.front(); + const RowSet::MeasureMark& mk = a->measure_marks_display.front(); if (mk.level) { r0.plot->set_cursor_y(r0.measure_h[0], mk.value + 3.0, plot::CursorPlacement::Free); } else { @@ -7603,9 +7697,13 @@ gboolean measures_probe_step(gpointer user) { default: { double restored = -1.0; double want = 0.0; - if (!a->measure_marks.empty()) { + // Both read *now*: a time mark rolls leftwards with its sample on a live axis, so the value + // placed on the most recent frame is the only one the plot's cursor can be expected to hold + // -- comparing against the value the drag was launched from would read 300 ms of scroll as a + // failure to restore. + if (!a->measure_marks_display.empty()) { const RowSet::Row& r0 = a->rows->row(0); - const RowSet::MeasureMark& mk = a->measure_marks.front(); + const RowSet::MeasureMark& mk = a->measure_marks_display.front(); want = mk.value; if (r0.plot != nullptr) { restored = mk.level ? r0.plot->cursor(r0.measure_h[0]).y @@ -7614,7 +7712,7 @@ gboolean measures_probe_step(gpointer user) { } std::fprintf(stderr, "[measures-probe] %-14s marks=%zu first=%.6f plot_now=%.6f restored=%d\n", - "after drag", a->measure_marks.size(), want, restored, + "after drag", a->measure_marks_display.size(), want, restored, std::fabs(restored - want) < 1.0e-9 ? 1 : 0); std::fflush(stderr); measure_dialog_report(*a, "final"); @@ -8384,8 +8482,13 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit // `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`). + const double s0 = model.stream_at_x0(); const RowSet::Row& r0 = rows.row(0); - const plot::Range win = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x) : plot::Range{}; + const plot::Range view = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x) : plot::Range{}; + const plot::Range win{view.min + s0, view.max + s0}; const double span = app.measure_window.span(); int in_window = 0; int marks = 0;