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
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
Vendored
+1
-1
Submodule deps/plotter updated: 98cfc5805b...e337c6a60c.
+37
-4
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
+88
-9
@@ -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));
|
||||
out_xy.push_back(vals[static_cast<std::size_t>(i)]);
|
||||
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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
+795
-186
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user