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
+1485 -226

No files matched your search

+197 -6
View File
@@ -14,7 +14,8 @@ cmake --build build
```
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per
buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (1..4).
buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row
count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards).
`deps/plotter` is built from source with only the GTK4 adapter enabled
(`PLOT_BUILD_GTK4`); its tests and examples are forced off. `deps/udpsclient` is a
@@ -25,7 +26,25 @@ there rather than worked around here: `Figure::set_min_y_gutter()` (a floor unde
common left gutter), and in the GTK4 adapter `PlotArea`'s **figure mode** —
`set_figure()`, one `input::Controller` per row, nearest-row event routing, a
figure-wide crosshair — plus `PlotArea::row_at()` for naming the row a pointer is over.
Changing those means committing in the submodule and moving its pin.
The render-quality work went there for the same reason: `kTextFS` now **filters** the
glyph atlas (a 2×2 bilinear clamped to the instance's own cell) instead of point-sampling
it, and `LineProgram::set_snap()` / `u_snap` rounds an axis-aligned segment's ink onto
whole pixels for the chrome that frames a plot (grid and axis lines), never for anything
drawn at a data position. `generate_ticks()` also stores the tick at the origin as
exactly `0.0` — the walk starts at `ceil(min/stride)*stride` and so never lands on zero
when the stride is not a power of two, which labelled it `-139a`. All three carry
upstream tests — `the_atlas_is_filtered_not_point_sampled`,
`grid_hairlines_land_on_whole_pixels` and `the_tick_at_the_origin_reads_zero`; README
records what they cured.
Changing any of this means committing in the submodule and moving its pin, and running
the library's own suite as well as the udpscope probes below:
```sh
cmake --build deps/plotter/build/debug
xvfb-run -a deps/plotter/build/debug/tests/plot_tests # expect "0 failed"
```
## Verification
@@ -49,6 +68,14 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
dropdowns. As much a crash test as a state check (see the GTK signal-signature invariant
below), so time it inside a capture: `[fields-probe] ... capturing=1` followed by no
SIGSEGV *is* the assertion.
- `UDPSCOPE_PROBE_WINDOW_MS` — drive the window control every way a user can: type a
number, change the unit under it, step a decade either way.
- `UDPSCOPE_PROBE_LAYOUT_MS` — drive the layout editor: switch it on, insert above the
first row, remove a row, append one at the bottom, switch it off. Its steps are 200 ms
apart on purpose — the buttons are placed from the rects the *last frame* was laid out
with, so a synchronous sequence would read every position one action stale. It clicks by
emitting `::clicked`, never `gtk_widget_activate()`, which holds a `GtkButton` for its
250 ms `ACTIVATE_TIMEOUT` and then emits into a button the rebuild has already destroyed.
- `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are
measured on a still picture.
- `UDPSCOPE_EXPORT_MS` (+ optional `UDPSCOPE_EXPORT_PATH`, default
@@ -111,7 +138,7 @@ Two lines of the self-check output are assertions in disguise:
`dx_ratio` above 1 scales every index up, `a` lands past the first visible sample, and
the samples in between sit in the buffer, readable by the crosshair, and are never
drawn: the live view's older part goes blank while its newest part looks fine. Also
read `axis=` in the companion line against the `window dropdown:` line in the dump —
read `axis=` in the companion line against the `window control:` line in the dump —
a live axis narrower than the requested window is the same symptom from the other
cause (see the `clamp_to_data()` invariant below). Both need a *saturated* ring to
show up, so run them under `--buffer-mb 1`, which wraps in a few seconds.
@@ -150,6 +177,47 @@ Two lines of the self-check output are assertions in disguise:
`64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)`, so a size that is not that is a
mispacked record. Read it back with `pd.DataFrame(np.load(path))` after changing
either writer.
- `[window-probe] <action> label=.. entry=.. unit=U window=W event_span=E cap=C decim=M
stored=R aa=F buffered=B` (stderr, `UDPSCOPE_PROBE_WINDOW_MS`) — **`window` must equal
`event_span`** on every line (the trigger's event span is the display window; a capture
cut to a different length than the axis frames is the "post window is empty" defect's
cousin) and **neither may exceed `cap`**. The `unit ms->s` line is the one that catches
the feedback loop: changing the unit re-interprets the typed number, so "500" in ms
becomes 500 **s exactly**, and a `window=499.98` there means the control re-parsed its own
rounded readout (`8.333 min × 60`) instead of the request — see the `window_sync_unit`
invariant below. `label` is a reading to three decimals in a natural unit and is
*expected* to be lossy (500 s reads `8.333 min`); it is the `window=` figure that has to
be exact.
For the resampling: **`buffered` must be ≥ `window`** on every line — that is what the
decimation is for, and a `buffered` short of the window is the old "the control is capped
to what the ring holds" behaviour coming back. `decim` must be a power of two,
`stored × decim` must equal the wire rate, and `aa` must be exactly `stored / 2`. Run it
under `--buffer-mb 1`, which saturates in a few seconds and makes windows past ~6 s
decimate; at 200 MB nothing inside `--max-window` needs to.
- **The anti-alias check** is the same probe run against a waveform above the stored
Nyquist: `--buffer-mb 1 --freq 2000` with the probe ending on a 60 s window gives
`decim=16`, a 625 S/s stored rate and a 312.5 Hz corner, and the channel dump's
`ch 0 'sine' ... y=[-0.059, 0.079]` is the assertion — a boxcar of 16 at 10 kS/s passes
2 kHz at about 6%. **A `y=[-1, 1]` there means the filter is gone** and the 2 kHz sine has
aliased to a full-amplitude 125 Hz one, which looks entirely plausible on screen. This is
the only check that distinguishes averaging from striding.
- `[layout-probe] <action> rows=N buttons=B rows_y=[..] close=[..] add=[..] traces=[..]`
(stderr, `UDPSCOPE_PROBE_LAYOUT_MS`) — **`buttons` must be `2N+1`** while the editor is
up (one `×` per row, one `+` per boundary) and **0 on the `editor off` line**: the
buttons exist only while the editor does. **`traces=` is the real assertion**: an insert
or a remove moves the rows' *contents* with them, so `add above 0` must read
`[0:-|1:sine,…]` — row 0's signals now on row 1, not renumbered onto the new empty row —
and `remove 0` must pull them back to `0:`. A `traces=` where the signals stayed on row 0
means `carry_row()` was skipped and the rows were renumbered under the user.
Geometrically, each `close=` must equal its row's top from `rows_y` plus the pad, and
`add=` must be centred on the boundaries (so strictly increasing, the first at 0 and the
last clamped to the area's bottom). The `rows_y` spans must tile: a gap other than
`kRowGap` means the figure and the overlay disagree about where a row is, which is how a
`×` ends up on the wrong row. Also read `x-desync 0` in the dump — every row the editor
adds has to join the one x link group. Run it at `--plots 8` as well: the cap and the
floor are the two refusals, and because the probe emits `::clicked` straight at the
widget it reaches the handler even when the button is insensitive, so `add above 0` there
must leave `rows=` and `traces=` untouched rather than growing a ninth row.
- `badge frames: lit=L dim=D idle=I` — the activity badge's phase per frame. Over a run
that acquired anything **both `lit` and `dim` must be non-zero**: that is what "it
blinks" means, and a badge stuck lit is indistinguishable from a working one in a
@@ -157,6 +225,23 @@ Two lines of the self-check output are assertions in disguise:
connected; a large `idle` is correct only when the run paused or disarmed (a
`UDPSCOPE_PROBE_DISARM_MS` run should show hundreds). The `mode ...` lines carry the
rest of the status line, so `rate` and `last event ... ago` are checked there.
- `frame cost: tick A/B ms over N, draw C/D ms (max at frame F), paint E/G ms over M` and
`frame path: raw=R lod=L frames, segments S/T (mean/max), visible max V samples` — the
drawing cost, split into the frame tick, `PlotArea`'s own draw (timed from the frame
clock's `before-paint` to the end of the host's `render` handler) and the whole GTK
paint. **`segments` max must stay in the tens of thousands and `lod` must dominate `raw`
on any run holding a large capture**: a max in the *millions* with `lod=0` is the
empty-pyramid defect (see the `MinMaxPyramid` invariant below) — the renderer drawing
every raw sample of a held event on every frame, which measured 6,000,073 segments and
100–230 ms a frame at 1 MS/s. `raw` frames are expected and cheap below 64 samples per
pixel, which is where `level_for()`'s own gate puts the boundary. **`draw`'s max must sit
at a frame near 0** — that is the warm-up (glyph atlas, first vertex-buffer growth); a
late `F` is a frame whose own geometry was expensive and worth tracing. `tick`'s max is
the publish stall: `publish_event()` builds the snapshot's pyramids under `mutex()`, so a
latch at over 1 MS/s costs one ~40 ms tick, which must not produce `gaps`. Both these
lines and the disarm probe's `axis=`/`snap=` need a fast source to say anything —
`tools/mock_udpstreamer.py --rate 1300000 --batch 20000` — since at the default 10 kS/s a
whole window fits in the raw path and the publish is too short to lose a race.
The probes are the regression net — when changing UI or plot-host behaviour, extend
the probe/dump in `src/ui.cpp` rather than adding a separate harness.
@@ -228,7 +313,8 @@ Layers to keep straight:
`ChannelBuffer` is a `plot::data::Series` over a (value, time) ring pair.
- `SlotSeries` (`src/channel.hpp`) — a trace's stable series object, rebound each frame
to either a `ChannelBuffer` or an `EventSeries`.
- `RowSet` (`src/plot_host.hpp`) — **one `plot::Figure`** of up to 4 rows, each a
- `RowSet` (`src/plot_host.hpp`) — **one `plot::Figure`** of up to `kMaxRows` rows (added
and removed one at a time by the layout editor), each a
borrowed `plot::Plot*` with `kSlotsPerRow` fixed trace slots. The figure owns
everything the rows have to agree on: the x window (one x link group every row joins),
the live follow (the *group's* — a member's own axis must be `XFollow::Off`, since
@@ -258,12 +344,69 @@ far the largest file.
again until the next latch. `PlotArea::create()` is handed `App::placeholder_plot`,
never a row: the area keeps a reference to the plot it was created with, and a rebuild
destroys every row.
- **A row's contents are state of the `RowSet`, not of the figure.** `insert_row()` and
`remove_row()` shift that state with `carry_row()` and let `rebuild_all()` re-deal the
figure handles, so inserting above row 1 moves what row 1 plotted down to row 2 rather
than renumbering the rows under the user. `carry_row()` deliberately copies fields
instead of assigning a whole `Row`: a `Row` also holds the figure handles (`plot`, the
axis and cursor ids) and the `SlotSeries` objects the traces point at, and those belong
to the row's *position*. It clears the destination's slot bindings for the same reason —
the next `bind()` re-resolves them, and without the clear the frame in between draws the
moved row's samples under the new row's legend.
Both call `rebuild_all()` themselves, so their callers must re-point the area
(`PlotArea::set_figure()`) directly; `relayout_areas()` is no help, since it only acts on
a *dirty* row.
- **The layout editor's buttons are *separate* overlay children, never one transparent
child covering the area.** A `GtkOverlay` child is picked wherever its allocation is, and
`gtk_widget_set_can_target(FALSE)` takes a widget's children out of picking with it, so a
full-area `GtkFixed` would swallow every pan, drop and hover the editor is not interested
in. Each button is therefore a small widget aligned to a corner or the centre and placed
by its **margins**, and the overlay is only measured from its main child, so moving them
cannot reach the plot's layout.
They are placed from `FigureLayout::rows` — the rects the frame was *drawn* from —
divided by `PlotArea::device_scale()`, because a `Rect` is framebuffer pixels and a GTK
margin is logical ones. Reading the figure's own rects rather than dividing the widget
height by the row count is what keeps a `×` on the row it names when the rows stop being
an equal division. `sync_layout_editor()` runs every frame on purpose:
`gtk_widget_set_margin_*()` returns early on an unchanged value, so a settled layout costs
the comparisons and nothing else, and a resize needs no notification.
- **Trace slots are identified by signal name, not channel index.** Enabling a signal
or a new CONFIG renumbers the channel list, so an index would silently re-point a
trace at another signal.
- **Any structural change rebuilds the buffer and drops history**: a CONFIG, an
enable/disable, or a calibration change. Calibration is applied at ingest, which is
why it re-partitions.
enable/disable, a calibration change, or a **change of display window that moves the
decimation factor**. Calibration is applied at ingest, which is why it re-partitions.
`ScopeModel::set_target_span()` is the window's path in, and it re-plans *only* when
`time_stride` actually changes, so walking the window inside one decade is free; it asks
`plan_partition()` rather than recomputing the factor, because two expressions for that
decision is how the buffer and the view end up disagreeing about the stored rate. It
takes `mutex()` itself — do not call it holding the lock.
- **The shared clock is decimated to fit the requested window; channels never are.** A
window too long to store at the wire rate used to be refused (the control was capped to
`Partition::span_s`), which made a 600 s view of a 1 MS/s stream unreachable.
`plan_partition()` now picks `time_stride`, a power of two, **after** the growth loop has
bought all the ring the budget allows — deciding it earlier over-decimates by whatever
factor growth was about to add. Per-channel `stride[i]` stays 1: a stride of
`round(dt_i / dt_fastest)` keeps one sample per stride, and when that equals the signal's
own period — a 2 kHz sine on a 1 MHz clock, stride 500 — every kept sample sits at the
same phase and the channel plots flat.
- **Decimation at ingest is an average, never a stride.** `Acquisition::on_data()` reads
and accumulates *every* sample and pushes the mean of each group of `time_stride`
(`value_acc_`/`value_acc_n_`, and `time_acc_` for the clock). That boxcar's first null is
at the stored rate, so content that would fold back is attenuated instead of reappearing
as a false low frequency — which is the whole reason decimation was once dropped
entirely. Skipping the read for unstored samples re-introduces the aliasing; the
`UDPSCOPE_PROBE_WINDOW_MS` run under `--freq 2000` is the guard (see below).
The stamp pushed is the group's **mean**, not its last member: that is where the boxcar's
group delay puts the value, and stamping at the end of the group shifts every trace half
a stored sample late. The accumulators are reset whenever `time_stride` or the channel
count changes, so a re-plan starts a fresh group rather than finishing one at the wrong
length. The LOD min/max pyramid still preserves the extremes *of what is stored*; a spike
narrower than a group is now filtered away rather than aliased, which is the correct
trade and is why the status line's `rate` field is worth reading.
- **The trigger sees the stored (filtered) signal, not the wire signal.** `engine.process()`
is fed the averaged value, inside the `if` that pushes it. An edge found on a sample that
is not in the buffer would be cut from data that does not contain it.
- **The `Series` grid contract does not hold for `ChannelBuffer`.** x is a streamed
timestamp, so `dx()`/`x0()` report a nominal grid (LOD level, coarse snap) while
`gather()`, `x_at()`, `neighbours()` and `x_bounds()` use the real time ring.
@@ -277,6 +420,21 @@ far the largest file.
at the end of the data / nothing is drawn but the crosshair reads values" defect: the
crosshair reads `x_at()`, which was right all along. The probe's `data=` vs `drawn=`
is the guard.
- **Every series bound to a trace must carry a real `MinMaxPyramid`**, including an
immutable one. `MinMaxPyramid::level_for()` descends to level 0 — raw samples — on a
pyramid with no levels, whatever `n` is, so a series that answers `pyramid()` with an
empty one is re-gathered sample by sample on every frame it is bound. That was the
"large data is laggy" defect: `EventSeries` had no pyramid, and a held 2 s capture at
1 MS/s staged 6 million segments per frame for as long as it stayed on screen. The
pyramid lives on `EventSnapshot`, not on `EventSeries`: the snapshot is immutable, so it
is built once in `publish_event()` inside the `O(n)` pass that already computes the y
bounds, and the same channel on two rows shares it. It costs ≈1 byte per sample per
channel. `frame path:`'s `segments` is the guard.
- **`EventSeries::gather()` emits a bucket's envelope, not every `2^level`-th sample.**
Point decimation hides exactly the spike the capture was triggered on; a scope has to
draw a bucket's extremes. It needs no index rebasing, unlike the ring: the snapshot's
pyramid is built from sample 0 and nothing is ever dropped from it, so `window_base()` is
0 and a window index *is* the absolute one.
- **A dropped time slot is clamped, never answered with a sentinel.** `acquire()` pins
the published window to the value ring's base and both rings keep sliding afterwards —
their capacities are equal, so there is no slack — which means the oldest few samples
@@ -324,6 +482,19 @@ far the largest file.
capture may legitimately be shorter than asked for — the ring need not reach back a
full pre-trigger share on the first latch after arming, so `snap=[-0.339..1.000]`
for a ±1 s request is correct there rather than a defect.
- **The frame that *frames* a snapshot is the one that consumes its `event_seq()`.** The
tick reads `display_mode()` *before* it takes `mutex()`, while the producer publishes
inside that mutex and lowers `trigger_pending` only at the end of its packet — so a tick
can read `Capturing`, block on the lock through `publish_event()`, and reach the framing
step with a new sequence number it is not bound to. (The other case is benign and was
always handled: a publication and the *next* edge in one frame, where the capture has
framed its own window.) Advancing `seen_event_seq` on such a frame dropped the pin
permanently — the axis kept the capture's requested `[-pre, +post]` over a shorter
snapshot and `event_held` stayed false, letting `clamp_to_data()` drag the frozen view —
so the test is `seq != seen_event_seq && display == Held` and the pin stays owed until a
frame is bound to the snapshot. Visible only at high rates, where the publish is long
enough to lose the race often; `[disarm-probe]`'s `axis=` against `snap=` is the guard,
which means **running the disarm probe at 10 kS/s alone does not check this.**
- **A capture in progress is the one place the axis is pinned from the *request*.** There
is no snapshot to measure yet, so `DisplayMode::Capturing` frames
`[-(span - post_span_s(span)), +post_span_s(span)]` — computed with the producer's own
@@ -365,6 +536,26 @@ far the largest file.
points (`on_trigger_field_changed`, `on_trigger_drop_changed`) onto one
`publish_trigger_fields()`: changing the edge or Normal/Single dropdown used to segfault
outright. `UDPSCOPE_PROBE_FIELDS_MS` is the guard.
- **`GtkDropDown`'s `notify::selected` arrives after the `set_selected()` that caused it
returns**, so the `updating_panels` flag — set around a programmatic write and cleared when
it returns — does *not* suppress it. Where the write is part of a readout the handler reads
back, that is a feedback loop: `sync_window_display()` writes a value rounded for reading
into the window entry and picks the unit for it, and the late notify had
`on_window_unit_changed()` re-parse that rounded text, turning a 500 s request into
499.98 s. The guard is `App::window_sync_unit` — the unit we last synced — and a selection
equal to it is ours, since GTK only notifies on a change. Any new control that both writes
and reads a `GtkDropDown` needs the same shape; the flag alone is not enough.
- **The window control's label is width-locked and ellipsized** (`kWindowLabelChars`), for
the same reason as the status label: it sits in the header bar above the plot, so a label
that grew from `1 s` to `123.456 ms` would widen the bar and relayout everything under it
on a mere change of window. That is also why the window is typed into a popover rather
than shown in the bar.
- **`apply_time_window()` is the only place the display window changes.** It clamps to
`window_limit()` and moves `RowSet::set_time_window()` and `ScopeModel::set_event_span()`
together, because the trigger's pre-trigger share is a share of *this* window. Paths that
change the span for their own reasons (Fit, an event pin) call `sync_window_display()`
instead, which only restates the control and never moves the plot — calling `apply_` there
would clamp a legitimately short capture up to the requested window.
- **The header bar's buttons are icon-only**, so headless probes must reach them
through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via
`find_labelled_button()`. The Arm/Disarm button in the trigger panel still has text.
+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).
+1 -1
+37 -4
View File
@@ -319,6 +319,17 @@ void Acquisition::on_data(const udps_frame_t* frame) {
BufferStore& store = model_.live();
const u64 g = part.time_stride;
// The filter's groups are `g` long, so a re-plan has to start a fresh group rather than
// finish the one it was in with the wrong length.
if (acc_stride_ != g || acc_channels_ != channels.size()) {
acc_stride_ = g;
acc_channels_ = channels.size();
value_acc_.assign(channels.size(), 0.0);
value_acc_n_.assign(channels.size(), 0);
time_acc_ = 0.0;
time_acc_n_ = 0;
}
// Calibration is read per frame; it only changes under the same lock.
TriggerEngine& engine = engine_;
const int trig_channel = tc.channel;
@@ -424,7 +435,16 @@ void Acquisition::on_data(const udps_frame_t* frame) {
last_stamp_used = rel;
last_stamp_ = rel;
if (tick % g == g - 1) store.time().push(static_cast<f32>(rel));
// The stored stamp is the *mean* of the group, not its last member: that is where the
// boxcar's group delay puts the averaged value, and stamping it at the end of the
// group would shift every trace half a stored sample late.
time_acc_ += rel;
++time_acc_n_;
if (tick % g == g - 1) {
store.time().push(static_cast<f32>(time_acc_ / static_cast<double>(time_acc_n_)));
time_acc_ = 0.0;
time_acc_n_ = 0;
}
// The latch belongs to this sample, but it cannot be published from inside the
// channel loop: `publish_event()` clamps the slice to every value ring's
@@ -438,8 +458,6 @@ void Acquisition::on_data(const udps_frame_t* frame) {
double latch_edge = 0.0;
for (std::size_t i = 0; i < channels.size(); ++i) {
const u64 period = g * part.stride[i];
if (tick % period != period - 1) continue;
const ChannelInfo& ch = channels[i];
// A signal's columns are consecutive samples of the same channel: a scalar
// (one element) carries one value per batched cycle, a block signal carries
@@ -449,8 +467,23 @@ void Acquisition::on_data(const udps_frame_t* frame) {
? udps_frame_value(frame, static_cast<std::uint32_t>(ch.signal), 0,
std::min<std::uint32_t>(k, ch.elements - 1))
: udps_frame_value(frame, static_cast<std::uint32_t>(ch.signal), k, 0);
const f32 value = static_cast<f32>(raw * ch.gain + ch.offset);
// Every sample is read and accumulated even when only one in `period` is stored:
// that is the whole point of the filter. Skipping the read -- which is what the
// old stride did -- is what made decimation alias.
value_acc_[i] += raw * ch.gain + ch.offset;
++value_acc_n_[i];
const u64 period = g * part.stride[i];
if (tick % period != period - 1) continue;
const f32 value =
static_cast<f32>(value_acc_[i] / static_cast<double>(value_acc_n_[i]));
value_acc_[i] = 0.0;
value_acc_n_[i] = 0;
store.values(i).push(value);
// The trigger sees the *stored* signal, not the wire signal. An edge found in a
// sample that is not in the buffer would be cut from data that does not contain it,
// and with the filter in front of it a spike above the stored Nyquist is exactly
// the thing that must not latch -- it is not in the record the user would get.
if (static_cast<int>(i) == trig_channel) {
if (engine.process(value, rel)) {
latch_here = true;
+14
View File
@@ -77,6 +77,20 @@ class Acquisition {
double arrival_epoch_ = 0.0;
bool have_arrival_epoch_ = false;
std::vector<double> stamps_;
/// The decimation filter's state: a running sum per channel (and one for the shared
/// clock) over the group of base samples that becomes one stored sample. A boxcar
/// average, which is what turns decimation into a *resampling* rather than a stride:
/// its first null sits at the stored rate, so what would fold back is attenuated
/// instead of reappearing as a false low frequency. Cleared whenever the partition
/// changes, since the group length is `time_stride`.
std::vector<double> value_acc_;
std::vector<std::uint64_t> value_acc_n_;
double time_acc_ = 0.0;
std::uint64_t time_acc_n_ = 0;
/// The partition the accumulators were sized for, so a re-plan resets them rather than
/// averaging across the boundary.
std::uint64_t acc_stride_ = 0;
std::size_t acc_channels_ = 0;
std::chrono::steady_clock::time_point started_{};
};
+9 -2
View File
@@ -3,6 +3,10 @@
#include "config.hpp"
// For `RowSet::kMaxRows`: `--plots` names a row count, and one constant for the ceiling
// is what stops the message and the clamp from drifting apart.
#include "plot_host.hpp"
#include <cstdio>
#include <cstdlib>
#include <stdexcept>
@@ -48,7 +52,8 @@ void print_usage(std::FILE* out, const char* argv0) {
" --max-window SEC largest time window the toolbar offers (default 600)\n"
" --holdoff SEC default trigger hold-off (default 0.010)\n"
" --recv-buffer BYTES SO_RCVBUF for the receiver socket (default 8388608)\n"
" --plots N initial number of stacked plots, 1..4 (default 1)\n"
" --plots N stacked plots to start with, 1..8 (default 1); rows are\n"
" added and removed from the layout editor\n"
" -h, --help this text\n",
argv0);
}
@@ -88,7 +93,9 @@ Config parse_args(int argc, char** argv) {
} else if (a == "--plots" || a.rfind("--plots=", 0) == 0) {
const std::string v = value_of(argc, argv, i, "--plots");
const long n = std::strtol(v.c_str(), nullptr, 10);
if (n < 1 || n > 4) fail("--plots must be 1..4: " + v);
if (n < 1 || n > RowSet::kMaxRows) {
fail("--plots must be 1.." + std::to_string(RowSet::kMaxRows) + ": " + v);
}
c.plots = static_cast<int>(n);
} else {
fail("unknown argument: " + a);
+2 -1
View File
@@ -27,7 +27,8 @@ struct Config {
double trigger_holdoff_s = 0.010;
int recv_buffer_bytes = 8 * 1024 * 1024;
/// Number of stacked plots (rows) to start with, 1..4.
/// Number of stacked plots (rows) to start with, 1..`RowSet::kMaxRows`. Only the
/// starting point: rows are added and removed from the layout editor.
int plots = 1;
};
+87 -8
View File
@@ -2,6 +2,7 @@
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/data/minmax_pyramid.hpp>
#include <plot/data/series.hpp>
#include <algorithm>
@@ -30,6 +31,18 @@ struct EventSnapshot {
double pre_s = 0.0; ///< seconds of pre-trigger data kept
double post_s = 0.0; ///< seconds of post-trigger data kept
std::vector<plot::Range> y; ///< per-channel value bounds, precomputed
/// One LOD pyramid per channel, built once here rather than by the series.
///
/// It is the *renderer's* performance contract that needs it: `level_for()` descends
/// to level 0 on a pyramid with no levels, so a snapshot without one is drawn from
/// every raw sample it holds, every frame, for as long as the event is held -- a
/// 2 s capture at 1 MS/s staged 6 million segments across three traces and took
/// ~100 ms a frame, which is the "large data is laggy" report. It belongs to the
/// snapshot and not to `EventSeries` because the snapshot is immutable and shared:
/// built here it costs one O(n) pass per channel inside the copy loop that is
/// already O(n), and the same channel on two rows shares it instead of building and
/// storing it twice.
std::vector<plot::data::MinMaxPyramid> pyramids;
};
/// A plot::data::Series view of one channel of an EventSnapshot. The stamps are
@@ -70,19 +83,35 @@ class EventSeries final : public plot::data::Series {
plot::Range y_bounds() const override {
return channel_ < snap_->y.size() ? snap_->y[channel_] : plot::Range{0.0, 0.0};
}
const plot::data::MinMaxPyramid& pyramid() const override { return empty_pyramid_; }
/// The snapshot's pyramid for this channel (see `EventSnapshot::pyramids`). The
/// empty one only stands in for a channel the snapshot does not carry, where the
/// series has no samples either.
const plot::data::MinMaxPyramid& pyramid() const override {
return channel_ < snap_->pyramids.size() ? snap_->pyramids[channel_] : empty_pyramid_;
}
bool streaming() const noexcept override { return false; }
/// Envelope geometry per whole bucket, raw samples at the two partial edges --
/// the same shape as `ChannelBuffer::gather()`, and for the same two reasons: the
/// emitted geometry must span `[begin, end)` exactly and never reach outside it,
/// and a *bucket's extremes* are what a scope has to draw. The decimation this
/// replaced took every `2^level`-th sample, which hides exactly the spike a
/// capture was triggered on -- and, since the level was always 0 before the
/// snapshot carried a pyramid, it never even ran.
///
/// Indices need no rebasing: the pyramid is built from sample 0 and nothing is ever
/// dropped from it, so `window_base()` is 0 and a window index *is* the absolute one
/// (unlike the ring, whose window slides).
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
plot::data::GatherMode /*mode*/ =
plot::data::GatherMode mode =
plot::data::GatherMode::Envelope) const override {
if (channel_ >= snap_->values.size()) return;
const auto& vals = snap_->values[channel_];
const auto& stamps = snap_->stamps;
const u64 n = size();
const u64 stride = u64{1} << level;
if (stride == 0 || begin >= n) return;
if (end > n) end = n;
const u64 b = std::min(begin, n);
const u64 e = std::min(std::max(end, b), n);
if (b >= e) return;
// x **relative to x0()**, which is the library's gather contract: the renderer
// adds `x0()` back before mapping to the screen. Emitting the absolute stamp made
// it add x0 twice, so the whole capture was drawn one `x0()` -- a full
@@ -94,11 +123,61 @@ class EventSeries final : public plot::data::Series {
// crosshair -- which reads `x_at()`, never `gather()` -- went on reporting the
// right values.
const f64 base = x0();
out_xy.reserve(out_xy.size() + static_cast<std::size_t>((end - begin) / stride * 2 + 4));
for (u64 i = (begin / stride) * stride; i < end; i += stride) {
out_xy.push_back(static_cast<f32>(stamps[static_cast<std::size_t>(i)] - base));
const auto rel = [&](u64 i) {
return static_cast<f32>(stamps[static_cast<std::size_t>(i)] - base);
};
const auto emit_raw = [&](u64 from, u64 to) {
for (u64 i = from; i < to; ++i) {
out_xy.push_back(rel(i));
out_xy.push_back(vals[static_cast<std::size_t>(i)]);
}
};
if (level == 0) {
emit_raw(b, e);
return;
}
level = std::min(level, plot::data::kMaxPyramidLevel);
const plot::data::MinMaxPyramid& pyr = pyramid();
const u64 span = u64{plot::data::kBucket} << level;
const u64 lbase = pyr.level_base(level);
const u64 lend = lbase + pyr.level_size(level);
const u64 first = std::max(b / span + (b % span == 0 ? u64{0} : u64{1}), lbase);
const u64 last = std::min(e / span, lend);
if (first >= last) {
emit_raw(b, e);
return;
}
if (b < first * span) emit_raw(b, std::min(first * span, e));
for (u64 bi = first; bi < last; ++bi) {
const u32 count = pyr.bucket_count(level, bi);
switch (mode) {
case plot::data::GatherMode::Nearest: {
const u64 centre = bi * span + span / 2;
out_xy.push_back(rel(centre));
out_xy.push_back(vals[static_cast<std::size_t>(centre)]);
break;
}
case plot::data::GatherMode::Mean: {
if (count == 0) break; // no finite sample: no mean to draw
const u64 centre = bi * span + span / 2;
out_xy.push_back(rel(centre));
out_xy.push_back(static_cast<f32>(pyr.bucket_sum(level, bi) / f64(count)));
break;
}
default: { // Envelope: min at the bucket's first sample, max at its last
if (count == 0) break;
const plot::data::MinMax m = pyr.bucket(level, bi);
out_xy.push_back(rel(bi * span));
out_xy.push_back(m.min);
out_xy.push_back(rel(std::min((bi + 1) * span, e) - 1));
out_xy.push_back(m.max);
break;
}
}
}
if (last * span < e) emit_raw(last * span, e);
}
bool neighbours(f64 x, u64& i0, u64& i1) const override {
+59 -11
View File
@@ -7,6 +7,7 @@
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <stdexcept>
#include <string>
#include <unordered_map>
@@ -29,7 +30,8 @@ u64 Partition::total_samples() const noexcept {
return total;
}
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes) {
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
double target_span_s) {
Partition p;
const std::size_t n = channel_dt.size();
p.stride.assign(n, 1);
@@ -48,16 +50,12 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
if (!(base_dt > 0.0)) base_dt = 1.0e-3;
p.base_dt = base_dt;
// No per-channel decimation, and none on the shared clock either. A stride of
// `round(dt_i / dt_fastest)` throws away all but one sample per stride, and when
// that stride equals the signal's own period -- a 2 kHz sine on a 1 MHz clock,
// stride 500 -- every kept sample sits at the same phase: the channel plots as a
// flat line. Decimating the shared clock to make a 600 s window fit did the same
// to *every* channel. The LOD min/max pyramid is what preserves the extremes at
// draw time, and it needs the real samples; the buffer simply holds as many as
// it holds, and the window dropdown is capped to that span.
// No *per-channel* decimation. A stride of `round(dt_i / dt_fastest)` keeps one sample
// in every stride, and when that stride equals the signal's own period -- a 2 kHz sine
// on a 1 MHz clock, stride 500 -- every kept sample sits at the same phase and the
// channel plots as a flat line. The shared clock is decimated below instead, which
// affects every channel equally and goes through the ingest filter.
for (std::size_t i = 0; i < n; ++i) p.stride[i] = 1;
p.time_stride = 1;
const u64 budget_bytes = static_cast<u64>(std::max(1.0, buffer_bytes));
const u64 budget = budget_bytes / sizeof(f32);
@@ -97,6 +95,23 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
for (std::size_t i = 0; i < n; ++i) {
p.value_capacity[i] = capacity_for(p.time_capacity, p.stride[i]);
}
// Only now, with the ring as long as the budget can buy, decimate the shared clock
// enough for the requested window to fit in it: deciding this earlier would over-
// decimate by whatever factor the growth loop was about to add. A power of two keeps
// the ring capacities (themselves powers of two) in step and keeps `tick % period`
// cheap. The cap stops a nonsense request -- a window far past `--max-window` -- from
// spinning here; at 2^20 the stored rate is six decades below the wire rate and the
// picture is averages of averages anyway.
u64 m = 1;
if (target_span_s > 0.0) {
while (m < (u64(1) << 20) &&
static_cast<double>(time_capacity) * static_cast<double>(m) * base_dt <
target_span_s) {
m *= 2;
}
}
p.time_stride = m;
p.span_s = static_cast<double>(p.time_capacity) * static_cast<double>(p.time_stride) * base_dt;
return p;
}
@@ -184,12 +199,37 @@ void ScopeModel::rebuild_locked() {
return;
}
part_ = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0);
part_ = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0,
target_span_.load(std::memory_order_relaxed));
store_ = std::make_unique<BufferStore>(part_, part_.span_s);
if (trigger_.channel >= static_cast<int>(channels_.size())) trigger_.channel = -1;
}
bool ScopeModel::set_target_span(double seconds) {
if (!(seconds > 0.0)) return false;
std::lock_guard<std::mutex> lock(mutex_);
const double previous = target_span_.exchange(seconds, std::memory_order_relaxed);
if (channels_.empty()) return false;
// Re-plan only if the factor moves. The planner is cheap, so ask it rather than
// reimplementing its arithmetic here -- two expressions for the same decision is how
// the buffer and the view end up disagreeing about the stored rate.
std::vector<double> dts;
dts.reserve(channels_.size());
for (const ChannelInfo& c : channels_) dts.push_back(c.dt);
const Partition want = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0, seconds);
if (want.time_stride == part_.time_stride) return false;
std::fprintf(stderr,
"[udpscope] window %.4f s -> %.4f s: decimation %llu -> %llu, stored rate "
"%.4g -> %.4g S/s (anti-alias %.4g Hz); buffer rebuilt\n",
previous, seconds, static_cast<unsigned long long>(part_.time_stride),
static_cast<unsigned long long>(want.time_stride), part_.stored_rate(),
want.stored_rate(), want.antialias_hz());
std::fflush(stderr);
rebuild_locked();
return true;
}
bool ScopeModel::is_time_signal(const std::vector<SignalInfo>& signals,
std::size_t signal) {
if (signal >= signals.size()) return false;
@@ -331,15 +371,23 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
snap->values.resize(channels_.size());
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0});
snap->pyramids.resize(channels_.size());
for (std::size_t c = 0; c < channels_.size(); ++c) {
auto& vals = snap->values[c];
vals.resize(static_cast<std::size_t>(n));
if (n > 0) src.values(c).copy_from(first, n, vals.data());
f32 lo = vals.empty() ? 0.0f : vals.front();
f32 hi = lo;
// The LOD index is folded into the bounds pass that was already here, so the
// snapshot costs one O(n) walk per channel rather than two. Without a pyramid
// the renderer's `level_for()` descends to level 0 and the whole capture is
// drawn raw on every frame the event is held: see `EventSnapshot::pyramids`.
auto& pyr = snap->pyramids[c];
pyr.reserve(n);
for (f32 v : vals) {
lo = std::min(lo, v);
hi = std::max(hi, v);
pyr.append(v);
}
snap->y[c] = plot::Range{lo, hi};
}
+33 -5
View File
@@ -69,7 +69,12 @@ struct ChannelInfo {
/// coincide and every slice is the same size.
struct Partition {
u64 time_capacity = 0; ///< time-base slots retained
u64 time_stride = 1; ///< base ticks per time-base slot (reserved; 1)
/// Base ticks per stored slot: the decimation factor the requested window forced. 1
/// until the window needs more samples than the budget can hold. The ingest path
/// *averages* each group of this many samples rather than keeping one of them (see
/// `Acquisition::on_data()`), so this is a sample rate reduction with an anti-alias
/// filter in front of it, not a stride.
u64 time_stride = 1;
std::vector<u64> stride; ///< time slots per channel sample (>= 1)
std::vector<u64> value_capacity; ///< samples retained per channel
double base_dt = 1.0; ///< seconds per base tick
@@ -79,12 +84,23 @@ struct Partition {
u64 total_bytes() const noexcept { return total_samples() * sizeof(f32); }
/// Seconds between two time-base slots (the sample interval of every channel).
double slot_dt() const noexcept { return base_dt * static_cast<double>(time_stride); }
/// The rate samples are actually stored at, in samples per second.
double stored_rate() const noexcept {
const double dt = slot_dt();
return dt > 0.0 ? 1.0 / dt : 0.0;
}
/// The anti-alias filter's corner, in Hz: half the stored rate, which is the new
/// Nyquist. The boxcar the ingest path averages with has its first null at the stored
/// rate itself, so everything that would fold back is attenuated.
double antialias_hz() const noexcept { return 0.5 * stored_rate(); }
};
/// Plans a partition for `channel_dt` cadences within `buffer_bytes`, targeting a
/// span of `target_span_s` seconds. Throws std::runtime_error when the budget
/// cannot hold even one sample per channel.
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes);
/// Plans a partition for `channel_dt` cadences within `buffer_bytes`, covering at least
/// `target_span_s` seconds of stream -- decimating (with the ingest filter) by whatever
/// power of two that takes. Throws std::runtime_error when the budget cannot hold even
/// one sample per channel.
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
double target_span_s);
/// One of the two identical sample buffers: a shared time-base ring plus one
/// value ring and one `ChannelBuffer` per channel.
@@ -287,6 +303,14 @@ class ScopeModel {
}
double event_span() const noexcept { return event_span_.load(std::memory_order_relaxed); }
/// The window the buffer has to cover, which is the display window. Re-plans the
/// partition -- and so drops the history -- only when the decimation factor it implies
/// actually changes, so walking the window inside one decade costs nothing. Takes
/// `mutex()` itself; do not call it holding the lock.
/// Returns true when the buffer was rebuilt.
bool set_target_span(double seconds);
double target_span() const noexcept { return target_span_.load(std::memory_order_relaxed); }
/// Trigger state for the UI, published by the acquisition thread. Atomics
/// rather than fields of `TriggerConfig` because the producer writes the arm
/// state when a single-shot trigger disarms itself, and the frame tick reads it
@@ -321,6 +345,10 @@ class ScopeModel {
StreamStats stats_{};
f64 epoch_ = 0.0;
std::atomic<double> event_span_{1.0};
/// The window the buffer is planned to cover. Read by `rebuild_locked()`, which a
/// CONFIG can reach from the producer thread, so it is an atomic rather than a plain
/// field.
std::atomic<double> target_span_{0.0};
std::atomic<bool> trigger_pending_{false};
std::atomic<double> capture_edge_{0.0};
std::atomic<std::shared_ptr<const EventSnapshot>> event_; ///< the event on screen
+47
View File
@@ -54,6 +54,53 @@ void RowSet::set_row_count(int rows) {
rebuild_all();
}
void RowSet::carry_row(Row& dst, const Row& src) {
dst.signal_name = src.signal_name;
dst.name = src.name;
dst.channel = src.channel;
dst.color_index = src.color_index;
dst.cursors_visible = src.cursors_visible;
dst.cursor_a_x = src.cursor_a_x;
dst.cursor_b_x = src.cursor_b_x;
dst.ruler_y = src.ruler_y;
// The slot's series stays where it is, but what it is bound to belonged to the row
// this state came *from*. `bind()` re-resolves every slot next frame; clearing it here
// is what stops the frame in between from drawing the moved row's data under the new
// row's legend.
for (SlotSeries& s : dst.slots) s.bind(nullptr);
}
void RowSet::clear_row(Row& r) {
r.signal_name = {};
r.name = {};
r.channel = {-1, -1, -1, -1};
r.color_index = {0, 1, 2, 3};
r.cursors_visible = false;
r.cursor_a_x = 0.0;
r.cursor_b_x = 0.0;
r.ruler_y = 0.0;
for (SlotSeries& s : r.slots) s.bind(nullptr);
}
bool RowSet::insert_row(int at) {
if (rows_ >= kMaxRows) return false;
const int pos = std::clamp(at, 0, rows_);
for (int i = rows_; i > pos; --i) carry_row(row(i), row(i - 1));
clear_row(row(pos));
++rows_;
rebuild_all();
return true;
}
bool RowSet::remove_row(int at) {
if (rows_ <= 1 || at < 0 || at >= rows_) return false;
for (int i = at; i + 1 < rows_; ++i) carry_row(row(i), row(i + 1));
--rows_;
clear_row(row(rows_));
rebuild_all();
return true;
}
void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
r.plot = &p;
r.axis_x = r.plot->add_x_axis(
+26 -1
View File
@@ -41,7 +41,12 @@ namespace udpscope {
/// context and is simply handed the new figure.
class RowSet {
public:
static constexpr int kMaxRows = 4;
/// The ceiling on rows, not the count: rows are added and removed one at a time
/// (`insert_row()`, `remove_row()`) rather than picked off a list of fixed layouts.
/// A cap still exists because `storage_` is the rows' persistent state and has to be
/// allocated somewhere, and because the figure keeps a floor height per row -- past
/// about this many a row is chrome and no plot.
static constexpr int kMaxRows = 8;
static constexpr int kSlotsPerRow = 4;
RowSet();
@@ -93,6 +98,17 @@ class RowSet {
/// Shows `rows` rows. A changed count rebuilds the figure, since a `Figure`'s row
/// count is fixed at construction.
void set_row_count(int rows);
/// Inserts an empty row at `at` (0 = above the first row, `row_count()` = below the
/// last), pushing the rows at and below it down. Returns false at `kMaxRows`.
///
/// The rows below move as *rows*: their trace assignments, colours and cursors follow
/// them, so inserting above row 1 does not renumber what row 1 was plotting. The
/// figure's `Plot`s are not what moves -- they are rebuilt and re-dealt -- which is
/// the same mechanism a row-count change already used.
bool insert_row(int at);
/// Removes row `at` and pulls the rows below it up. Returns false when only one row
/// is left: a figure with no rows has nothing to draw and no way back.
bool remove_row(int at);
Row& row(int i) { return storage_[static_cast<std::size_t>(i)]; }
const Row& row(int i) const { return storage_[static_cast<std::size_t>(i)]; }
@@ -192,6 +208,15 @@ class RowSet {
static plot::Color unplotted_color();
private:
/// Moves one row's *persistent* state -- what it plots, in which colours, where its
/// cursors are -- onto another row. Deliberately not a `Row` assignment: a `Row` also
/// carries the figure handles (`plot`, the axis and cursor ids) and the `SlotSeries`
/// objects the traces point at, and those belong to the row's *position*, not to its
/// contents. `rebuild_all()` re-deals them straight after.
static void carry_row(Row& dst, const Row& src);
/// Back to an empty row: nothing plotted, the default palette entries, no cursors.
static void clear_row(Row& r);
void install(Row& r, int row_index, plot::Plot& p);
void place_cursors(Row& r);
/// The newest x any row's live traces hold, which is what the group follows.
+770 -161
View File
File diff suppressed because it is too large. Load diff