Correct downsampling and better layout

This commit is contained in:
Martino Ferrari committed 2026-10-02 08:19:10 +02:00
1 parent 0d741c401b
commit 30db44eaa0
13 files changed
+1511 -252

No files matched your search

+203 -26
View File
@@ -20,7 +20,8 @@ cmake --build build
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` (initial number of stacked plots, 1..4).
`--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
@@ -67,8 +68,9 @@ bought nothing and cost half the memory budget, so `--buffer-mb 200` really is
`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 even the target window does not fit the budget, the
shared clock is decimated too (`Partition::time_stride`). Because the split is by
(`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.
@@ -129,6 +131,19 @@ back to the *previous* event, or to blank if this was the first. No event fired,
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
@@ -217,13 +232,41 @@ 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, the plot layout (1..4 rows),
pause/resume, the trigger toggle, the side-panel toggle and an overflow menu. Those
*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**, **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
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).
@@ -239,7 +282,7 @@ is a share of *that*. Tying it to the display window was a mistake — with the
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 dropdown follows.
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
@@ -278,8 +321,11 @@ the buffer's size and span. Three of those are worth calling out:
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 app does **no** decimation (see `plan_partition()`),
so the two normally agree and a gap between them is the datasource's real wire rate.
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.
@@ -372,23 +418,123 @@ The app also logs, once per second, one line per acquired channel with the numbe
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 clock=1 y=[-0.98, 0.98]
[udpscope] ch 1 'ch2_2kHz' stored=8500 (+4603 kS/s, declared 0 S/s) stride=1 decim=1 y=[-0.98, 0.98]
```
The app no longer decimates (`stride=1 clock=1`), so a stored rate far below the
declared one is the datasource's own wire rate, not something the viewer did.
`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 window dropdown offers the standard ladder up to `--max-window`, capped to
what one buffer actually holds (the cap is recomputed when a CONFIG changes the
partition, and never on a mere enable or calibration change). A span that is not
on the ladder — `Fit` frames whatever the ring holds, which is rarely a round
number — gets its own entry rather than being rounded to a neighbour, so the
dropdown reads what is on screen instead of approximating it.
**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
@@ -416,6 +562,34 @@ 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
@@ -462,13 +636,16 @@ now — they are listed so they are not mistaken for anything else:
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 not decimated at ingest.** The spec's `window * rate > buffer`
rule was 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 now stored at the shared clock's rate and the LOD min/max pyramid preserves
the extremes at draw time. When the requested window does not fit the budget the
*shared clock* is decimated instead (the status line's `time xN`).
- **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).