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:
1 parent
714d042809
commit
a92a6b5bf0
20 files changed
+1945
-243
No files matched your search
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Vendored
+1
-1
Submodule deps/plotter updated: faef19ba60...0ee6e10c81.
+12
-3
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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 |
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user