Correct downsampling and better layout
This commit is contained in:
1 parent
0d741c401b
commit
30db44eaa0
13 files changed
+1511
-252
No files matched your search
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user