11 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
Martino FerrariandClaude Opus 4.6 2f4530c128 fix: a zoomed trace is drawn on the x its readout quotes
gather() staged x as f32 relative to the series' own x0() -- the start of
the whole retained window -- so the quantisation was a fraction of the
ring rather than of the view: 1.9 us at 20 s of retained data, which at a
10 us view across a 716 px axis is over 100 px between where a sample is
drawn and the x a readout quotes for it (crosshair_point() reads x_at()
in exact f64). It halved on every zoom out, which is what made it read as
"zooming in makes the crosshair select a point beside the data".

The origin is now the caller's (plotter c2c8c59): the renderer passes the
visible range's minimum, so the error is a fixed 2^-24 of what is on
screen -- about 4e-5 px over a 700 px axis -- at any zoom. The parameter
sits before out_xy so every call site is a compile error rather than a
silent change of behaviour.

The renderer no longer reads series.x0() at paint time at all, which
removes two of the three symptoms of the torn window_x0_ read by
construction; the ink probe's now-vacuous kept_fixed_x0 walk goes with
them, replaced by a drawn-vs-quoted pixel figure. neighbours() still
tests the visible range against x_at(0), so the cache stays load-bearing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 18:49:54 +02:00
Martino FerrariandClaude Opus 4.6 39f30d913f fix: the t0 caption is chrome anchored to the axis, not an annotation in the data
The caption that names the relative x labels' anchor was an `Annotation`, whose anchor
is a data coordinate -- so it crawled across the plot under a pan and drifted under a
zoom, which is how it was reported. It is now `Plot::set_x_note()`: the bottom row's
caption, drawn by the renderer in the axes' bottom-left corner in pixels, on the
legend's own backplate.

The text stays the host's, because only the host knows that label + caption has to be
the exact absolute time (`%.6g`, where `format_time()` would round to three figures).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 18:21:28 +02:00
Martino FerrariandClaude Opus 4.6 e2985aa159 fix: the x labels go relative on what a label can write, not on the span
A `t0 = ...` caption appeared on every sub-second window, including a
triggered one whose axis is already relative to the edge -- so the ticks
were re-anchored away from the one position a triggered axis exists to
show, and the caption had nothing to explain.

The form is now decided by the tick step against `TickFormat::Time`'s own
resolution at the axis' magnitude (three significant figures below a
minute, whole seconds through `m:ss` to a day, three figures of days
above). The span said nothing about that and was wrong both ways: a 2 s
window at twenty minutes of uptime reads `20:00` on every tick and stayed
absolute.

The step is read back from the last layout's majors (a tick value depends
on the range and the axis width, not on the label form); with no ticks yet
the form is left alone rather than guessed, since the hysteresis would
latch a guess. Entering the relative form carries a ULP of slack, because
`step == res` is readable and a bare comparison of two differently
computed doubles flipped the form between frames.

`[timelabels-probe]` and the dump gain `distinct=`, which asserts that no
two major labels read the same -- the thing the relative form is for, and
the only reading that holds whatever the stream's uptime is.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 18:01:41 +02:00
Martino FerrariandClaude Opus 4.6 b505706d92 fix: a readout keeps its metric prefix when the stream declares no unit
The SI prefix ladder in format_measure() was gated on a non-empty unit, so two
decimals of a small unitless value printed `0.00` -- the reading gone, not merely
coarse. That is a whole class of channels rather than a corner: a CONFIG need not
declare a unit, and a math channel never has one. Below 1e-12 the same loss
happened even with a unit, so that range falls back to three significant digits.

With no unit the prefix goes tight against the number (`585.24m`), since the space
exists to separate the number from its unit and a spaced `m` alone reads as metres.

The mock declared `unit="V"` for every data signal, which is why no probe could see
this; it now takes `--unit ''`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 17:28:51 +02:00
Martino FerrariandClaude Opus 4.6 3d7e214295 fix: a fit undoes an x zoom instead of redefining the time window
The previous commit took "fit x sets the window properly" to mean a fit should
frame the data and propagate that as the new time scale. It should not: a fit is
a plot interaction, and the window is also the trigger's event span and how the
samples are stored, so a fit that framed the data could re-plan the rings and
drop the history on a menu click. What a fit means here is the undo of a zoom --
a 2 s window zoomed into reads 2 s again.

`fit_x()` therefore reads `window_s_` and never writes it. Live it takes the
window-control path with the window already set, which re-arms the follow and
parks the newest span at the live edge. Pinned -- a held event, a filling capture
or a paused view -- it restores the pin's full range, which is the snapshot's own
span and may be narrower than the requested window, and it writes the per-row
member axes as well as the link group: `hold_pinned_window()` prefers a member
that differs from `pinned_stated_`, so writing the group alone is reverted to the
zoomed range one frame later. `apply_window_scale()` is folded back into
`apply_time_window()`, which the window control is again the only caller of.

The probe now zooms in before fitting, since otherwise there is nothing to undo,
and reports `pinned=`. `window` and `event_span` must be unchanged on every line.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 16:26:37 +02:00
Martino FerrariandClaude Opus 4.6 9888fd9977 fix: only a window change sets the time scale; a fit states the whole of it
The time scale is more than the axis: it is also the trigger's event span, the
buffer partition and the control's readout. A zoom or a pan was already clean --
the controller writes the member axis and `clamp_to_data()` caps the span -- but
both fits moved `window_s_` alone and only restated the control. The event span
then stayed on the previous window until the next decade step or unit change
applied the fitted value for real, so one action changed the time scale twice.

`apply_window_scale()` is split out of `apply_time_window()` for the fits to use:
it moves the partition, the event span, the control and the session, and leaves
the view alone, because `set_time_window()` re-arms the live follow and would
throw a held event or a paused view back onto the stream. The fits report the
span they framed, `fit_all()` delegates its x half to `fit_x()` so the two cannot
drift, and the probe drives the menu handlers rather than `RowSet` so it sees the
half that lives there. `[fit-probe]` gained `event_span=`, which must equal
`window=` on every line.

Also moves the plotter pin to 03c41ad (three build warnings, no behaviour).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 16:12:59 +02:00
Martino Ferrari 1f6738031a fix: a readout mark sits on the sample it quotes, not on the pointer's column
The value was already the nearest stored sample's, but every mark drawn for it was
placed at the *marker's* x with that sample's y -- so the dot the eye follows and the
number in the pane were a different datum whenever the marker fell between two samples.
Half a sample period is nothing across a whole window and the width of the panel once a
few samples span the axes, which is the only zoom at which anyone is asking which point
a number came from.

`place_crossings()` now goes through the plotter's new `Plot::crosshair_point()`, which
returns the quoted sample's x alongside its y -- one lookup for the mark and the number,
so they cannot name different samples. The dot consequently sits a little off its own
vertical cursor line: the line is where the marker is, the dot is the datum being quoted,
and between two samples those are two places.

Pin moves to the upstream commit that does the same for the library's own hover crosshair
and draws its mark larger than a trace's sample dots, and that keeps the old blend
reachable as `CrosshairValue::Interpolated` -- which udpscope never states.

Verified: plotter suite 370 cases 0 failed; `[cursors-probe]` lines=3 marks=6 with
`first mark at x=3.292400 y=-0.525175` against the pane's `-525.17 mV`; `[help]`
pages=14 parsed=14 closed ok.
2026-10-05 14:41:02 +02:00
Martino FerrariandClaude Opus 4.6 d03c9fb443 fix: a cursor readout quotes the nearest stored sample, not a value between two
The crosshair and the cursors pane's mix values are one library call, and it
interpolated linearly between the two samples bracketing the pointer. That is
wrong twice over here. Ingest decimation is a boxcar average over `time_stride`
samples, so a number computed between two stored samples is a second, undeclared
estimate laid over the first that nothing on screen or in an export holds. And
past the sample spacing the polyline is a line the renderer drew, so quoting a
point on it reports the drawing rather than the data.

The fix is upstream and unconditional (plotter c028f48): `crosshair_span()`'s `t`
selects the nearer end instead of blending, with a tie reading the earlier
sample. `CrosshairRow::marker` is placed from the same y, so the dot follows the
quoted sample.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 13:45:21 +02:00
25 changed files with 1497 additions and 509 deletions

No files matched your search

