fixed plot flicker, measurement cost and toolbar icons

The flicker ("no data" at no constant frequency, worse with the measurements
and cursors panes up) was a torn read of the window's first stamp: acquire()
pins the window to the ring's oldest slot, and reading it at paint time races
the producer lapping it, returning a stamp one ring span too new. x0() is the
origin every trace point is reconstructed from, so one torn read produced a
refused neighbours(), a filter that dropped three quarters of the points, and
a polyline shifted off its axis. It is cached by acquire() under the lock now.

EventSeries::neighbours() answered from the (x - x0)/dx grid, whose drift from
the real arrival stamps arrives as a constant offset in seconds -- a doubling
share of the screen per zoom step, which is the "zooming into a held event
clips it more from the left" report. Both series binary-search the stamps now.

The measurements pane went through the LOD pyramid rather than reading its
window raw (19.7 ms per recompute to 1.2 ms, 0.1% of a 2 M-sample window) and
recomputes on a cadence; the math engine lost a per-slot heap allocation, a
per-sample source lookup by name and a fetch_add that cost more than the
arithmetic it counted.

The cursors pane's mix mode drew a line per *named* slot, so a slot holding a
signal that can never produce a channel -- the stream's timestamp -- got a row
that could only read "--", and such names were re-saved every session. It
counts bound traces now and apply_layout() refuses them.

Four toolbar icons are drawn for this app and compiled in as a GResource: the
stock ones were a record dot for the trigger and three magnifiers for cursors,
measurements and math.

New probes and dump lines for all of it (trace ink, measure cost, math cost,
event zoom, toolbar icons); README and CLAUDE.md record what they cure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-03 23:36:07 +02:00
1 parent 714d042809
commit a92a6b5bf0
20 files changed
+1945 -243

No files matched your search

