738 lines
47 KiB
Markdown
738 lines
47 KiB
Markdown
# 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).
|
||
- **The choice of time source is sticky, and a change of source empties the rings.**
|
||
It is decided once (and re-decided on a CONFIG) and then kept: choosing per frame
|
||
let a stream whose declared time signal fails one check and passes the next
|
||
alternate between two timelines, so one ring held stamps from both and the x axis,
|
||
the crosshair and an export read nonsense — a plateau of one timestamp, then a jump
|
||
to another epoch. On the switch the recorded history is dropped rather than left
|
||
half on each clock. The stamps themselves are spaced by the stream's *measured*
|
||
arrival cadence whenever the declared rate disagrees with it, because a CONFIG's
|
||
rate is a claim the stream may not keep (one that declared 1 MS/s and delivered a
|
||
hundred samples every fifth of a second made every frame span microseconds while
|
||
the frames were 100 ms apart).
|
||
|
||
## 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.
|