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>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-06 02:02:52 +02:00
1 parent 62df049f53
commit 5f5b87ca60
5 files changed
+94 -8

No files matched your search

+45 -5
View File
@@ -671,22 +671,34 @@ 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 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.
**`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`.)
- `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=..
@@ -1111,6 +1123,34 @@ far the largest file.
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.
- **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