Files
udpscope/README.md
T
2026-10-02 11:30:54 +02:00

727 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# udpscope
A GTK4 oscilloscope-style viewer for a UDPStreamer source: it receives one stream
with [`deps/udpsclient`](deps/udpsclient), buffers it in two identical large
circular buffers, and draws it with [`deps/plotter`](deps/plotter).
`SPECS.md` is the requirement document; this file describes what was built.
## Build and run
```sh
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build
./build/udpscope --server 127.0.0.1 --port 8000 # unicast
./build/udpscope --server 10.0.0.5 --port 8000 \
--multicast 239.1.2.3 --interface 10.0.0.9 # multicast
```
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb`
(size of the sample buffer, default 200), `--max-window` (largest time
window the toolbar offers, default 600), `--holdoff` (default trigger hold-off),
`--recv-buffer`, `--plots` (the number of stacked plots to start with, 1..8; rows are
added and removed from the **Layout editor** afterwards).
Every CONFIG is dumped to stderr once -- publish mode and, per signal, its name,
element count, rate, time mode and time-signal index -- because a signal that
refuses to plot is almost always a signal the stream described in an unexpected
way. The status line carries the same story live: `plot 1: 2 trace(s), 1400
samples`, and `time x3` when the shared clock is decimated.
Headless self-check (used by the smoke tests): `UDPSCOPE_EXIT_AFTER_MS=<ms>`
switches trigger mode on, waits for an event, then leaves trigger mode the way the
"Go live" button does, closes the window, and prints on stdout the status line in
both states, the frame and per-channel sample counts, each plot's painted-frame
count, which channel each trace slot holds, and the allocated size and reveal
state of both side panels.
## Layout
```
src/config.* command line
src/model.* the sample buffer, the partition plan, the signal/channel lists
src/channel.* ChannelBuffer (a ring pair as a plot::data::Series) and SlotSeries
src/acquisition.* the UDPStreamer client, the producer thread, frame decoding
src/trigger.* the trigger configuration and the producer-side state machine
src/plot_host.* the one plot::Figure, its rows' traces and cursors, the x link group
src/export.* the visible range as a table, and the .npy / .csv writers
src/layout_store.* the per-source session layout file (TOML), written and read back
src/event_series.hpp an immutable captured event as a plot::data::Series
src/ui.* the GTK4 window: toolbar, trigger panel, signal panel, plot box
```
Threading: one producer thread owns the `udps_client_t` and calls
`udps_client_poll()`; the GTK main thread owns every widget and the GL contexts.
They share `ScopeModel` under one mutex, which the producer takes once per DATA
frame and the GUI thread takes once per frame around binding the traces.
Samples cross the boundary through `plot::data::RingBuffer` (single producer,
single consumer), never through the lock.
## The session layout file
What you arranged is remembered per data source, in
`~/.config/udpscope/<host>_<port>.toml` (`$XDG_CONFIG_HOME` is honoured; the
host's unsafe characters are replaced with `_`). The file holds the display
window, the row layout and which signal each of a row's slots plots, and the
per-signal gain and offset:
```toml
window_s = 2
plots = 1
[[plot]]
signals = ["Ch1", "", "", ""]
[[calibration]]
name = "Ch1"
gain = 2
offset = 0.5
```
It is written (400 ms after the last change, and flushed on close) whenever the
window, the layout, a drop or a calibration changes, and applied once, when the
first CONFIG names the signals -- signal *names* are the identity, because a
CONFIG renumbers and an enable/disable rebuilds the channel list. Signals the
file plots are enabled on load, since a restored plot nobody acquires would come
back blank. It is plain TOML and safe to edit; a malformed file loads whatever
parsed rather than refusing to start. Writes go to a sibling temp file and are
renamed over the target, so a crash mid-write leaves the old layout.
The same reason the window control is a fixed-width label in the header bar
(see the DPI/relayout notes in `ui.cpp`) is why the bar's `window` control does
not expand: a header-bar child that expands is measured into the title's centre
box, so the control stretched to the title and every window resize moved it.
## How the spec maps onto the code
**One buffer of `N+1` slices, plus frozen event snapshots.** `ScopeModel`
allocates one `BufferStore` per CONFIG: one shared time-base ring plus one value
ring per channel (N enabled signals -- see the limitation on multi-element signals
below). The spec's second buffer is the latched `EventSnapshot`, which is already
an immutable copy of the captured window (see "Trigger" below); a second live ring
bought nothing and cost half the memory budget, so `--buffer-mb 200` really is
200 MB.
`plan_partition()` sizes the rings to cover `--max-window` seconds and then grows
them with the rest of the budget. When a channel's declared sample rate is lower
than the fastest channel's, it is decimated by the integer ratio
(`Partition::stride`); when the requested window does not fit the budget, the shared
clock is resampled too (`Partition::time_stride`, an averaging decimation — see
"A window longer than the buffer is resampled"). Because the split is by
*time span* rather than by bytes, every channel and the time base retain the same
history -- which is what makes one shared time slice meaningful. With no
decimation the two rules coincide and every slice is the same size.
The ring is always filling, whatever the trigger is doing. The pre-trigger history
has to already be there when an edge arrives; while acquisition followed the
trigger state, the first latch after arming opened on however little had
accumulated since.
**Circular, time from the streamed data.** Each ring overwrites its oldest
sample. Timestamps come from the stream's time signal when the CONFIG names one
(`UDPS_TIME_FULL_ARRAY` / `FIRST_SAMPLE` / `LAST_SAMPLE`), and otherwise from an
arrival-anchored estimate that accounts for `udps_frame_t::lost` by dividing the
elapsed wall time over the cycles the gap covers. `recv_time` is used only as the
fallback when the stream provides no usable time signal (it is then the sample's x,
via the arrival estimate); it is never used when the stream's own time signal
carries a span.
**Trigger: what freezes is the view, not acquisition.** `TriggerEngine` runs the
edge/hold-off/normal-single state machine on the selected channel, and once the
requested post-trigger share of the *event span* (the event-window control, not the
whole ring) has been collected, `ScopeModel::publish_event()` copies that window out
of the live ring into an immutable `EventSnapshot` and publishes it atomically.
Pre-trigger history is what the ring already holds, so the percentage is a share of
the event window and not of the buffer.
**A capture fills in view** (`DisplayMode::Capturing`). The moment an edge is accepted —
which, by the gate below, means the pre-trigger history is already complete — the axis is
pinned to the whole event window `[-pre, +post]` with the edge at x = 0, and the trace
follows the live ring inside it until the snapshot lands. So the pre side is there
immediately, the post side sweeps in from x = 0 to the right, and at the latch the
snapshot replaces the live binding on an axis that does not move: the only visible change
is that the picture stops.
This is a **deliberate deviation from the spec**, requested explicitly. SPECS:37 says the
visualisation "should not be refreshed till next event", and an earlier iteration of this
app read that strictly — a trace was bound to the snapshot and to nothing else, never the
live ring, not even while the post window was filling. What that reading cost was the whole
of every capture: for as long as the post side took to fill, the plot showed the *previous*
event with no sign that anything was happening. The literal rule is gone; what it was
protecting is not. A **held** event is still bound to the snapshot alone
and is never quietly overwritten by live data, and the axis is still the event's own
window throughout — a capture is live data on a frozen frame, not a live view.
The four display states are `Live` (no trigger), `Capturing`, `Held` (the frozen
snapshot) and `Waiting` (armed, nothing captured yet: blank). `ScopeModel::display_mode()`
answers which, and the frame tick reads it once and hands it to the bind, the display
epoch, the axis pin and the status line — the producer can open or close a capture between
any two reads, and the four disagreeing about which clock they are on is how a trace ends
up drawn a window away from its axis.
**Disarm during a capture keeps what the capture got to.** It cuts the window at the
newest sample, publishes that as the held event and freezes it — the axis tightens to the
part that filled (`[-pre, +0.3 s]` for a 1 s post window disarmed 300 ms in) with the edge
still at x = 0. It used to lose the capture outright: clearing the engine's pending flag
dropped the display out of `Capturing` with nothing published behind it, so the view fell
back to the *previous* event, or to blank if this was the first. No event fired, so the
latch counter does not move; a separate `ScopeModel::event_seq()` counts published
snapshots, and that is what the frame tick keys its framing on.
The frame that **frames** a snapshot is the one that consumes its sequence number, not
merely the first frame to see it. The tick reads `display_mode()` before it takes the
model's mutex, while the producer publishes the snapshot inside that mutex and only lowers
`trigger_pending` at the end of its packet — so a tick can read `Capturing`, wait out the
publish on the lock, and arrive at the framing step bound to a capture that no longer
exists. Consuming the number there dropped the pin for good: the axis kept the capture's
*requested* `[-pre, +post]` while the snapshot was shorter, and the "an event is held" flag
stayed false, so `clamp_to_data()` was free to drag the frozen view off it. The window was
visibly wrong only at high rates, where the publish is long enough to lose that race often
(`axis=[-1..1]` against `snap=[-1..0.27]` in the disarm probe at 1.3 MS/s, correct at
10 kS/s). Leaving the number unconsumed costs nothing: the pin is owed until a frame is
bound to the snapshot, and by then it is still the newest one.
Mid-capture the live ring is read on the *event's* clock: `display_epoch()` is set to
minus the edge, so `x = 0` is the edge for the live channels exactly as it is in a
snapshot's own stamps. That re-basing machinery already existed (it was written for the
frozen view and then never needed, since snapshots carry edge-relative stamps); this is
what finally uses it.
**The whole trigger path measures the window in stream seconds, and never in
samples.** `TriggerEngine` is fed each sample's timestamp and latches once that clock
has advanced past the post window; `publish_event()` then finds the slice covering
`[edge - pre, edge + post]` by binary search over the time ring. The two are the same
clock the x axis reads, so a capture covers the span that was asked for by
construction.
It did not used to. The engine took a post-trigger window in *samples*, converted
from seconds by a measured estimate of the trigger channel's cadence, and that
estimate is what made captures wrong — most visibly as the reported defect *"only the
pre window is filled, the post window is completely empty"*. Any stream whose time
signal advances in large steps (one sample per stream second, say) drove the estimate
to its 1 S/s floor; the sample count then came out as 1, which the engine read as "no
post window requested" and latched on the edge sample itself. The event held a single
sample, its span was zero, and the UI fell back to pinning a round `[-1, +1]` axis
that nothing filled past x = 0 — an axis that looked right, which is why the defect
read as a drawing bug rather than a capture bug. Removing the conversion removes the
whole failure class: there is no longer a rate estimate to be wrong.
An edge is refused until the buffer reaches back a full pre-trigger window behind it,
so a capture cannot open on a half-filled pre window. The gate compares the oldest
buffered stamp against the edge rather than timing how long the trigger has been
armed: the ring fills whether or not the trigger is on, so the history is normally
already there and waiting would delay the first event for nothing. It only bites just
after a rebuild — a CONFIG, enabling the signal, or a calibration change all drop the
history — which is exactly when the first event used to latch on an almost empty pre
window and draw half a plot.
The event is published on the sample that latched it, but only after every channel of
that sample has reached its ring: the cut is clamped to the shortest value ring, so
publishing from the middle of the channel loop dropped the newest sample — the very one
the post window had been waiting for. That cost one sample of twenty thousand on a
10 kS/s stream, and the whole post side on a one-sample-per-second one.
The axis is still pinned to what the snapshot actually captured, read off its own
stamps rather than off the request: the two can differ by a sample at each end, and
pinning the request would claim coverage the data does not have. The window's bounds
are rounded to the time ring's own `f32` before being compared against it, so a sample
sitting exactly on a bound is kept whatever the stream's absolute time happens to be —
in f64 that decision fell to how the stamp rounded, which cost a sample at 10 kS/s and
a third of the capture at 1 S/s.
**Where the capture is drawn was a separate bug from what it contained**, and it
outlived the fixes above. The plotting library's `gather()` emits x *relative to*
`x0()` and the renderer adds `x0()` back; the event series emitted absolute stamps, so
the renderer added `x0()` twice and drew the whole capture one pre-trigger window to the
left of the axis it had just been pinned to. Nothing about the numbers looked wrong —
the snapshot held the right samples over the right span, and the crosshair (which reads
`x_at()`, never `gather()`) reported correct values — but with a 50/50 split the right
half of the plot was empty, zooming out showed the trace ending at the x = 0 marker, and
zooming in past the shift showed nothing at all. The self-check now prints `data=` and
`drawn=` side by side for the bound trace so the contract is checked rather than
assumed.
**The live view had two clipping bugs of its own**, both wearing the same face as that
one: the older part of the trace blank, the newest part fine, and the crosshair still
reading values in the blank part. Neither is a data bug — the samples were always there.
The first is the requested time window being quietly redefined. `clamp_to_data()` stops a
zoom-out from running off the end of the ring, but it runs every frame and cannot tell
that apart from a ring that has only just started filling: the first frames after a
signal is enabled hold a couple of hundred samples, a 2 s window was clamped to the
~0.02 s they covered, and nothing there ever grows a span back. The view then stayed
pinned to the newest 20 ms of a 6.5 s buffer for the rest of the session. It now clamps
against the larger of the retained span and the requested window, so a partly filled
window simply has an empty left-hand side.
The second is subtler and needs a saturated ring. A `ChannelBuffer` sample is a (value,
stamp) pair split across two rings of equal capacity, and the published window is pinned
by `acquire()` while both rings keep sliding under it — so the oldest samples of the
window point at time slots the producer has since overwritten. Those used to read back as
`0`, a perfectly plausible coordinate, which landed in `x_bounds()` and from there in
`dx()` and `x0()`. That is the renderer's entire index mapping: it converts the visible x
range into indices with `(x - x0()) / dx()`, so *one* unreadable slot inflated `dx()` by
the ratio of elapsed stream time to the retained window, scaled every index up with it,
and the gather began well inside the visible range. The stamp is now clamped to the oldest
readable slot, which costs at most a short plateau of equal stamps at the very left edge
— the same bargain the library's own `StreamSeries` documents, where an overwritten slot
costs a y value and never the geometry. The self-check prints `dx_ratio` and the
renderer's index window beside the binary-searched one, so both are checked rather than
assumed.
**A held event carries its own LOD pyramid**, and the lack of one is what "the plot gets
laggy with large data" was. The renderer picks a level per trace per frame with
`MinMaxPyramid::level_for()`, which descends to level 0 — *raw samples* — on a pyramid
with no levels, whatever the sample count says. `ChannelBuffer` maintains a real pyramid
incrementally, so the live view was never affected; `EventSeries` returned a permanently
empty one, so every frame a capture was held re-gathered every sample it held. A 2 s
capture at 1 MS/s on three traces staged **6,000,073 line segments per frame** and took
100–230 ms to draw — and it stayed that way for as long as the event was on screen, which
in trigger mode is most of the time.
The pyramid belongs to `EventSnapshot`, not to the series: the snapshot is immutable and
shared, so building it in `publish_event()` costs one `append()` per sample inside the
`O(n)` copy-and-bounds pass that was already there, and the same channel on two rows
shares one pyramid instead of building and storing it twice. `EventSeries::gather()` then
draws envelopes per bucket with raw samples at the two partial edges, the same shape
`ChannelBuffer::gather()` uses — the old implementation took every `2^level`-th sample,
which hides exactly the spike a capture was triggered on, and since the level was always 0
it never even ran. On the same repro: **138,078 segments** (43× fewer) and a 66 ms worst
frame, with the steady-state draw around 2 ms. It costs about 1 byte per sample per
channel (≈25 % on top of the snapshot's `f32` values) and one stall per latch, since the
build runs under `ScopeModel::mutex()` — at 1.3 MS/s that is a ~40 ms tick, once, with no
packet gaps.
The self-check's two `frame cost:` / `frame path:` lines are the guard: they split a frame
into the tick, the plot's own draw and the whole GTK paint, and report the segment counts
and whether the LOD path was taken at all. A segment count in the millions is this defect
returning.
**The panels and the toolbar.** The header bar carries only what you reach for
*while watching a signal*: the time window, pause/resume, the trigger toggle, the
side-panel toggle and an overflow menu. Those
four buttons are icons with tooltips; nine text buttons was a wall of words to read
past every time you wanted the one you needed. The overflow menu holds **Fit to
data**, the **Layout editor**, **Cursors**, **Reset cursors**,
**Export visible range…** and the **Signal list** toggle — the things you set up once. The side column and the two panel toggles stay hidden until a
CONFIG arrives — there is nothing to configure before the stream describes itself —
and the first CONFIG reveals the signal panel (the trigger panel stays behind its
own toggle).
The trigger panel selects the **signal** (every signal the CONFIG described, not
only the acquired ones, so the list is never empty while a stream is known), the
edge, level, pre-trigger percentage, hold-off and mode, and arms the trigger.
**The event window is its own control**, separate from the display window: it is
how much of the stream one captured event covers, and the pre-trigger percentage
is a share of *that*. Tying it to the display window was a mistake — with the
600 s default a latch took 300 s (half of it) to fill, so the view sat frozen for
minutes and the trigger looked dead. With the default 50 ms event window a latch
completes 25 ms after the edge. When an event latches and the plot is zoomed out
wider than the event window, the display zooms to the event so the captured
waveform fills the plot; the window control follows.
**Hold-off is the quiet time after an event has been captured** — measured from
the end of the capture, not from the edge, because the capture itself already
blocks re-arming.
Choosing a signal, switching trigger mode on, or arming the trigger all enable
that signal's acquisition — a trigger on a signal nobody acquires could never
fire.
**Trigger mode is the switch between streaming and triggering**, and the status
line always names the state it is in:
| status line | meaning |
|---|---|
| `[live]` | trigger mode off: the view follows the stream continuously |
| `[trigger: armed, waiting for an event]` | watching for an edge; the view shows the previous state |
| `[trigger: CAPTURING (post-trigger window filling)]` | an edge was accepted; the post side is filling in view |
| `[trigger: EVENT HELD …]` | an event was latched; the view is frozen on it |
| `[trigger: disarmed (view frozen)]` | armed and waiting are not the same thing — nothing will arrive until it is armed again |
While an event is on screen — being captured or held — a **red vertical line marks the
trigger instant** on every row, at x = 0. The latch completes a post-window later, but
the line marks the edge, so the pre/post split can be read straight off the plot. It is
not one of the user's cursors: it has its own colour, ignores the cursor show/hide
toggle, and is parked (invisible) when there is no event.
**The status line.** It starts with an activity badge and then the mode, the
connection, the measured rate, the packet/frame/gap counters, what plot 1 holds and
the buffer's size and span. Three of those are worth calling out:
- **The badge blinks green at 1 Hz while the pipeline is actually running** — the
stream is connected, acquisition is not paused, and in trigger mode the trigger is
armed. Anything else leaves it a steady grey, with the reason on its tooltip
(`paused`, `trigger disarmed`, `waiting for the stream`). *Blinking* is the signal,
not the colour: a steady badge is a stopped pipeline, and that distinction survives
a screenshot. It is one glyph whose colour is all that changes, so blinking it
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.
- **`rate 9.99 kS/s` is the rate the buffer actually stored over the last second**, not
the rate the CONFIG declared. The two agree unless the requested window is too long
to hold at the wire rate, in which case the ingest resamples (see "A window longer
than the buffer is resampled" below) and the stored rate is the readout of how much
resolution the window costs; otherwise a gap between them is the datasource's real
wire rate.
One figure when every channel agrees within a percent, `lo..hi` when they do not;
the per-channel breakdown, stored against declared, is on the line's tooltip. A
per-channel list in the line itself would make its width depend on the stream.
- **`last event 12 s ago`** (trigger mode only) ages the picture on screen, in seconds,
then minutes, then hours. A frozen view looks the same whether the last event was a
second or an hour ago, so without it there is no way to tell a working trigger from
a dead one. Measured on the wall clock, not the stream's: it is a question about how
long *you* have been waiting. The clock starts at the **edge**, not at the latch — that
is the instant the user saw, and it is also when the capture appeared on screen — so a
long capture reads as an event that is already ageing rather than one that has not
happened. Before the first event it reads `no event yet`.
Switching the header bar's **trigger** button off returns to `[live]` and resumes
drawing the stream; the trigger panel carries the same action as a
**Go live (trigger off)** button, so the way out is inside the panel that froze
the view. Switching trigger mode on arms it immediately (arming is what "wait for
an event" means), and in single-shot mode the trigger disarms itself after a
latch, which the status line reports.
The signal panel is one line per signal: a colour chip naming the trace it is drawn
as, a checkbox that acquires it, its name, its unit, and a `⋯` menu holding the
information popup and the gain/offset calibration popup.
**The chip's colour belongs to the signal, not to the slot it landed in.** The
palette is indexed by position in the signal list (`signal_color_index()`), so a
signal dropped on two rows is the same colour in both and the chip names a trace
unambiguously; indexing by trace slot made the same signal blue on row 1 and orange
on row 2, and then the chip named neither. A signal that is on no plot — acquired or
not — gets a **grey** chip rather than no chip, so every row of the panel is the same
shape and the column of chips reads as a legend. Because `plot::Plot` fixes a trace's
colour at `add_trace()`, the index is stored on the row (`Row::color_index`) and a
colour change goes through a figure rebuild, which a drop already forces.
A `GtkSwitch` and two
always-visible buttons made a row about twice as tall (46 px against 26 px now),
which is what turned a four-signal stream into a panel you have to scroll; Adwaita's
button and list-row minimums, not the widgets themselves, set that height, so the
row carries a `.udpscope-compact` style class that relaxes them.
**Signals start disabled**, because enabling one is what starts acquiring it.
Enable a signal with its checkbox, or drag it onto a plot: the drop enables the
signal if needed, binds it to that row's first free trace slot, and rebuilds the
figure so the legend name is right (there is one widget, so the row a drop lands
on is `PlotArea::row_at()`). The status line reports the outcome of a drop
(`plotting 'sine' on plot 1`, `plot 1 already holds four signals`), so an action
that changes nothing still says why. Each plot's y axis has a right-click menu
with auto scale, a fixed range, and one **Remove '<signal>' from plot N** entry per
occupied trace slot (a plot with nothing on it says so instead).
**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,
which is how the second one was found:
1. *The gutter followed the y tick labels.* `TickFormat::Si` changes their width
as the auto-fitted range moves (`1.5` → `15` → `150` → `1.5k`), and
`set_min_y_gutter()` is only a floor — the library still takes the larger of
measurement and floor. `RowSet` therefore reserves the width of the widest
label the formatter can produce, measured once with the plot's own monospaced
font, and hands it to the *figure* (`Figure::set_min_y_gutter()`, added for
this): `Figure::layout()` clears every row's own floor each frame to measure
what its labels need, so a floor written onto the rows would survive exactly
until the next layout. The figure seeds its common gutter — the maximum over
the rows, which is also what aligns their axes — with that reservation.
2. *The status line drove the pane width.* A `GtkLabel`'s minimum width is its
text, and the status line is rewritten every frame; as its counters and
messages changed width they moved the paned divider, which resized the plot,
which re-laid out the axes and their ticks. The label is now ellipsized, so its
minimum is “…”; the plot keeps the width it was given.
The self-check reports both. `viewport.w` must collapse to a single value, and
`axes.x`, `axes.w` and `y-gutter` must settle: the one-shot label reservation
lands a few frames after the first measured frame, and because the figure serves
its floor out of the label strip the reservation converges over two layouts, so
their ranges show that settle and nothing after it. The companion `axes.w
changes: N (last at frame F)` line is what separates a settle from the twitch — a
handful of changes near the start is settled, a count near the frame count is the
bug. `x-ticks` is content, and varies by one as a live window slides over a round
number:
```
layout over 952 frames: axes.x 67..80, axes.w 546..559, viewport.w 627..627,
y-gutter 44..74, x-ticks 20..21, x-desync 0
axes.w changes: 2 (last at frame 35, side panel up=1)
```
Set `UDPSCOPE_PROBE_PAUSE_MS=<ms>` to pause the acquisition part-way through a
headless run, so the numbers are measured on a still picture.
The app also logs, once per second, one line per acquired channel with the number
of samples stored and the rate that implies, next to the rate the CONFIG declared:
```
[udpscope] ch 1 'ch2_2kHz' stored=8500 (+4603 kS/s, declared 0 S/s) stride=1 decim=1 y=[-0.98, 0.98]
```
`stride` is the per-channel decimation (always 1) and `decim` the shared clock's
(`Partition::time_stride`, 1 unless the requested window forced a resample). With
`decim=1`, a stored rate far below the declared one is the datasource's own wire rate,
not something the viewer did; with `decim=N` the stored rate is the wire rate over `N`.
**The labels and the hairlines were fixed in the plotter, not worked around here.**
Two defects made the plot look rough, and both were in `deps/plotter`'s shaders, so
the fix is a submodule commit and a pin move (see CLAUDE.md):
1. *Glyphs lost whole strokes.* `kTextFS` **point**-sampled the atlas — `GL_NEAREST`
plus `floor(v_uv * u_tex_size)` — and a point sample at a non-integer scale does
not blend texels, it **deletes** them. The library's own default is a 12 px style
over the 13 px embedded bake, a scale of 0.9231, which dropped one of every 8
columns and one of every 17 rows of every glyph; `500m` under a y axis rendered
with broken zeros, and which stroke went missing depended on where the unsnapped
pen fell. `kTextFS` now does a 2×2 bilinear fetch **clamped to the instance's own
cell**, so the no-bleed guarantee the `GL_NEAREST` sampler mode protects still
holds — the atlas is a tight strip of cells with no padding, and a filter that
knew nothing of cell bounds would pick up a glyph's neighbour. At scale 1 with a
whole pen the weights are exactly zero and the result is the point sample again.
Filtered glyphs peak at 0.844 coverage rather than 1.0, which is the honest cost
of not deleting strokes; total ink rose 5.7 %.
2. *A 1 px grid line lit two columns at half coverage.* The grid and axis lines are
staged in **data** space and mapped in the vertex shader, so their centres landed
on arbitrary fractions: x = 100.0 smears across two pixel columns, x = 100.5
lights one crisply, a frame mixed both, and panning made each shimmer between the
two. `LineProgram::set_snap()` / `u_snap` rounds an axis-aligned segment's ink
*leading edge* onto a pixel boundary — integer+0.5 for odd widths, integer for
even ones, from one expression. It is on for the chrome that *frames* a plot (the
grid, the axis lines) and **off** for everything drawn at a data position — traces,
cursors, region edges, the crosshair — because the rounding moves a line by up to
half a pixel, and a cursor that reports a value may not be moved to look tidy.
Diagonals are untouched either way. Measured on one scanline of 620 px: 22
distinct intensities before, 10 after, with the grid landing exactly on its own
`grid_major`/`grid_minor` colours instead of a spread of partial blends.
A third, found the same way, is in the tick generator rather than the shaders:
3. *The tick that meant zero was labelled `-139a`.* A y axis over `[-1, 1]` read
`-1 -500m -139a 500m 1`. `generate_ticks()` walks from
`ceil(min / stride) * stride`, which is not representable whenever the stride is
not a power of two — 0.1 and 0.2 are the everyday ones — so a range straddling
the origin arrives there a few ULP off, and `format_si` dutifully reaches six
prefixes down the SI table for it. Every tick is an integer multiple of the
stride, so the smallest a *non-zero* tick can be is the stride itself while the
residue is a few ULP of the range: anything inside a millionth of the stride is
the origin and nothing else. It is now stored as exactly `0.0`, not merely
labelled `0`, because `Axis::refresh()` re-formats a time axis from `tick.value`
— where the same residue read `3.19e-13ms`. A range whose ticks are *legitimately*
that small keeps them.
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`), and each
was verified to fail with its own fix reverted.
**Cursors start hidden** and are switched on from the overflow menu; **Reset cursors**
switches them on as well, because a reset nobody can see reads as a dead button.
The two panels collapse vertically (`SLIDE_DOWN`), so hiding one gives its space
back instead of leaving an invisible gap in the column.
**The time window is typed, not picked off a ladder.** The header bar's `window`
button reads the current span and opens onto `÷10` · a number · a unit (µs, ms, s,
min) · `×10`. A ladder of round numbers could only ever offer the spans someone had
thought of, and the useful range runs from microseconds — a fast datasource's few
hundred samples — to minutes; the two steppers cover "an order of magnitude wider"
without typing at all. The request is clamped to `--max-window` and nothing else —
a window too long to store at the wire rate is resampled rather than refused (next
section) — and the control then restates the window that was *taken*, so a clamped
request reads back honestly.
Changing the unit **re-interprets** the number rather than converting it: `2`
switched from ms to s means 2 s. Converting would make the selector a no-op, since
the span it names would not change. The button's label is a reading to three
decimals in whichever unit keeps the number above 1, so a 500 s window reads
`8.333 min` while the window itself stays exactly 500 s — the label is deliberately
lossy and width-locked, because it sits in the header bar directly above the plot
and a label that grew with its text would relayout everything under it on a mere
change of window. `Fit`, which frames whatever the ring holds and so rarely lands on
a round number, restates the control without going back through the clamp.
**A window longer than the buffer is resampled, not refused.** `window × rate ×
sample size` can exceed the byte budget long before `--max-window` does, and the
control used to be capped to whatever one buffer held — so on a fast datasource most
of the typed range was unreachable. Instead, `plan_partition()` now picks a
power-of-two `time_stride` for the shared clock: the growth loop first buys as long a
ring as the budget allows, and only then is the clock decimated by the smallest factor
that makes the requested span fit. Deciding it earlier would over-decimate by whatever
the growth loop was about to add.
What makes that safe is that the decimation is a **low-pass filter, not a stride**.
`Acquisition::on_data()` keeps one running sum per channel (and one for the clock) and
stores the *average* of each group of `time_stride` base samples; the stamp stored is
the group's mean, which is the boxcar's own group-delay centre. A boxcar of length *M*
has its first null exactly at the new stored rate and is about −3 dB at 0.886× the new
Nyquist, so content that would fold back is attenuated instead of reappearing as a
false low frequency. It is O(1) per sample and its only state is the accumulator, which
is cleared whenever a re-plan changes the group length — a group cannot be finished at
the wrong length. The trigger therefore sees the stored, filtered signal, which is the
same signal the user is looking at.
This reverses the earlier "the app does no decimation at all" decision, and keeps its
reason: a naive stride is what made a 2 kHz sine on a 1 MHz clock plot as a
full-amplitude 125 Hz sine — a plausible-looking waveform that was never in the data.
The headless check is the same aliasing case with the filter in place: at
`--buffer-mb 1 --freq 2000` with a 60 s window the clock decimates by 16 to a 625 S/s
stored rate (312.5 Hz corner) and the channel dump reads `y=[-0.059, 0.079]`, roughly
the boxcar's predicted 6%. A `y=[-1, 1]` there would mean the filter had been lost.
Changing the decimation factor is a **structural change**, like a CONFIG or an enable:
the rings are re-planned and history is dropped. The status line says so
(`window 1 min: resampled to 625 S/s, history cleared`) and the same transition is
logged with both rates and the new anti-alias corner. A window change that leaves
`time_stride` alone rebuilds nothing. Per-channel `stride` is still always 1 (see the
limitations below); only the shared clock is decimated.
**Fit to data** (overflow menu) does what the plot's own `f` key does: auto-scales every row's y
axis and re-frames the x window on the data the ring holds, propagated to every
row. The status line acknowledges it (`fitted to the buffered data (1.25 s)`).
**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.
A gesture changes the row it landed on; `Figure::layout()` adopts that change
into the group and pushes it to the others, so panning or zooming any row moves
them all with nothing to propagate by hand. The live follow is the *group's*
(`set_x_link_follow`), not each row's — `Figure::link_x()` refuses a member whose
own axis still follows, because a following axis would reinterpret the pushed
range as its own span and offset behind its own edge, which is per-row drift by
construction. The figure also owns the left gutter and the crosshair, so a
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
any row's x range differed from row 0's by more than a pixel of data. **It must
read 0.**
`Figure`'s row count is fixed at construction, so there is one structural path:
a changed row count, or a changed legend name (the one thing a `Plot` cannot
express), builds a new figure and the area is handed it. That costs no GL state —
the widget keeps its context. A displayed event survives the rebuild because the
pinned `[-pre, +post]` window is restated rather than re-derived from the live
edge.
**Rows are edited in place, not chosen from a count.** The header bar used to carry a
`plots` dropdown of 1 to 4, and a count is the wrong handle: it says how many rows you
want and leaves which row your traces land on to the renumbering. **Layout editor**
(overflow menu) instead puts the controls on the rows — a `×` at the top-left of each row
removes it, a `+` on each boundary inserts one there — so the row an action names is the
row you are pointing at. `--plots` is now only the count to start with, and `RowSet::kMaxRows`
(8) is a ceiling rather than a menu: past about that a row is chrome and no plot.
An insert or a remove **moves the rows' contents with them**: what row 1 plotted, in the
colours it plotted it in and with its cursors where they were, is on row 2 after inserting
above row 1. Only that state moves; the figure's `Plot`s are rebuilt and re-dealt, which is
the mechanism a row-count change already used.
There is **one `+` per boundary, not two per row**. The request was for a `+` at the top
centre and another at the bottom centre of every row, but the rows tile the figure, so a
row's bottom edge and the next row's top edge are the same line: "add below row 1" and "add
above row 2" are the same insertion, and two buttons there would sit one on top of the
other. The `rows + 1` boundary buttons — above the first row and below the last included —
reach every position the two-per-row reading offers.
The buttons are `GtkOverlay` children placed by their margins over the figure's own row
rects (`FigureLayout::rows`, divided by the area's device scale), deliberately **not** one
transparent widget covering the plot: an overlay child is picked wherever its allocation
is, and a full-area child would swallow every pan, drop and hover the editor has no
interest in. Reading the figure's rects rather than dividing the widget height by the row
count is what keeps a `×` on the row it names if the rows ever stop being an equal
division.
**Export visible range…** (overflow menu) writes every signal bound to a visible row,
over the shared x window, at the **stored** sample rate — never the LOD pyramid's
buckets, which are the envelope the renderer drew rather than the acquisition. A
signal on several rows is one column; the columns come out in channel order, so the
file reads in the same order as the signal panel. The table has two time columns:
`time` is the axis as displayed (stream seconds live, `[-pre, +post]` while an event
is held) and `stream_time` is always the stream's own clock, so two exports taken in
different modes line up.
Two formats, picked by the extension the save dialog ends up with:
| file | what it is |
|---|---|
| `.npy` | one NumPy structured array, `time`/`stream_time` as `<f8` and every signal as `<f4` — the width the ring stores, so the file invents no precision and is about half an all-`f8` one. `pd.DataFrame(np.load(path))` is the whole reader. |
| `.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
dependency for a feature whose output a three-line reader already handles.
The table is collected when the menu item is clicked, not when the dialog is
answered: a live view keeps scrolling while the file chooser is up. The live cut goes
through `ScopeModel::live_window_locked()`, the same index range `publish_event()`
uses, so an export and a capture of the same window hold the same samples.
The export follows the display, not the trigger switch: it reads the snapshot only while
one is **held**, and the live ring otherwise — including mid-capture, where the rows are
on the live ring too. So exporting during a capture writes the partly filled window that
is on screen (`time` from `-pre` up to wherever the post side has reached), not the
previous event.
## Review findings not (yet) implemented
These are known deviations, found by a spec/code review, that are deliberate for
now — they are listed so they are not mistaken for anything else:
- **There is one live buffer, not two.** The spec's second buffer is served by the
latched `EventSnapshot` — an immutable copy of the captured window, which is
exactly what "the buffer the view is frozen on" needs to be. Two live rings would
also mean acquisition had to follow the trigger state, which is what left the first
latch after arming with no pre-trigger history.
- **The buffer is allocated at the first CONFIG, not at initialisation.**
The spec's "N+1 split" needs N, which only a CONFIG gives; `--buffer-mb` is the
*budget*, and the power-of-two ring rounding means the final
allocation lands in `(budget/2, budget]` rather than exactly on it. Every CONFIG,
enable/disable and calibration re-partitions (and drops the history) because the
buffer layout *is* the channel list.
- **Channels are never decimated against each other.** The spec's
`window * rate > buffer` rule was first implemented as
`stride = round(rate_fastest / rate_channel)`, which for a 2 kHz sine on a 1 MHz
clock is exactly one sample per period: every kept sample sat at the same phase and
the channel plotted as a flat line. Channels are stored at the shared clock's rate
and the LOD min/max pyramid preserves the extremes at draw time. The budget rule is
honoured by resampling the *shared clock* (`Partition::time_stride`, the status
line's `time xN`) with an anti-alias average in front of it — see "A window longer
than the buffer is resampled" above. The filter is the difference: it is a stride
that aliases, not a rate reduction.
- **Pre-trigger % is a share of the *event* window**, not of the retained history —
see the trigger section above for why (the spec's sentence predates the event
window being a separate control).
- **A capture is drawn as it fills**, against SPECS:37's "should not be refreshed till
next event", which the app used to honour literally. Requested explicitly: a capture
whose post window takes any appreciable time showed the previous event and no sign of
progress. What the spec was protecting still holds — a *held* event is bound to its
snapshot alone, and the axis is the event's own window from the edge onwards rather
than a sliding live view.
- **A plot holds at most four signals** (`RowSet::kSlotsPerRow`), because a `Plot`
cannot give up a trace and the slots are therefore created up front.
- **One toggle drives both cursor orientations.** The vertical markers and the
horizontal ruler exist separately but share one show/hide and one reset.
- **The time reference is the first *enabled* channel's signal.** A CONFIG whose
time signal belongs to a signal that is disabled contributes no stream time, and
the app falls back to arrival-based timing (with a warning on stderr).
## Limitations
- **Multi-element (vector/matrix) signals are acquired with a zero-order hold.**
`udps_client` decodes exactly one *sample* of `num_rows x num_cols` values per
DATA packet for such a signal, regardless of the publish mode, so it cannot be
decimated on its declared rate. Each element becomes one channel sampled on the
shared clock's own cadence with the packet's value held until the next packet —
exactly faithful in `strict` mode, and the only reading of the wire that does
not invent samples in `accumulate` mode. The info popup and the CONFIG dump on
stderr report the element counts.
- **The event window can be shorter than the buffer.** The trigger latches a window
of the event-span selection; the ring holds far more, so with trigger mode off the
rest is scrollable history.
- **Calibration is applied at ingest**, so changing gain or offset rebuilds the
buffers (and drops the history). Applying it at read time would need the LOD
pyramid to be gain-aware.
- **Cursors are per row** and not synchronised with each other; show/hide parks
them outside the axis range rather than removing them, because `Plot` has no
`remove_cursor`. The trigger marker is the one exception: it has its own cursor
and is independent of the cursor toggle.
- **Verified headless only.** Acquisition, partitioning, the trigger state
machine and its integration with the buffers, and rendering under Xvfb
(llvmpipe) were exercised; the interactive paths (drag and drop, the dialogs,
the right-click menu) were not driven by an automated test.