3 Commits
Author SHA1 Message Date
Martino FerrariandClaude Opus 4.6 62df049f53 fix: the plot's two side margins are balanced, and the dump outlives its widgets
With the axis titles gone the left gutter was 200 px of an 870 px viewport and the right
side had no margin at all, so the newest tick's label was drawn half outside the plot and
the newest sample's ink landed on the frame.

Two causes on the left. The reservation covered `format_si()`'s `%.3g` fallback
(`-1.23e-45`), which no signal on an `Si` axis produces -- the widest real label for a
unitless axis is `-1.23m` -- and `note_y_gutter()` added 6 px of slack to a figure that is
already the maximum of the labels and last frame's floor, making the gutter a ratchet that
climbed to its 200 px cap in ~19 frames. It settles at 104 px now, with `axes.w changes: 1`
where it read 17.

The right side did not exist: a plot's `axes` rect reached the viewport's right edge by
construction. `deps/plotter` gains `Plot::set_right_gutter()` and `Figure::set_right_gutter()`
-- a want rather than a measurement, since nothing is drawn there, imposed figure-wide so
the rows end on one column as they already start on one. udpscope asks for half the widest
time label plus the label padding (59 px), x labels being centred on their tick.

Separately: the exit dump asked two destroyed widgets for their size, which is where the
"exactly two Gtk-CRITICALs" baseline came from. The pointer is freed memory by then, so a
run that had destroyed other windows first (UDPSCOPE_PROBE_HELP_MS opens three) segfaulted
in the middle of the dump instead. Both sizes are captured on the tick; a clean run now
carries no critical.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 22:34:10 +02:00
Martino Ferrari 811c6d80f0 fix: a source that has sent no CONFIG reads connecting, not connected
A stream that answered CONNECT but has sent no CONFIG has no signal list, so
nothing can be enabled and no DATA frame is owed. Calling that "connected, but
no data is arriving: another reader may hold the source" blamed a second reader
that does not exist, and the badge blinked green over it -- a working pipeline
is exactly what it did not have.

The glance word is `connecting` (ten characters, no wider than `event held`,
so the fixed-set layout rule still holds) and the badge takes a fourth, steady
yellow phase, both from the first frame rather than after the three-second
stall test. The stall notice itself now splits on `have_config`: the unicast
contention wording where a signal list exists, "trying to connect" where it
does not, with `[udpscope] connecting:` on stderr beside the old `starved:`.
2026-10-05 22:08:13 +02:00
Martino FerrariandClaude Opus 4.6 b313cac46e feat: the x axis is relative to now, and an export carries both clocks
Three asked-for changes, one commit because they are one coordinate system.

The live axis is a fixed [-window, 0] with the newest sample at 0, so x reads
as an age and the labels stand still while the trace rolls under them. That
makes the display clock distinct from the stream clock for the first time, so
`ScopeModel::stream_at_x0()` becomes the single conversion between them
(`display_x = stream_t - stream_at_x0()` in all four modes) and a snapshot
carries its own origin in `EventSnapshot::x_offset`. The pause view is now
rebased like an event's, reversing an earlier deliberate choice: a frozen
absolute-stamped picture would sit a whole uptime right of a newest-relative
axis. That also fixed a latent bug, the export adding `trigger_time()` to a
*paused* view's stamps.

The status bar says how long you have been paused -- wall time, since the
producer never stops, through `format_elapsed()` so the field cannot widen.

The export writes two time columns: `time`, the axis as displayed, and
`abs_seconds`, the stream's own clock for the same sample. f64 seconds and
not the wire's integer, because `udps_frame_t::values` is a `const double*`
and the integer never exists in this process.

Cursors and measurement marks move in opposite directions under the rolling
axis, deliberately: a cursor is a position on screen and stays put, a mark
names a sample and travels with it. `App::measure_window` is stream-clocked
for a sharper reason -- a display-coordinate live window never moves, so the
"the window moved" recompute would never fire again.

Moves the deps/plotter pin to 58473db, which stops a time label's mantissa
reaching its own radix: a one-second tick differenced off a live axis read
`1e+3ms`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 21:49:12 +02:00
20 changed files with 795 additions and 232 deletions

No files matched your search

