5 Commits
Author SHA1 Message Date
Martino FerrariandClaude Opus 4.6 f307d27e41 docs: a pause publish costs no packets, and the look is checked with frame path
An A/B of the same fast-mock run with and without a pause reads gaps=0 both
ways, the paused one having published in 86 ms -- so the gaps=73 recorded
yesterday as "a pause costs packet gaps, unexplained" was incidental load on
one run. It never scaled with the publish length and has not reproduced.

Record the publish figures as the range they actually span across runs
(event 69-115 ms, pause 22-86 ms) rather than as single readings, and name
`frame path:` across a pause as the check that the paused trace is drawn like
a live one -- raw=1 lod=555 against raw=0 lod=373, where the defect read
raw=21 lod=118.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 20:45:13 +02:00
Martino FerrariandClaude Opus 4.6 d2c51f2448 fix: a paused view carries its own LOD pyramid, so it is drawn like a held event
publish_pause_view() filled stamps, values and y and left pyramids empty, so
level_for() descended to raw samples and a frozen pause was re-gathered from
every sample it held on every frame. It cost draw time (12.34 -> 18.18 ms mean)
and, because stage_trace_density() normalises each column against its own peak,
it also changed how the trace looked -- which is how it was noticed.

Both publishes are now timed and reported as `publish cost:`, counted apart
because they stall opposite threads: the event publish runs on the producer
under mutex() and blocks the UI, the pause publish runs on the UI thread and
blocks the producer. That is the number a second live ring has to be judged
against.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 20:36:27 +02:00
Martino FerrariandClaude Opus 4.6 13574bfcef fix: a crossing is counted with hysteresis, so noise is not counted as cycles
period, frequency and pulse duration counted every sign change about a single
level, which is unusable on any real signal: the mid level is exactly where a
waveform moves fastest, so an interferer faster than the fundamental recrosses
it several times per true crossing. A 5 Hz sine with a 500 Hz tone at a tenth of
the amplitude read 79 crossings for 9, period=0.025622 against 0.2.

MeasureLevel gains a hysteresis band sized once in measure_set_level(): a tenth
of the window's peak-to-peak, clamped to half the room to the nearer extreme so
a threshold set near one stays usable. The band moves the edge decision only --
the sum/count split stays at the bare level, since that split is top/bottom and
has a documented exact reading. The bucket fast path tests the band and the
level separately rather than folding them, or it would refuse exactly the
buckets a crossing passes through: clean cost is unchanged at raw=0.1% and
1.41 ms a recompute.

tools/mock_udpstreamer.py gains --noise, defaulting to 0 so the exact probe
assertions still hold.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 10:07:56 +02:00
Martino FerrariandClaude Opus 4.6 b4b26d3e31 feat: a dense trace's columns are graded by dwell, so density reads as brightness
Stage 1 cured the moire by collapsing a dense gathered window onto one vertical
segment per pixel column; this is the information that collapse throws away. A
column's min and max are the same whether its samples are spread evenly or piled
at its two ends, so the flat parts of a square wave and the edges between them
drew as one ink. Style::intensity_grading, which the library leaves off and this
app turns on at the first tick (the renderer does not exist until the area is
realized), grades each column by the time the signal spent at each row -- the
digital-phosphor look.

frame path: gains a graded= field, which must equal columns=: grading is a way of
drawing the reduction, not a third path. UDPSCOPE_NO_PHOSPHOR=1 puts the style
back to the library's default and is the A/B, since a frame mean cannot attribute
its own cost -- measured on the fast mock, draw 3.24 ms graded against 2.30 ms,
with segments 1340/4157 against 131/4306 over the same lod=453 frames. That A/B
is also what found the level-floor regression now fixed upstream.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 03:02:48 +02:00
Martino FerrariandClaude Opus 4.6 5f5b87ca60 fix: a dense trace is drawn one pixel column at a time, so it cannot moiré
At a high rate, a high frequency or on noise the plots banded visibly and the bands
crawled under a pan. Three things compound to produce it, none a bug alone: a
pyramid bucket spans one to two pixel columns, Envelope emits two points per bucket
so each contributes a near-vertical hairline and a near-vertical connector, and
every one lands at a fractional x because a trace is deliberately not snapped --
so a column's brightness beats against the pixel grid, and a pan sweeps the beat.

The cure is upstream (deps/plotter, pin moved): draw_traces reduces such a window
onto the pixel grid, one snapped vertical segment per column from that column's
lowest to its highest value. One sub-pixel phase for the whole trace, so there is
nothing left to beat, and the geometry is bounded by axes.w rather than by the
buckets.

frame path: gains a columns= field, which is what separates "the segment count
collapsed because the reduction ran" from "the window got narrower". On the fast
mock at 3 kHz over 20 s: columns=1200, segments 2041/2237 over 1201 painted frames,
against columns=0, segments 10960/216093 over 263 before -- the same run at 4.6x
the frame rate, with trace ink: clean either way.

Also corrects the documented mock invocation: --batch 20000 exceeds the datagram
limit and the mock dies with Errno 90.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 02:02:52 +02:00
11 changed files with 713 additions and 89 deletions

No files matched your search