+267 -13
View File
@@ -95,8 +95,23 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
placed, which is the only way a stale result or a mark that should not be drawn shows up as a placed, which is the only way a stale result or a mark that should not be drawn shows up as a
number. It must never print `marks=0` while measurements with `show_on_plot` are on and the number. It must never print `marks=0` while measurements with `show_on_plot` are on and the
data is there -- that was the rulers blinking off at a mode change. data is there -- that was the rulers blinking off at a mode change.
`measure cost: N recomputes, X ms total, Y ms each` follows it: **read it against a run with `measure cost: N recomputes, X ms total, Y ms each, data D ms (P%), covered=C buckets=B
`UDPSCOPE_PROBE_MEASURE_MS` unset**, because a frame mean cannot attribute a cost. raw=R (F%), N ns/covered sample` follows it: **read it against a run with
`UDPSCOPE_PROBE_MEASURE_MS` unset**, because a frame mean cannot attribute a cost. The last
three fields are the assertion, and they are a *ratio*, not a time: `covered` is the samples
the window spans, `buckets` the LOD buckets the two walks answered from, and `raw` the samples
they still had to read. **`raw` must stay at a fraction of a percent of `covered`** on a clean
waveform over a window of millions -- 0.1% is what the pyramid-backed walks give (see the
measurement-cost invariant below). A `raw` near `covered` means the walks went back to reading
the window, which at 1 MS/s is 19.7 ms per recompute and a third of the run's frames. It is
*legitimately* near `covered` for a window shorter than a bucket (64 samples) or a signal noisy
enough to straddle a level everywhere, so read it on the fast mock
(`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`), where a window is ~2 M samples.
Note the **final** `measurements:` line and `[measure-trace]`'s last lines may read `--`:
`UDPSCOPE_EXIT_AFTER_MS` arms the trigger before it dumps, so the dump can land in `Waiting`
with nothing bound and the axis at its default. The race-free reading is an earlier
`[measure-trace]` line, which carries its own `view=`; those lines are also where the
level-mark fallback is visible (an invalid pass must still print its `level:` marks).
- `[pan-probe] asked=[..] was=[..]`, `[pan-probe] after=[..] held=D asked=[..]`, then - `[pan-probe] asked=[..] was=[..]`, `[pan-probe] after=[..] held=D asked=[..]`, then
`[pan-probe] zoom asked=[..] after=[..] span=S window=W limited=L` `[pan-probe] zoom asked=[..] after=[..] span=S window=W limited=L`
@@ -120,6 +135,29 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
that is the state the old `max(retained, window_s_)` bound leaked in, and it showed up here as that is the state the old `max(retained, window_s_)` bound leaked in, and it showed up here as
`span=2.502550 window=2.000000 limited=0` against `span=2.000000` after the fix. `span=2.502550 window=2.000000 limited=0` against `span=2.000000` after the fix.
- `[zoom-probe] <sweep>/x<step> axis=[..] span=.. n=N idx=[a..b) spp=.. level=L pts=P kept=K
back=.. ink=[..] x_at_first=.. gap_px=.. end_px=.. rect=[..]` (stderr,
`UDPSCOPE_PROBE_EVENTZOOM_MS`) — arms the trigger in **single** mode, polls for a **held
event** (a latch happens when the stream says so; a sweep on a timer has nothing to zoom
into), then zooms in eight doublings
twice: `ges/` writes the row's own axis the way a gesture does, `win/` drives the window
control (`apply_time_window`, which unpins and re-arms the live follow before the pin is
restated). Each step's reading is taken in the *tick*, after that frame's pin and clamp — a
`g_timeout` would report the ask. It recomputes what `Renderer::draw_traces` is handed, through
the same calls: `neighbours()` for `idx`, the 64-spp gate for `level`, then `gather()`.
**`gap_px` and `end_px` must stay within a pixel or two at every step** — they are how much of
the left and right margin no geometry reaches, and a `gap_px` that *doubles* per step is the
"zooming into a held event clips it more and more from the left" defect (374 px of a 716 px
axis by `ges/x128`), whose cause is a constant offset in data seconds (see the `neighbours()`
invariant below). `kept`/`back` are the renderer's own increasing-suffix filter counted rather
than applied: `kept` far below `pts` is a staircase, and a non-zero `back` is a non-monotonic
gather. **Run it with `--clock-jitter`** (`tools/mock_udpstreamer.py --rate 1300000 --batch
2000 --clock-jitter 0.4`): the ideal mock's stamps sit on a perfect grid, which is exactly
what hides this class of bug, and three runs against it reported `gap_px=0.0` throughout while
the defect was live. The jitter varies each *packet's* spacing rather than offsetting its
start, because the stream's clock has to stay strictly increasing. The `win/` sweep bottoms
out at `span=0.05` (`RowSet::set_time_window()`'s floor) and walks to the event's post side,
since `set_time_window()` frames the newest span before the pin clamps it — both are expected.
- `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are - `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are
measured on a still picture. It now also asserts what a pause *is* measured on a still picture. It now also asserts what a pause *is*
(`[pause-probe] <action> paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..]` on (`[pause-probe] <action> paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..]` on
@@ -275,17 +313,21 @@ Two lines of the self-check output are assertions in disguise:
(a parked cursor reports a real number, so a read-back would adopt it as a gesture — the (a parked cursor reports a real number, so a read-back would adopt it as a gesture — the
bug that made `T0` read 0). **`lines`** must be 0 for `vertical`, one per row for bug that made `T0` read 0). **`lines`** must be 0 for `vertical`, one per row for
`horizontal`/`both` (and the time row exists only in the modes that show the vertical `horizontal`/`both` (and the time row exists only in the modes that show the vertical
pair — `T=[]` in `horizontal` is the assertion that it does not), and one per bound trace pair — `T=[]` in `horizontal` is the assertion that it does not), and one per **bound**
for `mix`; in `mix` the values must be the trace for `mix` — a slot with a name *and* a channel the model built, never a name alone
(see the bound-slot invariant below; `lines=6 marks=3` over three real traces is that
defect). Each line reports as `<name>=<A> <B> <dY>`, so the names are the reading: a row
claiming a signal that is not on a plot is the phantom. In `mix` the values must be the
traces' own (`--` when a marker is past the newest sample, `partial` when only one traces' own (`--` when a marker is past the newest sample, `partial` when only one
marker is over samples). After the move, `t0` must have changed and every row's marker marker is over samples). After the move, `t0` must have changed and every row's marker
must have followed it (the report's `t0` is the shared value). **`marks`** is the `mix` must have followed it (the report's `t0` is the shared value). **`marks`** is the `mix`
mode's crossing annotations: **0 in every other mode, and one per bound trace for each mode's crossing annotations: **0 in every other mode, and one per bound trace for each
marker that is over samples in `mix`** (so a move that brings the second marker into the marker that is over samples in `mix`** (so a move that brings the second marker into the
data doubles it). The `first mark at x=.. y=..` line that follows must agree with the data doubles it). The `first mark at x=.. y=..` line that follows must agree with the
pane's *first* value for the same trace (the pane's lines are `chip Y1 Y2 dY`, with pane's *first* value for the same trace (the pane's lines are `chip name Y1 Y2 dY` --
the values formatted as measurements -- two decimals, an SI prefix, the channel's unit -- the chip carries the signal's name, since a chip alone leaves the user to match a colour
so the comparison is against the first number): both are read through the same against the plot by eye -- with the values formatted as measurements, two decimals, an SI
prefix, the channel's unit, so the comparison is against the first number): both are read through the same
`Plot::crosshair_value()`, and that line is the check that they stay the same number. `Plot::crosshair_value()`, and that line is the check that they stay the same number.
A **ruler** drag is the other direction and the other hazard: the pane's `A`/`B` cells A **ruler** drag is the other direction and the other hazard: the pane's `A`/`B` cells
for a ruler row must change when the ruler moves (`after ruler drag` in the probe walks for a ruler row must change when the ruler moves (`after ruler drag` in the probe walks
@@ -309,11 +351,18 @@ Two lines of the self-check output are assertions in disguise:
its unit, so `frequency` must read `Hz` (not the signal's unit), `period`/`pulse duration` its unit, so `frequency` must read `Hz` (not the signal's unit), `period`/`pulse duration`
seconds, and the rest the channel's unit. `marks_shown` counts two per shown measurement seconds, and the rest the channel's unit. `marks_shown` counts two per shown measurement
(a min/max showing both its marks counts two of its own). `measures window:` is the (a min/max showing both its marks counts two of its own). `measures window:` is the
timeliness assertion: **`drift` must stay at (or near) zero** -- the window the numbers timeliness assertion -- the window the numbers were computed over against the one on screen.
were computed over against the one on screen -- and every *time* mark must lie inside it **The reading that must hold is `marks_in_window=K/K`**: every *time* mark inside the window
(`marks_in_window=K/K`). A measurement is recomputed when the window moves (2 % of its on screen. `drift` itself is a **single sample of a sawtooth** and is not an assertion on its
span) or the held/live state changes, never on a wall-clock timer: a timer is what let a own: a live view scrolls between recomputes, so it rises to one `kMeasureRefreshUs` of scroll
marker sit outside the viewport and a dragged one survive a frame. **`marks_selectable` must be 0**: the and drops to zero at each pass. On a 2 s window at the 100 ms cadence the top of that sawtooth
is `drift=5%`, which is what a dump landing just before a scheduled pass reads -- a `0.0000%`
is a dump that landed just after one, not a better state. Read it against the recompute count:
*more* recomputes with a *higher* drift sample is the sawtooth, while a drift that exceeds one
refresh interval of scroll, or marks outside the window, is the defect. A measurement is
recomputed when the window moves (2 % of its span) or the held/live state changes, never on a
wall-clock timer: a timer is what let a marker sit outside the viewport and a dragged one
survive a frame. **`marks_selectable` must be 0**: the
measurement marks are created unselectable (`Plot::set_cursor_selectable()`, which the measurement marks are created unselectable (`Plot::set_cursor_selectable()`, which the
library gained for this), read back out of the plot here -- re-placing a marker every frame library gained for this), read back out of the plot here -- re-placing a marker every frame
does **not** stop a drag, because the controller holds it for the gesture's life and the does **not** stop a drag, because the controller holds it for the gesture's life and the
@@ -387,6 +436,15 @@ Two lines of the self-check output are assertions in disguise:
and an expanding one is measured into the title's centre box, so it stretched from the and an expanding one is measured into the title's centre box, so it stretched from the
label to the title (516 px at a 1270 px bar before the fix, 121 px after). A `W` that label to the title (516 px at a 1270 px bar before the fix, 121 px after). A `W` that
grows with the window width is that bug back. grows with the window width is that bug back.
- `toolbar icons: trigger=NAME(ok) pause=... cursors=... measures=... math=... sidebar=...
menu=...` (in the exit dump, beside `panel controls:`) — the icon name every toolbar button
carries, with `gtk_icon_theme_has_icon()`'s verdict. **Every entry must read `(ok)`.** Four
of the names are this app's own (`udpscope-trigger-symbolic` and friends, a `GResource`
compiled into the binary — see the toolbar-icon invariant below), and a `(MISSING)` on one of
those means the resource path was never registered. That failure is otherwise *silent*: GTK
substitutes a missing-image placeholder, the button still works, and every other line of the
dump still passes. Changing an icon name is a one-line change in `build_toolbar()` and this
is the only check that it names something that exists.
- **The session layout round trip** (`~/.config/udpscope/<host>_<port>.toml`) — run once - **The session layout round trip** (`~/.config/udpscope/<host>_<port>.toml`) — run once
with a drop and a window change, then again with no interaction: the second run's dump with a drop and a window change, then again with no interaction: the second run's dump
must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the
@@ -433,6 +491,48 @@ Two lines of the self-check output are assertions in disguise:
lines and the disarm probe's `axis=`/`snap=` need a fast source to say anything — 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 `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. whole window fits in the raw path and the publish is too short to lose a race.
- `trace ink: N paints checked, C collapsed, S staircase, H short, worst cover P%` plus a
`[ink] paint N <flags> mode=.. axis=[..] n=.. idx=[..) spp=.. level=.. pts=.. kept=..
ink=[..] cover=..% x0=.. x_at=[..] bounds=[..] rel=[..] dx=.. kept_fixed_x0=.. drops=[..]
back=..` line per anomalous frame (stderr) — **`collapsed` and `staircase` must both be 0**.
This is the only check that can see a frame whose *waveform* vanished: `staged_segments`
counts geometry including the grid and the instruments, and `lod_traces` counts a trace whose
points are then collapsed, so both read healthy through the flicker. The probe runs on every
painted frame that drew a trace and recomputes what the renderer did, through the same calls
— `neighbours()` for the index window, the 64-spp gate for the level, `gather()`, then the
increasing-suffix filter — and reports where the ink actually landed against the axis.
`collapsed` is fewer than two distinct positions left (nothing to draw at all), `staircase`
is the filter dropping over three quarters of the points, `short` is ink reaching under a
quarter of the axis (reported only once the data spans the window, since a filling ring is
*meant* to leave its left side empty). `kept_fixed_x0` is the same filter with `x0()` hoisted
out of the loop: it exceeding `kept` means `x0()` moved *during* the walk. **`back` ≈ one ring
span is the signature** of the torn `x0()` read (see the cached-first-stamp invariant); so is
an `ink=` range disjoint from `axis=`, or an `idx=` covering the whole ring with `cover` far
over 100%. It is a race, so it needs load and a reason for long frames —
`tools/mock_udpstreamer.py --rate 1300000 --batch 2000 --clock-jitter 0.4` with the
measurements *and* cursors panes up:
`UDPSCOPE_ENABLE_ALL_MS=1000 UDPSCOPE_PROBE_MEASURES_MS=2000 UDPSCOPE_PROBE_CURSORS_MS=3500
UDPSCOPE_EXIT_AFTER_MS=35000`, which reported three staircase frames in each of three runs
while the defect was live and none after. `frame path:`'s own numbers move with it (`segments`
max 156078 → 19681), since a refused `neighbours()` gathers the whole ring.
- `traceless paints: N (M after the first bind, last at paint P)` and `worst side/pane min
width: W px`, plus `[gen] frame F generation A -> B (bound=.. empty=..)` and `[width-probe]
...` lines (stderr) — three cheap per-frame counters that came out of the flicker hunt and
each guard a documented invariant from the side the existing lines cannot see. **`M` must be
only the frames in a state that draws nothing**: a traceless paint is legitimate in
`DisplayMode::Waiting`, which is why the line carries `mode=` — `UDPSCOPE_EXIT_AFTER_MS` arms
the trigger before it dumps, so a tail of them at the end of a run is expected. `[gen]` names
each buffer rebuild as it happens with the bind state around it: a rebuild drops every ring's
history, so the frames after one legitimately draw an empty plot, and `blank view frames`
cannot see that (it asks whether the ring holds samples the view has not got, and a rebuilt
ring holds none either) — so an unexplained `[gen]` burst is a structural change firing when
nothing structural happened. `worst side/pane min width` is the "layout must not depend on
content" invariant read from the **demand** side: a `GtkLabel`'s minimum width is its text and
a minimum wider than the screen is honoured by growing the toplevel, which hands the plot an
allocation wider than the window; `axes.w` sees that a frame later and cannot name the cause,
so the panes the tick rewrites are measured directly. The `[width-probe]` lines are the
per-change detail behind the `axes.w changes` count, and a step over 100 px dumps the widget
tree's wide subtrees — that is what the documented, intermittent ~320 px step wants read.
The probes are the regression net — when changing UI or plot-host behaviour, extend 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. the probe/dump in `src/ui.cpp` rather than adding a separate harness.
@@ -553,6 +653,24 @@ far the largest file.
while every other row showed them. The same goes for the vertical pair's `x`: it is one while every other row showed them. The same goes for the vertical pair's `x`: it is one
stream time for the figure, so it lives on the `RowSet` too, and `Row` keeps only what is stream time for the figure, so it lives on the `RowSet` too, and `Row` keeps only what is
genuinely per row (the rulers' values, the traces, the colours). genuinely per row (the rulers' values, the traces, the colours).
- **A trace is a slot with a name *and* a channel; a name alone is not one.** `Row::channel[s]`
is re-resolved by `RowSet::bind()` every frame (slots are identified by name, see above), and
`-1` means the model built no channel for that name. Some names can *never* bind: the stream's
**time signal** is skipped by `build_channels_locked()` however often it is enabled, so a slot
holding it has a name for ever and data never. The plot showed nothing for it, which is why it
went unnoticed, but the cursors pane's `mix` mode drew one line per *named* slot and so gave
each phantom a row that could only read `--` — "the pane shows a lot of empty signals that do
not exist", six lines over three real traces. Two halves to the rule. (1) Anything that counts
traces counts `channel[s] >= 0`, not `!signal_name[s].empty()` — and a per-frame bind means the
cursors pane cannot key its rebuild on `generation()` alone, hence `App::cursors_pane_bound`, a
bit per row/slot (`kMaxRows * kSlotsPerRow` is 32, so one `std::uint32_t`): a trace that
appears without a structural change still gets its line. (2) `apply_layout()` refuses a name
that cannot produce a channel (`ScopeModel::is_time_signal()`), because such a name was
**sticky**: `collect_layout()` writes `signal_name` back verbatim, so one that got into a
session file was re-saved every session for ever. The refusal also heals an existing file on
the next save. How the names first got in is unexplained — the drop path already refuses a
signal with no channel — most likely an older build or a CONFIG whose time signal was not yet
classified as one; the fix is the stickiness and the counting, not a guess at the origin.
- **A row's contents are state of the `RowSet`, not of the figure.** `insert_row()` and - **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 `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 figure handles, so inserting above row 1 moves what row 1 plotted down to row 2 rather
@@ -628,6 +746,20 @@ far the largest file.
computes `got` from the same call the renderer makes, so it cannot catch a renderer that went computes `got` from the same call the renderer makes, so it cannot catch a renderer that went
back to the grid — what it still reports is the drift that made the walk wrong (`dx_ratio`). back to the grid — what it still reports is the drift that made the walk wrong (`dx_ratio`).
- **`neighbours()` is a binary search over the real stamps in *every* series here, the
snapshot's included.** `EventSeries` answered from the `(x - x0()) / dx()` grid — the walk the
invariant above condemns: `dx()` is the *mean* spacing of the snapshot's stamps and the stamps
are an arrival clock, so grid and clock drift apart by whatever that clock wandered over the
capture. The renderer picks its visible index window from this call, so the drift arrived as a
**constant offset in seconds** between the axis and the gathered geometry — and a constant
number of seconds is a *doubling share of the screen* on every zoom step: 8 ms over a 2 s
capture is invisible, and seven halvings later it is 374 px of blank left margin on a 716 px
axis, with the ink pushed off the right-hand edge by the same amount. That is the "zooming
into a held event clips it more and more from the left while the crosshair still reads the
right values" report; the crosshair was right because it reads `x_at()`, a real stamp.
`ChannelBuffer` always searched; the two now agree. **A perfectly uniform source cannot show
this at all** — three probe runs against the ideal mock were clean — so the guard needs
`--clock-jitter` (see `UDPSCOPE_PROBE_EVENTZOOM_MS` above).
- **The `Series` grid contract does not hold for `ChannelBuffer`.** x is a streamed - **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 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. `gather()`, `x_at()`, `neighbours()` and `x_bounds()` use the real time ring.
@@ -668,6 +800,34 @@ far the largest file.
every live view. The library's `StreamSeries` is immune for a reason worth copying: every live view. The library's `StreamSeries` is immune for a reason worth copying:
its x is `x0 + i*dx`, a function of the absolute index, so an overwritten slot costs a its x is `x0 + i*dx`, a function of the absolute index, so an overwritten slot costs a
*y* value, never the geometry. Clamping buys the same bargain here. *y* value, never the geometry. Clamping buys the same bargain here.
- **The window's *first* stamp is cached by `acquire()` and never read at paint time.** The
clamp above makes an unreadable slot *survivable*; it does not make reading it *safe*, and
`absolute(0)` is not an ordinary slot — `acquire()` pins the window to `values_->base()`, so
sample 0 is the ring's oldest, the very slot the producer is about to overwrite. `time_of()`
checks the bounds and then copies, and when the producer laps the slot in between the stamp it
returns is **one whole ring-span too new**. Everything else on a `ChannelBuffer` is read at
paint time with no lock, and `x0()` is the origin the renderer reconstructs every trace point
from (`gather[i] + series.x0()`), read both inside its increasing-suffix filter and again at
`stage_xy()` — so one torn read of that one slot produced three pictures, all of them "the
waveform is gone while the grid and the axes stay": `neighbours()` tests against `x_at(0)` and
refused the whole visible range (the renderer then gathered the *entire* ring at a coarse level
and drew it off the axis — `idx=[0..8388608) level=7 cover=320%`); `x0()` changing *during* the
filter loop dropped three quarters of the points; and `stage_xy()` shifted the finished polyline
a ring-span off its axis. That is the "with measurements and cursors active the plot flickers as
if there were no data, at no constant frequency" defect. It is **live only** (a snapshot is
immutable, so `x0()` is constant for `Held`/`Paused`), it is a race against packet arrival so it
is irregular, and the measurements pane makes it both likelier and larger: the pane holds
`mutex()` for each `MeasureWindowReader`'s life, so the released producer bursts several packets
at once. `acquire()` runs on the consumer under `ScopeModel::mutex()` — the same lock the
producer's pushes hold — and the window is pinned on the line above, so that is the one point in
the object's life where the slot is safe to read; `window_x0_` is also the *right* answer rather
than merely a stable one (it is the stamp sample `window_base` actually carries, where a
paint-time clamp answered with whatever slot happened to be oldest *now*). `x_at(0)` is served
from it too, for the same reason, and `invalidate()` clears it with the window. The guard is
`trace ink:` / `[ink]` (see Verification). The same torn read still exists for a small `i > 0`
— the oldest frame or so of samples — but there its observable is a *prefix plateau*, which is
exactly what the renderer's increasing-suffix filter was written to absorb, so it is a different
symptom and deliberately not fixed here.
- **`clamp_to_data()` caps the span at the *requested window*, never at the retained - **`clamp_to_data()` caps the span at the *requested window*, never at the retained
span.** It runs every frame and cannot distinguish "the user zoomed out past the data" span.** It runs every frame and cannot distinguish "the user zoomed out past the data"
from "the ring has only just started filling", so `window_s_` — what the window control from "the ring has only just started filling", so `window_s_` — what the window control
@@ -764,7 +924,18 @@ far the largest file.
so no rebuild can invalidate a live callback handle -- the same shape the measurements dialog so no rebuild can invalidate a live callback handle -- the same shape the measurements dialog
uses. `UDPSCOPE_PROBE_MATH_MS` defines two graphs and opens the editor (a construction crash test uses. `UDPSCOPE_PROBE_MATH_MS` defines two graphs and opens the editor (a construction crash test
as much as a state check), and the dump prints each derived channel's extremes beside the as much as a state check), and the dump prints each derived channel's extremes beside the
source's. Four rules here were wrong first and are worth keeping: source's. Read it on the fast mock, where the two assertions that matter are **`gaps=0`** (the
math is on the producer thread, so what it can break is packet capture, never the frame rate)
and each `math '<name>': ... N samples vs M time slots` being **equal**, with `math x2` at
`y=[-2, 2]`, `math lp` at `y=[-0.998, 0.998]` and `math3` at `y=[-2, 2]`. The `0 visible
(order breaks 0, first at -1), y=[0, 0]` tail of that line is **not** a failure: it is
`visible_samples()` reading row 0's axis after the window was destroyed, and
`UDPSCOPE_EXIT_AFTER_MS` arms the trigger first, so the dump can land in `Waiting`. The live
`ch N '<name>' ... y=[..]` lines are the race-free reading of the same thing.
`math cost: S slots, N ns/slot (T timed), X ms total est` is the per-slot cost; see the
math-cost invariant below for why it is sampled, why it is an upper bound, and why a figure
anywhere in 78--101 ns/slot is one reading of the same thing. Four rules here were wrong first
and are worth keeping:
* **A `Source` may only name an *acquired* channel** -- `ScopeModel::channels()`, which is what the * **A `Source` may only name an *acquired* channel** -- `ScopeModel::channels()`, which is what the
dialog's dropdown lists, **and both places that create a node must take one from there**. dialog's dropdown lists, **and both places that create a node must take one from there**.
`signals().front()` is the stream's *timestamp* signal: it is first in every CONFIG, it is not `signals().front()` is the stream's *timestamp* signal: it is first in every CONFIG, it is not
@@ -931,6 +1102,67 @@ far the largest file.
compares with a relative tolerance, because clamping to exactly `window_s_` can leave the compares with a relative tolerance, because clamping to exactly `window_s_` can leave the
group's span an ULP above it and a clamp that fires every frame is a re-frame loop, not a group's span an ULP above it and a clamp that fires every frame is a re-frame loop, not a
clamp. clamp.
- **A measurement must not read its window raw.** Both passes go through
`plot::data::MinMaxPyramid`, which the producer already maintains at amortised O(1) -- so the
pane inherits the summary of the new samples rather than computing one, which is what the
"evaluate only on new samples" ask comes to here. Throttling the cadence (above) bounded *how
often*; this bounds *how much*, and it is the larger factor: 19.7 ms per recompute to 1.2--1.6
ms, reading **0.1 %** of a 2 M-sample window. Pass one (`measure_scan_buckets()`) adds whole
level-0 buckets -- 64 samples each, carrying min, max an **exact** sum and a finite count -- and
locates each extreme to a *bucket*; one raw `measure_scan()` of that bucket alone then pins it
to a sample, two buckets at most whatever the window's size. Pass two
(`measure_accumulate_bucket()`) folds a bucket whole only when it lies **strictly** on one side
of its level (`k.lo > level` or `k.hi < level`): then every sample decides the same side, none
is *at* the level, and an entering edge is exactly the bucket's first sample -- which is where
the raw walk would have found it, so this is exact and not an approximation. It refuses a
bucket that straddles, and a bucket holding a non-finite sample, because the raw walk takes the
first *finite* sample of the new side and an aggregate cannot say which that is; the caller
then opens that bucket. Four things this rests on, each of which breaks it silently:
* **Exactness is the point, so nothing may stride or decimate.** `peak to peak` must still read
exactly `2` on a unit sine and `top` exactly `0.636620` (2/pi). A bucket's sum is a sum.
* **The bucket index space must be the window's.** `ChannelBuffer::acquire()` calls
`pyramid_.drop_before(values_->base())`, so a channel's pyramid is in the value ring's
absolute index space -- the same space `live_window_locked()` answers in, since a channel's
stride is always 1. The snapshot's pyramid is built from sample 0 and never dropped from, so
its space is the snapshot's own. `MeasureWindowReader::origin_` is what turns one into a
window offset, and a mismatch there measures the wrong samples rather than failing.
* **The raw path stays, and stays exact**, for the window's partial edge buckets, for the
samples produced since this frame's `acquire_all()` (one frame of production, so bounded), and
for the straddling buckets. A window shorter than a bucket degrades to O(window) and must
still be right.
* **Both passes walk the same fetched bucket list** (`App::measure_bucket_scratch`, reused
across recomputes -- a 2 M-sample window is 32 k buckets). Fetching twice is a second walk of
the pyramid for nothing.
`measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say
anything.
- **The math channels' cost is the *producer's*, and the instrument must not be part of it.**
`push_math_sample()` runs once per stored slot -- up to the wire rate -- so what a regression
here looks like is `gaps`, never a frame time. Three things were taken off that path and must
not come back: a `std::vector` *local* (a heap allocation and free per slot; it is the member
`math_values_`, `assign()`d), a `Source` node's channel resolved **by name** per sample (a
linear scan with a `std::string` compare per candidate, per node, per slot, behind a
type-erased call -- `math_prepare()` resolves it once into `MathNodeRt::src`, and every rebuild
re-prepares), and a **`fetch_add` on the cost counters**. That last one was 20-25 % of the
measured figure: a `lock xadd` costs about what a two-node graph's whole arithmetic does, so
counting the slots cost more than the work being counted. The counters are producer-written
only, so each is a relaxed `store()` of a value already in hand (`math_slot_count_` and
friends). Two things about the figure itself:
* **It is sampled, one slot in `kMathCostSample`, and it is an upper bound.** A slot's work is
the same order as one `clock_gettime`, so the measured slot carries a clock read. Timing a
*run* of consecutive slots to amortise that away **is wrong and was tried**: the producer gets
its slots a packet at a time, so a run long enough to be worth timing straddles a packet
boundary now and then and charges the measured slots the **wait for the next packet**. At 2000
samples a packet a 64-slot run crosses one ~3 % of the time, and a ~1.5 ms gap spread over 64
slots reported **799 ns/slot against the true ~80**. One slot is always inside one packet.
* **78--101 ns/slot is one reading.** Four identical-config runs spread that far, so the per-slot
path is at the noise floor of this harness and a micro-optimisation cannot be shown to work
here. Converting `MathRuntime` from eight parallel `std::vector<double>` to one array of
`MathNodeRt` was measured and bought **nothing** -- at six nodes the whole runtime is
L1-resident either way. It was kept for the shape, not for a number; do not expect a cache
argument to pay at this size.
- **The status line's per-second fields are measured once a second, not per frame.** - **The status line's per-second fields are measured once a second, not per frame.**
`App::stored_rate`/`declared_rate` and the label's tooltip are refreshed on the same `App::stored_rate`/`declared_rate` and the label's tooltip are refreshed on the same
tick as the channel dump; the status line only formats what is already there. The tick as the channel dump; the status line only formats what is already there. The
@@ -980,4 +1212,26 @@ far the largest file.
runs after the window is destroyed and asking GTK about a widget then is four criticals, which is runs after the window is destroyed and asking GTK about a widget then is four criticals, which is
how the first version of that line earned them. What stays in the menu is what acts on the *data* how the first version of that line earned them. What stays in the menu is what acts on the *data*
(Fit, Export) and the two editors that are not panes (Layout, Signal list). The Arm/Disarm button in the trigger panel still has text. (Fit, Export) and the two editors that are not panes (Layout, Signal list). The Arm/Disarm button in the trigger panel still has text.
- **Four of the toolbar icons are this app's own, and they are a `GResource` compiled into the
binary.** No icon theme has a glyph for an oscilloscope trigger, a pair of cursors or a
measurement ruler, and the stock names those buttons used to carry said something else
entirely: a *record dot* for the trigger and three different **magnifiers** for cursors,
measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,math}-symbolic.svg`
are bundled by `src/icons/udpscope.gresource.xml` (CMake runs `glib-compile-resources`) and
reached through the `gtk_icon_theme_add_resource_path()` at the top of `build_toolbar()`.
A resource rather than files beside the binary because udpscope is run straight out of the
build tree and has no install step. Five authoring rules, each of which fails quietly:
* **GTK's symbolic recolour only reaches `fill` on `path`, `rect` and `circle`.** It wraps the
SVG in a stylesheet for those selectors, so a `stroke`, a `line` or a `polyline` keeps the
colour written in the file and stays dark on a dark theme. Everything is a filled `path`.
* **Whole-pixel coordinates**, because these are drawn at 16 px far more often than not: the
first draft placed features on half-pixels and the whole icon was a grey smear at 1x.
* `fill-rule="evenodd"` is how the ruler's body is a hole *and* its ticks still fill -- a tick
inside the hole is three crossings deep, which is odd. Nonzero would fill the hole back in.
* Overlapping subpaths under the default nonzero rule must be **wound the same way** or they
cancel instead of uniting. All of them run clockwise.
* `--` is illegal inside an XML comment and librsvg refuses the whole file for it.
`gtk_icon_theme_has_icon()` is checked for every toolbar button and reported by the dump (see
`toolbar icons:` under Verification): a resource path that was never added does not raise an
error, it draws `image-missing`.
- Every source file carries an SPDX MIT header; match it in new files. - Every source file carries an SPDX MIT header; match it in new files.
+22
View File
@@ -30,8 +30,30 @@ if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(udpsclient PRIVATE -Wall -Wextra) target_compile_options(udpsclient PRIVATE -Wall -Wextra)
endif() endif()
# --- icons: four symbolic SVGs compiled into the binary -----------------------
# There is no stock icon for an oscilloscope trigger, a pair of cursors or a measurement
# ruler, so this app draws its own. They go in a GResource rather than beside the binary
# because udpscope is run straight out of the build tree and has no install step: a
# search path would have to be located at runtime, and the failure mode of not locating
# it is a silent missing-image placeholder.
find_program(GLIB_COMPILE_RESOURCES NAMES glib-compile-resources REQUIRED)
set(UDPSCOPE_ICON_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/icons)
file(GLOB UDPSCOPE_ICON_FILES CONFIGURE_DEPENDS
${UDPSCOPE_ICON_DIR}/scalable/actions/*.svg)
add_custom_command(
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
COMMAND ${GLIB_COMPILE_RESOURCES}
--target=${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
--sourcedir=${UDPSCOPE_ICON_DIR}
--generate-source
${UDPSCOPE_ICON_DIR}/udpscope.gresource.xml
DEPENDS ${UDPSCOPE_ICON_DIR}/udpscope.gresource.xml ${UDPSCOPE_ICON_FILES}
COMMENT "Compiling udpscope icon resources"
VERBATIM)
# --- the application ---------------------------------------------------------- # --- the application ----------------------------------------------------------
add_executable(udpscope add_executable(udpscope
${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
src/main.cpp src/main.cpp
src/config.cpp src/config.cpp
src/channel.cpp src/channel.cpp
+174 -4
View File
@@ -148,6 +148,70 @@ the window its measurement was computed over. Re-placing alone was not enough: a
for the gesture's life, so the marker followed the pointer *between* frames and the readout for the gesture's life, so the marker followed the pointer *between* frames and the readout
looked draggable. looked draggable.
### What a measurement costs
A measurement is a statistic of the **visible window**, and at a megasample a second a window
is millions of samples — so the obvious implementation is two O(window) walks per measurement
per recompute, and that is what it was. With four measurements over a 2.2 M-sample window it
measured **19.7 ms per recompute** and a 1.8 ms frame tick mean: a third of the frames lost, on
a pane that is only showing four numbers.
It is now **1.2–1.6 ms**, and it reads **0.1 % of the window**. The samples are not what it
walks: `plot::data::MinMaxPyramid` already holds the window as 64-sample buckets, each carrying
its own min, max, exact sum and finite count, and the producer folds every sample into it as it
arrives at amortised O(1). That pyramid *is* "evaluate only on the new samples" — it is
maintained by the ingest, so the pane inherits a summary it never paid to build.
Both walks go through it:
- **Pass one** (the extremes, the mean) adds whole buckets: 1/64 of the reads, and *exact*,
because a bucket's sum is a sum and not an estimate. It locates each extreme to a **bucket**,
and one raw scan of that bucket alone then pins it to a sample — two buckets at most, whatever
the window's size. The two stamps the scan needed are read at the end rather than at every
chunk that moved an extreme, since a stamp is a ring read and an extreme moves many times.
- **Pass two** (the threshold types: `top`, `bottom`, `pulse duration`, `period`, `frequency`)
folds a bucket whole whenever it lies *strictly* on one side of its level — then every sample
in it decides the same side, none is *at* the level, and an entering edge is exactly the
bucket's first sample, which is where the raw walk would have found it. Only the buckets that
**straddle** a level are opened, which on a clean waveform is one per crossing.
The raw path is still there and still exact, for the window's partial edge buckets, for the
samples produced since this frame's `acquire_all()` (bounded by one frame of production), and
for those straddling buckets. Nothing is decimated or strided: `peak to peak` still reads
exactly `2` on a unit sine and `top` exactly `0.636620` — 2/π, the half-wave mean, which is the
check that a threshold type is not merely plausible. A window shorter than a bucket, or a signal
noisy enough to straddle everywhere, degrades back to the O(window) cost and nothing else
changes. The dump's `measure cost:` line reports `covered`, `buckets` and `raw` for exactly
this: `raw` near `covered` means the pyramid bought nothing.
### What the math channels cost
The derived channels run on the **producer** thread, once per stored slot, so they never appear
in a frame time at all — what they can do instead is drop packets. Three things were removed
from that per-slot path: a heap allocation and free (the slot's value scratch is a member now,
`assign()`d), a per-sample type-erased lookup of each `Source` node's channel *by name* (a
linear scan with a `std::string` compare per candidate, per node, per sample — resolved once in
`math_prepare()` into `MathNodeRt::src` instead), and a locked read-modify-write that belonged
to the cost counter itself. That last one is worth naming: one `lock xadd` costs about what a
two-node graph's whole arithmetic does, so counting the slots cost more than the work being
counted. The counters are written by the producer and only by the producer, so each is a relaxed
*store* of a value already in hand.
The figure is around 80–100 ns per slot for three two-node graphs at ~1.1 MS/s — roughly a tenth
of one core, with `gaps=0`. It is sampled one slot in a thousand, and it is an **upper bound**: a
slot's work is the same order as one `clock_gettime`, so the measured slot carries a clock read.
Timing a *run* of consecutive slots to amortise that away was tried and is wrong — the producer
gets its slots a packet at a time, so a run long enough to be worth timing straddles a packet
boundary now and then and charges the measured slots the wait for the next packet. At 2000
samples a packet that reported 799 ns/slot against the true ~80.
Below that the per-slot path is at the noise floor of what can be measured here: four
identical-config runs spread over 78–101 ns/slot, which is wider than any remaining
micro-optimisation. Converting `MathRuntime` from eight parallel `std::vector<double>` to one
array of per-node structs was measured and bought **nothing** — at six nodes the whole runtime is
L1-resident either way. It was kept because a node's state belonging on one cache line is the
right shape for a graph that grows, not because it showed up.
## The session layout file ## The session layout file
What you arranged is remembered per data source, in What you arranged is remembered per data source, in
@@ -362,6 +426,29 @@ arrangement as `panel controls: cursors=1 measurements=1 math=1 in the toolbar,
cursors pane`, computed on the first frame — a widget's parent is the only thing that says it, and cursors pane`, computed on the first frame — a widget's parent is the only thing that says it, and
the dump runs after the window is gone. the dump runs after the window is gone.
**Four of those icons are drawn for this app**, because no icon theme has a glyph for what they
name, and the stock ones they used to carry said something else entirely: the trigger was a
**record dot**, and cursors, measurements and math were three different **magnifiers**. In their
place: a rising edge crossing a dashed trigger level; a pair of flat-capped markers on dashed
shafts; a ruler with alternating long and short ticks; and a sigma. They live in
`src/icons/scalable/actions/` and are compiled into the binary as a `GResource` rather than
installed beside it — udpscope is run straight out of the build tree and has no install step, so
a search path would have to be found at runtime, and the failure mode of not finding it is a
silent placeholder. Hence the dump's `toolbar icons:` line, which names every button's icon and
whether the theme resolved it.
Drawing them was mostly learning what a 16 px symbolic icon will not forgive. Everything sits on
**whole-pixel** boundaries: the first draft placed features on half-pixels and the whole set was a
grey smear at 1x, legible only when magnified. Every shape is a filled `path` — GTK's symbolic
recolour reaches `fill` on `path`, `rect` and `circle` and nothing else, so a stroked line keeps
the file's own colour and stays dark on a dark theme. The ruler's body is a hole punched with
`fill-rule="evenodd"`, which also lets its ticks fill (three crossings deep is odd); the sigma's
four subpaths are wound the same direction instead, so the default nonzero rule unions them rather
than cancelling the overlaps. Two readings had to be designed away: the trigger's level at a
2-on/2-off dash rhythm read as scattered dots, fixed at 2-on/1-off with one wide gap where the
edge rises through, and the cursors first read as **audio faders** — a downward triangular cap
then read as a funnel, and what works is a flat cap over a *dashed* shaft.
**The session is saved with the layout**, per data source, in **The session is saved with the layout**, per data source, in
`~/.config/udpscope/<host>_<port>.toml`: the display window, the rows and which signal each slot `~/.config/udpscope/<host>_<port>.toml`: the display window, the rows and which signal each slot
holds, the per-signal calibration, **which signals were enabled**, **the math channels with their holds, the per-signal calibration, **which signals were enabled**, **the math channels with their
@@ -450,6 +537,22 @@ event pin already worked this way, and the pause agrees with it. `[pause-probe]`
beside `frozen_x=`, and while paused the axis must lie *inside* `frozen_x` — equal at the pause, a beside `frozen_x=`, and while paused the axis must lie *inside* `frozen_x` — equal at the pause, a
subset after a zoom-in. subset after a zoom-in.
**Zooming into a held event clipped it further and further from the left**, and the cause was the
one the library's own margin fix is about, in the half of the code the fix could not reach.
`EventSeries::neighbours()` — the call the renderer maps the visible range onto sample indices with
— answered from the nominal `(x - x0()) / dx()` grid, while `ChannelBuffer::neighbours()` next to
it binary-searches the real time ring. `dx()` on a snapshot is the *mean* spacing of its stamps and
the stamps are an arrival clock, so the grid and the clock drift apart by however much the clock
wandered over the capture; the renderer was handed an index window offset from the axis by that
drift. Because the offset is a constant number of *seconds*, its share of the screen doubles with
every zoom step: a 2 s capture off by 8 ms is invisible, and seven halvings later it is 374 px of
blank on a 716 px axis, with the trace's ink pushed off the right-hand edge by the same amount.
The crosshair reads `x_at()` — a real stamp — so it went on reporting the right value over the
blank part, which is exactly how the report described it. `neighbours()` is a binary search over
`stamps` now, like every other series here. A perfectly uniform source hides the defect
completely, so the mock can jitter its clock (`--clock-jitter`, applied to each packet's own
spacing so the stream stays monotonic) and `[zoom-probe]`'s `gap_px` is the guard.
**A pinned window is not a navigation surface.** In trigger mode the x window is the event — **A pinned window is not a navigation surface.** In trigger mode the x window is the event —
`[-pre, +post]` while a capture fills, the snapshot's own span once it is held — and a gesture `[-pre, +post]` while a capture fills, the snapshot's own span once it is held — and a gesture
must not move it. Nothing was pulling a drag back: `clamp_to_data()` is the live-view half of must not move it. Nothing was pulling a drag back: `clamp_to_data()` is the live-view half of
@@ -535,6 +638,53 @@ 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 renderer's index window beside the binary-searched one, so both are checked rather than
assumed. assumed.
**The clamp made that slot survivable; it did not make reading it safe**, and the difference is
the "with the measurements and cursors panes up the plot flickers as if there were no data, at no
constant frequency" report. Sample 0 of the window is not an ordinary slot: `acquire()` pins the
window to the value ring's `base()`, so it is the ring's *oldest* — the one the producer is about
to overwrite. `time_of()` checks the bounds and then copies, and everything on a `ChannelBuffer`
except `acquire()` is read at **paint time with no lock**, so when the producer lapped that slot
in between, the stamp came back one whole ring span too new. From there it is `x0()`, which is the
origin the renderer reconstructs *every* trace point from (`gather[i] + series.x0()`), read both
inside its increasing-suffix filter and again when it stages the geometry — so one torn read of
one slot produced three different pictures, all of them "the waveform is gone, the grid and the
axes are still there":
* `neighbours()` tests the asked-for x against `x_at(0)`, so a bad first stamp made it refuse the
whole visible range. The renderer then gathered the **entire ring** at a coarse level and drew it
off the axis — the probe caught `idx=[0..8388608) level=7 cover=320%`.
* `x0()` changing *during* the filter loop made it drop three quarters of the points (`kept=750`
against `kept_fixed_x0=1865` on the same frame).
* Staging shifted the finished polyline a ring span sideways: `ink=[-6.04..0.38]` against
`axis=[4.80..6.80]`.
The stamp is now read **once, by `acquire()`** — which runs on the consumer under
`ScopeModel::mutex()`, the same lock the producer's pushes hold, on the line after the window is
pinned, which is the one point in the object's life where that slot cannot move — and cached as
`window_x0_`; `x0()` and `x_at(0)` are served from it. It is also the *correct* answer rather than
merely a stable one: it is the stamp sample `window_base` actually carries, where the paint-time
clamp answered with whatever slot happened to be oldest at the moment of asking. The defect was
live-only (a snapshot is immutable, so its `x0()` never moves, which is why a held event never
flickered), and the measurements pane made it both likelier and larger because the pane holds
`mutex()` for each window reader's lifetime and the released producer then bursts through several
packets at once — hence the report's pairing with the panes, which turned out to be a symptom of
frame length rather than of the panes themselves. The user's own first guess, that it might be an
anti-aliasing artefact of a thin line, is ruled out by the line shader: a degenerate segment still
gets quad area on purpose, and `half_w = width/2 + aa` with the fragment ramp keeps a 1 px line
above about half coverage at the worst alignment. Anti-aliasing can dim a trace; it cannot erase
one.
Neither `staged_segments` nor `lod_traces` can see this — the first counts the grid and the
instruments too, the second counts a trace whose points are collapsed a moment later — so the
guard is a new per-paint instrument. `check_frame_ink()` recomputes what the renderer was handed,
through the same calls, and reports where the ink landed against the axis: the dump's
`trace ink: N paints checked, C collapsed, S staircase, H short` must read `0` for the first two,
and each anomalous frame prints an `[ink]` line whose `back` ≈ one ring span is this defect's
signature. The same torn read still exists for a small `i > 0` — the oldest frame or so of samples
— but there the observable is a prefix plateau of equal stamps, which is exactly what the
renderer's increasing-suffix filter exists to absorb, so it is left alone rather than fixed
speculatively.
**A held event carries its own LOD pyramid**, and the lack of one is what "the plot gets **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 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 `MinMaxPyramid::level_for()`, which descends to level 0 — *raw samples* — on a pyramid
@@ -823,7 +973,7 @@ chooses what the instruments are:
| `vertical` | two x markers, one pair for the whole figure | `T0`, `T1`, `dT` | | `vertical` | two x markers, one pair for the whole figure | `T0`, `T1`, `dT` |
| `horizontal` | two y rulers on **each** row | `Y1`, `Y2`, `dY` per row | | `horizontal` | two y rulers on **each** row | `Y1`, `Y2`, `dY` per row |
| `both` | both of the above | the times, then a line per row | | `both` | both of the above | the times, then a line per row |
| `mix` | the x pair, plus where each marker **crosses each trace** | the times, then `Y1`, `Y2`, `dY` per plotted signal | | `mix` | the x pair, plus where each marker **crosses each trace** | the times, then `Y1`, `Y2`, `dY` per **bound** trace |
The vertical pair is one stream time shared by every row, so the markers line up down The vertical pair is one stream time shared by every row, so the markers line up down
the column and dragging one on any row moves them all — the app reads the position back the column and dragging one on any row moves them all — the app reads the position back
@@ -854,9 +1004,29 @@ instruments can claim (a marker lands on a sample, and the sample is an `f32`),
trailing zero is dropped, so `1.50 V` reads `1.5 V`. A row's rulers use its signals' unit trailing zero is dropped, so `1.50 V` reads `1.5 V`. A row's rulers use its signals' unit
when they agree on one and print bare digits when they do not: a ruler is on the row's y when they agree on one and print bare digits when they do not: a ruler is on the row's y
axis, and two units on one axis make "the" unit a guess. A trace's line carries the axis, and two units on one axis make "the" unit a guess. A trace's line carries the
signal's **colour chip** instead of its name — the chip identifies it exactly as the signal signal's **colour chip and its name** — `● sine 616.49 mV -24.57 mV -641.06 mV` — and the
panel does — and the values are bare: `● 616.49 mV -24.57 mV -641.06 mV`, because the values are bare, because the pane is a readout beside a plot and `Y1:` labels there are noise.
pane is a readout beside a plot and `Y1:` labels there are noise. The chip began as the *whole* identifier, on the argument that it names a trace exactly as the
signal panel does; it does not, once there are several traces, because matching a colour
against the plot by eye is a guess, and a line with nothing but a chip is also unreadable in a
bug report. The name is appended to the chip's markup (escaped — a signal name is the stream's
text, not ours) and the cell is ellipsized with the full name on a tooltip, so a long name
cannot widen the pane.
**`mix` draws a line per *bound* trace, not per named slot.** A slot holds a signal **name**
and the channel behind it is re-resolved every frame, so a name with no channel is not a
trace — and some names can never get one: the stream's time signal is never acquired, so a
slot holding it has a name for ever and data never. The plot drew nothing for such a slot,
which is how it stayed invisible, but the pane counted names and gave each one a row that
could only read `--`: a user with three traces saw six lines, three of them blank. They were
also **sticky**, because a session file is written from the slot names verbatim, so one that
got in was saved again every session. Both halves are fixed — the pane follows the bind
(which is also why its rebuild watches a bit per slot rather than only the model's
generation: a trace can appear with no structural change behind it), and loading a layout
now refuses a name that cannot produce a channel, which heals an existing file on its next
save. Where those names originally came from is unexplained: dropping a signal that has no
channel is already refused, so the likely source is an older build or a datasource whose
time signal was not yet recognised as one.
`mix` also *draws* the crossings: a mark on each trace where each marker meets it, in the `mix` also *draws* the crossings: a mark on each trace where each marker meets it, in the
marker's own colour, placed with the same call the pane reads (so the mark and the number marker's own colour, placed with the same call the pane reads (so the mark and the number
+1 -1
+12 -3
View File
@@ -56,10 +56,15 @@ void ChannelBuffer::acquire() noexcept {
// The pyramid's window follows the ring's: sample index `base` is the oldest // The pyramid's window follows the ring's: sample index `base` is the oldest
// one still retained. drop_before() is monotonic and cheap. // one still retained. drop_before() is monotonic and cheap.
pyramid_.drop_before(values_->base()); pyramid_.drop_before(values_->base());
// And the window's first stamp with it, here and nowhere else (see the member's note): the
// window is pinned by the line above, so its origin is a fixed sample, and this is the one
// place in the object's life where that sample's slot is safe to read.
if (pyramid_.size() > 0) window_x0_ = time_of(absolute(0));
} }
void ChannelBuffer::invalidate() noexcept { void ChannelBuffer::invalidate() noexcept {
published_ = 0; published_ = 0;
window_x0_ = 0.0f;
pyramid_.clear(); pyramid_.clear();
} }
@@ -116,11 +121,16 @@ f64 ChannelBuffer::dx() const {
f64 ChannelBuffer::x0() const { f64 ChannelBuffer::x0() const {
if (pyramid_.size() == 0) return epoch_; if (pyramid_.size() == 0) return epoch_;
return epoch_ + f64(time_of(absolute(0))); return epoch_ + f64(window_x0_);
} }
f64 ChannelBuffer::x_at(u64 i) const { f64 ChannelBuffer::x_at(u64 i) const {
if (i >= pyramid_.size()) return x0(); if (i >= pyramid_.size()) return x0();
// Sample 0 is served from the cache, never from the ring: it is the slot the producer is
// overwriting, and `neighbours()` tests the asked-for x against this value, so a read one
// ring-span too new made it refuse the whole visible range -- which left the renderer
// gathering the entire ring at a coarse level and drawing it off the axis.
if (i == 0) return x0();
return epoch_ + f64(time_of(absolute(i))); return epoch_ + f64(time_of(absolute(i)));
} }
@@ -161,8 +171,7 @@ u64 ChannelBuffer::snap_index(f64 x) const {
plot::Range ChannelBuffer::x_bounds() const { plot::Range ChannelBuffer::x_bounds() const {
const u64 n = pyramid_.size(); const u64 n = pyramid_.size();
if (n == 0) return plot::Range{epoch_, epoch_}; if (n == 0) return plot::Range{epoch_, epoch_};
return plot::Range{epoch_ + f64(time_of(absolute(0))), return plot::Range{x0(), epoch_ + f64(time_of(absolute(n - 1)))};
epoch_ + f64(time_of(absolute(n - 1)))};
} }
plot::Range ChannelBuffer::y_bounds() const { plot::Range ChannelBuffer::y_bounds() const {
+13
View File
@@ -102,6 +102,19 @@ class ChannelBuffer final : public plot::data::Series {
f64 epoch_ = 0.0; f64 epoch_ = 0.0;
f64 dt_ = 1.0; f64 dt_ = 1.0;
u64 published_ = 0; u64 published_ = 0;
/// The stamp of the window's **first** sample, read once by `acquire()` -- which runs on the
/// consumer under `ScopeModel::mutex()`, the same lock the producer's pushes hold, so the
/// slot cannot be overwritten under the read. Everything else on this object is read at
/// *paint* time with no lock, and this one slot is the ring's oldest: the one the producer is
/// about to overwrite. Reading it there returned a stamp a whole ring-span too new whenever
/// the producer lapped it between the bounds check and the copy -- and `x0()` is the origin
/// the renderer reconstructs every trace point from (`gather[i] + series.x0()`), so one such
/// read moved the entire polyline a ring-span off its axis (an invisible trace) or, caught
/// inside the renderer's increasing-suffix filter, made it drop most of the points. That is
/// the "live plot flickers as if there were no data" defect. The cached value is also the
/// *right* answer rather than merely a stable one: it is the stamp sample `window_base`
/// actually carries, where the clamp answered with whatever slot happened to be oldest now.
f32 window_x0_ = 0.0f;
plot::data::MinMaxPyramid pyramid_; plot::data::MinMaxPyramid pyramid_;
}; };
+19 -6
View File
@@ -180,15 +180,28 @@ class EventSeries final : public plot::data::Series {
if (last * span < e) emit_raw(last * span, e); if (last * span < e) emit_raw(last * span, e);
} }
/// Bracketing samples by **binary search over the stamps**, which are monotonic -- never
/// through the `(x - x0()) / dx()` grid, for the same reason `ChannelBuffer` does not: `dx()`
/// is the window's *mean* spacing and the stamps are an arrival clock, so the grid and the
/// real stamps drift apart by however much the clock wandered. The renderer picks the visible
/// index window from this call, so that drift came out as a **constant offset in data
/// seconds** between the axis and the gathered geometry: a blank left margin whose *pixel*
/// width doubles with every zoom step (374 px of a 716 px axis after seven), with the trace's
/// ink pushed off the right-hand edge by the same amount and the crosshair -- which reads
/// `x_at()`, the real stamp -- still correct everywhere. That is the "zooming into a held
/// event clips it more and more from the left" report. A perfectly uniform source hides it
/// completely, which is why the mock can jitter its clock (`--clock-jitter`).
bool neighbours(f64 x, u64& i0, u64& i1) const override { bool neighbours(f64 x, u64& i0, u64& i1) const override {
const u64 n = size(); const u64 n = size();
if (n < 2) return false; if (n < 2) return false;
const f64 step = dx(); const auto& s = snap_->stamps;
if (!(step > 0.0)) return false; const auto begin = s.begin();
f64 idx = (x - x0()) / step; const auto end = begin + static_cast<std::ptrdiff_t>(n);
if (idx < 0.0) idx = 0.0; // The first stamp *greater* than x, so x itself brackets as [that - 1, that]: a pointer
if (idx > static_cast<f64>(n - 1)) idx = static_cast<f64>(n - 1); // exactly on a sample must not put the sample behind it.
i0 = static_cast<u64>(idx); const auto it = std::upper_bound(begin, end, static_cast<f32>(x));
const u64 above = static_cast<u64>(it - begin);
i0 = above > 0 ? above - 1 : 0;
i1 = i0 + 1 < n ? i0 + 1 : i0; i1 = i0 + 1 < n ? i0 + 1 : i0;
return true; return true;
} }
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- The two vertical markers: a solid cap you grab, over a dashed shaft. Both parts
earn their place. A bare pair of lines does not say the markers are draggable,
a cap over a *solid* shaft is an audio fader to anyone who has seen a mixer, and
a tapered cap turns into a funnel at this size. -->
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill="#2e3436" d="M2 1 H8 V4 H2 Z
M4 5 H6 V7 H4 Z
M4 8 H6 V10 H4 Z
M4 11 H6 V13 H4 Z
M4 14 H6 V16 H4 Z
M9 1 H15 V4 H9 Z
M11 5 H13 V7 H11 Z
M11 8 H13 V10 H11 Z
M11 11 H13 V13 H11 Z
M11 14 H13 V16 H11 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 984 B

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- A sigma: the one pane that computes rather than reads. Four filled subpaths, all
wound clockwise so the default nonzero rule unions them where they overlap; the
diagonals run from the bars' left ends to a vertex right of centre, which is the
way the glyph actually points. -->
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill="#2e3436" d="M3 2 H13 V4 H3 Z
M3 12 H13 V14 H3 Z
M4.64 2.23 L10.64 7.23 L9.36 8.77 L3.36 3.77 Z
M10.64 8.77 L4.64 13.77 L3.36 12.23 L9.36 7.23 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 762 B

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- A ruler: the pane measures the waveform. The body is a hole punched out of the
frame, so under `evenodd` the ticks inside it are an *odd* number of crossings
deep (frame, hole, tick) and fill rather than cutting a second hole. -->
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill="#2e3436" fill-rule="evenodd"
d="M0 4 H16 V12 H0 Z
M2 6 H14 V10 H2 Z
M4 6 H5 V9 H4 Z
M6 6 H7 V8 H6 Z
M8 6 H9 V9 H8 Z
M10 6 H11 V8 H10 Z
M12 6 H13 V9 H12 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 717 B

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- A rising edge crossing a dashed trigger level: what the trigger *is*. The dash
rhythm is 2 on, 1 off (tight enough to read as one line rather than as a row of
dots), with the one wide gap at x 5..9 that the edge rises through. Every
coordinate is a whole pixel of the 16px cell so nothing blurs at 1x. -->
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill="#2e3436" d="M0 11 H8 V13 H0 Z
M6 3 H8 V13 H6 Z
M6 3 H16 V5 H6 Z
M0 7 H2 V9 H0 Z
M3 7 H5 V9 H3 Z
M9 7 H11 V9 H9 Z
M12 7 H14 V9 H12 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 857 B

+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!--
The four icons this app had to draw for itself, compiled into the binary so there is
still exactly one file to run. `gtk_icon_theme_add_resource_path()` wants the tree an
icon theme has, so the prefix is followed by `<size>/<context>`; `scalable` is the one
that means "an SVG, use it at any size".
-->
<gresources>
<gresource prefix="/org/udpscope/icons">
<file>scalable/actions/udpscope-trigger-symbolic.svg</file>
<file>scalable/actions/udpscope-cursors-symbolic.svg</file>
<file>scalable/actions/udpscope-measure-symbolic.svg</file>
<file>scalable/actions/udpscope-math-symbolic.svg</file>
</gresource>
</gresources>
+36 -22
View File
@@ -5,11 +5,15 @@
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <limits>
namespace udpscope { namespace udpscope {
namespace { namespace {
constexpr double kPi = 3.14159265358979323846; constexpr double kPi = 3.14159265358979323846;
/// A value the step can return without a library call: `std::nan("")` parses its argument, and this
/// runs once per stored slot per node.
constexpr double kNaN = std::numeric_limits<double>::quiet_NaN();
/// A biquad's coefficients for one node, from the RBJ audio-EQ cookbook. `q` is 1/sqrt(2) /// A biquad's coefficients for one node, from the RBJ audio-EQ cookbook. `q` is 1/sqrt(2)
/// (Butterworth) for the plain low/high pass, and the node's own for the band pass -- which is /// (Butterworth) for the plain low/high pass, and the node's own for the band pass -- which is
@@ -119,44 +123,54 @@ bool math_op_uses_filter(MathOp op) {
} }
bool math_op_uses_clamp(MathOp op) { return op == MathOp::Clamp; } bool math_op_uses_clamp(MathOp op) { return op == MathOp::Clamp; }
void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt) { void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt,
const std::function<int(const std::string&)>& index_of) {
const std::size_t n = channel.nodes.size(); const std::size_t n = channel.nodes.size();
rt.value.assign(n, 0.0);
rt.b0.assign(n, 1.0);
rt.b1.assign(n, 0.0);
rt.b2.assign(n, 0.0);
rt.a1.assign(n, 0.0);
rt.a2.assign(n, 0.0);
// A rate change *keeps* the filter state: the state words are in the signal's units, not in // A rate change *keeps* the filter state: the state words are in the signal's units, not in
// time, so a decimation change moves the cutoffs (which is why the coefficients are rebuilt) // time, so a decimation change moves the cutoffs (which is why the coefficients are rebuilt)
// without making the trace jump. A node that is new gets zeros, which is a filter starting from // without making the trace jump. A node that is new gets zeros, which is a filter starting from
// rest -- the only honest starting point there is. // rest -- the only honest starting point there is.
rt.z1.resize(n, 0.0); const std::vector<MathNodeRt> was = std::move(rt.node);
rt.z2.resize(n, 0.0); rt.node.assign(n, MathNodeRt{});
for (std::size_t i = 0; i < n && i < was.size(); ++i) {
rt.node[i].z1 = was[i].z1;
rt.node[i].z2 = was[i].z2;
}
rt.dt = dt; rt.dt = dt;
rt.ready = true; rt.ready = true;
for (std::size_t i = 0; i < n; ++i) { for (std::size_t i = 0; i < n; ++i) {
const MathNode& node = channel.nodes[i]; const MathNode& node = channel.nodes[i];
MathNodeRt& out = rt.node[i];
if (node.op == MathOp::Source) {
out.src = index_of ? index_of(node.source) : -1;
continue;
}
if (!math_op_uses_filter(node.op)) continue; if (!math_op_uses_filter(node.op)) continue;
biquad(node.op, node.fc, node.q, dt, rt.b0[i], rt.b1[i], rt.b2[i], rt.a1[i], rt.a2[i]); biquad(node.op, node.fc, node.q, dt, out.b0, out.b1, out.b2, out.a1, out.a2);
} }
} }
double math_step(const MathChannel& channel, MathRuntime& rt, double math_step(const MathChannel& channel, MathRuntime& rt, const float* values,
const std::function<double(const std::string&)>& source) { std::size_t count) {
const std::size_t n = channel.nodes.size(); const std::size_t n = channel.nodes.size();
if (n == 0 || rt.value.size() != n) return std::nan(""); if (n == 0 || rt.node.size() != n) return kNaN;
MathNodeRt* rts = rt.node.data();
const auto at = [&](int index) -> double { const auto at = [&](int index) -> double {
if (index < 0 || static_cast<std::size_t>(index) >= n) return std::nan(""); if (index < 0 || static_cast<std::size_t>(index) >= n) return kNaN;
return rt.value[static_cast<std::size_t>(index)]; return rts[static_cast<std::size_t>(index)].value;
}; };
for (std::size_t i = 0; i < n; ++i) { for (std::size_t i = 0; i < n; ++i) {
const MathNode& node = channel.nodes[i]; const MathNode& node = channel.nodes[i];
MathNodeRt& self = rts[i];
double x = 0.0; double x = 0.0;
switch (node.op) { switch (node.op) {
case MathOp::Source: case MathOp::Source: {
x = source(node.source); const int c = self.src;
x = c >= 0 && static_cast<std::size_t>(c) < count
? static_cast<double>(values[static_cast<std::size_t>(c)])
: kNaN;
break; break;
}
case MathOp::Gain: case MathOp::Gain:
x = at(node.in_a) * node.gain + node.offset; x = at(node.in_a) * node.gain + node.offset;
break; break;
@@ -174,8 +188,8 @@ double math_step(const MathChannel& channel, MathRuntime& rt,
case MathOp::LowPass: case MathOp::LowPass:
case MathOp::HighPass: case MathOp::HighPass:
case MathOp::BandPass: case MathOp::BandPass:
x = biquad_step(at(node.in_a), rt.b0[i], rt.b1[i], rt.b2[i], rt.a1[i], rt.a2[i], rt.z1[i], x = biquad_step(at(node.in_a), self.b0, self.b1, self.b2, self.a1, self.a2, self.z1,
rt.z2[i]); self.z2);
break; break;
case MathOp::Sum: case MathOp::Sum:
x = at(node.in_a) + at(node.in_b); x = at(node.in_a) + at(node.in_b);
@@ -194,11 +208,11 @@ double math_step(const MathChannel& channel, MathRuntime& rt,
break; break;
} }
} }
rt.value[i] = x; self.value = x;
} }
const int out = channel.output; const int out = channel.output;
if (out < 0 || static_cast<std::size_t>(out) >= n) return std::nan(""); if (out < 0 || static_cast<std::size_t>(out) >= n) return kNaN;
return rt.value[static_cast<std::size_t>(out)]; return rts[static_cast<std::size_t>(out)].value;
} }
bool math_channel_valid(const MathChannel& channel, std::string* why) { bool math_channel_valid(const MathChannel& channel, std::string* why) {
+37 -12
View File
@@ -68,25 +68,50 @@ struct MathChannel {
/// The per-channel runtime: the filter memory and the per-node scratch. Kept apart from the /// The per-channel runtime: the filter memory and the per-node scratch. Kept apart from the
/// `MathChannel` because it is *state* (one sample of history per biquad) while the channel is /// `MathChannel` because it is *state* (one sample of history per biquad) while the channel is
/// *configuration* -- so the pane can edit one while the producer streams through the other. /// *configuration* -- so the pane can edit one while the producer streams through the other.
/// One node's runtime: its value this sample, its biquad coefficients and state, and its resolved
/// source. **One struct per node, not one vector per field**: the step touches all of them for the
/// node it is on and none of them for any other, so a node's state belongs on one cache line.
/// Eight parallel `std::vector<double>` put a filter node's eight words on eight separate lines,
/// which at the stored rate is eight misses per node per sample.
struct MathNodeRt {
double value = 0.0; ///< this node's output for the sample being evaluated
/// Biquad coefficients (transposed direct form II) and the two state words.
double b0 = 1.0;
double b1 = 0.0;
double b2 = 0.0;
double a1 = 0.0;
double a2 = 0.0;
double z1 = 0.0;
double z2 = 0.0;
/// The channel index a `Source` node reads, -1 when its name names nothing acquired (and for
/// every other op). Resolved **once**, by `math_prepare()`, rather than per sample: the step runs
/// at the stored rate -- a million times a second on a fast stream -- and reading a source by
/// name there was a linear scan of the channel list with a `std::string` compare per candidate,
/// per source node, per sample, behind a type-erased call. Names cannot change without a
/// rebuild, and every rebuild re-prepares.
int src = -1;
};
struct MathRuntime { struct MathRuntime {
std::vector<double> value; ///< one per node, this sample. std::vector<MathNodeRt> node;
/// Biquad coefficients per node (transposed direct form II), and its two state words.
std::vector<double> b0, b1, b2, a1, a2, z1, z2;
/// The rate the coefficients were built for; a change rebuilds them. /// The rate the coefficients were built for; a change rebuilds them.
double dt = 0.0; double dt = 0.0;
bool ready = false; bool ready = false;
}; };
/// Fills `rt`'s size and the biquad coefficients for `dt` seconds per sample. Call it whenever the /// Fills `rt`'s size, the biquad coefficients for `dt` seconds per sample, and the resolved source
/// graph or the stored rate changes -- cheap, and it is what keeps the filters' cutoffs honest /// indices -- `index_of(name)` is the channel a `Source` node names, or -1. Call it whenever the
/// across a decimation change, where `dt` is not what it was when the user typed a frequency. /// graph, the channel list or the stored rate changes -- cheap, and it is what keeps the filters'
void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt); /// cutoffs honest across a decimation change, where `dt` is not what it was when the user typed a
/// frequency.
void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt,
const std::function<int(const std::string&)>& index_of);
/// One sample through the graph. `source(name)` returns that channel's current value, and NAN when /// One sample through the graph. `values[c]` is channel `c`'s value for this slot; a `Source` node
/// it is not acquired -- which propagates to the output, so a math channel whose source is disabled /// whose channel is not among them reads NAN -- which propagates to the output, so a math channel
/// reads as "no data" rather than as whatever the last good sample was. /// whose source is disabled reads as "no data" rather than as whatever the last good sample was.
double math_step(const MathChannel& channel, MathRuntime& rt, double math_step(const MathChannel& channel, MathRuntime& rt, const float* values,
const std::function<double(const std::string&)>& source); std::size_t count);
/// Whether the graph is complete enough to produce a value: an output node that exists, and every /// Whether the graph is complete enough to produce a value: an output node that exists, and every
/// input it needs connected to a node that exists (with `Source` nodes naming something). /// input it needs connected to a node that exists (with `Source` nodes naming something).
+202 -105
View File
@@ -8,30 +8,185 @@
namespace udpscope { namespace udpscope {
MeasureStats measure_stats(const std::vector<double>& time, const std::vector<double>& value) { namespace {
MeasureStats s;
const std::size_t n = std::min(time.size(), value.size()); /// One crossing *into* side `e` at time `t`, which is also what closes the interval the walk was
for (std::size_t i = 0; i < n; ++i) { /// in. Shared by the raw walk and the bucket walk so the two cannot disagree about what an edge
const double v = value[i]; /// records -- an interval's width is the distance from the edge that entered it to the edge that
/// left it, which is the pairing the pulse duration averages.
void enter_side(MeasureLevel& acc, std::size_t e, double t) {
const std::size_t other = 1 - e;
if (acc.enters[e] == 0) acc.first_enter[e] = t;
acc.last_enter[e] = t;
++acc.enters[e];
if (acc.open[other]) {
acc.width_sum[other] += t - acc.open_at[other];
++acc.widths[other];
acc.open[other] = false;
}
acc.open_at[e] = t;
acc.open[e] = true;
}
} // namespace
void measure_scan(const MeasureChunk& chunk, MeasureStats& s) {
// The chunk's own extremes first, in a loop that touches nothing but the values: *where* an
// extreme is costs an index, and the caller turns the two that survive into stamps once at the
// end. The sum is accumulated per chunk and folded in once, which is also kinder numerically
// than one running total over a few million terms.
double sum = 0.0;
std::size_t count = 0;
double lo = 0.0;
double hi = 0.0;
std::size_t at_lo = 0;
std::size_t at_hi = 0;
bool any = false;
for (std::size_t i = 0; i < chunk.count; ++i) {
const double v = static_cast<double>(chunk.value[i]);
if (!std::isfinite(v)) continue; if (!std::isfinite(v)) continue;
if (!s.valid) { if (!any) {
s.lo = s.hi = v; lo = hi = v;
s.at_lo = s.at_hi = time[i]; at_lo = at_hi = i;
s.valid = true; any = true;
} else { } else {
if (v < s.lo) { if (v < lo) {
s.lo = v; lo = v;
s.at_lo = time[i]; at_lo = i;
} }
if (v > s.hi) { if (v > hi) {
s.hi = v; hi = v;
s.at_hi = time[i]; at_hi = i;
} }
} }
s.sum += v; sum += v;
++s.count; ++count;
}
if (!any) return;
s.sum += sum;
s.count += count;
if (!s.valid) {
s.lo = lo;
s.hi = hi;
s.lo_i = chunk.offset + at_lo;
s.hi_i = chunk.offset + at_hi;
s.lo_search = 0;
s.hi_search = 0;
s.valid = true;
return;
}
// Strictly better only: the *first* sample to reach an extreme is the one a reader's eye lands
// on, so a later chunk that merely ties it does not move the mark.
if (lo < s.lo) {
s.lo = lo;
s.lo_i = chunk.offset + at_lo;
s.lo_search = 0;
}
if (hi > s.hi) {
s.hi = hi;
s.hi_i = chunk.offset + at_hi;
s.hi_search = 0;
}
}
void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, MeasureStats& s) {
double sum = 0.0;
std::size_t finite = 0;
for (std::size_t b = 0; b < count; ++b) {
const MeasureBucket& k = buckets[b];
// A bucket of nothing (every sample non-finite) is stored as an empty aggregate, and the raw
// walk skips exactly those samples -- so it contributes nothing here either.
if (k.count == 0) continue;
sum += k.sum;
finite += k.count;
if (!s.valid) {
s.lo = k.lo;
s.hi = k.hi;
s.lo_i = s.hi_i = k.offset;
s.lo_search = s.hi_search = k.span;
s.valid = true;
continue;
}
if (k.lo < s.lo) {
s.lo = k.lo;
s.lo_i = k.offset;
s.lo_search = k.span;
}
if (k.hi > s.hi) {
s.hi = k.hi;
s.hi_i = k.offset;
s.hi_search = k.span;
}
}
s.sum += sum;
s.count += finite;
}
void measure_accumulate(const MeasureChunk& chunk,
const std::function<double(std::size_t)>& time_of, MeasureLevel* levels,
std::size_t level_count) {
for (std::size_t k = 0; k < level_count; ++k) {
MeasureLevel& acc = levels[k];
for (std::size_t i = 0; i < chunk.count; ++i) {
const double v = static_cast<double>(chunk.value[i]);
if (!std::isfinite(v)) continue;
const int s = v > acc.level ? 1 : 0;
acc.sum[static_cast<std::size_t>(s)] += v;
++acc.count[static_cast<std::size_t>(s)];
// A sample *at* the level is on neither side, so it decides no crossing and the walk keeps
// the side it last decided. That is what stops a signal which merely touches the level from
// counting every sample as an edge.
if (v == acc.level) continue;
if (acc.have_side && s != (acc.side ? 1 : 0)) {
enter_side(acc, static_cast<std::size_t>(s), time_of(chunk.offset + i));
}
acc.side = s != 0;
acc.have_side = true;
}
}
}
bool measure_accumulate_bucket(const MeasureBucket& k,
const std::function<double(std::size_t)>& time_of,
MeasureLevel& acc) {
if (k.count == 0) return true;
// A non-finite sample in the bucket would make the edge's position a guess: the raw walk takes
// the first *finite* sample of the new side, and an aggregate cannot say which that is.
if (k.count != k.span) return false;
// Strictly on one side, so every sample decides the same side and none is *at* the level --
// which is what makes this exact rather than an approximation of the walk.
const bool above = k.lo > acc.level;
const bool below = k.hi < acc.level;
if (!above && !below) return false;
const std::size_t e = above ? 1u : 0u;
acc.sum[e] += k.sum;
acc.count[e] += k.count;
if (acc.have_side && e != (acc.side ? 1u : 0u)) {
// The edge is the bucket's *first* sample: everything before it was on the other side and
// everything in the bucket is on this one, so that is where the raw walk would have found it.
enter_side(acc, e, time_of(k.offset));
}
acc.side = e != 0;
acc.have_side = true;
return true;
}
bool measure_level_pct(MeasureType type, const MeasureOptions& options, double& pct) {
switch (type) {
case MeasureType::Top:
case MeasureType::Bottom:
pct = std::clamp(options.threshold_pct, 0.0, 100.0) / 100.0;
return true;
case MeasureType::PulseDuration:
case MeasureType::Period:
case MeasureType::Frequency:
// The *mid* level, for all three. No option here on purpose: a period is the spacing of
// one crossing per cycle, and a threshold near either extreme counts noise as crossings.
pct = 0.5;
return true;
default:
return false;
} }
return s;
} }
@@ -77,27 +232,11 @@ bool measure_uses_side(MeasureType type) { return type == MeasureType::PulseDura
bool measure_can_show_on_plot(MeasureType) { return true; } bool measure_can_show_on_plot(MeasureType) { return true; }
MeasureResult compute_measure(const std::vector<double>& time, const std::vector<double>& value, MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level, MeasureType type,
MeasureType type, const MeasureOptions& options, const MeasureOptions& options) {
const MeasureStats* stats) {
MeasureResult out; MeasureResult out;
const std::size_t n = std::min(time.size(), value.size());
if (n == 0) return out;
// A threshold as a share of the window's own range, which is what makes one percentage
// travel across signals: a threshold in volts would have to be typed for each. The bounds
// come from the caller when it has them (several measurements on one signal), because the
// scan is O(visible samples) and this runs per frame while a live window scrolls.
const MeasureStats b = stats != nullptr ? *stats : measure_stats(time, value);
if (!b.valid) return out; if (!b.valid) return out;
// A threshold as a share of the window's own range. A flat window has no range at all, so
// the level is the signal's one value -- every sample is on neither side of it (nothing is
// strictly above or below), and the crossing types then find no edge, which is the honest
// answer for a signal that never moves.
const double pct = std::clamp(options.threshold_pct, 0.0, 100.0) / 100.0;
const double level = b.lo + pct * (b.hi - b.lo);
switch (type) { switch (type) {
case MeasureType::Average: { case MeasureType::Average: {
if (b.count == 0) return out; if (b.count == 0) return out;
@@ -133,17 +272,13 @@ MeasureResult compute_measure(const std::vector<double>& time, const std::vector
} }
case MeasureType::Top: case MeasureType::Top:
case MeasureType::Bottom: { case MeasureType::Bottom: {
const bool want_high = type == MeasureType::Top; // The mean of one side of the level, straight out of the second pass' running sums. A flat
double sum = 0.0; // window has no range at all, so every sample lands on the "below" side -- which is why
std::size_t used = 0; // `top` on a signal that never moves reads `--` rather than that one value.
for (std::size_t i = 0; i < n; ++i) { if (level == nullptr) return out;
if (!std::isfinite(value[i])) continue; const std::size_t s = type == MeasureType::Top ? 1 : 0;
if ((value[i] > level) != want_high) continue; if (level->count[s] == 0) return out;
sum += value[i]; out.primary = level->sum[s] / static_cast<double>(level->count[s]);
++used;
}
if (used == 0) return out;
out.primary = sum / static_cast<double>(used);
out.marks.push_back({true, out.primary}); out.marks.push_back({true, out.primary});
out.valid = true; out.valid = true;
return out; return out;
@@ -151,72 +286,34 @@ MeasureResult compute_measure(const std::vector<double>& time, const std::vector
case MeasureType::PulseDuration: case MeasureType::PulseDuration:
case MeasureType::Period: case MeasureType::Period:
case MeasureType::Frequency: { case MeasureType::Frequency: {
// No options here on purpose: a period is the spacing of *rising* crossings of the if (level == nullptr) return out;
// *mid* level -- one edge per cycle -- and a threshold or a side to choose from would // The pulse is one side of the mid level: its widths run from the edge that enters it to
// only let the user make the count wrong (a threshold near either extreme counts noise // the edge that leaves it, and a period is the spacing of the edges that *enter* it -- one
// as crossings; counting both edges halves the period).
const double period_level = b.lo + 0.5 * (b.hi - b.lo);
// Every strict side change is an edge. "Strict" is what keeps a signal that merely
// touches the level from counting each sample as a crossing: a sample equal to the
// level is on neither side, so the walk holds the last decided side until one is.
struct Edge {
double t;
bool rising;
};
std::vector<Edge> edges;
bool have_side = false;
bool side = false;
for (std::size_t i = 0; i < n; ++i) {
const double v = value[i];
if (!std::isfinite(v) || v == period_level) continue;
const bool cur = v > period_level;
if (have_side && cur != side) edges.push_back({time[i], cur});
side = cur;
have_side = true;
}
if (edges.size() < 2) return out;
// The pulse is the `above` side: its widths run from the edge that enters it to the
// edge that leaves it, and a period is the spacing of the edges that enter it -- one
// per cycle, whatever else the waveform does in between. // per cycle, whatever else the waveform does in between.
std::vector<double> starts; const std::size_t s = options.above ? 1 : 0;
std::vector<double> widths; const double enter_span = level->last_enter[s] - level->first_enter[s];
for (std::size_t i = 0; i + 1 < edges.size(); ++i) { const double enter_period = level->enters[s] >= 2
if (edges[i].rising != options.above) continue; ? enter_span / static_cast<double>(level->enters[s] - 1)
starts.push_back(edges[i].t); : 0.0;
if (edges[i + 1].rising == options.above) continue;
widths.push_back(edges[i + 1].t - edges[i].t);
}
if (type == MeasureType::PulseDuration) { if (type == MeasureType::PulseDuration) {
if (widths.empty()) return out; if (level->widths[s] == 0) return out;
double sum = 0.0; out.primary = level->width_sum[s] / static_cast<double>(level->widths[s]);
for (const double w : widths) sum += w;
out.primary = sum / static_cast<double>(widths.size());
// Duty as width/period, not as pulse-time over the window: a window that starts or // Duty as width/period, not as pulse-time over the window: a window that starts or
// ends mid-pulse holds partial cycles, and counting only the complete ones against // ends mid-pulse holds partial cycles, and counting only the complete ones against
// the whole span would report a duty that depends on where the window happened to // the whole span would report a duty that depends on where the window happened to
// begin. The period is the spacing of the pulse's own enter-edges. // begin. The period is the spacing of the pulse's own enter-edges, and it needs two of
double period = 0.0; // them -- a window holding one pulse reports the width alone.
if (starts.size() >= 2) { out.secondary = enter_period > 0.0 ? out.primary / enter_period : 0.0;
period = (starts.back() - starts.front()) / static_cast<double>(starts.size() - 1); out.has_secondary = enter_period > 0.0;
} out.marks.push_back({false, level->first_enter[s]});
// The duty needs a period, which needs two pulses; a window holding one reports the out.marks.push_back({false, level->first_enter[s] + out.primary});
// width alone rather than a duty computed against a span that is not a cycle.
out.secondary = period > 0.0 ? out.primary / period : 0.0;
out.has_secondary = period > 0.0;
if (!starts.empty()) {
out.marks.push_back({false, starts.front()});
out.marks.push_back({false, starts.front() + out.primary});
}
out.valid = true; out.valid = true;
return out; return out;
} }
if (starts.size() < 2) return out; if (!(enter_period > 0.0)) return out;
const double span = starts.back() - starts.front(); out.primary = type == MeasureType::Period ? enter_period : 1.0 / enter_period;
const double period = span / static_cast<double>(starts.size() - 1); out.marks.push_back({false, level->first_enter[s]});
if (!(period > 0.0)) return out; out.marks.push_back({false, level->first_enter[s] + enter_period});
out.primary = type == MeasureType::Period ? period : 1.0 / period;
out.marks.push_back({false, starts.front()});
out.marks.push_back({false, starts.front() + period});
out.valid = true; out.valid = true;
return out; return out;
} }
+113 -13
View File
@@ -2,6 +2,8 @@
// SPDX-FileCopyrightText: 2026 Martino Ferrari // SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <cstddef>
#include <functional>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -82,25 +84,123 @@ struct MeasureStats {
/// derived from them, so the window is read once for every type that is not an edge count. /// derived from them, so the window is read once for every type that is not an edge count.
double sum = 0.0; double sum = 0.0;
std::size_t count = 0; std::size_t count = 0;
/// The time of the *first* sample to reach each extreme -- "min" is a level and a place, and /// The *window index* of the first sample to reach each extreme -- "min" is a level and a
/// finding the place is why this is not just the two numbers. /// place, and finding the place is why this is not just the two numbers. An index rather than
/// a stamp because a stamp costs a ring read and the walk moves an extreme many times, while
/// the caller needs the time exactly twice.
std::size_t lo_i = 0;
std::size_t hi_i = 0;
/// How many samples from `lo_i`/`hi_i` still have to be searched to turn it into a *sample*,
/// or 0 when it already is one. A bucket pass knows only which bucket holds the extreme, so
/// the caller finishes with one raw `measure_scan()` over that bucket alone -- two buckets at
/// most, whatever the window's size.
std::size_t lo_search = 0;
std::size_t hi_search = 0;
/// The time of each extreme. Filled by the caller from `lo_i`/`hi_i` once the walk is done;
/// `compute_measure()` reads these, so a caller that skips the resolve reports t = 0.
double at_lo = 0.0; double at_lo = 0.0;
double at_hi = 0.0; double at_hi = 0.0;
bool valid = false; bool valid = false;
}; };
/// One pass over a window: both extremes with their first positions, and the sum and count. /// One chunk of a window's values, in time order, exactly as the rings hold them.
MeasureStats measure_stats(const std::vector<double>& time, const std::vector<double>& value);
/// The engine: the visible samples of one signal, in stream seconds and in the signal's own
/// units. Both arrays are the same length and in time order; the function reads nothing else,
/// which is what makes it testable and what keeps the answer independent of the plot.
/// ///
/// `stats` is `measure_stats(time, value)` when the caller already has it -- several measurements /// The engine is *fed* the window this way rather than handed a materialized copy. A visible
/// on one signal share one scan -- and null to compute it here. It must describe these arrays. /// window is millions of samples and the rings hold `f32`, so widening the whole thing into two
MeasureResult compute_measure(const std::vector<double>& time, const std::vector<double>& value, /// `std::vector<double>` cost a 16-byte-per-sample write and then a read of it for every pass --
MeasureType type, const MeasureOptions& options, /// 60 % of the pane's time before a single statistic had been computed, and 12 ns a sample over a
const MeasureStats* stats = nullptr); /// 1.7 M-sample window. Only the *values* are streamed: a stamp is needed at the extremes and at
/// the level crossings, which are a handful in a window of millions, so it is fetched by index.
struct MeasureChunk {
const float* value = nullptr;
std::size_t count = 0;
/// The window offset of `value[0]`, so `time_of()` below can be asked for one absolute sample.
std::size_t offset = 0;
};
/// One whole LOD bucket of a window, exactly as `plot::data::MinMaxPyramid` already holds it.
///
/// The pyramid *is* "evaluate only on the new samples": the producer folds every sample into it
/// as it arrives, at amortised O(1), so a bucket is 64 samples the measurement walks never have
/// to read. A 1.7 M-sample window is 26 k buckets, and only the handful that *straddle* a level
/// are opened.
struct MeasureBucket {
double lo = 0.0;
double hi = 0.0;
double sum = 0.0;
std::size_t count = 0; ///< finite samples folded in; 0 = a bucket with nothing usable
std::size_t offset = 0; ///< window offset of the bucket's first sample
std::size_t span = 0; ///< samples the bucket covers, finite or not
};
/// Pass one, chunk by chunk: both extremes with the *index* of the first sample to reach each,
/// and the sum and count of the finite samples. Feed the chunks in window order into a zeroed
/// `stats` -- the first sample to reach an extreme is the one the mark names, so a later chunk
/// that merely ties it must not move it.
void measure_scan(const MeasureChunk& chunk, MeasureStats& stats);
/// Pass one from whole buckets: the same figures, exactly, for 1/64 of the reads. The extreme's
/// position comes out as a *bucket* (`lo_search`/`hi_search` non-zero), which one raw
/// `measure_scan()` of that bucket then pins. Interleave with `measure_scan()` in window order.
void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, MeasureStats& stats);
/// Everything the threshold-relative types need from a second walk, at one level: the sum and
/// count of the samples on either side of it, and its crossings reduced to running figures -- the
/// first and last *enter* edge of each side, how many there were, and the summed width of the
/// intervals that closed. All of it is O(1) state, so `Top`, `Bottom`, `PulseDuration`, `Period`
/// and `Frequency` are all answered from one pass: adding a *type* must not add a pass, and nor
/// must adding a second measurement of the same type at the same level.
struct MeasureLevel {
double level = 0.0;
/// Index 0 is the side below the level (a sample exactly *at* it counts here, which is what
/// `bottom` has always reported), index 1 the side above it.
double sum[2] = {0.0, 0.0};
std::size_t count[2] = {0, 0};
double first_enter[2] = {0.0, 0.0};
double last_enter[2] = {0.0, 0.0};
std::size_t enters[2] = {0, 0};
double width_sum[2] = {0.0, 0.0};
std::size_t widths[2] = {0, 0};
/// Walk state carried between chunks: the side the signal was last *decided* to be on (a sample
/// equal to the level decides nothing, so the walk holds the previous side), and the interval
/// per side that has been entered but not yet left.
bool have_side = false;
bool side = false;
double open_at[2] = {0.0, 0.0};
bool open[2] = {false, false};
};
/// Pass two: one chunk into every level at once. The usual case is a single level -- every
/// crossing type reads the mid level and `Top`/`Bottom` default to it too -- so a pane full of
/// measurements on one signal costs two walks, not two per measurement.
void measure_accumulate(const MeasureChunk& chunk,
const std::function<double(std::size_t)>& time_of, MeasureLevel* levels,
std::size_t level_count);
/// Pass two from one whole bucket, into one level. True when the bucket answered it: a bucket
/// every one of whose samples is on the same side of the level holds no crossing, so its sums
/// fold in whole and the walk's side is decided by the bucket rather than by 64 samples. An edge
/// *into* such a bucket lands on its first sample, exactly, which is why that is the stamp taken.
///
/// False means the bucket straddles the level -- or holds a non-finite sample, which would make
/// the edge's position a guess -- and the caller must feed its raw samples to
/// `measure_accumulate()` for this level instead. That is the only place a measurement still
/// reads raw samples in bulk, and on a clean waveform it is a bucket per crossing.
bool measure_accumulate_bucket(const MeasureBucket& bucket,
const std::function<double(std::size_t)>& time_of,
MeasureLevel& level);
/// The level a type reads, as a share of the window's own min..max, or false when it reads none
/// (the pure statistics, answered from `MeasureStats` alone). `Top`/`Bottom` read the
/// measurement's own threshold; the crossing types read the mid level, deliberately -- see
/// `measure_uses_threshold()`.
bool measure_level_pct(MeasureType type, const MeasureOptions& options, double& pct);
/// The engine: one measurement's answer, O(1), from the two passes' results. `level` is the
/// accumulator for `measure_level_pct(type, options)` and may be null for the types that read
/// none. The function reads nothing else, which is what keeps the answer independent of the plot.
MeasureResult compute_measure(const MeasureStats& stats, const MeasureLevel* level,
MeasureType type, const MeasureOptions& options);
/// A measurement type's name, as the pane's row and the dialog's dropdown spell it. /// A measurement type's name, as the pane's row and the dialog's dropdown spell it.
const char* measure_type_name(MeasureType type); const char* measure_type_name(MeasureType type);
+54 -13
View File
@@ -6,6 +6,7 @@
#include <udps_client.h> #include <udps_client.h>
#include <algorithm> #include <algorithm>
#include <chrono>
#include <cmath> #include <cmath>
#include <cstdio> #include <cstdio>
#include <stdexcept> #include <stdexcept>
@@ -22,6 +23,23 @@ u64 pow2_floor(u64 v) {
return p; return p;
} }
std::uint64_t now_ns() {
return static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::steady_clock::now().time_since_epoch())
.count());
}
/// One stored slot in this many is timed: see `ScopeModel::push_math_sample()`.
///
/// A *single* slot, deliberately, even though one slot's work is the same order as one
/// `clock_gettime` -- so the figure carries a clock read's own cost and is an upper bound. Timing a
/// *run* of consecutive slots to amortise that away was tried and is wrong: the producer gets its
/// slots a packet at a time, so a run long enough to be worth timing straddles a packet boundary
/// now and then and charges the measured slots the **wait for the next packet**. At 2000 samples a
/// packet a 64-slot run crosses one about 3% of the time, and a ~1.5 ms gap spread over 64 slots
/// reported 799 ns/slot against the true 78. One slot is always inside one packet.
constexpr std::uint64_t kMathCostSample = 1024;
} // namespace } // namespace
u64 Partition::total_samples() const noexcept { u64 Partition::total_samples() const noexcept {
@@ -221,29 +239,52 @@ void ScopeModel::prepare_math_locked() {
// starting point for the new one. Both are reset here, together, so the two cannot drift apart. // starting point for the new one. Both are reset here, together, so the two cannot drift apart.
math_rt_.assign(math_.size(), MathRuntime{}); math_rt_.assign(math_.size(), MathRuntime{});
const double dt = part_.slot_dt(); const double dt = part_.slot_dt();
for (std::size_t i = 0; i < math_.size(); ++i) math_prepare(math_[i], math_rt_[i], dt); // A `Source` node's channel index, resolved here so the per-sample step never looks a name up.
const auto index_of = [this](const std::string& name) -> int {
for (std::size_t c = 0; c < channels_.size(); ++c) {
if (channels_[c].name == name) return static_cast<int>(c);
}
return -1;
};
for (std::size_t i = 0; i < math_.size(); ++i) {
math_prepare(math_[i], math_rt_[i], dt, index_of);
}
} }
void ScopeModel::push_math_sample(const std::vector<f32>& sample) { void ScopeModel::push_math_sample(const std::vector<f32>& sample) {
// The common case is no derived channels at all, and this runs once per *stored slot* -- so the
// early-out is what keeps a stream with no math from paying for the feature.
if (math_.empty()) return;
// The cost is *sampled*: a run of `kMathCostRun` consecutive slots every `kMathCostPeriod`. This
// runs up to the wire rate, where a slot's own work is tens of nanoseconds -- so timing every
// slot is itself able to drop packets, and timing one slot at a time charges it a whole clock
// read. A run amortises both: one pair of reads per 64 measured slots, one pair per 65536 slots
// overall, and the figure converges over the millions this counter sees.
const std::uint64_t slot = math_slot_count_++;
math_slots_.store(math_slot_count_, std::memory_order_relaxed);
const bool timed = slot % kMathCostSample == 0;
if (timed) math_cost_t0_ = now_ns();
// Values for this slot, extended as the derived ones are computed: a graph may name another math // Values for this slot, extended as the derived ones are computed: a graph may name another math
// channel, and nodes are evaluated in list order, so by the time one is asked for it is there. // channel, and nodes are evaluated in list order, so by the time one is asked for it is there.
std::vector<f32> values = sample; // `assign()` into a member reuses the capacity -- a local vector here was a heap allocation and
const auto value_of = [&](const std::string& name) -> double { // free per stored slot, i.e. up to a million pairs a second on a fast stream.
for (std::size_t c = 0; c < channels_.size(); ++c) { math_values_.assign(sample.begin(), sample.end());
if (channels_[c].name != name) continue; if (math_values_.size() < channels_.size()) math_values_.resize(channels_.size(), 0.0f);
return c < values.size() ? static_cast<double>(values[c]) : std::nan("");
}
return std::nan("");
};
for (std::size_t m = 0; m < math_.size(); ++m) { for (std::size_t m = 0; m < math_.size(); ++m) {
const int channel = m < math_channel_.size() ? math_channel_[m] : -1; const int channel = m < math_channel_.size() ? math_channel_[m] : -1;
if (channel < 0 || m >= math_rt_.size()) continue; if (channel < 0 || m >= math_rt_.size()) continue;
const double out = math_step(math_[m], math_rt_[m], value_of); const double out =
math_step(math_[m], math_rt_[m], math_values_.data(), math_values_.size());
live().values(static_cast<std::size_t>(channel)).push(static_cast<f32>(out)); live().values(static_cast<std::size_t>(channel)).push(static_cast<f32>(out));
if (values.size() <= static_cast<std::size_t>(channel)) { if (static_cast<std::size_t>(channel) < math_values_.size()) {
values.resize(static_cast<std::size_t>(channel) + 1, 0.0f); math_values_[static_cast<std::size_t>(channel)] = static_cast<f32>(out);
} }
values[static_cast<std::size_t>(channel)] = static_cast<f32>(out); }
if (timed) {
math_cost_count_ += now_ns() - math_cost_t0_;
++math_timed_count_;
math_cost_ns_.store(math_cost_count_, std::memory_order_relaxed);
math_timed_.store(math_timed_count_, std::memory_order_relaxed);
} }
} }
+33
View File
@@ -204,6 +204,21 @@ class ScopeModel {
/// the streamed channels. `sample[c]` is channel `c`'s value for this slot; the math channels are /// the streamed channels. `sample[c]` is channel `c`'s value for this slot; the math channels are
/// evaluated from those, in order, so a math channel may use another one. /// evaluated from those, in order, so a math channel may use another one.
void push_math_sample(const std::vector<f32>& sample); void push_math_sample(const std::vector<f32>& sample);
/// What the derived channels cost the *producer*: every stored slot that went through
/// `push_math_sample()`, and the nanoseconds taken by the `math_timed()` of them that were
/// measured. The producer owns the budget between two DATA packets, so this cost is invisible in
/// the frame mean and surfaces only as dropped packets -- which is why it is counted rather than
/// inferred. It is *sampled*, so that the instrumentation cannot itself cause what it is looking
/// for: read `math_cost_ns() / math_timed()` as the per-slot figure, scaled by `math_slots()`.
std::uint64_t math_slots() const noexcept {
return math_slots_.load(std::memory_order_relaxed);
}
std::uint64_t math_timed() const noexcept {
return math_timed_.load(std::memory_order_relaxed);
}
std::uint64_t math_cost_ns() const noexcept {
return math_cost_ns_.load(std::memory_order_relaxed);
}
const std::vector<SignalInfo>& signals() const noexcept { return signals_; } const std::vector<SignalInfo>& signals() const noexcept { return signals_; }
const std::vector<ChannelInfo>& channels() const noexcept { return channels_; } const std::vector<ChannelInfo>& channels() const noexcept { return channels_; }
@@ -434,6 +449,24 @@ class ScopeModel {
/// before the stream's own are re-appended -- a CONFIG must not leave a stale derived channel /// before the stream's own are re-appended -- a CONFIG must not leave a stale derived channel
/// behind, and must not drop a live one either. /// behind, and must not drop a live one either.
std::vector<std::string> math_signal_names_; std::vector<std::string> math_signal_names_;
/// The producer's scratch for one slot's channel values, derived ones appended as they are
/// computed. A member rather than a local because `push_math_sample()` runs once per *stored
/// slot* -- up to the wire rate -- and a local `std::vector` is a heap allocation and free per
/// slot. `assign()` reuses the capacity, so after the first slot it is a memcpy.
std::vector<f32> math_values_;
/// The counters' own copies, written and read by the producer and nobody else, so each publish
/// below is a relaxed **store** of a value already in hand rather than a `fetch_add`. The
/// read-modify-write is a locked instruction and this is the per-slot path: one `lock xadd` costs
/// about what a two-node graph's whole arithmetic does, so counting the slots cost more than the
/// work it was counting.
std::uint64_t math_slot_count_ = 0;
std::uint64_t math_timed_count_ = 0;
std::uint64_t math_cost_count_ = 0;
/// When the slot being timed began.
std::uint64_t math_cost_t0_ = 0;
std::atomic<std::uint64_t> math_slots_{0};
std::atomic<std::uint64_t> math_timed_{0};
std::atomic<std::uint64_t> math_cost_ns_{0};
std::vector<bool> enabled_; std::vector<bool> enabled_;
Partition part_; Partition part_;
+865 -49
View File
File diff suppressed because it is too large. Load diff
+16 -2
View File
@@ -16,6 +16,7 @@ Everything is float64 and unquantised.
import argparse import argparse
import math import math
import random
import socket import socket
import struct import struct
import time import time
@@ -87,6 +88,11 @@ def main():
"clock advances ten times slower than the wire delivers samples") "clock advances ten times slower than the wire delivers samples")
ap.add_argument("--declared-rate", type=float, default=-1.0, ap.add_argument("--declared-rate", type=float, default=-1.0,
help="sampling_rate to put in CONFIG; -1 = the real rate, 0 = 'unknown'") help="sampling_rate to put in CONFIG; -1 = the real rate, 0 = 'unknown'")
ap.add_argument("--clock-jitter", type=float, default=0.0,
help="per-packet stamp jitter, as a fraction of one packet's span: a device "
"that stamps each batch from its own clock does not land on a perfect "
"grid, so the stream's timestamps wobble about one. Non-cumulative "
"(the mean rate is unchanged); 0 = the ideal grid")
args = ap.parse_args() args = ap.parse_args()
dt = args.stream_scale / args.rate # stream seconds per sample dt = args.stream_scale / args.rate # stream seconds per sample
@@ -134,9 +140,17 @@ def main():
time.sleep(min(next_send - now, 0.002)) time.sleep(min(next_send - now, 0.002))
continue continue
sock.sendto(build_data(counter, t_stream, dt, args.batch, args.freq), peer) # The jitter varies each *packet's* own spacing rather than offsetting its start, because
# the stream's timestamps have to stay strictly increasing -- a jittered t0 can overlap the
# packet before it, and the buffer, the trigger and every binary search here assume a
# monotonic clock. The mean spacing is still `dt`, so the declared rate stays honest; what
# moves is the *local* spacing, which is what a uniform grid over the window cannot model.
pdt = dt
if args.clock_jitter > 0.0:
pdt = dt * (1.0 + random.uniform(-args.clock_jitter, args.clock_jitter))
sock.sendto(build_data(counter, t_stream, pdt, args.batch, args.freq), peer)
counter += 1 counter += 1
t_stream += args.batch * dt t_stream += args.batch * pdt
next_send += period next_send += period
if args.duration > 0.0 and started is not None and now - started > args.duration: if args.duration > 0.0 and started is not None and now - started > args.duration:
print("mock udpstreamer: done (%d packets)" % counter, flush=True) print("mock udpstreamer: done (%d packets)" % counter, flush=True)