+200 -61
View File
@@ -62,7 +62,11 @@ app's plain `GtkApplicationWindow` is supported and warning-free; it was checked
`deps/plotter` is upstream's own repository and this app's figure support was added
there rather than worked around here: `Figure::set_min_y_gutter()` (a floor under the
common left gutter), and in the GTK4 adapter `PlotArea`'s **figure mode** —
common left gutter), `Figure::set_right_gutter()` / `Plot::set_right_gutter()` (an empty
band on the *other* side, which the layout had no concept of — `axes` reached the
viewport's right edge, so the rightmost x tick label was centred on a line it had half of
itself outside of; the guard is `figure_rows_share_one_right_gutter`), and in the GTK4
adapter `PlotArea`'s **figure mode** —
`set_figure()`, one `input::Controller` per row, nearest-row event routing, a
figure-wide crosshair — plus `PlotArea::row_at()` for naming the row a pointer is over.
@@ -72,9 +76,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
@@ -105,9 +114,18 @@ Two baselines every run is read against. A clean build emits **five** warnings a
`-Woverlength-strings` on **both** generated GResource `.c` files — the help pages' and the
icons', whose blob crossed C99's 4095-byte string-literal limit when the two app icons joined the
four action ones (a note about a generated file, which GCC itself supports). And an exit-dump run
emits exactly **two** `Gtk-CRITICAL`s, `gtk_widget_get_height` and `gtk_widget_get_width`, from
the dump asking a destroyed widget for its size. A sixth warning, or a third critical, is a
finding.
emits **no** `Gtk-CRITICAL` at all. A sixth warning, or any critical, is a finding.
That second baseline used to be "exactly two, `gtk_widget_get_height` and `gtk_widget_get_width`,
from the dump asking a destroyed widget for its size", and tolerating them was a mistake: the dump
runs from the destroy handler, so the widget pointer is **freed memory**, and a critical is only
the lucky reading of it. A run that had built and destroyed other windows first —
`UDPSCOPE_PROBE_HELP_MS`, which opens three — has had that memory reused, and the same two calls
then **SIGSEGV in the middle of the dump**, which reads as a truncated dump rather than as a
crash in a debug line. Every size the dump prints is therefore captured on the *tick*, while the
window is alive (`App::dbg_*`), and **no `gtk_widget_*` call may appear in the dump** — the
`panel controls:` line was already written that way for the same reason, and that comment is the
precedent, not an exception.
- `UDPSCOPE_EXIT_AFTER_MS` — run the trigger-on → go-live probe, close the window,
then dump the whole pipeline state to stdout (frame/sample counts, per-plot painted
@@ -208,8 +226,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] <action> paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..]` on
stderr):
(`[pause-probe] <action> 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 +236,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 · <rate> · <window> · for <elapsed>`, 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.
@@ -235,10 +264,18 @@ Two lines of the self-check output are assertions in disguise:
monotone (`plot_host.cpp`), its one-shot widest-label reservation can land a frame or
two after the first measured frame, and the figure serves its floor out of the label
strip (`y_width = max(measured, floor - y_title_w)`, `plot.cpp`) so the reservation
converges over two layouts rather than one. A `y-gutter 44..74` dragging
`axes.x 67..80`/`axes.w 546..559` with it is that settle, not a twitch — confirm it
converges over two layouts rather than one. A `y-gutter 88..104` dragging
`axes.x 88..104`/`axes.w 707..782` with it is that settle, not a twitch — confirm it
with the `changes` line below. A range that keeps moving is the "twitching axis" bug:
the plot being re-measured every frame. **`x-desync` must read 0**: it counts frames
the plot being re-measured every frame. **A range that keeps *climbing* to 200 is the
other one**: `note_y_gutter()` is handed the gutter the row was laid out with, which
already includes the floor imposed last frame, so slack added to it is added again every
frame and the gutter ratchets to its own 200 px cap — `axes.x 88..200 / y-gutter 88..200
/ axes.w changes: 17` on an 870 px viewport is what that reads as, and 17 changes looks
like a settle. The reservation's own padding is the only slack there may be.
`axes.w` also no longer reaches the viewport's right edge: `apply_gutters()` reserves an
empty band there (~59 px of 870), so `viewport.w - axes.x - axes.w` is a *constant*, not
a zero. **`x-desync` must read 0**: it counts frames
in which any row's x range differed from row 0's by more than a pixel of data, i.e.
frames the x link group did not reach. `x-ticks` is content, not layout, and
legitimately varies by one as a live
@@ -258,7 +295,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 +346,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 +447,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 +495,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
@@ -563,15 +620,25 @@ Two lines of the self-check output are assertions in disguise:
first run's values, and the signals it names must come back *acquired* (a `row 0 slot 0
-> channel ... samples=N` line). Delete the file before a run whose layout you did not
mean to inherit (`127.0.0.1_9540.toml` and friends accumulate otherwise).
- `[udpscope] starved: connected (config=D), reconnects=N, no new frames in the last
- `[udpscope] starved: connected (config=1), reconnects=N, no new frames in the last
second ...` (stderr, once per second while the state holds, plus a status-line notice) —
"connected but nothing is arriving after a reconnect", which over unicast is the second
instance taking the source's single session. Two ways to see it without a second client:
point the app at a UDP port where nothing answers (the client CONNECTs, times out,
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
instance taking the source's single session. 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 same stall test has two outcomes, split on `have_config`, and they are different
diagnoses.** With a signal list in hand, the above. With **no CONFIG at all** it prints
`[udpscope] connecting: connected (config=0), reconnects=N, no configuration from the
stream ...` and the notice reads *trying to connect* — nothing was taken away, because
with no signal list nothing can be enabled and no DATA frame is owed, and the old wording
blamed a second reader that does not exist. That state is also named *without* waiting for
the three-sample stall: the glance word is `connecting` and the badge is **yellow** from the
first frame (see `badge frames:`). The way to see it with no second client is to point the
app at a UDP port where nothing answers (the client CONNECTs, times out, reconnects), which
is now the `config=0` branch and no longer exercises the `starved:` one at all — a check of
the unicast-contention wording needs a real second reader, or a source that configures and
then goes quiet.
- **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,18 +646,30 @@ 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).
- `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
**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 connecting=C` — 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
screenshot. One or two `idle` frames at the start are the frames before the socket
connected; a large `idle` is correct only when the run paused or disarmed (a
`UDPSCOPE_PROBE_DISARM_MS` run should show hundreds). The `mode ...` lines carry the
rest of the status line, so `rate` and `last event ... ago` are checked there.
`UDPSCOPE_PROBE_DISARM_MS` run should show hundreds). **`connecting` must be 0 on a run
against a live source** — it is the steady *yellow* phase, "the socket answered but the
stream has sent no CONFIG", and a non-zero count against a working mock means a signal list
that arrived late or not at all. It is the *only* non-zero phase on a dead-port run
(measured: `lit=0 dim=0 idle=90 connecting=446`, the `idle` being the frames before the
first CONNECT), which is also the check that a source that has sent nothing never gets the
blinking green. The `mode ...` lines carry the rest of the status line, so `rate` and
`last event ... ago` are checked there.
- `frame cost: tick A/B ms over N, draw C/D ms (max at frame F), paint E/G ms over M` and
`frame path: raw=R lod=L frames, segments S/T (mean/max), visible max V samples` — the
drawing cost, split into the frame tick, `PlotArea`'s own draw (timed from the frame
@@ -685,7 +764,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 `<key>: <widget-description>: <before>-><after>` — 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 (`connecting`, `live`, `paused`, `armed`, `capturing`, `event held`, `disarmed` — a word added to it must be **no wider than the widest already there**, which is what let `connecting` join at ten characters beside `event held`; the dead-port glance reads `max_glance_chars=51` against a live run's 54), 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 <elapsed>` — 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 +800,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 +832,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 +1128,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<T>` stores slots as `std::atomic<T>` 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<T>` stores
slots as `std::atomic<T>` 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
@@ -1082,8 +1196,18 @@ far the largest file.
Its pan clamping is likewise gated on the data being wider than the view, or the empty
left-hand side drags the live edge off the right-hand edge.
- **Layout must not depend on content.** The y gutter is reserved at the widest label
`TickFormat::Si` can produce and only ever grows; the status label is ellipsized so
`TickFormat::Si` can produce *for an axis with no unit* — a sign, three significant
figures and a one-glyph prefix, measured and not counted in characters — and only ever
grows; the status label is ellipsized so
its text cannot drive the paned divider. Both exist to stop per-frame relayout.
The reservation deliberately does **not** cover `format_si()`'s `%.3g` fallback
(`-1.23e-45`), which it used to: that form needs a value past 1e24, so it bought a third
of the gutter for a label no stream produces, and the floor being a monotone maximum over
the *measured* need means one that did appear widens the gutter for good anyway. Erring
wide is not free here — it is a third of the plot's width.
And **nothing may add slack to the gutter it is told**: `note_y_gutter()`'s argument is
`layout().y_axis_width`, which is `max(measured, floor)` — partly our own answer coming
back — so a constant added to it compounds. See the ratchet reading under Verification.
The same rule is why the activity badge is one glyph in three colours rather than
three glyphs, why the `rate` field collapses to one figure or a range instead of a
per-channel list, and why `format_elapsed()` steps to a coarser unit instead of
@@ -1093,6 +1217,16 @@ far the largest file.
plotter for this), not on the rows: `Figure::layout()` clears every row's
`set_min_y_gutter()` to measure what its own labels need, so a floor written onto the
rows survives exactly until the next layout.
- **The right-hand band is a want, and the left gutter is a measurement.** The two sides of
the plot are not symmetric in kind: the left holds the y labels, so it is measured and
floored; the right holds nothing, so there is nothing to measure and `RowSet` simply asks
for half the widest time label plus the label padding (`right_gutter_px_`, reserved in the
same one-shot block). Half a label is the figure that matters: an x tick label is
*centred* on its tick and a live axis is `[-window, 0]`, so there is always a tick exactly
on the axes' right edge, and with `axes` flush to the viewport half of that label was
drawn outside the plot. Both go on the **figure** and for the same reason — the rows have
to end on one column as well as start on one — which is why `apply_gutters()` applies the
pair and `rebuild_all()` calls it: a new `Figure` carries neither.
- **Signals start disabled** — enabling is what starts acquisition. A trigger on a
signal nobody acquires can never fire, so selecting/arming enables it.
- **The trigger's event window is separate from the display window**, and
@@ -1604,9 +1738,14 @@ far the largest file.
Those two used to read the *label*, and the label no longer carries `packets`, `gaps`,
`plot 1:` or the buffer figures — so a `gtk_label_get_text()` there would silently reduce
every documented `mode …:` assertion to four fields. The glance line's state field is **one
word from a fixed set** (`live`, `paused`, `armed`, `capturing`, `event held`, `disarmed`) for
the layout reason above; a seventh word, or a field whose width tracks the stream, breaks the
row the plot is laid out under.
word from a fixed set** (`connecting`, `live`, `paused`, `armed`, `capturing`, `event held`,
`disarmed`) for the layout reason above; a word **wider than the widest already there**, or a
field whose width tracks the stream, breaks the row the plot is laid out under. The set may
grow — `connecting` was added for the no-CONFIG state — but only on that condition, and the
check is the dump's `max_glance_chars`. `connecting` is derived from `have_config` alone and
sits **ahead of every other branch**, including the display mode's: with no signal list, every
other word claims something that does not exist yet. The `mode` prose branches the same way for
the same reason, which is what keeps the two tiers from disagreeing.
- **The ⓘ popover's widgets are built once and only their text is set.** It is visible when the
tick updates it, so a rebuild is a per-frame relayout of a widget on screen.
`update_status_popover()` only ever calls `gtk_label_set_text()`, and `popover_rebuilds` in
+157 -23
View File
@@ -41,6 +41,15 @@ both states, the frame and per-channel sample counts, each plot's painted-frame
count, which channel each trace slot holds, and the allocated size and reveal
state of both side panels.
Every size it prints is read from a value the frame tick captured while the window was alive, not
from the widget: the dump runs from the destroy handler, where the widget pointer is already freed
memory. It used to ask two of them directly, which is where the long-standing "an exit-dump run
emits exactly two `Gtk-CRITICAL`s" baseline came from — and a critical was only the lucky reading.
`UDPSCOPE_PROBE_HELP_MS` builds and destroys three windows of its own before the dump, so by then
the memory had been reused and the same two calls **crashed in the middle of the dump**, which
looks like a truncated log rather than like a segfault in a debug line. The dump now makes no
`gtk_widget_*` call at all and a clean run carries no critical.
## Layout
```
@@ -63,16 +72,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 +162,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
@@ -942,13 +1004,21 @@ connection, the measured rate, the packet/frame/gap counters, what plot 1 holds
the buffer's size and span. Three of those are worth calling out:
- **The badge blinks green at 1 Hz while the pipeline is actually running** — the
stream is connected, acquisition is not paused, and in trigger mode the trigger is
armed. Anything else leaves it a steady grey, with the reason on its tooltip
(`paused`, `trigger disarmed`, `waiting for the stream`). *Blinking* is the signal,
not the colour: a steady badge is a stopped pipeline, and that distinction survives
a screenshot. It is one glyph whose colour is all that changes, so blinking it
stream is connected, a CONFIG has arrived, acquisition is not paused, and in trigger
mode the trigger is armed. Anything else leaves it steady, with the reason on its
tooltip (`paused`, `trigger disarmed`, `waiting for the stream`). *Blinking* is the
signal, not the colour: a steady badge is a stopped pipeline, and that distinction
survives a screenshot. It is one glyph whose colour is all that changes, so blinking it
cannot change its width — a badge that grew and shrank twice a second would move the
label beside it and re-lay out the plot with it.
There are two steady colours, and the difference is how far the handshake got.
**Grey** is a socket with nothing behind it, or a pipeline the user stopped.
**Yellow** is a source that answered CONNECT but has sent **no CONFIG**: there is no
signal list, so nothing can be enabled and no DATA frame is owed. The glance line reads
`connecting` in both, because neither has the signal list every other state word claims;
the colour is what separates "no socket yet" from "a socket and no stream behind it". A
blinking green over a source that has sent nothing would claim a working pipeline, which
is the one reading the badge exists to prevent.
- **`rate 9.99 kS/s` is the rate the buffer actually stored over the last second**, not
the rate the CONFIG declared. The two agree unless the requested window is too long
to hold at the wire rate, in which case the ingest resamples (see "A window longer
@@ -1022,13 +1092,40 @@ which is how the second one was found:
what its labels need, so a floor written onto the rows would survive exactly
until the next layout. The figure seeds its common gutter — the maximum over
the rows, which is also what aligns their axes — with that reservation.
The reservation is the widest label `format_si()` writes for an axis with no
unit (a sign, three significant figures and a one-glyph prefix), not the
wider `%.3g` fallback it used to cover: that form needs a value past 1e24, so
it cost a third of the gutter for a label no real signal produces — and since
the floor is a monotone maximum over the *measured* need, one that did appear
would widen the gutter for good anyway.
2. *The status line drove the pane width.* A `GtkLabel`'s minimum width is its
text, and the status line is rewritten every frame; as its counters and
messages changed width they moved the paned divider, which resized the plot,
which re-laid out the axes and their ticks. The label is now ellipsized, so its
minimum is “…”; the plot keeps the width it was given.
3. *The gutter was a ratchet.* `note_y_gutter()` is handed the gutter the row was
**laid out with** — already the maximum of its labels and the floor imposed on
the frame before — and it added six pixels of slack to it. Those six pixels came
back next frame and were added again, so the left margin climbed a decade of
pixels a second until it hit the 200 px cap and stopped: 200 px of an 870 px
viewport, with `axes.w changes: 17` beside it, which reads as a settle and is a
runaway that ran out of room. The reservation's own padding is the slack, once;
nothing is added to a measurement that is partly our own answer.
The self-check reports both. `viewport.w` must collapse to a single value, and
**The two sides are balanced.** The axes rect used to reach the viewport's right
edge, because that is all the library's layout had ever reserved — so the
rightmost x tick label, *centred* on its tick, had half of itself outside the plot
(a live axis is `[-window, 0]`, so there is always a tick exactly on that edge) and
the newest sample's ink landed on the frame. `Plot::set_right_gutter()` and
`Figure::set_right_gutter()` were added upstream for this: an empty band, served
from the same half-viewport allowance as the label and title strips, set once on
every row because the rows' axes have to end on a common column as well as start on
one. `RowSet` asks for half the widest time label plus the label padding, which is
exactly what the last label needs to be drawn whole. With the two fixes above the
figure now sits in a 104 px left gutter holding the y labels and a 59 px empty band
on the right, where it was 200 px and nothing.
The self-check reports all of it. `viewport.w` must collapse to a single value, and
`axes.x`, `axes.w` and `y-gutter` must settle: the one-shot label reservation
lands a few frames after the first measured frame, and because the figure serves
its floor out of the label strip the reservation converges over two layouts, so
@@ -1039,9 +1136,9 @@ bug. `x-ticks` is content, and varies by one as a live window slides over a roun
number:
```
layout over 952 frames: axes.x 67..80, axes.w 546..559, viewport.w 627..627,
y-gutter 44..74, x-ticks 20..21, x-desync 0
axes.w changes: 2 (last at frame 35, side panel up=1)
layout over 583 frames: axes.x 88..104, axes.w 707..782, viewport.w 870..870,
y-gutter 88..104, x-ticks 20..41, x-desync 0
axes.w changes: 1 (last at frame 1, side panel up=1)
```
Set `UDPSCOPE_PROBE_PAUSE_MS=<ms>` to pause the acquisition part-way through a
@@ -1107,6 +1204,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 +1240,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.
@@ -1358,7 +1466,7 @@ them all with nothing to propagate by hand. The live follow is the *group's*
(`set_x_link_follow`), not each row's — `Figure::link_x()` refuses a member whose
own axis still follows, because a following axis would reinterpret the pushed
range as its own span and offset behind its own edge, which is per-row drift by
construction. The figure also owns the left gutter and the crosshair, so a
construction. The figure also owns both gutters and the crosshair, so a
vertical feature lands in the same pixel column in every row.
The self-check's layout line carries `x-desync N`, the number of frames in which
@@ -1409,16 +1517,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 `<f8` and every signal as `<f4` — the width the ring stores, so the file invents no precision and is about half an all-`f8` one. `pd.DataFrame(np.load(path))` is the whole reader. |
| `.npy` | one NumPy structured array, `time`/`abs_seconds` as `<f8` and every signal as `<f4` — the width the ring stores, so the file invents no precision and is about half an all-`f8` one. `pd.DataFrame(np.load(path))` is the whole reader. |
| `.csv` | the same table as text, `%.17g` for the times and `%.9g` for the values: the shortest widths that round-trip each type exactly. |
Parquet (the original ask) would have meant linking Arrow — tens of megabytes of
@@ -1487,7 +1609,8 @@ fails validation renders a one-line stand-in so the window always opens rather t
no `Gtk-CRITICAL`.
**The status bar is two tiers.** The bar itself shows one glance line: the activity badge, the
display state (one word from a fixed set), the stored rate and the window. Everything else —
display state (one word from a fixed set — `connecting`, `live`, `paused`, `armed`, `capturing`,
`event held`, `disarmed`, none wider than ten characters), the stored rate and the window. Everything else —
packet and frame counts, gaps, buffer size and span, per-plot trace counts — is behind the ⓘ
button at the bar's right-hand end, opening a popover. The two moved there because a bar that
carried all of them was too wide to read while watching a signal, and because the bar sits
@@ -1572,6 +1695,17 @@ status line as `N reconnects` once it is nonzero) it says so in the status line
leaving an empty plot to be puzzled over, and prints a `starved:` line to stderr while the
state holds.
That notice is **split on whether a CONFIG ever arrived**, because the two states are
different diagnoses and only one of them is about another reader. With a signal list in
hand, a stream that stops is the fight above: something took the session away, and the
notice says so (`starved:` on stderr). With **no CONFIG at all** nothing was ever taken —
there is no signal list, so nothing can be enabled and no DATA frame is owed — and the app
says `trying to connect` instead (`connecting:` on stderr), with the glance line reading
`connecting` and the badge yellow from the first frame rather than after the three-second
stall test. Calling that one "connected, but no data is arriving" blamed a second reader
that does not exist; the usual cause is a streamer that is not running, or one serving a
different port.
## Limitations
- **Multi-element (vector/matrix) signals are acquired with a zero-order hold.**
+1 -1
+16 -6
View File
@@ -26,14 +26,24 @@ using plot::u64;
/// live ring buffers switched by a pending flag, plus a display-epoch shift)
/// could not stop.
struct EventSnapshot {
/// Seconds, monotonic -- edge-relative for a trigger event, the stream's own for a
/// pause view. **f64, like the live ring it is cut from** (see `BufferStore::time()`):
/// an event's stamps are small enough that f32 would nearly do, but a *pause* is
/// deliberately not rebased, so its stamps carry the full stream time and quantise at
/// the same 3.8 us per 32 s of uptime the live ring did. Keeping the two the same type
/// also makes the cut in `publish_event()`/`publish_pause_view()` a straight copy.
/// Seconds, monotonic, and **in the coordinates the axis reads**: a trigger event's
/// stamps are edge-relative and a pause view's are relative to the instant it froze,
/// which is what `x_offset` below records. **f64, like the live ring it is cut from**
/// (see `BufferStore::time()`), which also makes the cut in `publish_event()`/
/// `publish_pause_view()` a straight copy.
std::vector<f64> stamps;
std::vector<std::vector<f32>> 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<plot::Range> y; ///< per-channel value bounds, precomputed
+23 -19
View File
@@ -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<std::string> column_labels(const ExportTable& table) {
std::vector<std::string> taken{"time", "stream_time"};
std::vector<std::string> taken{"time", "abs_seconds"};
std::vector<std::string> 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<const EventSnapshot> 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<std::size_t>(lo - st.begin());
const std::size_t n = static_cast<std::size_t>(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<std::size_t>(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<std::mutex> 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<f64> stamps(static_cast<std::size_t>(n));
store.time().copy_from(first, n, stamps.data());
table.time.resize(static_cast<std::size_t>(n));
table.stream_time.resize(static_cast<std::size_t>(n));
table.abs_seconds.resize(static_cast<std::size_t>(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<std::size_t>(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', '<f8'), ('stream_time', '<f8')";
std::string descr = "[('time', '<f8'), ('abs_seconds', '<f8')";
for (const std::string& label : labels) descr += ", ('" + label + "', '<f4')";
descr += "]";
std::string header = "{'descr': " + descr + ", 'fortran_order': False, 'shape': (" +
@@ -211,7 +215,7 @@ bool write_npy(const std::string& path, const ExportTable& table, std::string& e
std::size_t at = 0;
std::memcpy(record.data() + at, &table.time[i], sizeof(f64));
at += sizeof(f64);
std::memcpy(record.data() + at, &table.stream_time[i], sizeof(f64));
std::memcpy(record.data() + at, &table.abs_seconds[i], sizeof(f64));
at += sizeof(f64);
for (const ExportTable::Column& col : table.columns) {
const f32 v = i < col.values.size() ? col.values[i] : 0.0f;
@@ -239,7 +243,7 @@ bool write_csv(const std::string& path, const ExportTable& table, std::string& e
if (!opened(out, path, error)) return false;
const std::vector<std::string> 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) {
+16 -7
View File
@@ -24,13 +24,22 @@ struct ExportTable {
std::vector<f32> 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<f64> 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<f64> 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<f64> abs_seconds;
std::vector<Column> 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
/// `<f8`, every signal as `<f4` -- the width the ring actually stores, so the file is
/// about half the size of an all-`f8` one and invents no precision.
///
+9 -3
View File
@@ -6,9 +6,15 @@ Acquisition runs on its own thread and writes a ring buffer sized from <tt>--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 <i>relative to now</i>. While streaming it is a fixed <tt>[-window .. 0]</tt>
with the newest sample at 0, so x reads as an age: <tt>-1.5s</tt> 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.
<b>How to reach it</b>
+4 -1
View File
@@ -1,7 +1,10 @@
<b>Rows, layout and drag &amp; drop</b>
The figure is a stack of rows, 1 to 8 of them, each holding up to 4 traces. Rows share one
time axis and one left gutter, so their y labels line up and their windows cannot drift apart.
time axis and one pair of side margins, so their y labels line up, their data areas start and
end on the same two columns, and their windows cannot drift apart. The left margin holds the y
labels; the right one is empty, and is there so the newest tick's label and the newest sample's
ink have somewhere to land instead of sitting on the frame.
<b>How to reach it</b>
+5
View File
@@ -24,6 +24,11 @@ The full table is in the shortcut overview (<tt>Ctrl+/</tt>).
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 <i>data</i> does. A live axis is a fixed <tt>[-window .. 0]</tt>
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.
+2
View File
@@ -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.
<b>Example</b>
+5
View File
@@ -37,6 +37,11 @@ owns a <b>Reset</b> 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. <tt>horizontal</tt> has no
time row, by design.
• A cursor <i>stays where you put it</i> while a live view rolls under it, which is what a bench
scope does: its <tt>T0</tt> is an age, so a marker left at <tt>-1.5s</tt> 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.
<b>Example</b>
+6 -4
View File
@@ -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 <i>that</i> — read the status bar's ⓘ popover
for the figure before trusting a sample count.
• Two time columns. <tt>time</tt> is the display timeline (relative to the trigger edge while an
event is held) and <tt>stream_time</tt> 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. <tt>time</tt> 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. <tt>abs_seconds</tt> 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 <i>mid-capture</i> 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.
<b>Example</b>
A 2 s window at 10 kS/s over 3 traces writes 20001 rows and 5 columns
(<tt>time</tt>, <tt>stream_time</tt>, three signals). The <tt>.npy</tt> is
(<tt>time</tt>, <tt>abs_seconds</tt>, three signals). The <tt>.npy</tt> is
<tt>64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)</tt> bytes. Read it back with
<tt>pd.DataFrame(np.load(path))</tt>.
+18 -8
View File
@@ -9,11 +9,19 @@ The bar is always visible under the plot. Click <b>ⓘ</b> for the detail popove
<b>Worth knowing</b>
• 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: <tt>live</tt>, <tt>paused</tt>, <tt>armed</tt>,
<tt>capturing</tt>, <tt>event held</tt>, <tt>disarmed</tt>. In trigger mode the bar adds
exactly one more field, how long ago the last event was.
• The badge is one glyph in four colours: green <i>alternating</i> lit and dim means data is
arriving, and a steady colour means it is not. A badge that never blinks is not a working badge.
Of the two steady colours, <i>grey</i> is no stream at all or a pipeline you stopped (the
tooltip says which), and <i>yellow</i> is a source that answered but has sent no configuration.
• The state field is one word from a fixed set: <tt>connecting</tt>, <tt>live</tt>,
<tt>paused</tt>, <tt>armed</tt>, <tt>capturing</tt>, <tt>event held</tt>, <tt>disarmed</tt>. Two
states add one field each: in trigger mode, how long ago the last event was; while paused, how
long you have been paused.
• <tt>connecting</tt> means there is no <i>signal list</i> yet — either no session or a session
with no configuration behind it. Every other word claims a signal list, so until one arrives
there is nothing for them to describe: nothing can be enabled and no data is owed.
• The paused figure is <i>wall</i> 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
@@ -22,9 +30,11 @@ The bar is always visible under the plot. Click <b>ⓘ</b> for the detail popove
non-zero count means samples are absent from the buffer, not merely late.
• <tt>reconnects</tt> counts sessions re-established with the source. Over unicast a climbing
count usually means a second client is taking the source's single session.
• The starvation notice — <i>connected, but no new frames in the last second</i> — is that same
situation seen from the other side. Pointing the app at a port where nothing answers produces
it too.
• The starvation notice — <i>connected, but no data is arriving</i> — is that same situation seen
from the other side: the signal list is here, so something took the stream away. A source that
has sent <i>no</i> configuration gets a different notice, <i>trying to connect</i>, because
nothing was taken away — pointing the app at a port where nothing answers is that case, and the
usual cause is a streamer that is not running or is serving another port.
• <tt>stored</tt> versus <tt>wire</tt> in the popover is the decimation. They differ exactly
when the window is too long to store at full rate.
+10 -4
View File
@@ -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<EventSnapshot>();
snap->x_offset = x_offset;
std::lock_guard<std::mutex> 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<std::size_t>(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<EventSnapshot>();
snap->x_offset = edge; // the stream time x = 0 means, for everything that has to cross back
BufferStore& src = live();
plot::data::RingBuffer<f64>& tsrc = src.time();
+29 -10
View File
@@ -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<const EventSnapshot> 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<const EventSnapshot> snap = display_snapshot()) return snap->x_offset;
return -display_epoch();
}
// --- stream state ---------------------------------------------------------
StreamStats& stats() noexcept { return stats_; }
const StreamStats& stats() const noexcept { return stats_; }
+28 -12
View File
@@ -125,9 +125,9 @@ bool RowSet::remove_row(int at) {
void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
r.plot = &p;
r.axis_x = r.plot->add_x_axis(
{"time", {-1.0, 1.0}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
{"", {-1.0, 1.0}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
r.axis_y = r.plot->add_y_axis(
{"value", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
{"", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t i = static_cast<std::size_t>(s);
r.plot->add_trace(
@@ -199,7 +199,7 @@ void RowSet::rebuild_all() {
}
figure_->link_x(static_cast<plot::u32>(i), x_group_);
}
if (y_gutter_px_ > 0.0) apply_y_gutter();
apply_gutters();
// A new figure starts with no window at all, so the one the rows were showing has to
// be restated -- as the pin it was, if an event is on screen: assigning a trace or
// changing the row count while a capture is displayed must not throw the view back
@@ -844,10 +844,16 @@ void RowSet::fit_all() {
}
void RowSet::note_y_gutter(double need_px) {
double want = std::min(need_px + 6.0, 200.0);
// No slack added here, and that is load-bearing: `need_px` is the gutter the row was
// *laid out with*, which is already the maximum of its labels and the floor imposed last
// frame -- so a few pixels added to it are added again next frame. The `+ 6.0` this used
// to carry made the gutter a ratchet that climbed 6 px a frame until it hit the cap
// below, which is the whole reason the left margin was `200` of an 870 px viewport (and
// the `axes.w changes: 17` beside it). The label padding the reservation below adds is
// the slack, once.
double want = std::min(need_px, 200.0);
// Reserve room for the widest label the formatter can produce, once, with the
// font the labels are actually drawn in (the embedded font is monospaced, so a
// character count is an exact width).
// font the labels are actually drawn in.
if (!y_gutter_reserved_) {
for (int i = 0; i < rows_; ++i) {
Row& r = row(i);
@@ -855,10 +861,19 @@ void RowSet::note_y_gutter(double need_px) {
const plot::render::TextBackend* text = r.plot->text_backend();
if (text == nullptr) continue;
const plot::render::Style& style = r.plot->style();
const double advance = double(text->measure("0123456789")) / 10.0;
const double pad = double(style.device(style.grid_label_pad));
// "-1.23e-45" is the longest label `format_si()`'s fallback can print.
want = std::max(want, 9.0 * advance + 2.0 * pad + 2.0);
// A sign, three significant figures and a one-glyph SI prefix: the widest label
// `format_si()` writes for an axis with no unit, measured rather than counted in
// characters. Its `%.3g` fallback is wider (`-1.23e-45`) and was what this reserved,
// which cost a *third* of the gutter for a label no signal on a `Si` axis produces --
// it needs a value past 1e24, and `note_y_gutter()` is a monotone maximum over the
// measured need, so one that did appear widens the gutter for good anyway.
want = std::max(want, double(text->measure("-1.23m")) + 2.0 * pad + 2.0);
// The other side, from the same metrics. An x tick label is *centred* on its tick and
// the newest tick sits on the axes' right edge (a live axis is [-window, 0]), so half
// the widest time label is what the rightmost one needs to be drawn at all -- and that
// is also the band which keeps the newest sample's ink off the frame.
right_gutter_px_ = 0.5 * double(text->measure("-1.23ms")) + pad;
y_gutter_reserved_ = true;
break;
}
@@ -866,9 +881,10 @@ void RowSet::note_y_gutter(double need_px) {
if (want > y_gutter_px_) y_gutter_px_ = want;
}
void RowSet::apply_y_gutter() {
if (!figure_ || !(y_gutter_px_ > 0.0)) return;
figure_->set_min_y_gutter(static_cast<f32>(y_gutter_px_));
void RowSet::apply_gutters() {
if (!figure_) return;
if (y_gutter_px_ > 0.0) figure_->set_min_y_gutter(static_cast<f32>(y_gutter_px_));
figure_->set_right_gutter(static_cast<f32>(right_gutter_px_));
}
void RowSet::set_trigger_marker(double x, bool visible) {
+31 -12
View File
@@ -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.
@@ -359,18 +373,22 @@ class RowSet {
/// left and right by a few pixels every frame. Since `set_min_y_gutter()` is a
/// floor and the library still takes the larger of the two, a floor smaller than
/// the labels cannot stop that; the reservation therefore covers the widest label
/// `format_si()` can produce (three significant digits, a sign and a prefix, or the
/// `%.3g` fallback) plus the label padding, measured once with the plot's own font.
/// Both reservations only ever grow, so the gutter is constant in practice.
/// `format_si()` writes for an axis with no unit -- a sign, three significant figures
/// and a one-glyph prefix -- plus the label padding, measured once with the plot's own
/// font. Both reservations only ever grow, so the gutter is constant in practice.
///
/// The same metrics fix the **right** gutter, which is a want and not a measurement:
/// see `apply_gutters()`.
void note_y_gutter(double need_px);
/// Applies the stable gutter as the figure's floor. Call once per frame, before the
/// frame is drawn.
/// Applies the stable left gutter as the figure's floor, and the right gutter beside
/// it. Call once per frame, before the frame is drawn.
///
/// It has to be the *figure's* floor and not each row's: `Figure::layout()` clears
/// every row's `set_min_y_gutter()` to measure what its own labels need, so a floor
/// written onto the rows survives exactly until the next layout.
void apply_y_gutter();
/// Both have to be the *figure's* and not each row's: `Figure::layout()` clears every
/// row's `set_min_y_gutter()` to measure what its own labels need, so a floor written
/// onto the rows survives exactly until the next layout, and the rows have to end on
/// one column as well as start on one.
void apply_gutters();
/// The palette entry `index`, wrapped. Keyed by the *signal*, so that the colour a
/// trace is drawn in is the colour the signal list's chip shows, on every row the
@@ -399,8 +417,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<plot::Figure> figure_;
/// The one x link group every visible row belongs to. Groups are numbered from 1.
@@ -419,6 +435,9 @@ class RowSet {
/// gesture to be honoured; one that matches it is just the clamp's own answer arriving back.
plot::Range pinned_stated_{0.0, 0.0};
double y_gutter_px_ = 0.0;
/// The empty band kept to the right of the data, device pixels. Nothing is drawn in it,
/// so it is reserved once from the font's metrics and never measured or grown.
double right_gutter_px_ = 0.0;
bool y_gutter_reserved_ = false;
std::array<Row, static_cast<std::size_t>(kMaxRows)> storage_{};
CursorMode cursor_mode_ = CursorMode::Vertical;
+6
View File
@@ -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<unsigned long long>(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";
+18 -5
View File
@@ -11,9 +11,12 @@ namespace udpscope {
/// two cannot disagree. No GTK here on purpose: this is the one part of the status bar that
/// can be reasoned about as text.
struct StatusSnapshot {
/// One word from the fixed set: "live", "paused", "armed", "capturing", "event held",
/// "disarmed". It is a word from a *fixed* set because the bar sits directly above the plot
/// and a field whose width tracked the stream would relayout it every frame.
/// One word from the fixed set: "connecting", "live", "paused", "armed", "capturing",
/// "event held", "disarmed". It is a word from a *fixed* set because the bar sits directly
/// above the plot and a field whose width tracked the stream would relayout it every frame --
/// so a word added here must be no wider than the widest already in it ("connecting" and
/// "event held" are both ten characters). "connecting" is the state before a CONFIG has
/// arrived, in which there is no signal list and so nothing any other word here can describe.
const char* state_word = "live";
/// The long prose the old single-line bar carried, kept for the detail string and the dump.
std::string state_detail;
@@ -44,10 +47,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
+211 -56
View File
@@ -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<std::string> 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,13 +257,30 @@ struct App {
std::vector<MeasureResult> 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<RowSet::MeasureMark> 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<RowSet::MeasureMark> 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.
int dbg_win_btn_w = -1;
int dbg_win_label_w = -1;
int dbg_bar_w = -1;
/// The measurements pane's allocation, for the same reason and more urgently: the dump used
/// to ask the widget itself, which is where the two documented `Gtk-CRITICAL`s came from --
/// and a critical is the lucky outcome. The pointer is freed memory by then, so a run that
/// built and destroyed other windows first (`UDPSCOPE_PROBE_HELP_MS`, three of them) has had
/// that memory reused and the read is a **SIGSEGV** in the middle of the dump.
int dbg_measures_pane_w = -1;
int dbg_measures_pane_h = -1;
bool measures_revealed = false;
/// The measurement dialog while one is open (null otherwise). A probe needs a handle on it:
/// it is a window of its own, so the main window's widget tree does not reach its buttons.
@@ -430,7 +459,7 @@ struct App {
/// Frames the badge spent in each `BadgePhase`. Both `lit` and `dim` have to be non-zero
/// over a run that acquired anything -- that is what "it blinks" means, and a badge
/// stuck lit looks identical to a working one in a screenshot.
int dbg_badge_frames[3] = {0, 0, 0};
int dbg_badge_frames[4] = {0, 0, 0, 0};
int dbg_reset_visible = -1;
std::vector<std::uint64_t> last_stored;
std::uint64_t dbg_latches_at_disarm = 0;
@@ -904,10 +933,14 @@ std::string format_window(double s) {
/// What the activity badge is saying. `Lit`/`Dim` are the two halves of the blink, so
/// the badge is only *blinking* while the pipeline is actually running; a steady badge is
/// a stopped one, which is the state worth noticing on a glance.
enum class BadgePhase { Lit = 0, Dim = 1, Idle = 2 };
/// a stopped one, which is the state worth noticing on a glance. `Connecting` is the
/// half-way state -- the socket answered but the stream has sent no CONFIG, so there is no
/// signal list and nothing to acquire -- and it is steady yellow rather than green,
/// because a blinking green badge over a source that has sent nothing claims a working
/// pipeline.
enum class BadgePhase { Lit = 0, Dim = 1, Idle = 2, Connecting = 3 };
/// The badge is one glyph in one of three colours. It must stay the *same glyph*: the
/// The badge is one glyph in one of four colours. It must stay the *same glyph*: the
/// status row sits above the plot, and a badge that changed width as it blinked would
/// move the label beside it and re-lay out the plot twice a second.
const char* badge_markup(BadgePhase phase) {
@@ -916,6 +949,8 @@ const char* badge_markup(BadgePhase phase) {
return "<span foreground=\"#45E045\">\u25CF</span>";
case BadgePhase::Dim:
return "<span foreground=\"#1C5A1C\">\u25CF</span>";
case BadgePhase::Connecting:
return "<span foreground=\"#E0B020\">\u25CF</span>";
case BadgePhase::Idle:
break;
}
@@ -1978,8 +2013,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 +2037,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 +2060,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 +2925,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 +2952,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 +2985,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 +3198,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.
@@ -4814,6 +4885,11 @@ gboolean tick(gpointer user) {
std::fflush(stderr);
}
}
// Captured every frame so the dump has the last live value: see `dbg_measures_pane_w`.
if (app->measures_list != nullptr) {
app->dbg_measures_pane_w = gtk_widget_get_width(app->measures_list);
app->dbg_measures_pane_h = gtk_widget_get_height(app->measures_list);
}
if (model.generation() != app->signal_generation) rebuild_signal_panels(*app);
// A gesture moves the *plot's* cursor; adopting it here is what makes the two
// vertical markers one time across every row, and what the pane then reads.
@@ -4865,16 +4941,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<std::mutex> 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
@@ -5158,7 +5255,7 @@ gboolean tick(gpointer user) {
if (r.plot) gutter = std::max(gutter, double(r.plot->layout().y_axis_width));
}
app->rows->note_y_gutter(gutter);
app->rows->apply_y_gutter();
app->rows->apply_gutters();
// The editor's buttons are placed from the rects this frame was laid out with, so they
// follow a resize and the gutter settling without being told.
@@ -5361,7 +5458,12 @@ gboolean tick(gpointer user) {
}
StreamStats& st = model.stats();
const char* mode = "live";
if (trigger_on) {
if (!st.have_config.load()) {
// Ahead of every other branch for the reason the glance word is: with no CONFIG there is no
// signal list, so "live" (or "armed", which cannot happen without one) would name a pipeline
// that has not been described yet.
mode = "trying to connect: no configuration, and so no signal list, from the stream yet";
} else if (trigger_on) {
if (app->capturing) {
mode = "trigger: CAPTURING (post-trigger window filling)";
} else if (app->event_held) {
@@ -5386,14 +5488,26 @@ gboolean tick(gpointer user) {
// green light over a dead socket is the one reading that must not happen. The blink is
// a 1 Hz square wave off the monotonic clock rather than its own timer, so it cannot
// drift away from the frame it is drawn on.
// A source that answered CONNECT but has sent no CONFIG is *connecting*, not acquiring:
// there is no signal list, so nothing can be enabled and no DATA frame can arrive. It gets
// the steady yellow rather than the blinking green, because the green says "the pipeline is
// running end to end" and here it has not started.
const bool paused = app->acq->paused();
const bool active = st.connected.load() && !paused && (!trigger_on || armed);
const bool connecting = st.connected.load() && !st.have_config.load();
const bool active =
st.connected.load() && !connecting && !paused && (!trigger_on || armed);
const bool lit = ((g_get_monotonic_time() / (G_TIME_SPAN_SECOND / 2)) & 1) == 0;
const BadgePhase phase =
!active ? BadgePhase::Idle : (lit ? BadgePhase::Lit : BadgePhase::Dim);
const int reason = active ? 0 : !st.connected.load() ? 1 : paused ? 2 : 3;
const BadgePhase phase = connecting ? BadgePhase::Connecting
: !active ? BadgePhase::Idle
: (lit ? BadgePhase::Lit : BadgePhase::Dim);
const int reason = active ? 0
: !st.connected.load() ? 1
: connecting ? 4
: paused ? 2
: 3;
static const char* const kWhy[] = {"acquiring", "waiting for the stream", "paused",
"trigger disarmed"};
"trigger disarmed",
"trying to connect: no configuration from the stream yet"};
// Keyed on the reason as well as the phase: three of the reasons share the Idle phase,
// so a pause that becomes a disarm has to reach the tooltip.
++app->dbg_badge_frames[static_cast<int>(phase)];
@@ -5430,8 +5544,16 @@ gboolean tick(gpointer user) {
// Derive the one-word state for the glance line from the same conditions the mode prose
// branches on, so the two cannot disagree.
//
// `connecting` is first and covers both halves of the handshake -- no socket yet, and a
// socket with no CONFIG behind it -- because in neither is there a signal list, and every
// other word here claims one. The badge is what distinguishes them (grey against yellow):
// the word says what the app is doing, the colour says how far it got. It is no wider than
// `event held`, which is what lets it join a set the plot's layout is measured against.
const char* state_word = "live";
if (display == DisplayMode::Capturing) {
if (!st.have_config.load()) {
state_word = "connecting";
} else if (display == DisplayMode::Capturing) {
state_word = "capturing";
} else if (display == DisplayMode::Held) {
state_word = "event held";
@@ -5468,6 +5590,12 @@ gboolean tick(gpointer user) {
? double(g_get_monotonic_time() - app->last_latch_us) / 1.0e6
: 0.0;
snap.reconnects = static_cast<int>(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;
@@ -5600,9 +5728,6 @@ void note_starvation(App& app) {
const std::uint64_t reconnects = st.reconnects.load(std::memory_order_relaxed);
const bool connected = st.connected.load(std::memory_order_relaxed);
// No `have_config` here: the losing client of a single-client source need never see a
// CONFIG at all (the server answered the first one), and "connected, kicked, nothing
// arriving" is the state worth naming either way.
// "Connected, but nothing is arriving" is a *rate* fact, not a reconnect one: a source that
// answers CONNECT and then goes quiet -- the unicast case where another client took the session --
// never reconnects while it is silent, so requiring a reconnect made the notice invisible in
@@ -5621,16 +5746,28 @@ void note_starvation(App& app) {
// stream was arriving perfectly well.
return;
}
// Two diagnoses, split on `have_config`, because they are not the same situation. A source
// that sent a CONFIG and then went quiet is the unicast fight: the signal list is here, so
// something took the stream away. One that has sent no CONFIG at all never had the stream to
// lose -- there is no signal list, so nothing can be enabled and no DATA frame is owed -- and
// naming that "connected, but no data is arriving" blamed a second reader that does not exist.
// It is still the same stall test: a CONFIG that arrives promptly leaves no notice at all.
const bool have_config = st.have_config.load(std::memory_order_relaxed);
app.notice =
"connected, but no data is arriving: another reader may hold the source (a unicast "
"source serves one client at a time) -- use --multicast to watch from several UIs";
have_config
? "connected, but no data is arriving: another reader may hold the source (a unicast "
"source serves one client at a time) -- use --multicast to watch from several UIs"
: "trying to connect: the source answered, but has sent no configuration, so there is "
"no signal list yet -- check that the streamer is running and serving this port";
app.notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND;
if (!app.starve_reported) {
app.starve_reported = true;
std::fprintf(stderr,
"[udpscope] starved: connected (config=%d), reconnects=%llu, no new frames "
"in the last second -- a unicast source serves one client at a time\n",
st.have_config.load(std::memory_order_relaxed) ? 1 : 0,
have_config
? "[udpscope] starved: connected (config=1), reconnects=%llu, no new frames "
"in the last second -- a unicast source serves one client at a time\n"
: "[udpscope] connecting: connected (config=0), reconnects=%llu, no "
"configuration from the stream -- there is no signal list to acquire\n",
static_cast<unsigned long long>(reconnects));
std::fflush(stderr);
}
@@ -6625,8 +6762,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 +7179,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 +7258,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 +7730,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 +7749,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 +7764,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");
@@ -8244,8 +8394,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
: 0.0);
}
}
std::printf("[udpscope] badge frames: lit=%d dim=%d idle=%d\n", app.dbg_badge_frames[0],
app.dbg_badge_frames[1], app.dbg_badge_frames[2]);
std::printf("[udpscope] badge frames: lit=%d dim=%d idle=%d connecting=%d\n",
app.dbg_badge_frames[0], app.dbg_badge_frames[1], app.dbg_badge_frames[2],
app.dbg_badge_frames[3]);
std::printf("[udpscope] trigger panel revealed before the probe toggled it: %d; "
"signal list items=%d selected=%d; column visible after sidebar "
"toggle off=%d\n",
@@ -8384,8 +8535,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;
@@ -8422,8 +8578,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
"pane_w=%d pane_h=%d\n",
app.measures.size(), app.measures_revealed ? 1 : 0,
rows.measure_marks_placed(), app.measure_labels.size(),
app.measures_list != nullptr ? gtk_widget_get_width(app.measures_list) : -1,
app.measures_list != nullptr ? gtk_widget_get_height(app.measures_list) : -1);
app.dbg_measures_pane_w, app.dbg_measures_pane_h);
}
std::printf("[udpscope] header: window_button_w=%d window_label_w=%d (bar_w=%d)\n",
app.dbg_win_btn_w, app.dbg_win_label_w, app.dbg_bar_w);