+396 -95
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 `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 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 `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. 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 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 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 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`, 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 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 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 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 `-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 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 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 emits **no** `Gtk-CRITICAL` at all. A sixth warning, or any critical, is a finding.
the dump asking a destroyed widget for its size. A sixth warning, or a third 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, - `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 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. 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 - `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* 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 (`[pause-probe] <action> paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..]
stderr): glance=[..]` on stderr):
it presses the **button**, not `Acquisition::set_paused()`, because pausing is a display 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 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 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 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 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. 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 - `UDPSCOPE_EXPORT_MS` (+ optional `UDPSCOPE_EXPORT_PATH`, default
`/tmp/udpscope-export`) — collect the export table and write it both ways, skipping `/tmp/udpscope-export`) — collect the export table and write it both ways, skipping
only the file chooser. 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 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 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 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 converges over two layouts rather than one. A `y-gutter 88..104` dragging
`axes.x 67..80`/`axes.w 546..559` with it is that settle, not a twitch — confirm it `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: 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. 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 frames the x link group did not reach. `x-ticks` is content, not layout, and
legitimately varies by one as a live 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 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 `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). 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=` 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. `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 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 `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 `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. 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 `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 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 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 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 the wrong clock (the live branch puts the window back on the stream's clock with
ring). In `mode=event`, `stream=` must be the edge ± the window; in `mode=live` it `stream_at_x0()` before searching the ring, and that offset is a whole window while
equals `time=`. streaming — not the 0 it once was).
The one exception to both: an export taken **mid-capture** reports `mode=live` with **`abs - time` must be a single constant across both ends of a line**, and that constant is
`time=[-pre..e]` where `e` is how far the post window has filled, and `stream=` offset where the display's origin sits in the stream's own clock: `epoch()` plus the newest stamp in
from it by the edge (so `stream - time` reads the edge exactly) — the export is what is `mode=live`, `epoch()` plus the accepted edge in `mode=event`. That is the one assertion on
on screen, and during a capture that is a partly filled window. Pin the probe outside a the second column, and it replaces the old "`stream=` must be the edge ± the window" reading,
capture when checking the `rows=` count. 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 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 `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 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 (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. 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 **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 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 and drops to zero at each pass. The figure is `(|Δmin| + |Δmax|) / span`, so a pure scroll
is `drift=5%`, which is what a dump landing just before a scheduled pass reads -- a `0.0000%` counts **twice** and the top of the sawtooth is `2 × refresh / window`: ~10 % on a 2 s window
is a dump that landed just after one, not a better state. Read it against the recompute count: 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 *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 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 recomputed when the window moves (2 % of its span) or the held/live state changes, never on a
@@ -435,27 +487,58 @@ Two lines of the self-check output are assertions in disguise:
*not* every channel, and never a stream's timestamp signal, which is what an acquired-but- *not* every channel, and never a stream's timestamp signal, which is what an acquired-but-
unplotted list put first and auto-selected), and `drop_h` must be a combo box's height unplotted list put first and auto-selected), and `drop_h` must be a combo box's height
(tens of pixels — a scrolled-window wrapper asked it to grow to 160). (tens of pixels — a scrolled-window wrapper asked it to grow to 160).
- `[timelabels-probe] <action> relative=D window=.. origin=.. note=[..] ticks=[..] - `[timelabels-probe] <action> relative=D distinct=D window=.. origin=.. note=[..] ticks=[..]
first_value=.. first_rel=.. range=[..]` + the dump's `time labels: relative=D origin=.. first_value=.. first_rel=.. range=[..]` + the dump's `time labels: relative=D distinct=D
note=[..] x_origin_set=.. ticks=[..] first_rel=..` (stderr, origin=.. note=[..] x_origin_set=.. ticks=[..] first_rel=..` (stderr,
`UDPSCOPE_PROBE_TIMELABELS_MS`) — the x label form across a zoom, driven through `UDPSCOPE_PROBE_TIMELABELS_MS`) — the x label form across a zoom, driven through
`apply_time_window()`: **under the figure's x link group the group owns the window, so a `apply_time_window()`: **under the figure's x link group the group owns the window, so a
row-level `set_time_window()` does nothing** (a probe that changes the window must use the row-level `set_time_window()` does nothing** (a probe that changes the window must use the
app's path). Absolute at 2 s and at 500 ms, `relative=1` below a second, and the caption app's path). **`distinct` must be 1 on every line, and that is the assertion** — it says no two
must be non-empty exactly then. The probe *races the paint* — it reads the axis on a timer, 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` 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
position the axis exists to show. The probe *races the paint* — it reads the axis on a timer,
and the ticks it sees are the previous layout's — so its `first_rel` (the first major minus and the ticks it sees are the previous layout's — so its `first_rel` (the first major minus
the axis' origin) is a fraction of a step; the dump runs after the last paint and is the the axis' origin) is a fraction of a step; the dump runs after the last paint and is the
race-free reading. Either way the assertion that matters is the *sum*: `first_value` must race-free reading. Either way the assertion that matters is the *sum*: `first_value` must
equal `origin + first_rel` as a *tick label* plus its caption, i.e. the labels and the equal `origin + first_rel` as a *tick label* plus its caption, i.e. the labels and the
caption must share one anchor. `x_origin_set` is read back out of the plot; it must be 0 in caption must share one anchor. `x_origin_set` is read back out of the plot; it must be 0 in
the absolute form. the absolute form. Note the last two steps both report `window=0.05`: `RowSet::set_time_window()`
- `[fit-probe] <action> window=.. x=[..] span=.. y=[..]` (stderr, `UDPSCOPE_PROBE_FIT_MS`) — floors the window there, so the 20 ms step is clamped.
the two fits, through the same `RowSet` calls the right-click menu wires. It first puts - `[fit-probe] <action> pinned=D window=.. event_span=.. x=[..] span=.. y=[..]` (stderr,
the y scale and the window where the data is *not* (a fixed `[-100, 100]`, a 50 ms `UDPSCOPE_PROBE_FIT_MS`) — the two fits, through the **menu handlers** rather than the bare
window), so the assertions have something to be wrong about: **`fit x` must widen the `RowSet` calls, because the notice and the control are the handler's half. A fit is the **undo
window to the data's span and leave `y` exactly as it was**, and **`fit all` must then of an x zoom**, so the probe performs one: it moves the y scale off the data (a fixed
move `y` to the data's own range**. A `y` that changed on the `fit x` line is the two `[-100, 100]`), sets a 2 s window through the control's own path, then zooms row 0 to a quarter
paths aliased. of it the way a gesture does — on the *member* axis — and fits. Four assertions.
**`window` and `event_span` must be unchanged on every line** and equal to the typed 2 s: a fit
is a plot interaction and may not restate the time scale (see the invariant below), and they are
the only two readings that can see it try. The `zoomed` line's `span` must be a quarter of the
window, or the fits have nothing to undo and the lines below mean nothing. **`fit x` must put
`span` back to the whole window and leave `y` exactly as it was**, and **`fit all` must do the
same and move `y` to the data's own range** — a `y` that changed on the `fit x` line is the two
paths aliased. `x`'s *position* advances between lines on a live view and is not an assertion:
the follow is re-armed on the newest data, which is what the whole window means there.
Run it **inside a pause** as well (`UDPSCOPE_PROBE_PAUSE_MS=2000 UDPSCOPE_PROBE_FIT_MS=2200`),
which is the other branch and the one that can silently revert: `pinned=1`, and `fit x` must
restore the *pin's* full range — the snapshot's own stamps, legitimately narrower than the
requested window (`span=1.179900` against `window=2.000000` is correct, not a finding) — and
must still read `pinned=1` afterwards. The pin is restored by writing the **members** as well as
the group, because `hold_pinned_window()` prefers a member that has moved since `pinned_stated_`
— that is how it tells a gesture from its own echo — so a fit that wrote the group alone reads
back as the zoomed range one frame later.
- `[layout-probe] <action> rows=N buttons=B done=D done_box=[x,y,WxH] rows_y=[..] close=[..] add=[..] traces=[..]` - `[layout-probe] <action> rows=N buttons=B done=D done_box=[x,y,WxH] rows_y=[..] close=[..] add=[..] traces=[..]`
(stderr, `UDPSCOPE_PROBE_LAYOUT_MS`) — **`buttons` must be `2N+1`** while the editor is (stderr, `UDPSCOPE_PROBE_LAYOUT_MS`) — **`buttons` must be `2N+1`** while the editor is
up (one `×` per row, one `+` per boundary) and **0 on the `editor off` line**: the up (one `×` per row, one `+` per boundary) and **0 on the `editor off` line**: the
@@ -537,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 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 -> 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). 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) — 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 "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: instance taking the source's single session. Check it does **not** fire against a live
point the app at a UDP port where nothing answers (the client CONNECTs, times out, source. The status line carries ` | N reconnects` only when N > 0, which is the same
reconnects; `config=0`), and check it does **not** fire against a live source. The status counter without the prose.
line carries ` | N reconnects` only when N > 0, which is the same counter without the **The same stall test has two outcomes, split on `have_config`, and they are different
prose. diagnoses.** With a signal list in hand, the above. With **no CONFIG at all** it prints
- **The export's `time`/`stream_time` columns must be strictly increasing and evenly `[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 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 `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 — backwards stamps mean the recorded timeline holds two clocks or a collapsed cadence —
@@ -553,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 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 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. 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 **The step must hold to a few ULP of the column's own magnitude**, and this is the check that
run at a high rate: a *plateau* of equal stamps, or a diff taking two or three values a few needs a long run at a high rate: the old form of this assertion was "`set(np.diff(t))` holds
hundred nanoseconds apart, is the time ring losing precision with uptime (see the `f64` exactly one value", and that was only true while `time` *was* the stored stamp. Both columns
time-ring invariant below — the figure to compute is the ULP of `t[0]`, since the defect are derived now — `time` is `stamp - stream_at_x0()` and `abs_seconds` is that plus a base of a
grows with how long the app has run, not with the export's own length). wholly different magnitude — so each subtraction costs a rounding and `set()` legitimately
- `badge frames: lit=L dim=D idle=I` — the activity badge's phase per frame. Over a run holds a handful of neighbouring doubles. Measured: four values spanning ~1.5 ULP, 6.8e-16 s.
that acquired anything **both `lit` and `dim` must be non-zero**: that is what "it 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 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 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 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 `UDPSCOPE_PROBE_DISARM_MS` run should show hundreds). **`connecting` must be 0 on a run
rest of the status line, so `rate` and `last event ... ago` are checked there. 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 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 `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 drawing cost, split into the frame tick, `PlotArea`'s own draw (timed from the frame
@@ -582,10 +687,12 @@ Two lines of the self-check output are assertions in disguise:
lines and the disarm probe's `axis=`/`snap=` need a fast source to say anything — lines and the disarm probe's `axis=`/`snap=` need a fast source to say anything —
`tools/mock_udpstreamer.py --rate 1300000 --batch 20000` — since at the default 10 kS/s a `tools/mock_udpstreamer.py --rate 1300000 --batch 20000` — since at the default 10 kS/s a
whole window fits in the raw path and the publish is too short to lose a race. whole window fits in the raw path and the publish is too short to lose a race.
- `trace ink: N paints checked, C collapsed, S staircase, H short, worst cover P%` plus a - `trace ink: N paints checked, C collapsed, S staircase, H short, worst cover P%, worst
drawn-vs-quoted D px over R raw paints` plus a
`[ink] paint N <flags> mode=.. axis=[..] n=.. idx=[..) spp=.. level=.. pts=.. kept=.. `[ink] paint N <flags> mode=.. axis=[..] n=.. idx=[..) spp=.. level=.. pts=.. kept=..
ink=[..] cover=..% x0=.. x_at=[..] bounds=[..] rel=[..] dx=.. kept_fixed_x0=.. drops=[..] ink=[..] cover=..% x0=.. x_at=[..] bounds=[..] rel=[..] dx=.. drops=[..]
back=..` line per anomalous frame (stderr) — **`collapsed` and `staircase` must both be 0**. back=..` line per anomalous frame (stderr) — **`collapsed` and `staircase` must both be 0**,
and **`drawn-vs-quoted` must stay far under a pixel** (0.0000 is what a clean run reads).
This is the only check that can see a frame whose *waveform* vanished: `staged_segments` This is the only check that can see a frame whose *waveform* vanished: `staged_segments`
counts geometry including the grid and the instruments, and `lod_traces` counts a trace whose counts geometry including the grid and the instruments, and `lod_traces` counts a trace whose
points are then collapsed, so both read healthy through the flicker. The probe runs on every points are then collapsed, so both read healthy through the flicker. The probe runs on every
@@ -595,11 +702,22 @@ Two lines of the self-check output are assertions in disguise:
`collapsed` is fewer than two distinct positions left (nothing to draw at all), `staircase` `collapsed` is fewer than two distinct positions left (nothing to draw at all), `staircase`
is the filter dropping over three quarters of the points, `short` is ink reaching under a is the filter dropping over three quarters of the points, `short` is ink reaching under a
quarter of the axis (reported only once the data spans the window, since a filling ring is quarter of the axis (reported only once the data spans the window, since a filling ring is
*meant* to leave its left side empty). `kept_fixed_x0` is the same filter with `x0()` hoisted *meant* to leave its left side empty). `drawn-vs-quoted` is the other reading, and it is the
out of the loop: it exceeding `kept` means `x0()` moved *during* the walk. **`back` ≈ one ring one that names a *defect of position* rather than of presence: at a raw level a gathered pair
span is the signature** of the torn `x0()` read (see the cached-first-stamp invariant); so is **is** sample `first + k`, so the line differences where a sample is drawn
an `ink=` range disjoint from `axis=`, or an `idx=` covering the whole ring with `cover` far (`axis.min + gather[2k]`, the renderer's own reconstruction) against the x a readout quotes
over 100%. It is a race, so it needs load and a reason for long frames — for it (`x_at()`, exact f64) and reports the worst of them in pixels. That is the
gather-origin invariant below read from the screen's side; it was over a hundred pixels at a
deep zoom before the origin became the caller's. It is measured on the raw levels only — above
them a gathered point is a bucket's envelope and has no single sample to be off from — so `R`
is legitimately below `N`. **`back` ≈ one ring span is the signature** of the torn `x0()` read
(see the cached-first-stamp invariant); so is an `ink=` range disjoint from `axis=`, or an
`idx=` covering the whole ring with `cover` far over 100%. There was a `kept_fixed_x0` field
beside `kept` — the same filter with `x0()` hoisted out of the loop, since the renderer used to
re-read `x0()` per point and a ring-backed one advances with the producer. The origin is a
local of the renderer's now, so that failure mode is structurally impossible and the field is
gone; the `[ink]` walk and the renderer still agree because both add `axis.min`. It is a race,
so it needs load and a reason for long frames —
`tools/mock_udpstreamer.py --rate 1300000 --batch 2000 --clock-jitter 0.4` with the `tools/mock_udpstreamer.py --rate 1300000 --batch 2000 --clock-jitter 0.4` with the
measurements *and* cursors panes up: measurements *and* cursors panes up:
`UDPSCOPE_ENABLE_ALL_MS=1000 UDPSCOPE_PROBE_MEASURES_MS=2000 UDPSCOPE_PROBE_CURSORS_MS=3500 `UDPSCOPE_ENABLE_ALL_MS=1000 UDPSCOPE_PROBE_MEASURES_MS=2000 UDPSCOPE_PROBE_CURSORS_MS=3500
@@ -646,7 +764,7 @@ Two lines of the self-check output are assertions in disguise:
it afterwards. 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). - `[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). - `[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 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. the probe/dump in `src/ui.cpp` rather than adding a separate harness.
@@ -682,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 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: `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 - `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 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 (`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. post side fills in view and the marker sits at x = 0 throughout.
- `Held` — the frozen `EventSnapshot` of the last event. - `Held` — the frozen `EventSnapshot` of the last event, whose stamps are already edge-relative,
- `Waiting` — armed, nothing captured yet: blank. 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 **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 cuts the window at the newest sample and that partial snapshot becomes the held event, so
@@ -711,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 reads, and two reads in one frame put the bind, the epoch, the pin and the status line on
different clocks. 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: Layers to keep straight:
- `SignalInfo` — one signal as the CONFIG described it. The unit of enable/disable. - `SignalInfo` — one signal as the CONFIG described it. The unit of enable/disable.
@@ -749,6 +899,47 @@ far the largest file.
again until the next latch. `PlotArea::create()` is handed `App::placeholder_plot`, again until the next latch. `PlotArea::create()` is handed `App::placeholder_plot`,
never a row: the area keeps a reference to the plot it was created with, and a rebuild never a row: the area keeps a reference to the plot it was created with, and a rebuild
destroys every row. destroys every row.
- **A readout quotes a stored sample, never a value between two of them.** The hover
crosshair and the cursors pane's `mix` values are one call, `Plot::crosshair_value()`, and it
reports the **nearer** of the two samples bracketing the x (a tie reads the earlier). Upstream
interpolated linearly, and that is wrong twice over here: ingest decimation is a *boxcar
average* over `time_stride` samples, so a number between two stored samples is a second
estimate laid over the first that nothing on screen or in an export holds; and past the sample
spacing the polyline is a line the renderer drew, so a point on it reports the drawing. The
change is in `deps/plotter`, which keeps the blend as `CrosshairValue::Interpolated` for a host
whose x is a quantity it is willing to interpolate — the nearest sample is the **default** and
**udpscope never states the other**, so a `set_crosshair_value_mode()` call appearing here is a
regression, not a feature.
**Both the mark and the number come from one call, `Plot::crosshair_point()`**, which returns
the quoted sample's own **x** as well as its y — so every mark this app draws for a readout is
*snapped to the sample it quotes*. The library's `CrosshairRow::marker` and the cursors pane's
`mix` crossing annotations (`place_crossings()`, which must use `crosshair_point()` and not
`crosshair_value()`) are both placed from it. Two consequences that look like defects and are
not: the mark **sits beside the polyline at a deep zoom** (one y feeding both the number and the
dot is what keeps them from disagreeing), and a crossing dot **sits a little off its own
vertical cursor line** — up to half a sample period, invisible at a whole-window view and the
width of the panel at a few samples across, which is the only zoom at which anyone is asking
which point it is. The line says where the *marker* is; the dot says which *datum* is quoted.
The hover mark is also deliberately *larger* than a trace's own sample dots
(`Style::crosshair_marker_px` 9 against `marker_px` 3), because a snapped mark lands among them
and at their size reads as one more sample. The guards are the library's
`crosshair_value_is_the_nearest_sample`, `the_crosshair_marker_snaps_to_the_sample_it_reports`,
`crosshair_interpolated_mode_blends_the_two_samples` and
`the_crosshair_mark_is_bigger_than_a_sample_dot`, plus `[cursors-probe]`'s `first mark at x=..
y=..` agreeing with the pane's first value.
- **`format_measure()` is the one formatter for both panes, and its SI prefix does not depend on
there being a unit.** The cursors pane's values and the measurements pane's `text=` go through
it, so a change reaches both. The prefix ladder used to be inside `if (!unit.empty())`, which
made two decimals of a small unitless value read `0.00` — the reading gone, not merely coarse —
and that is a whole class of channels, not a corner: `measure_unit()` returns `""` for any signal
whose CONFIG declares no unit, and a **math channel never has one**. The regression net could not
see it because `tools/mock_udpstreamer.py` declared `unit="V"` for every data signal; it now takes
**`--unit ''`**, and that is the flag to run the measures and cursors probes under when touching
this (`text=[636.62m]`, `● sine=585.24m`). With no unit the prefix is written **tight** against
the number — `1.34m`, not `1.34 m` — because the space exists to separate the number from its
unit and a spaced `m` alone reads as metres; with one it stays spaced and the prefix joins the
unit (`636.62 mV`). Below `1e-12` the ladder runs out, and `%.2f` would print `0.00` again, so
that range falls back to `%.3g`.
- **The cursors are read back from the plots only while a gesture is on that row.** `Cursor::x` - **The cursors are read back from the plots only while a gesture is on that row.** `Cursor::x`
is the *free* pointer position while a drag runs and the snapped value otherwise -- which is is the *free* pointer position while a drag runs and the snapped value otherwise -- which is
what the app itself wrote a frame earlier, or the snap of it. A tick that reads a row on what the app itself wrote a frame earlier, or the snap of it. A tick that reads a row on
@@ -879,15 +1070,32 @@ far the largest file.
`gather()`, `x_at()`, `neighbours()` and `x_bounds()` use the real time ring. `gather()`, `x_at()`, `neighbours()` and `x_bounds()` use the real time ring.
Nothing may walk that grid to find *which* samples a range covers — the renderer did, Nothing may walk that grid to find *which* samples a range covers — the renderer did,
and it lost the two end segments (see the margin invariant above). `gather()` emits x and it lost the two end segments (see the margin invariant above). `gather()` emits x
relative to `x0()` because absolute epoch seconds do not survive `f32`. relative to an origin because absolute epoch seconds do not survive `f32`.
- **`gather()` emits x relative to `x0()` in *every* series here**, `EventSeries` - **`gather()` emits x relative to an origin *the caller names*, in every series here**,
included, and the renderer adds `x0()` back. An event's stamps are small (edge `EventSeries` included, and the consumer adds that same origin back in `f64`. Two separate
relative, `[-pre, +post]`), so emitting them absolute does not overflow anything and rules live here and both were learned from a defect.
does not look like a bug — it just draws the whole capture `x0()` = one pre-trigger (1) **It must be relative at all.** An event's stamps are small (edge relative,
window to the left of the axis. That was the "half the plot is empty / the marker sits `[-pre, +post]`), so emitting them absolute does not overflow anything and does not look
at the end of the data / nothing is drawn but the crosshair reads values" defect: the like a bug — it just draws the whole capture `x0()` = one pre-trigger window to the left of
crosshair reads `x_at()`, which was right all along. The probe's `data=` vs `drawn=` the axis. That was the "half the plot is empty / the marker sits at the end of the data /
is the guard. nothing is drawn but the crosshair reads values" defect: the crosshair reads `x_at()`, which
was right all along. `[trig-probe]`'s `data=` vs `drawn=` is the guard.
(2) **The origin is the *view's* minimum, not `x0()`.** `x0()` is the start of the
**retained window**, so the f32 had to resolve 2^-24 of the whole ring however little of it
was on screen: 1.9 µs over 20 s of retained data, which at a view 10 µs wide across a 716 px
axis is over a hundred pixels between where a sample is drawn and the x `x_at()` quotes for
it — and it halved on every zoom out, so it read as "zooming in makes the crosshair select a
point beside the data". `Renderer::draw_traces` passes `x_range.min`, making the error a fixed
4e-5 of a pixel at any zoom. The parameter sits **before** `out_xy` deliberately, so a call
site that was not thought about is a compile error rather than silent drift. A caller that has
no view in mind may still pass `x0()`, and `[trig-probe]`'s `drawn=`/`data=` does exactly that
on purpose: it reads the series' *whole* span, which is the widest relative value a gather can
be asked to stage. The two guards are upstream's
`a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes` and the
`trace ink:` line's `drawn-vs-quoted` figure.
**A probe that recomputes what the renderer gathers must pass the same origin the renderer
does** (`[ink]` and `[zoom-probe]` both pass the axis minimum), or its reconstruction stops
being the renderer's and its pixel readings mean nothing.
- **Every series bound to a trace must carry a real `MinMaxPyramid`**, including an - **Every series bound to a trace must carry a real `MinMaxPyramid`**, including an
immutable one. `MinMaxPyramid::level_for()` descends to level 0 — raw samples — on a immutable one. `MinMaxPyramid::level_for()` descends to level 0 — raw samples — on a
pyramid with no levels, whatever `n` is, so a series that answers `pyramid()` with an pyramid with no levels, whatever `n` is, so a series that answers `pyramid()` with an
@@ -920,12 +1128,15 @@ far the largest file.
the doubling against overflow); `live_window_locked()` compares its bounds in the ring's own 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 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 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 `EventSnapshot::stamps` is `f64` because it is cut from this ring and is therefore in the same
therefore carries full stream time (an event's own stamps are edge-relative and small — the units, whatever a given snapshot's own origin is (both kinds are rebased now — an event's
symmetry is not the reason). `RingBuffer<T>` stores slots as `std::atomic<T>` behind a stamps are edge-relative, a pause's freeze-relative — so neither is magnitude-bound the way the
`static_assert(is_always_lock_free)`, which `double` satisfies on every supported target. ring is, and the width is for the cut rather than for the coordinate). `RingBuffer<T>` stores
The guard is the **export**: `np.diff(t)` over a multi-million-row `.npy` must hold exactly slots as `std::atomic<T>` behind a `static_assert(is_always_lock_free)`, which `double`
one distinct step. Three steps at 2, 2.5 and 3 ULP of the stamps' magnitude is this defect. 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 - **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 — 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 their capacities are equal, so there is no slack — which means the oldest few samples
@@ -943,14 +1154,19 @@ far the largest file.
sample 0 is the ring's oldest, the very slot the producer is about to overwrite. `time_of()` sample 0 is the ring's oldest, the very slot the producer is about to overwrite. `time_of()`
checks the bounds and then copies, and when the producer laps the slot in between the stamp it checks the bounds and then copies, and when the producer laps the slot in between the stamp it
returns is **one whole ring-span too new**. Everything else on a `ChannelBuffer` is read at returns is **one whole ring-span too new**. Everything else on a `ChannelBuffer` is read at
paint time with no lock, and `x0()` is the origin the renderer reconstructs every trace point paint time with no lock, and `x0()` *was* the origin the renderer reconstructed every trace
from (`gather[i] + series.x0()`), read both inside its increasing-suffix filter and again at point from (`gather[i] + series.x0()`), read both inside its increasing-suffix filter and again
`stage_xy()` — so one torn read of that one slot produced three pictures, all of them "the at `stage_xy()` — so one torn read of that one slot produced three pictures, all of them "the
waveform is gone while the grid and the axes stay": `neighbours()` tests against `x_at(0)` and waveform is gone while the grid and the axes stay": `neighbours()` tests against `x_at(0)` and
refused the whole visible range (the renderer then gathered the *entire* ring at a coarse level refused the whole visible range (the renderer then gathered the *entire* ring at a coarse level
and drew it off the axis — `idx=[0..8388608) level=7 cover=320%`); `x0()` changing *during* the and drew it off the axis — `idx=[0..8388608) level=7 cover=320%`); `x0()` changing *during* the
filter loop dropped three quarters of the points; and `stage_xy()` shifted the finished polyline filter loop dropped three quarters of the points; and `stage_xy()` shifted the finished polyline
a ring-span off its axis. That is the "with measurements and cursors active the plot flickers as a ring-span off its axis. The gather origin is the *view's* minimum now (see the `gather()`
invariant), a local of the renderer's, so the second and third of those are structurally
impossible — but **the first is not**, `x_at(0)` is still what `neighbours()` tests the visible
range against, and that is the one that gathered the whole ring. The cache is load-bearing for
that reason; do not read the slot at paint time because two of its three symptoms are gone.
That is the "with measurements and cursors active the plot flickers as
if there were no data, at no constant frequency" defect. It is **live only** (a snapshot is if there were no data, at no constant frequency" defect. It is **live only** (a snapshot is
immutable, so `x0()` is constant for `Held`/`Paused`), it is a race against packet arrival so it immutable, so `x0()` is constant for `Held`/`Paused`), it is a race against packet arrival so it
is irregular, and the measurements pane makes it both likelier and larger: the pane holds is irregular, and the measurements pane makes it both likelier and larger: the pane holds
@@ -980,8 +1196,18 @@ far the largest file.
Its pan clamping is likewise gated on the data being wider than the view, or the empty 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. 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 - **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. 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 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 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 per-channel list, and why `format_elapsed()` steps to a coarser unit instead of
@@ -991,6 +1217,16 @@ far the largest file.
plotter for this), not on the rows: `Figure::layout()` clears every row's 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 `set_min_y_gutter()` to measure what its own labels need, so a floor written onto the
rows survives exactly until the next layout. 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 - **Signals start disabled** — enabling is what starts acquisition. A trigger on a
signal nobody acquires can never fire, so selecting/arming enables it. signal nobody acquires can never fire, so selecting/arming enables it.
- **The trigger's event window is separate from the display window**, and - **The trigger's event window is separate from the display window**, and
@@ -1328,12 +1564,72 @@ far the largest file.
that grew from `1 s` to `123.456 ms` would widen the bar and relayout everything under it that grew from `1 s` to `123.456 ms` would widen the bar and relayout everything under it
on a mere change of window. That is also why the window is typed into a popover rather on a mere change of window. That is also why the window is typed into a popover rather
than shown in the bar. than shown in the bar.
- **`apply_time_window()` is the only place the display window changes.** It clamps to - **No plot interaction changes the time scale; only the window control does.** The *time scale*
`window_limit()` and moves `RowSet::set_time_window()` and `ScopeModel::set_event_span()` is more than the axis: `RowSet::window_s_` also feeds `ScopeModel::set_event_span()` (the
together, because the trigger's pre-trigger share is a share of *this* window. Paths that trigger's pre-trigger share is a share of *this* window), `ScopeModel::set_target_span()`
change the span for their own reasons (Fit, an event pin) call `sync_window_display()` (which may re-plan the partition, move the decimation and **drop the history**), the window
instead, which only restates the control and never moves the plot — calling `apply_` there control's readout and the session file. A **zoom or a pan** does not touch it by construction —
would clamp a legitimately short capture up to the requested window. the controller writes the *member* axis and `clamp_to_data()` caps the span at `window_s_`, so
nothing has to be written to keep it that way. **Neither do the two fits**: a fit is the
*undo of a zoom*, so `RowSet::fit_x()` restores the window that is already set and reads
`window_s_` without ever writing it. Both fits used to frame the *data* instead and move
`window_s_` alone, which was wrong twice over — a gesture-triggered re-plan of the rings, and
an event span left on the *old* window until the next decade step or unit change applied the
fitted value for real (one action, two changes of time scale).
- **`apply_time_window()` is the only place the time scale changes**, and the window control is
its only caller. It clamps to `window_limit()` and moves `set_target_span()`,
`set_event_span()`, `RowSet::set_time_window()`, the control and the session save together —
the partition **before** the axis, so the axis is framed over the rings that will exist. A fit
must not come here for two reasons: it is not entitled to the scale at all, and
`set_time_window()` unpins and re-arms the live follow, which would throw a held event or a
paused view onto a live edge it has nothing bound to. Paths that change the span for their own
reasons (an event pin) call `sync_window_display()` alone, which only restates the control and
never moves the plot — calling `apply_time_window()` there would clamp a legitimately short
capture up to the requested window.
- **The x labels go relative on the tick *step against the label resolution at the axis'
magnitude*, never on the span.** `RowSet::update_time_labels()`'s question is only ever "can
this step be written at this magnitude": `TickFormat::Time` carries three significant figures
below a minute, **whole seconds** through `m:ss`/`h:mm:ss` up to a day, and three figures of
days above — so the resolution is a step function of the magnitude, which `time_label_resolution()`
states and nothing else may re-derive. The old test was `span < 1.0` and it was wrong in both
directions: a 2 s window at twenty minutes of uptime reads `20:00` on every tick and stayed
absolute, and a **triggered** sub-second window — whose axis is already `[-25ms, +25ms]` about
the edge — went relative and so re-anchored its ticks *away from the trigger*, with a `t0`
caption that had nothing to explain. Three details hold it up. The step is read back from the
majors the **last layout** produced, which is not a feedback loop (a tick's value depends on the
range and the axis' pixel width, never on the label form) but *is* absent before the first
layout — and with no ticks the form must be **left alone**, because a step guessed from the span
would be latched by the hysteresis below. The hysteresis is a 1.05 band on *leaving* the relative
form. And *entering* it needs a ULP of slack (`1 - 1e-9`): a step differenced from two tick
values and a resolution from `pow(10, n)` differ in their last bits when they are the same
number, and `step == res` is a readable axis (`1.73s`, `1.74s`, `1.75s` — one in the last digit
is exactly enough), so the bare comparison picked a different form from one frame to the next.
The guard is `[timelabels-probe]`'s `distinct=1`, which asserts what the form is *for* rather
than which form a given window gets.
- **The `t0 = …` caption is chrome, never an annotation.** It is `Plot::set_x_note()` (added to
`deps/plotter` for this), drawn by `Renderer::draw_x_note()` on the legend's own
`draw_readout_panel()` backplate in the bottom-left corner of the axes, in **pixels**. It was an
`Annotation` first, and an annotation's anchor is a *data* coordinate: the caption therefore
crawled across the plot under a pan and drifted under a zoom, which is what "[t0=...s] move
slightly around when user zoom/pan" was. Three things follow. The **text is the host's**, because
the caption has to name the anchor precisely enough that label + caption is the exact absolute
time and `format_time()` would round it to three figures (`"t0 = %.6g s"`); only the **bottom
row** of the figure carries one, every other row being set to `""`; and the renderer measures it
rather than `Plot::layout()` — it reserves no space and nothing can be picked on it, so there is
no rect to read back, and a caption explaining the x labels must not be able to move the layout
those labels were measured into. The guards are the library's
`the_x_note_is_chrome_that_a_pan_or_a_zoom_does_not_move` (the footprint is the pixels a frame
with the caption differs from one without, and a pan plus a 64× zoom must give the same box to
the pixel) and `[timelabels-probe]`'s `note=`, which reads `Plot::x_note()` back.
- **`fit_x()` restores the *whole of what the view may show*, which is not always `window_s_`.**
Live, that is the newest `window_s_` at the live edge (`set_time_window(window_s_)`, the same
path a resume takes). Pinned — a held event, a filling capture or a paused view — it is the
**pin's** full range, which is the snapshot's own stamps and may be legitimately *narrower*
than the requested window. It must write the per-row **member** axes as well as the link group:
`hold_pinned_window()` prefers a member that differs from `pinned_stated_`, which is how it
tells a real gesture from its own echo, so writing the group alone is reverted to the zoomed
range on the next frame. `fit_all()` is the y fit plus this, shared rather than duplicated so
the two cannot drift apart.
- **The header bar's buttons are icon-only**, so headless probes must reach them - **The header bar's buttons are icon-only**, so headless probes must reach them
through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via
`find_labelled_button()`. The **pane switches are toolbar buttons** too (`cursors_toggle`, `find_labelled_button()`. The **pane switches are toolbar buttons** too (`cursors_toggle`,
@@ -1442,9 +1738,14 @@ far the largest file.
Those two used to read the *label*, and the label no longer carries `packets`, `gaps`, 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 `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 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 word from a fixed set** (`connecting`, `live`, `paused`, `armed`, `capturing`, `event held`,
the layout reason above; a seventh word, or a field whose width tracks the stream, breaks the `disarmed`) for the layout reason above; a word **wider than the widest already there**, or a
row the plot is laid out under. 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 - **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. 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 `update_status_popover()` only ever calls `gtk_label_set_text()`, and `popover_rebuilds` in
+275 -43
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 count, which channel each trace slot holds, and the allocated size and reveal
state of both side panels. 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 ## Layout
``` ```
@@ -63,27 +72,101 @@ frame and the GUI thread takes once per frame around binding the traces.
Samples cross the boundary through `plot::data::RingBuffer` (single producer, Samples cross the boundary through `plot::data::RingBuffer` (single producer,
single consumer), never through the lock. 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 **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* 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 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 immutable-series path a held trigger event uses (`DisplayMode::Paused` sits beside `Held`).
difference is the anchoring: an event's stamps are edge-relative, a pause's are the stream's own, Resuming drops the snapshot and
so the times on the axis do not change when the pause begins. Resuming drops the snapshot and
re-frames on the newest data, so the seconds that passed while paused are *there*, not a hole — 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.
**Sub-second windows read relative.** The x ticks are absolute until the window's span goes ## Measurements
under a second (with a band to 1.05 s, so the form cannot flicker while a zoom is nudged), and
relative below it: the ticks are then generated on a grid anchored at the window's left edge — **The x ticks read relative when, and only when, they could not be written absolutely.** A
so they read `0`, `5ms`, `10ms` instead of `12.345671s`, `12.345672s`, which three significant `TickFormat::Time` label carries three significant digits, so the test is the tick **step**
digits cannot even tell apart — and a caption names that anchor in the axes' top-left corner: against the label's own **resolution at the axis' magnitude**: `12.345671s` and `12.345672s` are
`t0 = 12.3456 s`. The labels scale through the prefixed units the SI formatter uses — `500ms`, the same string, and there the ticks are re-anchored on a grid at the window's left edge — so
`500us`, `5us`, `5ns` — so a microsecond window reads in microseconds instead of `0.005ms`. Adding the two is the tick's absolute time. The caption is an annotation they read `0`, `5ms`, `10ms` — with a caption naming that anchor, `t0 = 12.3456 s`. Adding the
inside the axes rather than the axis title on purpose: its text changes with the window, and a two is the tick's absolute time. The labels scale through the prefixed units the SI formatter
title of a changing width would relayout the plot every frame. Everything a user copies out — uses — `500ms`, `500us`, `5us`, `5ns` — so a microsecond window reads in microseconds instead of
the cursors' `T0`/`T1`/`dT`, a measurement, an export — stays in absolute stream seconds. `0.005ms`.
The rule used to be the *span* alone (relative under a second, with a band to 1.05 s), and a
span says nothing about whether a label can be written: it was wrong in **both** directions. A
2 s window at twenty minutes of uptime is labelled `20:00` on every tick — `m:ss` carries whole
seconds — and was left absolute and unreadable. And a **triggered** sub-second window, whose
axis is already `[-25ms, +25ms]` about the edge, reads `-20ms`, `-10ms`, `0`, `10ms` perfectly
well, yet was re-anchored anyway: the one position a triggered axis exists to show was displaced
by a `t0` caption that had nothing to explain. The step is read back from the majors the last
layout produced (a function of the range and the axis width, not of the label form, so no
feedback loop), the magnitude is the larger of the two endpoints, and the comparison carries a
1.05 band leaving the relative form plus a ULP of slack entering it — a step *equal* to the
resolution is readable (`1.73s`, `1.74s`, `1.75s`: one in the last digit is exactly enough), so a
decision taken on the last bits of two differently computed doubles picked a different form from
one frame to the next. The caption is **chrome** — `Plot::set_x_note()`, added to the plotter for
it, drawn on the legend's own backplate in the bottom-left corner of the axes — rather than the
axis title or an annotation. Not the title, because its text changes with the window and a title
of a changing width would relayout the plot every frame; not an annotation, because an
annotation's anchor is a *data* coordinate, so the caption crawled across the plot under a pan and
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 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 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 by the app: the window's edge is derived from the newest sample during the layout, so a value
@@ -921,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 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 - **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 stream is connected, a CONFIG has arrived, acquisition is not paused, and in trigger
armed. Anything else leaves it a steady grey, with the reason on its tooltip mode the trigger is armed. Anything else leaves it steady, with the reason on its
(`paused`, `trigger disarmed`, `waiting for the stream`). *Blinking* is the signal, tooltip (`paused`, `trigger disarmed`, `waiting for the stream`). *Blinking* is the
not the colour: a steady badge is a stopped pipeline, and that distinction survives signal, not the colour: a steady badge is a stopped pipeline, and that distinction
a screenshot. It is one glyph whose colour is all that changes, so blinking it 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 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. 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 - **`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 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 to hold at the wire rate, in which case the ingest resamples (see "A window longer
@@ -983,9 +1074,9 @@ that changes nothing still says why. Each plot's y axis has a right-click menu
with auto scale, a fixed range, **Fit X axis**, **Fit all**, and one with auto scale, a fixed range, **Fit X axis**, **Fit all**, and one
**Remove '<signal>' from plot N** entry per occupied trace slot (a plot with nothing on **Remove '<signal>' from plot N** entry per occupied trace slot (a plot with nothing on
it says so instead). The two fits are the same pair the overflow menu and the `f` key it says so instead). The two fits are the same pair the overflow menu and the `f` key
offer, split by what they touch: **Fit X axis** frames the window on the data the ring offer, split by what they touch: **Fit X axis** puts the x view back on the whole time
holds and leaves every y scale exactly as the user set it, **Fit all** does that and window and leaves every y scale exactly as the user set it, **Fit all** does that and
auto-scales the y axes too. auto-scales the y axes too. Neither changes the time window itself — see below.
**The plot does not twitch.** Two independent causes were fixed, both of which **The plot does not twitch.** Two independent causes were fixed, both of which
showed up as the x axis moving a few pixels every frame — including while paused, showed up as the x axis moving a few pixels every frame — including while paused,
@@ -1001,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 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 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 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 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 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, 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 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. 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 `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 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 its floor out of the label strip the reservation converges over two layouts, so
@@ -1018,9 +1136,9 @@ bug. `x-ticks` is content, and varies by one as a live window slides over a roun
number: number:
``` ```
layout over 952 frames: axes.x 67..80, axes.w 546..559, viewport.w 627..627, layout over 583 frames: axes.x 88..104, axes.w 707..782, viewport.w 870..870,
y-gutter 44..74, x-ticks 20..21, x-desync 0 y-gutter 88..104, x-ticks 20..41, x-desync 0
axes.w changes: 2 (last at frame 35, side panel up=1) 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 Set `UDPSCOPE_PROBE_PAUSE_MS=<ms>` to pause the acquisition part-way through a
@@ -1086,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* — where the same residue read `3.19e-13ms`. A range whose ticks are *legitimately*
that small keeps them. 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 **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 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 need them read as two competing sets of ticks. They are now three things the style states
@@ -1112,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`, 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`, `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 `a_sparse_window_between_samples_still_inks_both_margins`), and each
was verified to fail with its own fix reverted. was verified to fail with its own fix reverted.
@@ -1139,10 +1268,64 @@ is what made a dragged cursor spring to where it had been. A ruler is per row: e
two different values, and the pane names the row. two different values, and the pane names the row.
`mix` needs no rulers: it reports the value the trace actually has at each marker, read `mix` needs no rulers: it reports the value the trace actually has at each marker, read
through the library's `Plot::crosshair_value()` — the two bracketing samples, through the library's `Plot::crosshair_value()` — **the nearer of the two bracketing
interpolated — so it is the answer to "what is this signal at these two times" rather samples**, never a number between them — so it is the answer to "which datum of this
than "drag a ruler until it crosses the trace". A readout is `--` when the trace has signal is at these two times" rather than "drag a ruler until it crosses the trace". A
nothing at that time and `partial` when only one of the two markers is over samples. readout is `--` when the trace has nothing at that time and `partial` when only one of
the two markers is over samples.
The value used to be a linear interpolation between the brackets, which is upstream's
old convention and was wrong for this app in two ways at once. The samples here are
**already averaged** — ingest decimation is a boxcar over `time_stride` samples (see
"Decimation at ingest is an average") — so a number computed *between* two of them is a
second, undeclared estimate laid over the first, and nothing on screen or in an export
holds it. And at a zoom past the sample spacing it is visibly not the datum the pointer
is over: the polyline between two samples is a straight line the renderer drew, and
quoting a point on it reports the drawing rather than the data. A tie reads the earlier
sample. The fix is upstream (`deps/plotter`, `crosshair_span()`'s `t` is a selector rather
than a blend factor), so the library's own hover crosshair changed with the pane — which is
the point, since both are the same lookup and a user comparing them must not see two
numbers. Upstream keeps the blend as `CrosshairValue::Interpolated` for hosts whose x is a
quantity they are willing to interpolate; the nearest sample is the default and udpscope
never states the other.
**The crossing dot is on the sample, not on the marker's column.** The dot is placed
through `Plot::crosshair_point()` — the same lookup as the value, returning the quoted
sample's own x as well as its y — so the mark the eye follows and the number in the pane
are the same datum. The two x's differ by up to half a sample period: nothing at a
whole-window view, and the whole width of the panel once a few samples span the axes, which
is exactly the state in which a reader is asking "which point is that?". It therefore leaves
the dot a little off its own vertical line by design — the line says where the *marker* is
and the dot says which *datum* is being quoted, and when the marker is between two samples
those are two places. The library's hover crosshair snaps the same way and draws its mark
`Style::crosshair_marker_px` across (9 logical px against a trace's own 3 px sample dots),
since a snapped mark lands among those dots and at their size would read as one more sample
instead of as the answer to the question the hover asks.
**A zoomed-in trace is drawn on the x its readout quotes, which needed the gather's origin to
become the caller's.** Separately from the half-sample offset above — which is a property of
*which datum* is quoted — there was an offset in *where the polyline was drawn*, and it grew
with the zoom. `plot::data::Series::gather()` stages x as `f32`, which cannot hold an absolute
epoch time, so it emits a value relative to an origin the consumer adds back in `f64`. That
origin was fixed at `x0()`, the start of the **retained window**: the f32 then had to resolve
2^-24 of the whole ring however little of it was on screen — 1.9 µs over 20 s of retained data
— while a readout reads `x_at()`, exact `f64`. At a view 10 µs wide across a 716 px axis
(14 ns a pixel) that is over a hundred pixels between the drawn sample and the x reported for
it, and it halves with every zoom *out*, which is why it read as "zooming in makes the
crosshair pick the wrong point".
The origin is now an argument, and `Renderer::draw_traces` passes the **visible range's
minimum** — so the quantisation is 2^-24 of the span on screen, a fixed 4e-5 of a pixel, at
every zoom. The fix is upstream (`deps/plotter`), where the parameter was deliberately placed
*before* the output buffer so that every one of the library's and this app's call sites was a
compile error rather than a silent change of behaviour. One simplification fell out of it:
`stage_xy()` took a gather origin and a draw origin, those are now always the same value, and
the linear case hands the gathered buffer straight to the line batch with no per-point copy.
Two guards: the library's
`a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes`, which measures the
error in pixels both ways (under 0.05 px with the view's minimum, over 10 px with `x0()`), and
this app's `trace ink:` line, which now reports `worst drawn-vs-quoted P px` over the raw-level
paints of a whole run.
The readouts are a **table**: a header naming the columns, then one row per instrument — The readouts are a **table**: a header naming the columns, then one row per instrument —
`| who | A | B | delta |`. The first row is the two times and their difference, and the `| who | A | B | delta |`. The first row is the two times and their difference, and the
@@ -1156,7 +1339,16 @@ Every readout is a **measurement**, not a raw number: at most two decimals, scal
SI prefix so the digits sit in `1..999`, and the unit the stream declared appended — SI prefix so the digits sit in `1..999`, and the unit the stream declared appended —
`10.52 mV`, `-9.8 V`, `dT -398.67 ms`, `1.06 pV`. Two decimals is the resolution the `10.52 mV`, `-9.8 V`, `dT -398.67 ms`, `1.06 pV`. Two decimals is the resolution the
instruments can claim (a marker lands on a sample, and the sample is an `f32`), and a instruments can claim (a marker lands on a sample, and the sample is an `f32`), and a
trailing zero is dropped, so `1.50 V` reads `1.5 V`. A row's rulers use its signals' unit trailing zero is dropped, so `1.50 V` reads `1.5 V`. The prefix is applied **whether or not
the stream declared a unit**, and that is not a detail: a signal whose CONFIG declares none is
ordinary, a *math* channel never has one, and two decimals of an unprefixed small value is
`0.00` — the reading was simply gone for an entire class of channels, which went unnoticed
because the mock declares `V` for everything (it now takes `--unit ''`, which is the only way
a probe can see this). With no unit the prefix is written *tight* against the number —
`585.24m`, not `585.24 m` — because the space is there to separate the number from its unit,
and a spaced `m` with nothing after it reads as metres. Below `1e-12`, where no prefix can
help, the value falls back to three significant digits in whatever form fits rather than to
`0.00`. A row's rulers use its signals' unit
when they agree on one and print bare digits when they do not: a ruler is on the row's y when they agree on one and print bare digits when they do not: a ruler is on the row's y
axis, and two units on one axis make "the" unit a guess. A trace's line carries the axis, and two units on one axis make "the" unit a guess. A trace's line carries the
signal's **colour chip and its name** — `● sine 616.49 mV -24.57 mV -641.06 mV` — and the signal's **colour chip and its name** — `● sine 616.49 mV -24.57 mV -641.06 mV` — and the
@@ -1247,10 +1439,24 @@ logged with both rates and the new anti-alias corner. A window change that leave
limitations below); only the shared clock is decimated. limitations below); only the shared clock is decimated.
**Fit to data** (overflow menu, the `f` key, or **Fit all** in a plot's right-click menu) **Fit to data** (overflow menu, the `f` key, or **Fit all** in a plot's right-click menu)
auto-scales every row's y axis and re-frames the x window on the data the ring holds, auto-scales every row's y axis to the data it holds and puts the x view back on the whole time
propagated to every row. **Fit X axis** is the same re-framing without the y scaling, for window, propagated to every row. **Fit X axis** is that x half alone, for the common case where
the common case where the window should be the data's but the levels were placed by hand. a zoom should be undone but the levels were placed by hand. The status line acknowledges either
The status line acknowledges either (`x fitted to the buffered data (1.25 s)`). (`x back to the 2 s window`).
**Nothing you do on the plot changes the time window** — only the window control does. A fit is
the *undo of a zoom*: a 2 s window zoomed into reads 2 s again. That matters because the window
is not just the axis. It is also the trigger's event span, how the samples are stored (a window
long enough to move the decimation re-plans the rings and **drops the history**), the control's
readout and what the session file records — so a wheel notch or a Fit that redefined it would
quietly throw the buffer away. A zoom and a pan are view-only by construction; the fits read the
window and never write it.
Which 2 s depends on the state, and the fit restores the whole of whatever the view is allowed to
show: live, the newest window at the live edge; and for a held event or a paused view, the pin's
full range — the snapshot's own span, which is legitimately *narrower* than the requested window
when the ring had not filled it. A fit of a frozen view keeps it frozen rather than throwing it
back onto the stream.
**Shared x axis.** The rows are the rows of **one `plot::Figure`**, drawn by one **Shared x axis.** The rows are the rows of **one `plot::Figure`**, drawn by one
`plot::gtk4::PlotArea` in figure mode, and they all join a single x link group. `plot::gtk4::PlotArea` in figure mode, and they all join a single x link group.
@@ -1260,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 (`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 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 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. 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 The self-check's layout line carries `x-desync N`, the number of frames in which
@@ -1311,16 +1517,30 @@ division.
over the shared x window, at the **stored** sample rate — never the LOD pyramid's 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 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 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: file reads in the same order as the signal panel.
`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 The table has **two time columns**, which is the whole answer to "relative or absolute":
different modes line up.
- **`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: Two formats, picked by the extension the save dialog ends up with:
| file | what it is | | 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. | | `.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 Parquet (the original ask) would have meant linking Arrow — tens of megabytes of
@@ -1389,7 +1609,8 @@ fails validation renders a one-line stand-in so the window always opens rather t
no `Gtk-CRITICAL`. no `Gtk-CRITICAL`.
**The status bar is two tiers.** The bar itself shows one glance line: the activity badge, the **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 ⓘ 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 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 carried all of them was too wide to read while watching a signal, and because the bar sits
@@ -1474,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 leaving an empty plot to be puzzled over, and prints a `starved:` line to stderr while the
state holds. 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 ## Limitations
- **Multi-element (vector/matrix) signals are acquired with a zero-order hold.** - **Multi-element (vector/matrix) signals are acquired with a zero-order hold.**
+1 -1
+5 -5
View File
@@ -189,7 +189,7 @@ void ChannelBuffer::raw(u64 begin, u64 end, f32* out) const {
for (u64 i = count; i < end - begin; ++i) out[i] = 0.0f; for (u64 i = count; i < end - begin; ++i) out[i] = 0.0f;
} }
void ChannelBuffer::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, void ChannelBuffer::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
plot::data::GatherMode mode) const { plot::data::GatherMode mode) const {
const u64 n = pyramid_.size(); const u64 n = pyramid_.size();
const u64 b = std::min(begin, n); const u64 b = std::min(begin, n);
@@ -197,7 +197,7 @@ void ChannelBuffer::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_
if (b >= e) return; if (b >= e) return;
const u64 origin = pyramid_.window_base(); const u64 origin = pyramid_.window_base();
const f64 base = x0(); // x is emitted relative to this (see the header) const f64 base = x_origin; // x is emitted relative to this (see the header)
const auto emit_raw = [&](u64 from, u64 to) { const auto emit_raw = [&](u64 from, u64 to) {
for (u64 i = from; i < to; ++i) { for (u64 i = from; i < to; ++i) {
@@ -302,13 +302,13 @@ u64 SlotSeries::snap_index(f64 x) const {
return buffer_ != nullptr ? buffer_->snap_index(x) : 0; return buffer_ != nullptr ? buffer_->snap_index(x) : 0;
} }
void SlotSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, void SlotSeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
plot::data::GatherMode mode) const { plot::data::GatherMode mode) const {
if (event_ != nullptr) { if (event_ != nullptr) {
event_->gather(level, begin, end, out_xy, mode); event_->gather(level, begin, end, x_origin, out_xy, mode);
return; return;
} }
if (buffer_ != nullptr) buffer_->gather(level, begin, end, out_xy, mode); if (buffer_ != nullptr) buffer_->gather(level, begin, end, x_origin, out_xy, mode);
} }
void SlotSeries::copy_raw(u64 begin, u64 end, f32* out) const { void SlotSeries::copy_raw(u64 begin, u64 end, f32* out) const {
+11 -8
View File
@@ -73,16 +73,18 @@ class ChannelBuffer final : public plot::data::Series {
/// datagram leaves a gap, so index times must come from the time ring. /// datagram leaves a gap, so index times must come from the time ring.
f64 x_at(u64 i) const override; f64 x_at(u64 i) const override;
/// Bracketing samples by binary search over the time ring, which is monotonic, /// Bracketing samples by binary search over the time ring, which is monotonic,
/// so a crosshair value is interpolated between the two samples actually either /// so a crosshair reads whichever of the two samples actually either side of the
/// side of the pointer -- a grid walk would land on the wrong pair after a gap. /// pointer is nearer -- a grid walk would land on the wrong pair after a gap.
bool neighbours(f64 x, u64& i0, u64& i1) const override; bool neighbours(f64 x, u64& i0, u64& i1) const override;
u64 snap_index(f64 x) const override; u64 snap_index(f64 x) const override;
plot::Range x_bounds() const override; plot::Range x_bounds() const override;
plot::Range y_bounds() const override; plot::Range y_bounds() const override;
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; } const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
/// x is emitted **relative to `x0()`** (the library's contract): staging an /// x is emitted **relative to `x_origin`** (the library's contract): staging an
/// absolute epoch time as f32 would quantise it far coarser than a pixel. /// absolute epoch time as f32 would quantise it far coarser than a pixel, and
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, /// staging it relative to the whole *retained window* quantises it coarser than a
/// pixel of a zoomed view -- see `plot::data::Series::gather()`.
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override; plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override;
/// `copy_raw`, published so the retargetable trace series can forward the /// `copy_raw`, published so the retargetable trace series can forward the
@@ -109,8 +111,9 @@ class ChannelBuffer final : public plot::data::Series {
/// slot cannot be overwritten under the read. Everything else on this object is read at /// slot cannot be overwritten under the read. Everything else on this object is read at
/// *paint* time with no lock, and this one slot is the ring's oldest: the one the producer is /// *paint* time with no lock, and this one slot is the ring's oldest: the one the producer is
/// about to overwrite. Reading it there returned a stamp a whole ring-span too new whenever /// about to overwrite. Reading it there returned a stamp a whole ring-span too new whenever
/// the producer lapped it between the bounds check and the copy -- and `x0()` is the origin /// the producer lapped it between the bounds check and the copy -- and `x0()` is what
/// the renderer reconstructs every trace point from (`gather[i] + series.x0()`), so one such /// `neighbours()` tests the visible range against, as well as the origin the renderer
/// reconstructed every trace point from before the caller named one, so one such
/// read moved the entire polyline a ring-span off its axis (an invisible trace) or, caught /// read moved the entire polyline a ring-span off its axis (an invisible trace) or, caught
/// inside the renderer's increasing-suffix filter, made it drop most of the points. That is /// inside the renderer's increasing-suffix filter, made it drop most of the points. That is
/// the "live plot flickers as if there were no data" defect. The cached value is also the /// the "live plot flickers as if there were no data" defect. The cached value is also the
@@ -163,7 +166,7 @@ class SlotSeries final : public plot::data::Series {
plot::Range x_bounds() const override; plot::Range x_bounds() const override;
plot::Range y_bounds() const override; plot::Range y_bounds() const override;
const plot::data::MinMaxPyramid& pyramid() const override; const plot::data::MinMaxPyramid& pyramid() const override;
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override; plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override;
bool streaming() const noexcept override { return event_ == nullptr && buffer_ != nullptr; } bool streaming() const noexcept override { return event_ == nullptr && buffer_ != nullptr; }
+28 -18
View File
@@ -26,14 +26,24 @@ using plot::u64;
/// live ring buffers switched by a pending flag, plus a display-epoch shift) /// live ring buffers switched by a pending flag, plus a display-epoch shift)
/// could not stop. /// could not stop.
struct EventSnapshot { struct EventSnapshot {
/// Seconds, monotonic -- edge-relative for a trigger event, the stream's own for a /// Seconds, monotonic, and **in the coordinates the axis reads**: a trigger event's
/// pause view. **f64, like the live ring it is cut from** (see `BufferStore::time()`): /// stamps are edge-relative and a pause view's are relative to the instant it froze,
/// an event's stamps are small enough that f32 would nearly do, but a *pause* is /// which is what `x_offset` below records. **f64, like the live ring it is cut from**
/// deliberately not rebased, so its stamps carry the full stream time and quantise at /// (see `BufferStore::time()`), which also makes the cut in `publish_event()`/
/// the same 3.8 us per 32 s of uptime the live ring did. Keeping the two the same type /// `publish_pause_view()` a straight copy.
/// also makes the cut in `publish_event()`/`publish_pause_view()` a straight copy.
std::vector<f64> stamps; std::vector<f64> stamps;
std::vector<std::vector<f32>> values; ///< one array per channel, same length 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 pre_s = 0.0; ///< seconds of pre-trigger data kept
double post_s = 0.0; ///< seconds of post-trigger data kept double post_s = 0.0; ///< seconds of post-trigger data kept
std::vector<plot::Range> y; ///< per-channel value bounds, precomputed std::vector<plot::Range> y; ///< per-channel value bounds, precomputed
@@ -108,7 +118,7 @@ class EventSeries final : public plot::data::Series {
/// Indices need no rebasing: the pyramid is built from sample 0 and nothing is ever /// Indices need no rebasing: the pyramid is built from sample 0 and nothing is ever
/// dropped from it, so `window_base()` is 0 and a window index *is* the absolute one /// dropped from it, so `window_base()` is 0 and a window index *is* the absolute one
/// (unlike the ring, whose window slides). /// (unlike the ring, whose window slides).
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
plot::data::GatherMode mode = plot::data::GatherMode mode =
plot::data::GatherMode::Envelope) const override { plot::data::GatherMode::Envelope) const override {
if (channel_ >= snap_->values.size()) return; if (channel_ >= snap_->values.size()) return;
@@ -118,17 +128,17 @@ class EventSeries final : public plot::data::Series {
const u64 b = std::min(begin, n); const u64 b = std::min(begin, n);
const u64 e = std::min(std::max(end, b), n); const u64 e = std::min(std::max(end, b), n);
if (b >= e) return; if (b >= e) return;
// x **relative to x0()**, which is the library's gather contract: the renderer // x **relative to `x_origin`**, which is the library's gather contract: the
// adds `x0()` back before mapping to the screen. Emitting the absolute stamp made // renderer adds that same origin back before mapping to the screen. Emitting the
// it add x0 twice, so the whole capture was drawn one `x0()` -- a full // absolute stamp made it add an origin twice, so the whole capture was drawn one
// pre-trigger window -- to the left of the axis it had just been measured // `x0()` -- a full pre-trigger window -- to the left of the axis it had just been
// against. The event's own stamps are small (edge-relative, so [-pre, +post]), // measured against. The event's own stamps are small (edge-relative, so
// which is why that shift looked like a plausible picture: with a 50/50 split the // [-pre, +post]), which is why that shift looked like a plausible picture: with a
// right half of the plot was simply empty, zooming out showed the trace ending at // 50/50 split the right half of the plot was simply empty, zooming out showed the
// the x = 0 marker, and zooming in past the shift showed nothing at all while the // trace ending at the x = 0 marker, and zooming in past the shift showed nothing
// crosshair -- which reads `x_at()`, never `gather()` -- went on reporting the // at all while the crosshair -- which reads `x_at()`, never `gather()` -- went on
// right values. // reporting the right values.
const f64 base = x0(); const f64 base = x_origin;
const auto rel = [&](u64 i) { const auto rel = [&](u64 i) {
return static_cast<f32>(stamps[static_cast<std::size_t>(i)] - base); return static_cast<f32>(stamps[static_cast<std::size_t>(i)] - base);
}; };
+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 /// two time columns, with a `_2`, `_3` suffix. Two elements of one vector signal can
/// easily sanitise to the same string. /// easily sanitise to the same string.
std::vector<std::string> column_labels(const ExportTable& table) { 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; std::vector<std::string> out;
out.reserve(table.columns.size()); out.reserve(table.columns.size());
for (const ExportTable::Column& col : table.columns) { 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 // 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. // snapshots, so the export is the picture the user is looking at either way.
std::shared_ptr<const EventSnapshot> snap = model.display_snapshot(); 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) { if (snap != nullptr) {
// The snapshot is immutable and already index-aligned, so the visible window is two // 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. // 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; if (hi <= lo) return table;
const std::size_t first = static_cast<std::size_t>(lo - st.begin()); 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); 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.time.resize(n);
table.stream_time.resize(n); table.abs_seconds.resize(n);
for (std::size_t i = 0; i < n; ++i) { for (std::size_t i = 0; i < n; ++i) {
table.time[i] = st[first + 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) { for (const int c : chans) {
const std::size_t ch = static_cast<std::size_t>(c); 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 // 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. // dropping the rings under the copy, which is what the UI thread has to fear here.
std::lock_guard<std::mutex> lock(model.mutex()); std::lock_guard<std::mutex> lock(model.mutex());
// The axis reads `display_epoch + stamp`, so the window has to be put back on the // The axis reads `stamp - stream_at_x0()`, so the window has to be put back on the stream's
// stream's clock before the ring is searched. It is 0 in live mode, but reading it is // clock before the ring is searched -- and that offset is a whole window while streaming now,
// what keeps this correct rather than merely true today. // not the 0 it once was: the live axis is `[-window, 0]` about the newest sample.
const f64 epoch = model.display_epoch(); const f64 s0 = model.stream_at_x0();
u64 first = 0, last = 0; 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; const u64 n = last - first;
if (n == 0) return table; 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)); std::vector<f64> stamps(static_cast<std::size_t>(n));
store.time().copy_from(first, n, stamps.data()); store.time().copy_from(first, n, stamps.data());
table.time.resize(static_cast<std::size_t>(n)); 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) { for (std::size_t i = 0; i < stamps.size(); ++i) {
table.stream_time[i] = stamps[i]; table.time[i] = stamps[i] - s0;
table.time[i] = epoch + table.stream_time[i]; table.abs_seconds[i] = abs_base + table.time[i];
} }
for (const int c : chans) { for (const int c : chans) {
const std::size_t ch = static_cast<std::size_t>(c); 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 // `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 // only inserts alignment padding when asked to (`align=True`) -- so the records below
// are field widths back to back with no gaps. // 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')"; for (const std::string& label : labels) descr += ", ('" + label + "', '<f4')";
descr += "]"; descr += "]";
std::string header = "{'descr': " + descr + ", 'fortran_order': False, 'shape': (" + 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::size_t at = 0;
std::memcpy(record.data() + at, &table.time[i], sizeof(f64)); std::memcpy(record.data() + at, &table.time[i], sizeof(f64));
at += 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); at += sizeof(f64);
for (const ExportTable::Column& col : table.columns) { for (const ExportTable::Column& col : table.columns) {
const f32 v = i < col.values.size() ? col.values[i] : 0.0f; 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; if (!opened(out, path, error)) return false;
const std::vector<std::string> labels = column_labels(table); 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; for (const std::string& label : labels) line += "," + label;
line += "\n"; line += "\n";
std::fwrite(line.data(), 1, line.size(), out.f); 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(); line.clear();
std::snprintf(buf, sizeof(buf), "%.17g", table.time[i]); std::snprintf(buf, sizeof(buf), "%.17g", table.time[i]);
line += buf; line += buf;
std::snprintf(buf, sizeof(buf), "%.17g", table.stream_time[i]); std::snprintf(buf, sizeof(buf), "%.17g", table.abs_seconds[i]);
line += ","; line += ",";
line += buf; line += buf;
for (const ExportTable::Column& col : table.columns) { 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` 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, /// The x the plot shows and the crosshair reads, which is **relative** in every mode: seconds
/// edge-relative seconds in `[-pre, +post]` while an event is held. /// 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; std::vector<f64> time;
/// The stream's own clock, so two exports taken in different modes line up. Equal to /// The stream's own absolute clock -- the number the time signal carried, which is what the
/// `time` in live mode; `time + trigger_time` while an event is held, which is the one /// source stamped the samples with -- so two exports taken in different modes, or from
/// case where the visible x is not the stream's. /// different runs, line up on one timeline.
std::vector<f64> stream_time; ///
/// `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::vector<Column> columns;
std::size_t rows() const noexcept { return time.size(); } 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. /// model access. In trigger mode nothing is locked: the `EventSnapshot` is immutable.
ExportTable collect_export(ScopeModel& model, RowSet& rows); 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 /// `<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. /// 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 the drawing thread reads whatever that ring holds when a frame starts, which is why a trace
never makes the window stutter. never makes the window stutter.
The stream is the clock. Every time you read on screen is a timestamp the source sent, never The stream is the clock. Every interval you read on screen is measured between timestamps the
your machine's, so a stalled source freezes the view instead of scrolling it -- and a source source sent, never your machine's, so a stalled source freezes the view instead of scrolling it
whose clock steps still gets the window you asked for. -- 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> <b>How to reach it</b>
+4 -1
View File
@@ -1,7 +1,10 @@
<b>Rows, layout and drag &amp; drop</b> <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 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> <b>How to reach it</b>
+3
View File
@@ -19,6 +19,9 @@ unit, width-locked so a change of window cannot widen the header bar.
value. Stamping at the end of a group would shift every trace half a stored sample late. value. Stamping at the end of a group would shift every trace half a stored sample late.
• Moving the window across a decimation boundary rebuilds the buffer and drops the history. • Moving the window across a decimation boundary rebuilds the buffer and drops the history.
Walking it inside one decade is free. Walking it inside one decade is free.
• <b>This control is the only thing that changes the window.</b> A zoom, a pan or a fit moves the
view <i>inside</i> it and leaves it alone — which is the point of the previous bullet: a wheel
notch that redefined the window could re-plan the buffer and throw your samples away.
• The trigger sees the <i>stored</i> (filtered) signal, not the wire signal, because an edge • The trigger sees the <i>stored</i> (filtered) signal, not the wire signal, because an edge
found on a sample that is not in the buffer could not be cut out of it. found on a sample that is not in the buffer could not be cut out of it.
+10
View File
@@ -19,6 +19,16 @@ The full table is in the shortcut overview (<tt>Ctrl+/</tt>).
• A <i>pinned</i> view is navigable within its own data but cannot leave it. While an event is • A <i>pinned</i> view is navigable within its own data but cannot leave it. While an event is
held, a capture is filling, or the view is paused, a zoom <i>in</i> works normally — "not held, a capture is filling, or the view is paused, a zoom <i>in</i> works normally — "not
outside the event" is not "not readable" — but a pan or a zoom out is clamped to the data. outside the event" is not "not readable" — but a pan or a zoom out is clamped to the data.
• Nothing you do on the plot changes the <i>time window</i> — only the header control does. A
zoom or a pan moves the view inside the window; <b>f</b> (and Fit X axis) is the <i>undo</i> of a
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. • 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 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. 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. 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 • Pausing is the easiest way to measure a layout or read a cursor precisely, because nothing
under the pointer moves between frames. 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> <b>Example</b>
+18 -1
View File
@@ -19,12 +19,29 @@ owns a <b>Reset</b> button. Drag a marker to move it. The mode dropdown offers f
• <tt>mix</tt> is the reading mode: one line per <i>bound</i> trace, with the trace's own values • <tt>mix</tt> is the reading mode: one line per <i>bound</i> trace, with the trace's own values
at each marker and their difference. A slot with a name but no channel behind it is not a at each marker and their difference. A slot with a name but no channel behind it is not a
trace and gets no line. trace and gets no line.
• A value is the <i>nearest stored sample's</i>, never a number between two samples. Zoom in past
the sample spacing and the line between two samples is something the plot drew, not data —
so a reading off it would be the drawing. The hover crosshair reads the same way.
• Every reading carries a <i>metric prefix</i> so the digits sit in <tt>1..999</tt>:
<tt>10.52 mV</tt>, not <tt>0.01 V</tt>. Two decimals is all an instrument landing on an
<tt>f32</tt> sample can claim, so without the prefix a small value would read <tt>0.00</tt>
and the reading would be gone. A stream that declares no unit gets the prefix anyway, written
tight against the number (<tt>585.24m</tt>) — the space separates the number from its unit,
and a spaced <tt>m</tt> with nothing after it would read as metres.
• A marker past the newest sample reads <tt>--</tt>. With only one marker over data the row • A marker past the newest sample reads <tt>--</tt>. With only one marker over data the row
reads <tt>partial</tt>: a difference between a number and nothing is not a number. reads <tt>partial</tt>: a difference between a number and nothing is not a number.
• In <tt>mix</tt> each marker also places a crossing annotation per trace, which is what lets • In <tt>mix</tt> each marker also places a crossing annotation per trace, which is what lets
you see on the plot which sample a number came from. you see on the plot which sample a number came from. It is <i>on that sample</i>, so when the
marker falls between two it sits a little off its own line: the line is where you put the
marker, the dot is the datum being quoted. The hover crosshair's mark snaps the same way, and
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 • The time row exists only in the modes that show the vertical pair. <tt>horizontal</tt> has no
time row, by design. 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> <b>Example</b>
+5 -1
View File
@@ -27,7 +27,11 @@ set, with <b>Save</b> and <b>Delete</b>.
• A window with too few crossings reads <tt>invalid</tt> / <tt>--</tt> rather than a plausible • A window with too few crossings reads <tt>invalid</tt> / <tt>--</tt> rather than a plausible
invented number. Frequency over a window holding one edge is the usual case. invented number. Frequency over a window holding one edge is the usual case.
• <tt>frequency</tt> is reported in Hz and <tt>period</tt>/<tt>pulse duration</tt> in seconds; • <tt>frequency</tt> is reported in Hz and <tt>period</tt>/<tt>pulse duration</tt> in seconds;
every other type carries the channel's own unit. every other type carries the channel's own unit. A value is written with a <i>metric prefix</i>
so its digits sit in <tt>1..999</tt> — <tt>636.62 mV</tt>, not <tt>0.64 V</tt> — including when
the stream declares no unit at all, which is always the case for a math channel: there the
prefix goes tight against the number (<tt>636.62m</tt>), since two decimals of an unprefixed
small value is <tt>0.00</tt>.
• Cost is bounded by reading the summary pyramid the producer already maintains, not the raw • Cost is bounded by reading the summary pyramid the producer already maintains, not the raw
window. The values are still exact: a bucket's sum is a sum. window. The values are still exact: a bucket's sum is a sum.
+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 • 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 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. 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 • Two time columns. <tt>time</tt> is the display timeline — negative ages ending at 0 while
event is held) and <tt>stream_time</tt> is the source's own clock. Both are strictly increasing streaming, offsets from the trigger edge while an event is held — so it is the x you read a
and evenly spaced; duplicate or backwards stamps would mean the file held two clocks. 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 • 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. shorter than the event span. Export outside a capture if you want the whole event.
<b>Example</b> <b>Example</b>
A 2 s window at 10 kS/s over 3 traces writes 20001 rows and 5 columns 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>64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)</tt> bytes. Read it back with
<tt>pd.DataFrame(np.load(path))</tt>. <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> <b>Worth knowing</b>
• The badge is one glyph in three colours: lit and dim alternating means data is arriving, and a • The badge is one glyph in four colours: green <i>alternating</i> lit and dim means data is
steady idle colour means it is not. A badge that never blinks is not a working badge. arriving, and a steady 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>, Of the two steady colours, <i>grey</i> is no stream at all or a pipeline you stopped (the
<tt>capturing</tt>, <tt>event held</tt>, <tt>disarmed</tt>. In trigger mode the bar adds tooltip says which), and <i>yellow</i> is a source that answered but has sent no configuration.
exactly one more field, how long ago the last event was. • 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 • 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 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 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. 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 • <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. 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 • The starvation notice — <i>connected, but no data is arriving</i> — is that same situation seen
situation seen from the other side. Pointing the app at a port where nothing answers produces from the other side: the signal list is here, so something took the stream away. A source that
it too. 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 • <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. 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>(); auto snap = std::make_shared<EventSnapshot>();
snap->x_offset = x_offset;
std::lock_guard<std::mutex> lock(mutex_); std::lock_guard<std::mutex> lock(mutex_);
BufferStore& src = live(); BufferStore& src = live();
u64 first = 0; u64 first = 0;
@@ -525,10 +526,14 @@ void ScopeModel::publish_pause_view(double lo_s, double hi_s) {
const u64 n = last - first; const u64 n = last - first;
snap->stamps.resize(static_cast<std::size_t>(n)); snap->stamps.resize(static_cast<std::size_t>(n));
if (n > 0) { if (n > 0) {
// The stamps go in *as the ring holds them*: the stream's own seconds, which is the // The stamps come out of the ring on the stream's own clock and are rebased onto
// coordinate the axis was already reading. Nothing is rebased, so a pause does not move the // `x_offset`, which is the coordinate the axis was *already* reading: a live view is
// dates under the reader the way a trigger event's edge-relative axis deliberately does. // 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()); src.time().copy_from(first, n, snap->stamps.data());
for (f64& t : snap->stamps) t -= x_offset;
} }
snap->values.resize(channels_.size()); snap->values.resize(channels_.size());
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0}); 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) { void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
auto snap = std::make_shared<EventSnapshot>(); 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(); BufferStore& src = live();
plot::data::RingBuffer<f64>& tsrc = src.time(); 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); 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 /// 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 /// 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 /// series the plot binds to -- and both are anchored the same way: the stamps are the
/// edge-relative, a pause's are the stream's own, so the axis does not move when the pause /// coordinates the axis was already reading, with `x_offset` recording which instant that
/// begins and the reader keeps the times they were already reading. /// x = 0 was. So the axis does not move when the pause begins.
std::shared_ptr<const EventSnapshot> pause_view() const noexcept { std::shared_ptr<const EventSnapshot> pause_view() const noexcept {
return pause_view_.load(std::memory_order_acquire); return pause_view_.load(std::memory_order_acquire);
} }
/// Snapshots the live ring over `[lo_s, hi_s]` (stream seconds, the display epoch already /// Snapshots the live ring over `[lo_s, hi_s]` (stream seconds, i.e. the display window put
/// removed) as the pause view. The acquisition thread keeps writing, so this is a copy: what /// back on the stream's clock) as the pause view, rebased onto `x_offset` so its stamps are
/// is frozen is the picture, not the data. /// the x the axis is already showing. The acquisition thread keeps writing, so this is a
void publish_pause_view(double lo_s, double hi_s); /// 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); } 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 /// 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; return event() != nullptr ? DisplayMode::Held : DisplayMode::Waiting;
} }
/// The x origin the plot uses: 0 for "seconds since the stream started", or minus /// The offset the **live** channels report their x with: `display_x = stream_t + epoch`.
/// the trigger instant while in trigger mode, so the axis reads seconds from the /// Minus the newest stored sample's stamp while streaming, so the axis reads
/// event. Applied to the live store's channels. /// `[-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); } f64 display_epoch() const noexcept { return display_epoch_.load(std::memory_order_relaxed); }
void set_display_epoch(f64 e) noexcept; 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 --------------------------------------------------------- // --- stream state ---------------------------------------------------------
StreamStats& stats() noexcept { return stats_; } StreamStats& stats() noexcept { return stats_; }
const StreamStats& stats() const noexcept { return stats_; } const StreamStats& stats() const noexcept { return stats_; }
+140 -78
View File
@@ -30,10 +30,23 @@ constexpr plot::u32 kRulerBColor = 0x8FA8C8u;
/// The measurements' own colour, distinct from both cursor rulers so a readout mark cannot be /// The measurements' own colour, distinct from both cursor rulers so a readout mark cannot be
/// mistaken for something the user placed. /// mistaken for something the user placed.
constexpr plot::u32 kMeasureColor = 0x9BE8A0u; constexpr plot::u32 kMeasureColor = 0x9BE8A0u;
/// The window-start caption's colour: quiet chrome, brighter than a grid line.
constexpr plot::u32 kTimeNoteColor = 0xC8C8C8u;
constexpr plot::u32 kTriggerColor = 0xFF5B5Bu; constexpr plot::u32 kTriggerColor = 0xFF5B5Bu;
/// The smallest difference a `TickFormat::Time` label can *write* at the magnitude `t` -- what
/// the last digit of a label at that magnitude is worth. `format_time` writes three significant
/// figures below a minute (of the SI-prefixed mantissa under a second, of the seconds above it),
/// whole seconds through `m:ss`/`h:mm:ss` up to a day, and three figures of days above. So the
/// resolution is a step function of the *magnitude* and not of the span -- which is the whole
/// reason a relative axis is ever needed, and the reason a span threshold alone gets it wrong in
/// both directions.
double time_label_resolution(double t) {
t = std::fabs(t);
if (!std::isfinite(t) || !(t > 0.0)) return 0.0;
if (t >= 86400.0) return 86400.0 * std::pow(10.0, std::floor(std::log10(t / 86400.0)) - 2.0);
if (t >= 60.0) return 1.0;
return std::pow(10.0, std::floor(std::log10(t)) - 2.0);
}
constexpr double kParked = -1.0e12; ///< hides an instrument without removing it constexpr double kParked = -1.0e12; ///< hides an instrument without removing it
/// The measurement marks' stroke: thinner than the chrome that frames the plot (a trace is 1.5, /// The measurement marks' stroke: thinner than the chrome that frames the plot (a trace is 1.5,
/// the axis lines and the trigger marker `Style::line_width_px`), and broken, so a readout over /// the axis lines and the trigger marker `Style::line_width_px`), and broken, so a readout over
@@ -112,9 +125,9 @@ bool RowSet::remove_row(int at) {
void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) { void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
r.plot = &p; r.plot = &p;
r.axis_x = r.plot->add_x_axis( 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( 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) { for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t i = static_cast<std::size_t>(s); const std::size_t i = static_cast<std::size_t>(s);
r.plot->add_trace( r.plot->add_trace(
@@ -155,11 +168,6 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
r.plot->set_cursor_stroke(r.measure_v[mi], kMeasureStrokeWidth, r.plot->set_cursor_stroke(r.measure_v[mi], kMeasureStrokeWidth,
plot::Vec2{kMeasureDashOn, kMeasureDashOff}); plot::Vec2{kMeasureDashOn, kMeasureDashOff});
} }
// The window-start caption for the relative x labels: an annotation, hidden until the
// labels are relative. Inside the axes on purpose -- its text changes with the window, and
// an axis title of a changing width would relayout the plot every frame.
r.time_origin_note = r.plot->add_annotation(
plot::Annotation{0.0, 0.0, "", plot::Color::hex(kTimeNoteColor), r.axis_x, r.axis_y, false});
r.trigger_marker = r.plot->add_cursor(kParked, plot::Color::hex(kTriggerColor)); r.trigger_marker = r.plot->add_cursor(kParked, plot::Color::hex(kTriggerColor));
// A marker, not an instrument: it must sit exactly on the edge's time, so its // A marker, not an instrument: it must sit exactly on the edge's time, so its
// snap is Free and the controller never rounds it onto a sample. // snap is Free and the controller never rounds it onto a sample.
@@ -191,7 +199,7 @@ void RowSet::rebuild_all() {
} }
figure_->link_x(static_cast<plot::u32>(i), x_group_); 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 // 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 // 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 // changing the row count while a capture is displayed must not throw the view back
@@ -396,18 +404,30 @@ void RowSet::place_crossings() {
if (ida >= r.plot->annotation_count() || idb >= r.plot->annotation_count()) continue; if (ida >= r.plot->annotation_count() || idb >= r.plot->annotation_count()) continue;
plot::Annotation a = r.plot->annotation(ida); plot::Annotation a = r.plot->annotation(ida);
plot::Annotation b = r.plot->annotation(idb); plot::Annotation b = r.plot->annotation(idb);
double xa = 0.0;
double ya = 0.0; double ya = 0.0;
double xb = 0.0;
double yb = 0.0; double yb = 0.0;
const bool has_a = mix && r.plot->crosshair_value(static_cast<plot::u32>(s), cursor_a_x_, ya); // `crosshair_point()`, not `crosshair_value()`: the number beside this dot is a *stored
const bool has_b = mix && r.plot->crosshair_value(static_cast<plot::u32>(s), cursor_b_x_, yb); // sample's*, so the dot belongs on that sample and not on the marker's own column. The
// two differ by up to half a sample period, which is nothing at a whole-window view and
// the whole width of the panel once the view is zoomed to a handful of samples -- the
// state in which a reader is asking "which point is that?" in the first place. It leaves
// the dot a little off its vertical line by design: the line says *where the marker is*
// and the dot says *which datum is being quoted*, and when the marker is between two
// samples those are not the same place.
const bool has_a =
mix && r.plot->crosshair_point(static_cast<plot::u32>(s), cursor_a_x_, xa, ya);
const bool has_b =
mix && r.plot->crosshair_point(static_cast<plot::u32>(s), cursor_b_x_, xb, yb);
a.visible = has_a; a.visible = has_a;
b.visible = has_b; b.visible = has_b;
if (has_a) { if (has_a) {
a.x = cursor_a_x_; a.x = xa;
a.y = ya; a.y = ya;
} }
if (has_b) { if (has_b) {
b.x = cursor_b_x_; b.x = xb;
b.y = yb; b.y = yb;
} }
r.plot->set_annotation(ida, a); r.plot->set_annotation(ida, a);
@@ -497,12 +517,48 @@ bool RowSet::update_time_labels() {
const double span = view.span(); const double span = view.span();
if (!(span > 0.0) || !std::isfinite(span)) return false; if (!(span > 0.0) || !std::isfinite(span)) return false;
// Sub-second windows read relative: `TickFormat::Time` writes three significant digits, so // A relative axis is the answer to one problem only: a tick step the label form cannot write.
// `12.345671s` and `12.345672s` are the same string and a 1 ms step around a multi-second // `TickFormat::Time` carries three significant digits, so a 1 ms step around a 12 s stamp
// time cannot be labelled at all. The band is the switch's hysteresis -- entering below a // writes `12.3s` on every tick -- there the ticks have to be re-anchored and the anchor named.
// second, leaving only above 1.05 -- so a span sitting on the boundary cannot flicker // The test is therefore the *step against the resolution at the axis' own magnitude*, never the
// between two forms while a zoom is nudged. // span: the two part company in both directions, and both cases are real here. A 2 s window at
const bool want_relative = relative_x_ ? span < 1.05 : span < 1.0; // twenty minutes of uptime is labelled `20:00` throughout and needs the anchor although its
// span is wide; a 50 ms trigger window spanning [-25ms, +25ms] is labelled `-20ms .. +20ms`
// perfectly well and needs nothing, although its span is narrow. That second case is what made
// a triggered sub-second view carry a `t0` caption *and* re-anchor its ticks away from the
// edge, which is the one position a triggered axis exists to show.
// The 1.05 band is the switch's hysteresis, so a step sitting on the boundary cannot flicker
// between two forms while a zoom is nudged, and the `1 - 1e-9` is a slack the *entry* needs for
// its own reason: a step read back from two tick values and a resolution from `pow(10, n)`
// differ in their last bits even when they are the same number, and `step == res` is a readable
// axis (`1.73s, 1.74s, 1.75s` -- one in the last digit is exactly enough), so a decision taken
// on those bits picked a different form from one frame to the next.
const double mag = std::max(std::fabs(view.min), std::fabs(view.max));
const double res = time_label_resolution(mag);
// The step the labels are actually written at: the majors the *last* layout produced. They are
// a function of the range and the axis' pixel width, not of the label form, so reading them
// back here is no feedback loop.
double step = 0.0;
{
const plot::Axis& ax = first.plot->x_axis(first.axis_x);
double prev = 0.0;
bool have = false;
for (const plot::Tick& t : ax.ticks) {
if (!t.major) continue;
if (have) {
step = t.value - prev;
break;
}
prev = t.value;
have = true;
}
}
// No ticks yet -- the frames before the first layout. The form is a property of the labels, so
// with no labels there is nothing to decide, and keeping the current one is right where
// guessing a step from the span is not: a guess that entered the relative form would be
// *latched* there by the hysteresis above.
const bool want_relative =
step > 0.0 ? step < res * (relative_x_ ? 1.05 : 1.0 - 1.0e-9) : relative_x_;
const bool changed = want_relative != relative_x_; const bool changed = want_relative != relative_x_;
relative_x_ = want_relative; relative_x_ = want_relative;
@@ -518,27 +574,25 @@ bool RowSet::update_time_labels() {
r.plot->set_x_axis_origin_from_range(r.axis_x, relative_x_); r.plot->set_x_axis_origin_from_range(r.axis_x, relative_x_);
if (!relative_x_) r.plot->set_x_axis_origin(r.axis_x, 0.0); if (!relative_x_) r.plot->set_x_axis_origin(r.axis_x, 0.0);
// The caption belongs with the labels, which a figure draws once, on the bottom row (the // The caption belongs with the labels, which a figure draws once, on the bottom row (the
// one whose x axis is the figure's). Every row gets it parked. // one whose x axis is the figure's). Every other row gets an empty one, which draws nothing.
const bool bottom = i == rows_ - 1; const bool bottom = i == rows_ - 1;
if (r.time_origin_note >= r.plot->annotation_count()) continue; if (!relative_x_ || !bottom) {
plot::Annotation note = r.plot->annotation(r.time_origin_note); r.plot->set_x_note("");
note.visible = relative_x_ && bottom; continue;
if (note.visible) {
// Inside the axes, just above the axis line, at their left edge: the reader adds this
// to a tick, so it sits beside the ticks.
const plot::Range yr = r.plot->y_range(r.axis_y);
note.x = view.min;
note.y = yr.min + 0.04 * (yr.span() > 0.0 ? yr.span() : 1.0);
char text[64];
// The anchor the *drawn* labels were built with: the axis' own origin, which the last
// layout set from its range. Reading it back (rather than keeping our own copy) is what
// makes the reader's addition exact -- label plus caption is that tick's absolute time.
const double shown = r.plot->x_axis_origin(r.axis_x);
x_origin_ = shown;
std::snprintf(text, sizeof(text), "t0 = %.6g s", shown);
note.text = text;
} }
r.plot->set_annotation(r.time_origin_note, note); char text[64];
// The anchor the *drawn* labels were built with: the axis' own origin, which the last
// layout set from its range. Reading it back (rather than keeping our own copy) is what
// makes the reader's addition exact -- label plus caption is that tick's absolute time.
const double shown = r.plot->x_axis_origin(r.axis_x);
x_origin_ = shown;
std::snprintf(text, sizeof(text), "t0 = %.6g s", shown);
// Chrome, not an annotation: an annotation is anchored to a *data* coordinate, so the caption
// crawled across the plot as the view panned and drifted as it zoomed -- and it came with the
// annotation's own pin cross, which pointed at nothing. `set_x_note()` places it in the axes'
// bottom-left corner in pixels, on the legend's backplate, which is where the labels it
// qualifies are.
r.plot->set_x_note(text);
} }
return changed; return changed;
} }
@@ -646,20 +700,26 @@ void RowSet::unpin_pause_window() {
} }
void RowSet::fit_x() { void RowSet::fit_x() {
Row& first = row(0); if (!figure_) return;
if (!first.plot || !figure_) return; if (!pinned_) {
first.plot->auto_fit_x(); // Live: the same path the window control and a resume take, with the window it already has.
const plot::Range fitted = first.plot->x_range(first.axis_x); // That re-arms the follow and parks the newest `window_s_` at the live edge, which is the
const double span = fitted.span(); // whole of what a live view may show.
if (!std::isfinite(span) || !(span > 0.0)) return; set_time_window(window_s_);
// The same two paths `fit_all()` takes: a held event owns its window and states it return;
// through the link group, a live view re-arms the follow and frames the newest span.
if (pinned_) {
window_s_ = span;
figure_->set_x_link_range(x_group_, fitted);
} else {
set_time_window(span);
} }
// Pinned (a held event or a paused view): the pin's full range, and the pin stays. The
// *members* have to be written as well as the group, because `hold_pinned_window()` prefers a
// member that has moved since our own last statement -- that is how it tells a gesture from its
// own echo -- so writing the group alone would be reverted to the zoomed-in member next frame.
const plot::Range pin{-pinned_pre_, pinned_post_};
if (!(pin.span() > 0.0) || !std::isfinite(pin.min) || !std::isfinite(pin.max)) return;
for (int i = 0; i < rows_; ++i) {
Row& r = row(i);
if (r.plot) r.plot->set_x_range(r.axis_x, pin);
}
pinned_stated_ = pin;
figure_->set_x_link_range(x_group_, pin);
} }
void RowSet::sync_cursors(int drag_row) { void RowSet::sync_cursors(int drag_row) {
@@ -778,30 +838,22 @@ void RowSet::fit_all() {
Row& r = row(i); Row& r = row(i);
if (r.plot) r.plot->auto_fit_y(r.axis_y); if (r.plot) r.plot->auto_fit_y(r.axis_y);
} }
Row& first = row(0); // The x half is `fit_x()`'s exactly -- the zoom undone, the time window untouched -- and
if (!first.plot || !figure_) return; // sharing it is what keeps the two fits from drifting apart.
// Row 0 fits its own x to the data it holds; that span becomes the shared window, and fit_x();
// stating it through the group is what puts every row on it.
first.plot->auto_fit_x();
const plot::Range fitted = first.plot->x_range(first.axis_x);
const double span = fitted.span();
if (!std::isfinite(span) || !(span > 0.0)) return;
if (pinned_) {
// Fitting a captured event: the window is the snapshot's own `[-pre, +post]` span,
// and `set_time_window()` would re-arm the follow and re-frame on a live edge that
// trigger mode has nothing bound to.
window_s_ = span;
figure_->set_x_link_range(x_group_, fitted);
} else {
set_time_window(span);
}
} }
void RowSet::note_y_gutter(double need_px) { 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 // 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 // font the labels are actually drawn in.
// character count is an exact width).
if (!y_gutter_reserved_) { if (!y_gutter_reserved_) {
for (int i = 0; i < rows_; ++i) { for (int i = 0; i < rows_; ++i) {
Row& r = row(i); Row& r = row(i);
@@ -809,10 +861,19 @@ void RowSet::note_y_gutter(double need_px) {
const plot::render::TextBackend* text = r.plot->text_backend(); const plot::render::TextBackend* text = r.plot->text_backend();
if (text == nullptr) continue; if (text == nullptr) continue;
const plot::render::Style& style = r.plot->style(); 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)); const double pad = double(style.device(style.grid_label_pad));
// "-1.23e-45" is the longest label `format_si()`'s fallback can print. // A sign, three significant figures and a one-glyph SI prefix: the widest label
want = std::max(want, 9.0 * advance + 2.0 * pad + 2.0); // `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; y_gutter_reserved_ = true;
break; break;
} }
@@ -820,9 +881,10 @@ void RowSet::note_y_gutter(double need_px) {
if (want > y_gutter_px_) y_gutter_px_ = want; if (want > y_gutter_px_) y_gutter_px_ = want;
} }
void RowSet::apply_y_gutter() { void RowSet::apply_gutters() {
if (!figure_ || !(y_gutter_px_ > 0.0)) return; if (!figure_) return;
figure_->set_min_y_gutter(static_cast<f32>(y_gutter_px_)); 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) { void RowSet::set_trigger_marker(double x, bool visible) {
+51 -24
View File
@@ -106,11 +106,6 @@ class RowSet {
/// it keeps its position whatever the cursor toggle says. /// it keeps its position whatever the cursor toggle says.
plot::u32 trigger_marker = 0; plot::u32 trigger_marker = 0;
/// The caption naming the window's start while the x labels are relative: an annotation
/// inside the axes, so its text can change every frame without moving the layout (an axis
/// *title* would, and it would be a different width each time).
plot::u32 time_origin_note = 0;
/// The measurements' readout marks: a pool created with the plot (a `Plot` cannot add or /// The measurements' readout marks: a pool created with the plot (a `Plot` cannot add or
/// drop a cursor after the fact) and parked unless a measurement is using the slot. /// drop a cursor after the fact) and parked unless a measurement is using the slot.
std::array<plot::u32, kMeasureMarks> measure_h{}; std::array<plot::u32, kMeasureMarks> measure_h{};
@@ -183,6 +178,14 @@ class RowSet {
void set_time_window(double seconds); void set_time_window(double seconds);
double time_window() const noexcept { return window_s_; } 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_auto(int row, bool on);
void set_y_range(int row, double lo, double hi); void set_y_range(int row, double lo, double hi);
@@ -196,7 +199,13 @@ class RowSet {
void place_crossings(); void place_crossings();
CursorMode cursor_mode() const noexcept { return cursor_mode_; } 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_a_x() const noexcept { return cursor_a_x_; }
double cursor_b_x() const noexcept { return cursor_b_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. /// One row's ruler values, or 0 for a row that does not exist.
@@ -298,11 +307,25 @@ class RowSet {
void set_data_pin(double lo, double hi); void set_data_pin(double lo, double hi);
void unpin_pause_window(); void unpin_pause_window();
/// Frames the x window on the data row 0 holds, leaving every y axis alone. The x /// Undoes an x zoom: puts the view back on the whole time window, leaving every y axis alone.
/// counterpart of `fit_all()`, for the one thing the y axes cannot say: how wide the /// The x counterpart of `fit_all()`.
/// window should be. ///
/// **It does not change the time window** -- `window_s_` is read, never written. A fit is a
/// plot interaction, and the window is also the trigger's event span, the buffer partition and
/// the control's readout, so a fit that *framed the data* instead would redefine all of that
/// from a gesture (and re-plan the rings, dropping the history). What a user asks for here is
/// the zoom undone: a 2 s window zoomed into reads 2 s again.
///
/// Which 2 s depends on the state, and both cases restore the *whole* of what the view is
/// allowed to show: live, the newest window at the live edge; pinned (a held event or a paused
/// view), the pin's full range, with the pin kept -- `set_time_window()` would clear it and
/// re-frame on a live edge that a frozen view has nothing bound to.
void fit_x(); void fit_x();
/// Whether a window is pinned: a held event, a filling capture or a paused view. The condition
/// `fit_x()` branches on, which is why the fit probe reports it.
bool window_pinned() const noexcept { return pinned_; }
/// Adopts the instruments a gesture is moving, on `drag_row` (the row whose controller holds /// Adopts the instruments a gesture is moving, on `drag_row` (the row whose controller holds
/// a cursor), and nothing otherwise. /// a cursor), and nothing otherwise.
/// ///
@@ -336,10 +359,9 @@ class RowSet {
/// 0, so nothing outside the pre+post window is visible. /// 0, so nothing outside the pre+post window is visible.
void pin_event_window(double pre_s, double post_s); void pin_event_window(double pre_s, double post_s);
/// Fits every row's y axis to the data it holds, and re-frames the shared x window /// Fits every row's y axis to the data it holds, and puts x back on the whole time window --
/// on what row 0 retains (the same thing the plot's own `f` key does). The window /// `fit_x()`'s x half exactly, so the two cannot drift apart. Like it, **this does not change
/// the toolbar reports becomes the fitted span, so the dropdown can say what is /// the time window**: the y axes are fitted to their data, x is merely un-zoomed.
/// actually on screen.
void fit_all(); void fit_all();
/// Reports the left gutter the rows currently need, and reserves at least the width /// Reports the left gutter the rows currently need, and reserves at least the width
@@ -351,18 +373,22 @@ class RowSet {
/// left and right by a few pixels every frame. Since `set_min_y_gutter()` is a /// 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 /// 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 /// 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 /// `format_si()` writes for an axis with no unit -- a sign, three significant figures
/// `%.3g` fallback) plus the label padding, measured once with the plot's own font. /// and a one-glyph prefix -- plus the label padding, measured once with the plot's own
/// Both reservations only ever grow, so the gutter is constant in practice. /// 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); void note_y_gutter(double need_px);
/// Applies the stable gutter as the figure's floor. Call once per frame, before the /// Applies the stable left gutter as the figure's floor, and the right gutter beside
/// frame is drawn. /// 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 /// Both have to be the *figure's* and not each row's: `Figure::layout()` clears every
/// every row's `set_min_y_gutter()` to measure what its own labels need, so a floor /// row's `set_min_y_gutter()` to measure what its own labels need, so a floor written
/// written onto the rows survives exactly until the next layout. /// onto the rows survives exactly until the next layout, and the rows have to end on
void apply_y_gutter(); /// 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 /// 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 /// trace is drawn in is the colour the signal list's chip shows, on every row the
@@ -391,8 +417,6 @@ class RowSet {
/// at the end of the gesture (`Controller::end_drag()`), and a value read out of the plot /// 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. /// mid-drag has already been through that act.
void place_cursors(Row& r, plot::CursorPlacement placement = plot::CursorPlacement::Snapped); 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_; std::unique_ptr<plot::Figure> figure_;
/// The one x link group every visible row belongs to. Groups are numbered from 1. /// The one x link group every visible row belongs to. Groups are numbered from 1.
@@ -411,6 +435,9 @@ class RowSet {
/// gesture to be honoured; one that matches it is just the clamp's own answer arriving back. /// 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}; plot::Range pinned_stated_{0.0, 0.0};
double y_gutter_px_ = 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; bool y_gutter_reserved_ = false;
std::array<Row, static_cast<std::size_t>(kMaxRows)> storage_{}; std::array<Row, static_cast<std::size_t>(kMaxRows)> storage_{};
CursorMode cursor_mode_ = CursorMode::Vertical; CursorMode cursor_mode_ = CursorMode::Vertical;
+6
View File
@@ -48,6 +48,11 @@ std::string format_glance(const StatusSnapshot& s) {
out += " · "; out += " · ";
out += s.window_text; out += s.window_text;
out += " window"; 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) { if (s.trigger_on) {
out += " · "; out += " · ";
if (s.have_event) { 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, s.plot1_traces, s.plot1_samples, s.buffer_mb, s.span_s,
static_cast<unsigned long long>(s.time_stride)); static_cast<unsigned long long>(s.time_stride));
std::string out = buf; std::string out = buf;
if (s.paused) out += " | paused for " + format_elapsed(s.paused_for_s);
if (s.trigger_on) { if (s.trigger_on) {
out += s.have_event ? " | last event " + format_elapsed(s.since_event_s) + " ago" out += s.have_event ? " | last event " + format_elapsed(s.since_event_s) + " ago"
: " | no event yet"; : " | 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 /// two cannot disagree. No GTK here on purpose: this is the one part of the status bar that
/// can be reasoned about as text. /// can be reasoned about as text.
struct StatusSnapshot { struct StatusSnapshot {
/// One word from the fixed set: "live", "paused", "armed", "capturing", "event held", /// One word from the fixed set: "connecting", "live", "paused", "armed", "capturing",
/// "disarmed". It is a word from a *fixed* set because the bar sits directly above the plot /// "event held", "disarmed". It is a word from a *fixed* set because the bar sits directly
/// and a field whose width tracked the stream would relayout it every frame. /// 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"; const char* state_word = "live";
/// The long prose the old single-line bar carried, kept for the detail string and the dump. /// The long prose the old single-line bar carried, kept for the detail string and the dump.
std::string state_detail; std::string state_detail;
@@ -44,10 +47,20 @@ struct StatusSnapshot {
bool have_event = false; bool have_event = false;
double since_event_s = 0.0; double since_event_s = 0.0;
int reconnects = 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 /// The bar: "live · 10 kS/s · 2 s window", plus " · event 4.2 s ago" in trigger mode, " · for
/// nothing else ever. The badge glyph is a separate widget and is not part of this string. /// 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); 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 /// The popover's prose and the exit dump's line, field for field as the old single-line bar
+404 -169
View File
File diff suppressed because it is too large. Load diff
+9 -5
View File
@@ -50,16 +50,16 @@ def signal_desc(name, *, rate, time_mode, time_idx, unit, rows=1, cols=1, dims=0
return bytes(d) return bytes(d)
def build_config(rate, time_mode=TIME_FULL_ARRAY): def build_config(rate, time_mode=TIME_FULL_ARRAY, unit="V"):
sigs = [ sigs = [
# The stream's clock: referenced by every data signal, so udpscope treats # The stream's clock: referenced by every data signal, so udpscope treats
# it as the time base rather than a plottable channel. Its *own* time_mode # it as the time base rather than a plottable channel. Its *own* time_mode
# is what udpscope switches on, so `--time-mode first` has to land here. # is what udpscope switches on, so `--time-mode first` has to land here.
signal_desc("time", rate=0.0, time_mode=time_mode, signal_desc("time", rate=0.0, time_mode=time_mode,
time_idx=NO_TIME_SIGNAL, unit="s"), time_idx=NO_TIME_SIGNAL, unit="s"),
signal_desc("sine", rate=rate, time_mode=time_mode, time_idx=0, unit="V"), signal_desc("sine", rate=rate, time_mode=time_mode, time_idx=0, unit=unit),
signal_desc("square", rate=rate, time_mode=time_mode, time_idx=0, unit="V"), signal_desc("square", rate=rate, time_mode=time_mode, time_idx=0, unit=unit),
signal_desc("ramp", rate=rate, time_mode=time_mode, time_idx=0, unit="V"), signal_desc("ramp", rate=rate, time_mode=time_mode, time_idx=0, unit=unit),
] ]
payload = struct.pack("<I", len(sigs)) + b"".join(sigs) payload = struct.pack("<I", len(sigs)) + b"".join(sigs)
payload += bytes([PUBLISH_ACCUMULATE]) payload += bytes([PUBLISH_ACCUMULATE])
@@ -101,6 +101,10 @@ def main():
"sample (UDPS_TIME_FIRST_SAMPLE) and leaves the viewer to space the " "sample (UDPS_TIME_FIRST_SAMPLE) and leaves the viewer to space the "
"rest by the declared cadence -- the shape a real MARTe2-style " "rest by the declared cadence -- the shape a real MARTe2-style "
"producer with a single u64 clock has") "producer with a single u64 clock has")
ap.add_argument("--unit", default="V",
help="the unit the data signals declare in CONFIG; '' declares none, which "
"is an ordinary stream (and what every math channel looks like) and is "
"the only way a probe can see the unitless readout formatting")
ap.add_argument("--clock-jitter", type=float, default=0.0, ap.add_argument("--clock-jitter", type=float, default=0.0,
help="per-packet stamp jitter, as a fraction of one packet's span: a device " help="per-packet stamp jitter, as a fraction of one packet's span: a device "
"that stamps each batch from its own clock does not land on a perfect " "that stamps each batch from its own clock does not land on a perfect "
@@ -138,7 +142,7 @@ def main():
t_stream = 0.0 t_stream = 0.0
started = time.monotonic() started = time.monotonic()
next_send = started next_send = started
sock.sendto(build_config(declared, time_mode), peer) sock.sendto(build_config(declared, time_mode, args.unit), peer)
print("mock udpstreamer: CONNECT from %s:%d, CONFIG sent" % peer, flush=True) print("mock udpstreamer: CONNECT from %s:%d, CONFIG sent" % peer, flush=True)
elif magic == MAGIC and ptype == PKT_DISCONNECT: elif magic == MAGIC and ptype == PKT_DISCONNECT:
print("mock udpstreamer: DISCONNECT", flush=True) print("mock udpstreamer: DISCONNECT", flush=True)