+192 -16
View File
@@ -170,9 +170,12 @@ precedent, not an exception.
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
*legitimately* higher for a window shorter than a bucket (64 samples) or a signal noisy
enough to cross a threshold everywhere, so read it on the fast mock
(`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`), where a window is ~2 M samples.
`--noise 0.1` there reads **2.0%** at 1.96 ms a recompute against the clean 0.1% at 1.41 ms, and
that is the interferer's own cost rather than a regression -- the figure to compare a change
against is the **clean** one.
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
@@ -438,7 +441,14 @@ Two lines of the self-check output are assertions in disguise:
plottable signal and lets the tick compute them, so what it prints is what a user reads.
On the test stream's 1 Hz ±1 sine the values must be: average ≈ 0, `top` ≈ 0.64 and
`bottom` ≈ -0.64 (the mean of each half of a sine), max/min ≈ ±1, `pulse duration` ≈ 0.5 s
with `duty` ≈ 0.5, `period` ≈ 1 s and `frequency` ≈ 1 Hz. `marks=` lists each mark with its kind, and the **mix is the point**:
with `duty` ≈ 0.5, `period` ≈ 1 s and `frequency` ≈ 1 Hz. It needs **`UDPSCOPE_ENABLE_ALL_MS`
ahead of it** or it prints `no acquired signal to measure` and the dump reads `n=0`.
**Run it against `--noise 0.1 --freq 1` as well, and the three crossing types must still read
those figures** (measured: `period=1.000100 frequency=0.999900 pulse duration=0.504100`): the
hysteresis band is the only thing holding them there, a single level reads a 500 Hz interferer as
the signal, and the default `--noise 0` mock cannot see it. The band costs `raw`, not accuracy --
the clean fast mock reads `raw=0.1%` at 1.41 ms a recompute and the noisy one `raw=2.0%` at 1.96
ms, so read `measure cost:` on **both**. `marks=` lists each mark with its kind, and the **mix is the point**:
`level:V` alone for average/top/bottom, `level:V,time:T` for min/max (a ruler at the value
*and* a marker at the first sample that reached it — two level marks at both extremes was
the bug), `time:A,time:B` for pulse/period/frequency. `text=` is the pane's own string with
@@ -671,22 +681,67 @@ Two lines of the self-check output are assertions in disguise:
blinking green. The `mode ...` lines carry the rest of the status line, so `rate` and
`last event ... ago` are checked there.
- `frame cost: tick A/B ms over N, draw C/D ms (max at frame F), paint E/G ms over M` and
`frame path: raw=R lod=L frames, segments S/T (mean/max), visible max V samples` — the
`frame path: raw=R lod=L columns=C graded=G frames, segments S/T (mean/max), visible max V samples`
— the
drawing cost, split into the frame tick, `PlotArea`'s own draw (timed from the frame
clock's `before-paint` to the end of the host's `render` handler) and the whole GTK
paint. **`segments` max must stay in the tens of thousands and `lod` must dominate `raw`
paint. **`segments` max must stay in the low thousands and `lod` must dominate `raw`
on any run holding a large capture**: a max in the *millions* with `lod=0` is the
empty-pyramid defect (see the `MinMaxPyramid` invariant below) — the renderer drawing
every raw sample of a held event on every frame, which measured 6,000,073 segments and
100–230 ms a frame at 1 MS/s. `raw` frames are expected and cheap below 64 samples per
pixel, which is where `level_for()`'s own gate puts the boundary. **`draw`'s max must sit
pixel, which is where `level_for()`'s own gate puts the boundary.
**`columns` must equal the frames that drew a trace at all on any run at a high rate**, and
it is the reading that says *why* `segments` is small: a dense gathered window is reduced
onto the pixel grid upstream (one snapped vertical segment per column — see the moiré
invariant below), so the geometry is bounded by `axes.w`, and a `columns=0` beside a large
`segments` is that reduction gone rather than a narrower window. Measured on
`--rate 1300000 --batch 2000 --freq 3000` over 20 s: `raw=2 lod=1198 columns=1200 frames,
segments 2041/2237` over 1201 painted frames, against `columns=0 ... segments
10960/216093` over 263 before it existed — the same run at 4.6× the frame rate.
**`graded` must equal `columns`**: this app turns `Style::intensity_grading` on, and grading is
a way of *drawing* the reduction rather than a third path, so a `graded` below `columns` is the
style not reaching a frame. **`UDPSCOPE_NO_PHOSPHOR=1` is the A/B** and the only way to read
either of these two lines against the ungraded path, since a frame mean cannot attribute its own
cost: measured on `--rate 1300000 --batch 2000`, `columns=1166 graded=1166` with
`draw 3.24/21.43 ms` and `segments 1340/4157` against `graded=0`, `draw 2.30/25.76 ms` and
`segments 131/4306` over the same `lod=453` frames. The segment rise *is* the gradient (alpha is
quantised into steps, one `LineBatch` and one draw call each, because the line pipeline's colour
is a uniform) and the ~1 ms is its cost; a `raw` that *grows* when the grading is on is the
level-floor invariant below gone, which measured `raw=1154 lod=0` and 6.36 ms.
**`draw`'s max must sit
at a frame near 0** — that is the warm-up (glyph atlas, first vertex-buffer growth); a
late `F` is a frame whose own geometry was expensive and worth tracing. `tick`'s max is
the publish stall: `publish_event()` builds the snapshot's pyramids under `mutex()`, so a
latch at over 1 MS/s costs one ~40 ms tick, which must not produce `gaps`. Both these
lines and the disarm probe's `axis=`/`snap=` need a fast source to say anything —
`tools/mock_udpstreamer.py --rate 1300000 --batch 20000` — since at the default 10 kS/s a
whole window fits in the raw path and the publish is too short to lose a race.
`tools/mock_udpstreamer.py --rate 1300000 --batch 2000` — 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. (Do not
raise `--batch` to chase the rate: a batch of 20000 exceeds the datagram limit and the mock
dies with `OSError: [Errno 90] Message too long`.)
- `publish cost: N events, A/B ms (mean/worst, blocks the UI); M pauses, C/D ms (mean/worst,
blocks the producer)` — what *freezing a view* costs, and the line that attributes the stall
`frame cost:`'s `tick` max can only hint at. The two publishes are counted apart because they
stall **opposite threads**, and nothing else in the dump distinguishes them:
`publish_event()` runs on the producer with `mutex()` already held, so its cost lands on the UI
as a long tick; `publish_pause_view()` runs on the UI thread and takes the lock, so its cost
lands on the producer and can only surface as `gaps`. Read the event figure against `tick`'s max
and the pause figure against `gaps`. Both are **wide**: on the fast mock the event publish has
read **69, 89, 92 and 115 ms** and the pause publish **22 and 86 ms** across runs, so treat the
line as an order of magnitude and not a figure to compare a change against at the millisecond.
The event reading matters anyway because it is far above the ~40 ms the `frame cost:` entry above
quotes — treat that 40 as the old reading and this line as the authority.
It is also **the number to judge a second live ring against**, which is the one design question
it exists to answer: freezing a whole `BufferStore` copies nothing and inherits the pyramid a
`ChannelBuffer` already maintains incrementally, so both `worst` figures would go to **0** — at
the price of a second budget's worth of memory (README records that one live ring is why
`--buffer-mb 200` really is 200 MB) and a second per-sample write on the producer. What the line
says about that trade is that the stall is real but **costs no packets**: an A/B of the same run
with and without a pause read `gaps=0` both ways, the paused one with an 86 ms publish, so a
second ring would buy a smoother frame at a latch rather than recovered data. (An earlier run
reported `gaps=73` with a pause against 0 without, which is what made the stall worth measuring;
it has not reproduced and did not scale with the publish length — 79 gaps with the pyramid build
removed against 73 with it — so it was incidental load on one run. Do not quote it as a cost.)
- `trace ink: N paints checked, C collapsed, S staircase, H short, worst cover P%, worst
drawn-vs-quoted D px over R raw paints` plus a
`[ink] paint N <flags> mode=.. axis=[..] n=.. idx=[..) spp=.. level=.. pts=.. kept=..
@@ -742,7 +797,7 @@ Two lines of the self-check output are assertions in disguise:
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.
- `[help] pages=14 parsed=14 shortest='Pause and play' 1434 B; shortcuts: app=12 plot=13; about version=… commit=… built=… settings=… icon=org.udpscope.app; closed ok` (stderr, `UDPSCOPE_PROBE_HELP_MS`) — **`parsed` must equal `pages`** and no page may be empty: a missing resource or markup Pango rejects renders a one-line stand-in instead of failing, so the window *looks* fine and only this count notices. `settings=` must be a real path (the same one the About dialog shows). **`icon=` must read `org.udpscope.app`** — it is read back through `adw_about_dialog_get_application_icon()` while the dialog is open, so the dialog is asserted to *carry* the icon rather than merely to open (a `set_application_icon()` lost in an edit leaves a dialog that opens, looks almost right, and shows a generic placeholder). `closed ok` with **no `Gtk-CRITICAL`** is as much a crash test of three windows built, presented and closed in one run as it is a state check, and a stronger one than it was: the three are now closed by **two different calls**, so a mismatched pair crashes or leaks here. One of the three is a `GtkWindow` (the help window, `gtk_window_close()`); the About dialog and the shortcut overview are `AdwDialog`s and close with `adw_dialog_close()`, cleared on `::closed`. `shortest=` is the page title — a page whose body was never compiled into the binary is absent rather than empty, and a title alone is the stand-in's shape.
- `[help] pages=14 parsed=14 shortest='Choosing a source' 1563 B; shortcuts: app=12 plot=13; about version=… commit=… built=… settings=… icon=org.udpscope.app; closed ok` (stderr, `UDPSCOPE_PROBE_HELP_MS`) — **`parsed` must equal `pages`** and no page may be empty: a missing resource or markup Pango rejects renders a one-line stand-in instead of failing, so the window *looks* fine and only this count notices. `settings=` must be a real path (the same one the About dialog shows). **`icon=` must read `org.udpscope.app`** — it is read back through `adw_about_dialog_get_application_icon()` while the dialog is open, so the dialog is asserted to *carry* the icon rather than merely to open (a `set_application_icon()` lost in an edit leaves a dialog that opens, looks almost right, and shows a generic placeholder). `closed ok` with **no `Gtk-CRITICAL`** is as much a crash test of three windows built, presented and closed in one run as it is a state check, and a stronger one than it was: the three are now closed by **two different calls**, so a mismatched pair crashes or leaks here. One of the three is a `GtkWindow` (the help window, `gtk_window_close()`); the About dialog and the shortcut overview are `AdwDialog`s and close with `adw_dialog_close()`, cleared on `::closed`. `shortest=` is the page title — a page whose body was never compiled into the binary is absent rather than empty, and a title alone is the stand-in's shape.
- `[menu] N 'label' accel='key' h=P label_x=X accel_right=R/W order=D ok=O`, one line per row,
then `[menu] rows=R expected=E order=D rows_bad=B popover_visible=V mapped=M button_mapped=B`
and `[menu] status pos=top bubble=WxH button_top=T button_right=R fits=F` (stderr,
@@ -1106,11 +1161,93 @@ far the largest file.
is built once in `publish_event()` inside the `O(n)` pass that already computes the y
bounds, and the same channel on two rows shares it. It costs ≈1 byte per sample per
channel. `frame path:`'s `segments` is the guard.
**"Every" includes the pause**, and that is the half this invariant was written without and
which then went wrong for real: `publish_pause_view()` filled `stamps`, `values` and `y` and
left `pyramids` empty, so a *paused* view was re-gathered raw on every frame. Both halves of
the cost are readable, and the second is the one a performance line cannot show.
`frame path:` read `raw=21 lod=118` over a 1.6 s pause against `raw=1 lod=170` for the same run
live, with the draw mean 12.34 → 18.18 ms — and the **trace changed appearance**, which is what
gets reported. `stage_trace_density()` grades a column against *its own peak* from a `1/height`
deposit per segment, so a column fed ~660 raw inter-sample segments accumulates a far spikier
distribution, and a far larger peak to normalise against, than one fed the one or two bucket
envelopes the pyramid gives: the paused trace read as a hard bright core with its dwell tail
dimmed out. **A pause is not a cheaper case than a capture — it freezes the same rings**, so
anything `publish_event()` does to a snapshot, `publish_pause_view()` owes too. Building it is
not free (see `publish cost:` below): it took the pause publish from 6.15 ms to 22.31 ms, a
one-off producer stall against ~5 ms a frame of draw for the pause's whole length, which at
~60 fps repays about thirty times over on a 1.6 s pause — and the producer absorbs it without
losing packets (`gaps=0` with and without a pause, the paused run at an 86 ms publish).
**The check that the look is restored is `frame path:` across the pause, not the cost line**:
a paused run must read the same path as a live one — measured `raw=1 lod=555 columns=555
graded=555` against `raw=0 lod=373 columns=373 graded=373`, same 24711-segment worst frame,
segment mean within 5 % — where the defect read `raw=21 lod=118`. A `raw` in the tens over a
pause is this returning, and it is the appearance as much as the cost.
- **`EventSeries::gather()` emits a bucket's envelope, not every `2^level`-th sample.**
Point decimation hides exactly the spike the capture was triggered on; a scope has to
draw a bucket's extremes. It needs no index rebasing, unlike the ring: the snapshot's
pyramid is built from sample 0 and nothing is ever dropped from it, so `window_base()` is
0 and a window index *is* the absolute one.
- **A trace denser than the axis is drawn one pixel column at a time, and that is what keeps
the plots free of moiré.** The envelope staged as a polyline banded visibly and *crawled*
under a pan on exactly the signals this app is pointed at — a high rate, a high frequency or
noise — and three things compound to produce it, none a bug alone: `level_for()` sizes a
bucket at one to two pixel columns, `Envelope` emits two points per bucket so each
contributes a near-vertical min→max hairline *and* a near-vertical connector (≈3.6 per
column per trace), and every one lands at a **fractional** x because a trace is deliberately
not snapped. Under src-alpha compositing a column's brightness is then a non-linear function
of the sub-pixel phases overlapping it, the pattern repeats at the beat between the bucket
pitch and the pixel pitch, and a pan sweeps the beat. `deps/plotter`'s
`Renderer::stage_trace_columns()` emits one snapped vertical segment per integer column
instead, so the trace has **one** phase and there is nothing left to beat. Four things to
know before touching it, all upstream in `docs/data-and-performance.md`: the gate is the
gathered **density** (≥2 points per column) and `Envelope`, never the LOD level — the raw
path at 1.5–64 spp beats the same way, and the density term is also what keeps the reduction
off a *sparse* window whose trace is the two segments entering and leaving the frame; a hole
stays a hole; disjoint neighbours are joined so a fast edge keeps its join; and it is the one
carve-out in `u_snap`'s "never anything drawn at a data position" rule, because a column *is*
a pixel column.
**It needs a thin ribbon to be visible at all** — a window whose buckets span several periods
paints a saturated block and hides the beat — so a screenshot of a slow sine at a whole-window
view says nothing either way. The guards are upstream's
`a_dense_trace_is_drawn_as_whole_pixel_columns`, which counts the **distinct coverage levels**
in the ink (4 against the polyline's 126; a count of *partially covered* pixels separates
nothing, 577 against 652, because a 1.5 px stroke's fringe always spills into the next
column), and `frame path:`'s `columns=` field here. `trace ink:` deliberately cannot see this
path: it recomputes the **gathered** points, which the reduction is downstream of, so its
`drawn-vs-quoted` reads 0.0000 either way and remains the guard for the gather origin alone.
- **A reduced column is graded by dwell, and the grading may not undo the reduction.** The
column above is honest about *where* the signal went and silent about *how long it was there*
— a column's min and max are identical whether its samples fill the span or pile at its two
ends, so a ramp and a pulse are one flat band. `Style::intensity_grading` grades it instead
(bright where the signal sits, faint where it only passes through, the digital-phosphor look),
and **udpscope turns it on; the library's default is off**, so other hosts are not restyled by
an upgrade. The switch is a one-shot in `tick()` guarded by `App::style_stated`, not a line in
the window build: `PlotArea::renderer()` is null until the area is realized, so there is no
earlier place to state a style. Three things to know here, the rest upstream in
`docs/data-and-performance.md` (the `1/height` dwell weighting, the one-column accumulator, the
per-column-peak normalisation, the quantised alpha steps):
* **The finer gather level is floored at 1 whenever the flat path would have used the
pyramid.** Grading asks for `samples_per_pixel / intensity_sub_columns`, and
`MinMaxPyramid::level_for()` answers **0 — raw samples** — as soon as that falls under one
bucket (`kBucket` is 64). Unfloored, every moderately dense frame became an O(visible)
gather: at 1.3 MS/s over a 2 s window, 172 samples a pixel, `raw=1154 lod=0` against the flat
path's `raw=710 lod=453`, and 2.30 → 6.36 ms of draw mean. The floor degrades the *look*
(fewer sub-columns than asked for, down to what the coarsest buckets give — still two or
three per column at 64 samples a bucket) and never the path. It is also why
`intensity_sub_columns` past about 8 buys nothing: the bucket size is the limit.
* **`graded=` must equal `columns=`** in `frame path:`, and `UDPSCOPE_NO_PHOSPHOR=1` is the A/B
that makes either line readable against the ungraded path (see Verification).
* **The steps are draw calls, not per-segment alpha.** `LineInstance` is four floats of
geometry and the colour is a **uniform** (only `TextInstance` carries `a_colour`), so the
gradient is `intensity_steps` `LineBatch`es flushed at different alphas. That is why the
segment mean rises from 131 to 1340 on the same frames — the rise is the tiers, not extra
geometry per column, and the bound is still `axes.w` (measured 14.5 runs a column in the
library's own guard, 8303 segments over a 572 px axis).
The guard is upstream's `a_dense_trace_can_be_graded_by_dwell`, which draws one frame both ways
and profiles the band's outer fifths against its middle: flat reads **191/191**, graded
**191/131**. That 0.69 is a *tier* ratio, not the dwell ratio — `intensity_gamma` (0.45) and
`intensity_floor` (0.3) compress it deliberately, the floor so that a fast riser, one
sub-bucket deep everywhere, is never dimmed out of existence.
- **The time ring is `f64` and the value rings are `f32`, and the asymmetry is the point.**
A value's precision is a property of the signal (24 bits is plenty for a sample); a
timestamp's is a property of *how long the scope has been running*, because the stamps are
@@ -1485,13 +1622,19 @@ far the largest file.
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:
(`measure_accumulate_bucket()`) folds a bucket's **sums** only when it lies **strictly** on one
side of its level (`k.lo > level` or `k.hi < level`): then every sample falls in the same bin and
none is *at* the level, so the fold is exact and not an approximation. It asks **separately**
whether the bucket enters a side of the hysteresis band (see the invariant below), because a
single test against `level ± h` refuses exactly the buckets a crossing passes through. A bucket
wholly past a threshold holds that crossing at its first sample, exactly -- which is where the raw
walk would have found it. It refuses a bucket that straddles the level, one that enters a side
somewhere inside itself, and one holding a non-finite sample (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. **Nothing may be folded before a refusal** -- a refused bucket goes to
`measure_accumulate()`, which adds the same samples again, and sums added above the `return false`
double-counted every opened bucket, reading `top=0.614304` on a unit sine against the 2/pi it must
be. 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
@@ -1510,6 +1653,39 @@ far the largest file.
`measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say
anything.
- **A crossing is counted with hysteresis, and the band moves the *edge decision* only.**
`MeasureLevel::hysteresis` is a Schmitt trigger about `level`: an edge is counted once the signal
has passed `level + h` going up or `level - h` going down, and a sample inside the band decides
nothing. Without it `period`, `frequency` and `pulse duration` are unusable on anything real --
the mid level is exactly where a waveform moves *fastest*, so an interferer faster than the
fundamental recrosses a single level several times per true crossing. Measured on a 5 Hz sine with
a 500 Hz tone at a tenth of the amplitude (`tools/mock_udpstreamer.py --noise 0.1`): 9 true
crossings counted as **79**, `period=0.025622 freq=39.029` for a true 0.2 s / 5 Hz, against
`period=0.200188` with the band. Four rules, each of which was wrong first:
* **`measure_set_level()` is the only place the band is sized**, and `src/ui.cpp` calls it rather
than assigning `.level` itself: two expressions for the width is how the raw walk and the bucket
path end up disagreeing about where a crossing was. `h = min(0.10 * (hi - lo), 0.5 * min(hi -
level, level - lo))`. The 0.10 is measured -- 5 % reads a 0.1 interferer and fails at 0.2
(`period=0.075`), 10 % holds to 0.3 and to white noise at sigma=0.05.
* **The clamp is load-bearing.** `PulseDuration` lets the user put the split near an extreme, and
a fixed tenth of the span there puts the upper threshold *above every sample*: the walk never
decides that side and the measurement reads nothing at all, which is worse than reading
something noisy. On a ringing 3.3 V logic signal at a 90 % split: `enters=0` with a fixed band,
`enters=4 period=0.200000` with the clamp.
* **The `sum`/`count` split stays at the bare `level`.** That split *is* `Top`/`Bottom` -- the mean
above and below the threshold -- so a band there changes a documented exact reading. `top` on a
unit sine must still be `0.636620`.
* **`h = 0` must reduce exactly to the single-level walk**, including the `v == level` skip, so the
band is a parameter of the engine rather than a mode of it.
The cost is the noise's, not the band's: the clean fast mock reads `raw=119412 (0.1%)` of 215 M at
1.41 ms a recompute and with the 0.1 interferer `raw=2.0%` at 1.96 ms. A
`/tmp/check_measure.cpp`-style check is how the walk itself was verified (`g++ -std=c++20 -O2 -I
src ... src/measure.cpp`): over a clean sine, sines with 0.1 and 0.3 interferers, 10 %/50 % duty
squares and a 90 % threshold, the **bucket walk must equal the raw walk exactly** -- same
`enters`, same `count[]`, identical `first_enter`/`last_enter` to 0.0 -- which is the assertion
that the fast path is a fast path and not an approximation. The app's reading is
`[measures-probe]` against `--noise 0.1`.
- **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
+163 -16
View File
@@ -201,13 +201,53 @@ measurement.
`period` and `frequency` deliberately take **no options**: the engine counts *rising*
crossings of the *mid* level, one edge per cycle, and a split or a side to choose from could
only make that count wrong — a split near either extreme counts noise as crossings, and
counting both edges halves the period.
only make that count wrong — a split near either extreme leaves the hysteresis band below no room
to reject noise with, and counting both edges halves the period.
The split is a share of the **window's own** min..max, not volts: a measurement is about this
signal in this window, and a level in volts would have to be typed for every signal. A
crossing has to be *strict* — a sample exactly at the level is on neither side — so a signal
that merely touches the level does not count every sample as an event.
signal in this window, and a level in volts would have to be typed for every signal.
### Why a crossing is counted with hysteresis
A crossing is not read off a single level. It is read off a **band**: two thresholds a tenth of
the window's peak-to-peak either side of the split, and an edge is counted only once the signal
has passed the far one. Inside the band a sample decides nothing. That is a Schmitt trigger, and
it is the difference between counting the waveform's cycles and counting its noise.
Counting on one level was the first implementation and it fails on anything real, badly and
silently. The mid level is exactly where a waveform moves *fastest* — on a sine the slope there
is 2πf and the signal only clears a disturbance of amplitude *a* after a/2πf seconds — so any
interferer faster than the fundamental recrosses the level several times per true crossing, and
each recrossing is counted. Measured on a 5 Hz sine with a 500 Hz tone at a tenth of the
amplitude (`tools/mock_udpstreamer.py --noise 0.1`): 9 true crossings read as **79**, giving
`period=0.025622` and `frequency=39.029` for a true 0.2 s and 5 Hz. The number is not merely
noisy, it is a different order. With the band the same window reads `period=0.200188`. The band
holds to an interferer at **0.3** — a third of the waveform — where it reads `period=0.200688`,
and to white noise at σ=0.05; at 5 % of peak-to-peak it already fails at 0.2.
Three things about it are deliberate, and each was wrong first.
- **The band moves the edge decision only.** The side the `sum`/`count` split uses stays the bare
level, because that split *is* `top` and `bottom` — the mean of the samples above and below the
threshold — and a band there changes a documented exact reading. `top` on a unit sine must still
be `0.636620`, 2/π.
- **The band is clamped to stay inside the data**, to half the room between the level and the
nearer extreme. `pulse duration` lets the user put the split near an extreme (a 3.3 V logic
signal with ringing wants it nearer the top), and a fixed tenth of the span there puts the upper
threshold *above every sample in the window*: the walk never decides the upper side and the
measurement reads nothing at all, which is strictly worse than reading something noisy. Measured
on such a signal at a 90 % split: `enters=0` with a fixed band, `enters=4 period=0.200000` with
the clamp.
- **The LOD fast path tests the band and the level separately.** Tightening the bucket-fold
condition to `level ± h` would refuse every bucket lying inside the band — and those are
precisely the buckets a crossing passes through, so the raw walk would get a *share of the
window* instead of a bucket or two per crossing. A bucket on one side of the **level** can always
be binned; only a side *change* whose position is inside the bucket is refused. The clean fast
mock still reads `raw=119412 (0.1%)` of a 215 M-sample total at 1.41 ms a recompute; with the
0.1 interferer it is `raw=2.0%` at 1.96 ms, which is the noise's own cost and not the band's.
A sample exactly *at* a threshold is still on neither side, so a signal that merely touches one
does not count every sample as an event.
Each measurement's row carries **two badges**: its own colour (the same one its marks use on
the plot) and the signal's chip, in the palette colour every other view names that signal
@@ -258,18 +298,20 @@ Both walks go through it:
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.
folds a bucket's sums whenever it lies *strictly* on one side of its level — then every sample
in it falls in the same bin and none is *at* the level, so the fold is exact. It then asks
separately whether the bucket *enters* a side of the hysteresis band: one wholly past a threshold
holds that crossing at its first sample, exactly, which is where the raw walk would have found
it. Only a bucket that straddles the level, or one that enters a side somewhere inside itself, is
opened — on a clean waveform a bucket or two 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
for those opened 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
noisy enough to cross a threshold everywhere, degrades back towards 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
@@ -417,7 +459,8 @@ ring per channel (N enabled signals -- see the limitation on multi-element signa
below). The spec's second buffer is the latched `EventSnapshot`, which is already
an immutable copy of the captured window (see "Trigger" below); a second live ring
bought nothing and cost half the memory budget, so `--buffer-mb 200` really is
200 MB.
200 MB. What it *would* buy is priced in the dump's `publish cost:` line — see
"There is one live buffer, not two" under the review findings.
`plan_partition()` sizes the rings to cover `--max-window` seconds and then grows
them with the rest of the budget. When a channel's declared sample rate is lower
than the fastest channel's, it is decimated by the integer ratio
@@ -931,14 +974,109 @@ which hides exactly the spike a capture was triggered on, and since the level wa
it never even ran. On the same repro: **138,078 segments** (43× fewer) and a 66 ms worst
frame, with the steady-state draw around 2 ms. It costs about 1 byte per sample per
channel (≈25 % on top of the snapshot's `f32` values) and one stall per latch, since the
build runs under `ScopeModel::mutex()` — at 1.3 MS/s that is a ~40 ms tick, once, with no
packet gaps.
build runs under `ScopeModel::mutex()`. That stall is now measured rather than estimated —
the dump's `publish cost:` line read **115 ms** for one event publish at 1.3 MS/s, against
the ~40 ms this paragraph used to quote — and it produced no packet gaps.
**A paused view carries one too**, and that half was missing until it was found by its
*appearance*. `publish_pause_view()` filled `stamps`, `values` and `y` and left `pyramids`
empty, so a frozen pause was drawn from every raw sample it held, on every frame — the same
defect as above with the same cause, in the other snapshot. `frame path:` read
`raw=21 lod=118` over a 1.6 s pause against `raw=1 lod=170` for the same run live, with the
draw mean going 12.34 → 18.18 ms and its worst frame 30.35 → 86.92 ms. But the reason it was
noticed is that the trace *looked* different: `Renderer::stage_trace_density()` grades each
pixel column against that column's own peak, from a `1/height` dwell deposit per segment, so
a column fed several hundred raw inter-sample segments accumulates a far spikier
distribution — and a far larger peak to normalise against — than one fed the one or two
bucket envelopes the pyramid gives. The paused trace read as a hard bright core with its
dwell tail dimmed away. The rule the invariant was written without: **a pause is not a
cheaper case than a capture — it freezes the same rings.**
It is not free, and the trade is one-off against per-frame. Building the pause pyramid took
that publish from 6.15 ms to 22.31 ms, and unlike the event publish this one runs on the
**UI** thread and so blocks the *producer* — against ~5 ms a frame of draw for as long as the
pause lasts, which over a 1.6 s pause at ~60 fps repays thirty times over. The producer
absorbs it: an A/B of the same fast-mock run with and without a pause read `gaps=0` both
ways, with the pause publish at 86 ms in the paused one. (One earlier run reported `gaps=73`
with a pause against 0 without, which is why the stall was worth measuring at all — but it
has not reproduced, it did not scale with the publish length, and an 86 ms publish costing
nothing is the stronger reading. Treat that 73 as incidental load on one run.)
The verification that the *look* is restored is the same A/B read through `frame path:`:
`raw=0 lod=373 columns=373 graded=373` without a pause against `raw=1 lod=555 columns=555
graded=555` with one — the same path, the same 24711-segment worst frame and a segment mean
within 5 % — where before the fix the paused run read `raw=21 lod=118`.
The self-check's two `frame cost:` / `frame path:` lines are the guard: they split a frame
into the tick, the plot's own draw and the whole GTK paint, and report the segment counts
and whether the LOD path was taken at all. A segment count in the millions is this defect
returning.
**A dense trace is drawn one pixel column at a time, which is what cured the moiré.** At a
high sample rate, a high signal frequency or on a noisy signal the plots showed strong,
*crawling* banding — and three things compounded to produce it, none of which is a bug on its
own. `MinMaxPyramid::level_for()` sizes a bucket at one to two pixel columns; `Envelope`
emits two points per bucket, so each bucket contributes a near-vertical min→max hairline
*and* a near-vertical connector to the next, about 3.6 near-vertical hairlines per column per
trace; and every one of them lands at a *fractional* x, because a trace is deliberately not
snapped. With src-alpha compositing a column's brightness is then a non-linear function of
the sub-pixel phases overlapping it, the phase pattern repeats at the beat between the bucket
pitch and the pixel pitch, and a pan sweeps the beat. It needs a *thin ribbon* to be visible:
a window whose buckets each span several periods paints a saturated block and hides it
entirely, which is why it showed up on fast and noisy signals and not on a slow sine at a
whole-window view.
`deps/plotter`'s `Renderer::draw_traces` now reduces such a window onto the pixel grid: one
exactly vertical segment per integer column, from the lowest to the highest screen y any
gathered point put in it, flushed in framebuffer pixels with the snap on. The whole trace
then has **one** sub-pixel phase, so there is nothing left to beat. See the upstream
`docs/data-and-performance.md` for the four rules it keeps (a hole stays a hole, disjoint
neighbours are joined so a fast edge keeps its join, a span under the stroke is drawn *as*
the stroke, and the gate is the gathered density rather than the LOD level) and for why this
is the one carve-out in `u_snap`'s rule that chrome may be snapped and data may not.
It is also a large speed-up, because the geometry becomes O(axes.w) instead of O(buckets).
Measured on the fast mock (`--rate 1300000 --batch 2000 --freq 3000`, a 20 s run): `frame
path: raw=1 lod=262 columns=0 frames, segments 10960/216093` and **263 painted frames**
before, `raw=2 lod=1198 columns=1200 frames, segments 2041/2237` and **1201 painted frames**
after — the same run at 4.6× the frame rate, with `trace ink:` reading `0 collapsed, 0
staircase, 0 short, worst drawn-vs-quoted 0.0000 px` either way. `frame path:`'s
`columns=` field is the reading that separates "the segment count collapsed because the
reduction ran" from "the window got narrower".
**Those columns are then graded by dwell, which is the rest of the answer.** A reduced column
is honest about *where* the signal went and silent about *how long it was there*: its min and
max are the same whether its samples fill the span evenly or pile at its two ends, so a ramp
and a pulse are one flat band. `Style::intensity_grading` — off in the library, which udpscope
turns on at the first tick, since the renderer does not exist until the area is realized —
draws each column graded instead: bright where the signal sits, faint where it only passes
through, the way a digital-phosphor scope reads. The distribution cannot be recovered from the
band, so the gather is asked at a level finer than the pixel grid and the dwell read from those
sub-buckets; the measure is dwell *per row*, each segment depositing one unit spread over the
rows it spans, and normalisation is against the column's own peak. See the upstream
`docs/data-and-performance.md` for the five things that carry it and for the two that were
wrong first (counting rows at +1 each inverts the pattern, grading a sine's fast middle as
brightly as its rails; a frame-wide scale needs a 33 MB density image *and* fades the trace out
exactly where it moves fastest).
It costs about a millisecond a frame and the A/B is the only way to read that, because a frame
mean cannot attribute its own cost: **`UDPSCOPE_NO_PHOSPHOR=1` puts the style back to the
library's default**, and the same probe run on the fast mock (`--rate 1300000 --batch 2000`)
reads `draw 3.24/21.43 ms` with the grading against `draw 2.30/25.76 ms` without, over the same
`lod=453` frames. **`frame path:`'s `graded=` must equal `columns=`** — grading is a way of
drawing the reduction and not a third path, so the two counts agreeing is what says the look
reaches every reduced frame (`columns=1166 graded=1166` in that run, `graded=0` under the env
var). The segment count rises with it, from a mean of 131 to 1340, which is the gradient's
tiers: alpha is quantised into steps because the line pipeline's colour is a uniform and a
per-segment alpha is inexpressible, and each step is its own draw call.
The one performance trap here is worth stating because only the A/B found it. Asking for a
finer level drops straight to **raw samples** once a sub-column falls under one pyramid bucket
(64 samples), which at the run's 172 samples a pixel it did: `raw=1154 lod=0` and 6.4 ms a
frame, i.e. grading undoing the very reduction it draws. The finer ask is floored at level 1
whenever the flat path would have used the pyramid, so what degrades is the *look* — fewer
sub-columns than asked for — and never the path.
**The panels and the toolbar.** The header bar carries only what you reach for
*while watching a signal*: the time window, pause/resume, the trigger toggle, the
side-panel toggle and an overflow menu. Those
@@ -1631,7 +1769,16 @@ now — they are listed so they are not mistaken for anything else:
latched `EventSnapshot` — an immutable copy of the captured window, which is
exactly what "the buffer the view is frozen on" needs to be. Two live rings would
also mean acquisition had to follow the trigger state, which is what left the first
latch after arming with no pre-trigger history.
latch after arming with no pre-trigger history. What a second ring would buy is the
publish: freezing a whole `BufferStore` copies nothing and inherits the pyramid the
producer already maintains, so both figures in the dump's `publish cost:` line would
go to zero. They are worth knowing before pricing that — at 1.3 MS/s an event publish
measured **69–115 ms of blocked UI tick** and a pause publish **22–86 ms of blocked
producer** — but they do not pay for a second budget's worth of memory, a second
per-sample write on the producer, and a single frozen slot where the app today holds
an event, a pause and an export's window at once. The *observable* cost is also
smaller than the stall: an A/B with and without a pause read `gaps=0` both ways, so
what a second ring would buy is a smoother frame at a latch, not recovered packets.
- **The buffer is allocated at the first CONFIG, not at initialisation.**
The spec's "N+1 split" needs N, which only a CONFIG gives; `--buffer-mb` is the
*budget*, and the power-of-two ring rounding means the final
+1 -1
+13
View File
@@ -35,6 +35,19 @@ The full table is in the shortcut overview (<tt>Ctrl+/</tt>).
• Zooming hard into a held capture is exact. The renderer finds the visible samples by binary
search over the real timestamps, never over a nominal sample grid, so a capture whose clock
wandered does not drift away from its axis as you zoom.
• A trace holding far more samples than the plot has pixels is drawn <i>one pixel column at a
time</i>: each column is a single vertical line from the lowest to the highest value that falls
in it. Drawn as a polyline instead, such a window puts three or four near-vertical lines in
every column at slightly different sub-pixel positions, and how they overlap beats against the
pixel grid — which reads as bands of light and dark that <i>crawl</i> as you pan. One line per
column removes the beat, and it is also much faster: a 1 MS/s view costs a couple of thousand
line segments a frame instead of a couple of hundred thousand.
• Those columns are <i>graded by brightness</i>, the way a digital-phosphor scope reads: bright
where the signal spent its time, faint where it only passed through. A column's highest and
lowest value are the same whether the samples in it are spread evenly or piled at its two ends,
so the grading is what tells the two apart — the flat parts of a square wave come out solid and
the edges between them faint. It is read from a finer division of each column rather than from
the line that is drawn, and it costs about a millisecond a frame.
<b>Example</b>
+11
View File
@@ -24,6 +24,17 @@ set, with <b>Save</b> and <b>Delete</b>.
• An invalid pass keeps its <i>level</i> marks and drops its <i>time</i> marks. A level is a
property of the signal and outlives the window; a position inside the window does not. That is
what stops the rulers blinking off at a mode change.
• A crossing is counted with <i>hysteresis</i>, not on a bare level: the thresholds are a tenth of
the window's peak to peak either side of the share, and an edge is counted only once the signal
has passed the far one. Period, frequency and pulse duration all depend on it. The mid level is
exactly where a waveform moves fastest, so any interferer faster than the fundamental recrosses a
single level several times per real crossing — a 5 Hz sine with a 500 Hz tone at a tenth of the
amplitude reads 39 Hz without the band and 5 Hz with it. The band holds to an interferer a third
of the waveform's own size.
• The band is clamped to stay inside the data, which is what keeps a threshold set near an extreme
usable: a fixed band there would sit above every sample and the measurement would read nothing.
A share near an extreme still leaves the band little room, so it rejects less — which is one
reason period and frequency offer no share to set.
• A window with too few crossings reads <tt>invalid</tt> / <tt>--</tt> rather than a plausible
invented number. Frequency over a window holding one edge is the usual case.
• <tt>frequency</tt> is reported in Hz and <tt>period</tt>/<tt>pulse duration</tt> in seconds;
+63 -15
View File
@@ -28,8 +28,27 @@ void enter_side(MeasureLevel& acc, std::size_t e, double t) {
acc.open[e] = true;
}
/// The hysteresis band's half-width, as a share of the window's peak-to-peak. Measured rather than
/// chosen: 5 % reads the mock's 0.1 interferer correctly and fails at 0.2 (`period=0.075` for a
/// true 0.2), while 10 % holds to an interferer amplitude of 0.3 -- a third of the waveform -- and
/// to white noise at sigma=0.05. Above that the band starts eating a real waveform's own extremes
/// on anything less square than a sine.
constexpr double kHysteresisFrac = 0.10;
} // namespace
void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct) {
const double span = s.hi - s.lo;
acc.level = s.lo + pct * span;
// Half the room to the nearer extreme is the clamp. A threshold at 90 % has a tenth of the span
// above it, so a fixed 10 % band would put `level + h` *above every sample*: the walk then never
// decides the upper side and the measurement reads nothing at all, which is strictly worse than
// a noisy reading. Measured on a 3.3 V logic signal with ringing at thr=90 %: `enters=0` with a
// fixed band, `enters=4 period=0.200000` with the clamp.
const double room = std::min(s.hi - acc.level, acc.level - s.lo);
acc.hysteresis = std::max(0.0, std::min(kHysteresisFrac * span, 0.5 * room));
}
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
@@ -130,17 +149,27 @@ void measure_accumulate(const MeasureChunk& chunk,
for (std::size_t i = 0; i < chunk.count; ++i) {
const double v = static_cast<double>(chunk.value[i]);
if (!std::isfinite(v)) continue;
// The *sums* split at the bare level: `Top` and `Bottom` are defined as the mean above and
// below the threshold, and a band here would move a documented exact reading.
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));
// The *edge* decision reads the band instead. A sample inside it is on neither side, so it
// decides no crossing and the walk keeps the side it last decided -- which is what stops a
// signal that merely wobbles about the level from counting every wobble as an edge. With
// `hysteresis == 0` this is the single-level test, `v == level` skip included.
int d;
if (v > acc.level + acc.hysteresis) {
d = 1;
} else if (v < acc.level - acc.hysteresis) {
d = 0;
} else {
continue;
}
acc.side = s != 0;
if (acc.have_side && d != (acc.side ? 1 : 0)) {
enter_side(acc, static_cast<std::size_t>(d), time_of(chunk.offset + i));
}
acc.side = d != 0;
acc.have_side = true;
}
}
@@ -153,21 +182,40 @@ bool measure_accumulate_bucket(const MeasureBucket& k,
// 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.
// The *sums* need only the bare level: strictly on one side of it, so every sample decides the
// same bin and none is *at* it, which is what makes the fold exact rather than an approximation.
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;
// The band is deliberately *not* folded into the test above. Tightening the whole condition to
// `level +/- h` refuses every bucket lying inside the band -- a tenth of the amplitude either side
// of the mid level is where a waveform moves fastest, so those are exactly the buckets a crossing
// passes through -- which hands the raw walk a share of the window rather than a bucket or two per
// crossing, and breaks the "`raw` stays at a fraction of a percent of `covered`" invariant.
// Instead: a bucket on one side of the level can always be *binned*, and only a *side change*
// whose position is inside the bucket is refused.
const double top = acc.level + acc.hysteresis;
const double bot = acc.level - acc.hysteresis;
// Does the bucket reach past the band at all? One wholly inside it decides no side, exactly as a
// raw sample inside it decides none, so the walk state is left alone.
const bool reaches = above ? (k.hi > top) : (k.lo < bot);
const bool change = reaches && acc.have_side && e != (acc.side ? 1u : 0u);
// A bucket that *enters* a side while straddling its own threshold holds the crossing somewhere
// inside itself, and only the raw walk can say where. One wholly past the threshold holds it at
// the bucket's first sample, exactly, which is where the raw walk would have found it.
const bool fully_outside = above ? (k.lo > top) : (k.hi < bot);
// Nothing may be folded before this point: a refusal hands the bucket to `measure_accumulate()`,
// which adds the same samples again. Sums added above the refusal double-counted every opened
// bucket, which read as `top` 0.614304 on a unit sine against the 2/pi it must be.
if (change && !fully_outside) return false;
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));
if (change) enter_side(acc, e, time_of(k.offset));
if (reaches) {
acc.side = e != 0;
acc.have_side = true;
}
acc.side = e != 0;
acc.have_side = true;
return true;
}
+30 -4
View File
@@ -152,6 +152,20 @@ void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, Measu
/// must adding a second measurement of the same type at the same level.
struct MeasureLevel {
double level = 0.0;
/// Half-width of the hysteresis band about `level`: a crossing is counted only once the signal
/// has passed `level + hysteresis` going up or `level - hysteresis` going down, and a sample
/// inside the band decides nothing. It is a Schmitt trigger, and it is what makes an edge count
/// a count of the *waveform's* cycles rather than of its noise: a single level is recrossed
/// several times per true crossing by any interferer faster than the fundamental, because near
/// the mid level the fundamental's own slope is at its smallest. Measured on a 5 Hz sine with a
/// 500 Hz interferer at a tenth of the amplitude: 9 true crossings counted as 79, reading
/// `period=0.025622 freq=39.029` for a true 0.2 s / 5 Hz. With the band, `period=0.200188`.
///
/// It moves the *edge decision* only. The `sum`/`count` split below stays at the bare `level`,
/// because that is what `Top`/`Bottom` are -- the mean of the samples above/below the threshold --
/// and a band there would change a documented exact reading (`top` on a unit sine is 2/pi).
/// `measure_set_level()` is what sizes it; zero reduces the walk exactly to a single level.
double hysteresis = 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};
@@ -170,6 +184,14 @@ struct MeasureLevel {
bool open[2] = {false, false};
};
/// Fill a level accumulator's `level` and `hysteresis` from the window's extremes and the share
/// `measure_level_pct()` gave. The one place the band is sized, so every caller gets the same rule:
/// a tenth of the window's peak-to-peak, *clamped* to half the room between the level and the
/// nearer extreme. The clamp is what keeps a threshold near an extreme usable -- `PulseDuration`
/// lets the user put one at 90 %, and a fixed band there puts the upper threshold above every
/// sample in the window, which reads as nothing measured at all rather than as a noisy reading.
void measure_set_level(MeasureLevel& level, const MeasureStats& stats, double pct);
/// 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.
@@ -182,10 +204,14 @@ void measure_accumulate(const MeasureChunk& chunk,
/// 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.
/// False means the caller must feed the bucket's 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 or two per crossing. Three things make it happen: the bucket
/// straddles the level (so the sums cannot be binned); it holds a non-finite sample, which would
/// make the edge's position a guess; or it is where the signal passes `level +/- hysteresis` and the
/// *position* of that crossing is inside the bucket. The band is deliberately not folded into the
/// first test -- see the comment in the implementation for why refusing every bucket inside the band
/// costs a share of the window rather than a bucket.
bool measure_accumulate_bucket(const MeasureBucket& bucket,
const std::function<double(std::size_t)>& time_of,
MeasureLevel& level);
+32
View File
@@ -516,6 +516,9 @@ void ScopeModel::reset_live_history_locked() {
}
void ScopeModel::publish_pause_view(double lo_s, double hi_s, double x_offset) {
// Timed from before the lock: the wait for it is part of what freezing a view costs the thread
// doing it, and this one is the UI's.
const std::uint64_t t0 = now_ns();
auto snap = std::make_shared<EventSnapshot>();
snap->x_offset = x_offset;
std::lock_guard<std::mutex> lock(mutex_);
@@ -537,6 +540,7 @@ void ScopeModel::publish_pause_view(double lo_s, double hi_s, double x_offset) {
}
snap->values.resize(channels_.size());
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0});
snap->pyramids.resize(channels_.size());
for (std::size_t c = 0; c < channels_.size(); ++c) {
const u64 stride = std::max<u64>(1, part_.stride[c]);
auto& vals = snap->values[c];
@@ -545,16 +549,42 @@ void ScopeModel::publish_pause_view(double lo_s, double hi_s, double x_offset) {
if (take > 0) src.values(c).copy_from(first + n - take, take, vals.data());
f32 lo = vals.empty() ? 0.0f : vals.front();
f32 hi = lo;
// The LOD index, folded into the bounds pass exactly as `publish_event()` folds it, and for
// the same reason: a snapshot that answers `pyramid()` with an empty one has `level_for()`
// descend to level 0, so the frozen view is re-gathered from every raw sample it holds on
// every frame it is on screen. A pause is not a cheaper case than a capture -- it freezes the
// *same* rings -- and leaving it out was visible twice over. The cost: `frame path:` read
// `raw=21 lod=118` over a 1.6 s pause against `raw=1 lod=170` for the same run live, with the
// draw mean 12.34 -> 18.18 ms. And the *look* changed, which is the half a cost figure does
// not show: `stage_trace_density()` grades a column against its own peak from a `1/height`
// deposit per segment, so a column fed ~660 raw inter-sample segments accumulates a far
// spikier distribution -- and a far larger peak to normalise against -- than one fed the one
// or two bucket envelopes the pyramid gives. The paused trace read as a hard bright core with
// its dwell tail dimmed out, i.e. the trace changed appearance on pause.
//
// It is not free, and the trade is one-off against per-frame: building it took the pause
// publish from 6.15 ms to 22.31 ms (`publish cost:`), which blocks the *producer* -- against
// ~5 ms a frame of draw for as long as the pause lasts, which at ~60 fps a 1.6 s pause repays
// thirty times over. The producer absorbs the stall: `gaps=0` with and without a pause, the
// paused run having published in 86 ms.
auto& pyr = snap->pyramids[c];
pyr.reserve(take);
for (f32 v : vals) {
lo = std::min(lo, v);
hi = std::max(hi, v);
pyr.append(v);
}
snap->y[c] = plot::Range{lo, hi};
}
pause_view_.store(std::move(snap), std::memory_order_release);
note_publish(publish_pause_count_, publish_pause_us_total_, publish_pause_us_worst_,
(now_ns() - t0) / 1000u);
}
void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
// The caller is the producer and already holds `mutex()`, so every microsecond counted here is a
// microsecond the UI tick is blocked for -- which is what the documented `tick` max is made of.
const std::uint64_t t0 = now_ns();
auto snap = std::make_shared<EventSnapshot>();
snap->x_offset = edge; // the stream time x = 0 means, for everything that has to cross back
BufferStore& src = live();
@@ -605,6 +635,8 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
// Bumped after the snapshot is in place, so a consumer that sees the new sequence
// number sees the snapshot it counts.
event_seq_.fetch_add(1, std::memory_order_release);
note_publish(publish_event_count_, publish_event_us_total_, publish_event_us_worst_,
(now_ns() - t0) / 1000u);
}
void BufferStore::set_epoch(f64 epoch) noexcept {
+47
View File
@@ -224,6 +224,37 @@ class ScopeModel {
std::uint64_t math_cost_ns() const noexcept {
return math_cost_ns_.load(std::memory_order_relaxed);
}
/// What freezing a view costs, and *which thread it costs it to*. The two publishes are stalls on
/// opposite sides of `mutex()` and so are counted apart: `publish_event()` runs on the **producer**
/// with the lock already held, so its cost lands on the UI as a long tick (the documented `tick`
/// max); `publish_pause_view()` runs on the **UI** thread and takes the lock, so its cost lands on
/// the producer and can only surface as `gaps`. Neither is attributable in a frame mean, which is
/// why they are counted rather than inferred. Each counter is written by one thread and read by the
/// dump, so a relaxed store of a value already in hand is enough.
///
/// This is the number to judge a second live ring against: freezing a whole `BufferStore` would
/// make both figures zero, since a `ChannelBuffer` maintains its pyramid incrementally and there
/// would be nothing to copy -- but it costs a second budget's worth of memory and a second
/// per-sample write on the producer, so it is worth paying only if the worst figures here are
/// large enough to matter.
std::uint64_t publish_event_count() const noexcept {
return publish_event_count_.load(std::memory_order_relaxed);
}
std::uint64_t publish_event_us_total() const noexcept {
return publish_event_us_total_.load(std::memory_order_relaxed);
}
std::uint64_t publish_event_us_worst() const noexcept {
return publish_event_us_worst_.load(std::memory_order_relaxed);
}
std::uint64_t publish_pause_count() const noexcept {
return publish_pause_count_.load(std::memory_order_relaxed);
}
std::uint64_t publish_pause_us_total() const noexcept {
return publish_pause_us_total_.load(std::memory_order_relaxed);
}
std::uint64_t publish_pause_us_worst() const noexcept {
return publish_pause_us_worst_.load(std::memory_order_relaxed);
}
const std::vector<SignalInfo>& signals() const noexcept { return signals_; }
const std::vector<ChannelInfo>& channels() const noexcept { return channels_; }
@@ -491,6 +522,22 @@ class ScopeModel {
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};
/// See `publish_event_count()`. Single-writer each: the first three are the producer's, the last
/// three the UI thread's.
std::atomic<std::uint64_t> publish_event_count_{0};
std::atomic<std::uint64_t> publish_event_us_total_{0};
std::atomic<std::uint64_t> publish_event_us_worst_{0};
std::atomic<std::uint64_t> publish_pause_count_{0};
std::atomic<std::uint64_t> publish_pause_us_total_{0};
std::atomic<std::uint64_t> publish_pause_us_worst_{0};
/// Fold one publish's elapsed microseconds into a counter triple. Called by the one thread that
/// owns the triple, so the load/store pairs need no read-modify-write.
static void note_publish(std::atomic<std::uint64_t>& count, std::atomic<std::uint64_t>& total,
std::atomic<std::uint64_t>& worst, std::uint64_t us) noexcept {
count.store(count.load(std::memory_order_relaxed) + 1, std::memory_order_relaxed);
total.store(total.load(std::memory_order_relaxed) + us, std::memory_order_relaxed);
if (us > worst.load(std::memory_order_relaxed)) worst.store(us, std::memory_order_relaxed);
}
std::vector<bool> enabled_;
Partition part_;
+62 -4
View File
@@ -311,6 +311,9 @@ struct App {
unsigned signal_generation = ~0u;
bool updating_panels = false; ///< guards signal handlers during a rebuild
bool relayout_pending = false;
/// True once the renderer's `Style` has been stated (see `tick()`): the renderer does not
/// exist until the area is realized, so the one-shot has to be a tick's job.
bool style_stated = false;
bool signals_panel_initialised = false;
/// Set once the user edits the trigger level, so the panel stops defaulting it
/// into the middle of the declared range.
@@ -411,6 +414,17 @@ struct App {
std::uint64_t dbg_visible_max = 0;
int dbg_raw_frames = 0;
int dbg_lod_frames = 0;
/// Frames on which at least one trace's geometry was reduced onto the pixel grid (one
/// vertical segment per column). It is orthogonal to `raw`/`lod` on purpose -- the
/// reduction is gated on the *density* of the gathered points, not on which level
/// produced them -- and it is the only reading that distinguishes "the segment count
/// collapsed because the reduction ran" from "the window got narrower".
int dbg_column_frames = 0;
/// Frames on which at least one reduced column was drawn **graded by dwell** rather than
/// as one flat band (`Style::intensity_grading`, which this app turns on). A subset of
/// `columns`: grading is a way of drawing the reduction, not a third path, so the two
/// counts being equal is what says the look is reaching every reduced frame.
int dbg_graded_frames = 0;
/// Painted frames on which `draw_traces` drew **no trace at all**, and the last one's index.
/// This is the observable for "the plot flickers as if there were no data": `staged_segments`
/// cannot see it, because the grid and the instruments stage geometry of their own, so a frame
@@ -1247,6 +1261,8 @@ int on_render_probe(GtkGLArea*, GdkGLContext*, gpointer user) {
app->dbg_seg_last = s.staged_segments;
if (s.raw_traces > 0) ++app->dbg_raw_frames;
if (s.lod_traces > 0) ++app->dbg_lod_frames;
if (s.column_traces > 0) ++app->dbg_column_frames;
if (s.graded_traces > 0) ++app->dbg_graded_frames;
if (s.raw_traces > 0 || s.lod_traces > 0) check_frame_ink(*app);
if (s.raw_traces == 0 && s.lod_traces == 0) {
++app->dbg_notrace_frames;
@@ -3109,7 +3125,10 @@ void update_measures(App& app, bool force) {
if (w.ok && !w.pct.empty()) {
w.level.assign(w.pct.size(), MeasureLevel{});
for (std::size_t k = 0; k < w.pct.size(); ++k) {
w.level[k].level = w.stats.lo + w.pct[k] * (w.stats.hi - w.stats.lo);
// The level *and* its hysteresis band, in the engine: how wide the band is is a property of
// how an edge is counted, not of the pane, and two expressions for it is how the walk and
// the bucket fast path end up disagreeing about where a crossing was.
measure_set_level(w.level[k], w.stats, w.pct[k]);
}
const auto accumulate = [&](const MeasureChunk& c) {
measure_accumulate(c, time_of, w.level.data(), w.level.size());
@@ -4865,6 +4884,22 @@ gboolean tick(gpointer user) {
}
}
// The trace look, stated on the first tick that finds a renderer -- there is none until
// the area is realized, and this is the same "claim it when it exists" shape as the frame
// clock above. `intensity_grading` is off in the library by default (it is a change of
// look, not a correction, and other hosts must not be restyled by an upgrade); an
// oscilloscope is exactly the host it is for, so udpscope turns it on. A one-shot rather
// than a per-frame write because `Style` is plain data and nothing else touches it.
if (!app->style_stated && app->area != nullptr) {
if (plot::Renderer* r = app->area->renderer()) {
// `UDPSCOPE_NO_PHOSPHOR` puts it back to the library's default, which is the only way
// to read `frame cost:` and `frame path:` against the ungraded path -- a frame mean
// cannot attribute its own cost, so the A/B is the measurement.
r->style().intensity_grading = g_getenv("UDPSCOPE_NO_PHOSPHOR") == nullptr;
app->style_stated = true;
}
}
if (app->relayout_pending) {
app->relayout_pending = false;
relayout_areas(*app);
@@ -8212,9 +8247,10 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
app.dbg_draw_ms_max, app.dbg_draw_max_frame,
app.dbg_paint_n > 0 ? app.dbg_paint_ms_sum / app.dbg_paint_n : 0.0,
app.dbg_paint_ms_max, app.dbg_paint_n);
std::printf("[udpscope] frame path: raw=%d lod=%d frames, segments %llu/%u "
"(mean/max), visible max %llu samples\n",
app.dbg_raw_frames, app.dbg_lod_frames,
std::printf("[udpscope] frame path: raw=%d lod=%d columns=%d graded=%d frames, segments "
"%llu/%u (mean/max), visible max %llu samples\n",
app.dbg_raw_frames, app.dbg_lod_frames, app.dbg_column_frames,
app.dbg_graded_frames,
static_cast<unsigned long long>(app.dbg_paint_n > 0
? app.dbg_seg_sum / app.dbg_paint_n
: 0),
@@ -8347,6 +8383,28 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
per_slot * static_cast<double>(app.model->math_slots()) / 1.0e6);
}
}
// What freezing a view costs, counted apart for the two publishes because they stall opposite
// threads (see `ScopeModel::publish_event_count()`). This is the figure a second live ring would
// buy: freezing a whole `BufferStore` copies nothing and inherits the pyramid the producer
// already maintains, so both `worst` figures would be 0 -- at a second budget's worth of memory
// and a second per-sample write on the producer. Read `event worst` against `frame cost:`'s
// `tick` max (the same stall, seen from the UI) and `pause worst` against `gaps`.
{
const auto ev = app.model->publish_event_count();
const auto pa = app.model->publish_pause_count();
std::printf("[udpscope] publish cost: %llu events, %.2f/%.2f ms (mean/worst, blocks the UI);"
" %llu pauses, %.2f/%.2f ms (mean/worst, blocks the producer)\n",
static_cast<unsigned long long>(ev),
ev > 0 ? static_cast<double>(app.model->publish_event_us_total()) /
static_cast<double>(ev) / 1000.0
: 0.0,
static_cast<double>(app.model->publish_event_us_worst()) / 1000.0,
static_cast<unsigned long long>(pa),
pa > 0 ? static_cast<double>(app.model->publish_pause_us_total()) /
static_cast<double>(pa) / 1000.0
: 0.0,
static_cast<double>(app.model->publish_pause_us_worst()) / 1000.0);
}
// Unconditional, unlike the pane's own lines below: this is about the instruments the app
// *created*, which exist whether or not any measurement was added.
std::printf("[udpscope] readout instruments locked: %d (trigger marker + measurement marks)\n",
+99 -33
View File
@@ -55,8 +55,9 @@ def build_config(rate, time_mode=TIME_FULL_ARRAY, unit="V"):
# The stream's clock: referenced by every data signal, so udpscope treats
# it as the time base rather than a plottable channel. Its *own* time_mode
# is what udpscope switches on, so `--time-mode first` has to land here.
signal_desc("time", rate=0.0, time_mode=time_mode,
time_idx=NO_TIME_SIGNAL, unit="s"),
signal_desc(
"time", rate=0.0, time_mode=time_mode, time_idx=NO_TIME_SIGNAL, unit="s"
),
signal_desc("sine", rate=rate, time_mode=time_mode, time_idx=0, unit=unit),
signal_desc("square", rate=rate, time_mode=time_mode, time_idx=0, unit=unit),
signal_desc("ramp", rate=rate, time_mode=time_mode, time_idx=0, unit=unit),
@@ -66,10 +67,25 @@ def build_config(rate, time_mode=TIME_FULL_ARRAY, unit="V"):
return header(PKT_CONFIG, 0, len(payload)) + payload
def build_data(counter, t0, dt, batch, freq, time_mode=TIME_FULL_ARRAY):
def build_data(counter, t0, dt, batch, freq, time_mode=TIME_FULL_ARRAY, noise=0.0):
stamps = [t0 + i * dt for i in range(batch)]
sine = [math.sin(2.0 * math.pi * freq * t) for t in stamps]
square = [1.0 if math.sin(2.0 * math.pi * freq * 0.5 * t) >= 0.0 else -1.0 for t in stamps]
# The interferer is a tone at 100x the fundamental with a randomly scaled amplitude, which
# is the shape that is hardest on anything counting crossings: near the sine's own mid level
# the fundamental's slope is 2*pi*f while the interferer's is 100x that, so the signal
# recrosses the level several times per true crossing. `--noise 0` is the ideal waveform, and
# it is the default because the documented probe assertions are *exact* (`peak to peak=2`,
# `top=0.636620`) and any interferer moves the extremes.
if noise > 0.0:
sine = [
math.sin(2.0 * math.pi * freq * t)
+ random.random() * noise * math.sin(2.0 * math.pi * 100.0 * freq * t)
for t in stamps
]
else:
sine = [math.sin(2.0 * math.pi * freq * t) for t in stamps]
square = [
1.0 if math.sin(2.0 * math.pi * freq * 0.5 * t) >= 0.0 else -1.0 for t in stamps
]
ramp = [(freq * t) % 1.0 * 2.0 - 1.0 for t in stamps]
# In FIRST_SAMPLE mode only element 0 of the time signal is meaningful: the producer
# stamps the first sample of the batch and says nothing about the rest. A scalar
@@ -90,30 +106,58 @@ def main():
ap.add_argument("--batch", type=int, default=100, help="samples per packet")
ap.add_argument("--freq", type=float, default=5.0, help="waveform frequency, Hz")
ap.add_argument("--duration", type=float, default=0.0, help="0 = forever")
ap.add_argument("--stream-scale", type=float, default=1.0,
help="stream seconds per wall second: 0.1 mimics a producer whose own "
"clock advances ten times slower than the wire delivers samples")
ap.add_argument("--declared-rate", type=float, default=-1.0,
help="sampling_rate to put in CONFIG; -1 = the real rate, 0 = 'unknown'")
ap.add_argument("--time-mode", choices=("full", "first"), default="full",
help="how the stream carries its clock: 'full' stamps every sample "
"(UDPS_TIME_FULL_ARRAY), 'first' stamps only the batch's first "
"sample (UDPS_TIME_FIRST_SAMPLE) and leaves the viewer to space the "
"rest by the declared cadence -- the shape a real MARTe2-style "
"producer with a single u64 clock has")
ap.add_argument("--unit", default="V",
help="the unit the data signals declare in CONFIG; '' declares none, which "
"is an ordinary stream (and what every math channel looks like) and is "
"the only way a probe can see the unitless readout formatting")
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")
ap.add_argument(
"--stream-scale",
type=float,
default=1.0,
help="stream seconds per wall second: 0.1 mimics a producer whose own "
"clock advances ten times slower than the wire delivers samples",
)
ap.add_argument(
"--declared-rate",
type=float,
default=-1.0,
help="sampling_rate to put in CONFIG; -1 = the real rate, 0 = 'unknown'",
)
ap.add_argument(
"--time-mode",
choices=("full", "first"),
default="full",
help="how the stream carries its clock: 'full' stamps every sample "
"(UDPS_TIME_FULL_ARRAY), 'first' stamps only the batch's first "
"sample (UDPS_TIME_FIRST_SAMPLE) and leaves the viewer to space the "
"rest by the declared cadence -- the shape a real MARTe2-style "
"producer with a single u64 clock has",
)
ap.add_argument(
"--unit",
default="V",
help="the unit the data signals declare in CONFIG; '' declares none, which "
"is an ordinary stream (and what every math channel looks like) and is "
"the only way a probe can see the unitless readout formatting",
)
ap.add_argument(
"--noise",
type=float,
default=0.0,
help="amplitude of an interferer added to `sine`: a tone at 100x the fundamental, "
"scaled by a fresh uniform random factor per sample. 0 = the ideal sine, which is "
"what the exact probe assertions (peak to peak = 2, top = 2/pi) are read against; "
"0.1 is the figure the measurement hysteresis is sized against",
)
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()
dt = args.stream_scale / args.rate # stream seconds per sample
period = args.batch / args.rate # wall seconds per packet
dt = args.stream_scale / args.rate # stream seconds per sample
period = args.batch / args.rate # wall seconds per packet
declared = args.rate if args.declared_rate < 0.0 else args.declared_rate
time_mode = TIME_FIRST_SAMPLE if args.time_mode == "first" else TIME_FULL_ARRAY
@@ -121,9 +165,19 @@ def main():
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", args.port))
sock.settimeout(0.001)
print("mock udpstreamer: waiting for CONNECT on :%d (%.0f S/s wire, %d per packet, "
"stream clock x%.3g, declared %.0f S/s, time-mode %s)"
% (args.port, args.rate, args.batch, args.stream_scale, declared, args.time_mode), flush=True)
print(
"mock udpstreamer: waiting for CONNECT on :%d (%.0f S/s wire, %d per packet, "
"stream clock x%.3g, declared %.0f S/s, time-mode %s)"
% (
args.port,
args.rate,
args.batch,
args.stream_scale,
declared,
args.time_mode,
),
flush=True,
)
peer = None
counter = 1
@@ -143,7 +197,10 @@ def main():
started = time.monotonic()
next_send = started
sock.sendto(build_config(declared, time_mode, args.unit), peer)
print("mock udpstreamer: CONNECT from %s:%d, CONFIG sent" % peer, flush=True)
print(
"mock udpstreamer: CONNECT from %s:%d, CONFIG sent" % peer,
flush=True,
)
elif magic == MAGIC and ptype == PKT_DISCONNECT:
print("mock udpstreamer: DISCONNECT", flush=True)
peer = None
@@ -166,11 +223,20 @@ def main():
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, time_mode), peer)
sock.sendto(
build_data(
counter, t_stream, pdt, args.batch, args.freq, time_mode, args.noise
),
peer,
)
counter += 1
t_stream += args.batch * pdt
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)
return