Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96ee35da25 | ||
|
|
f307d27e41 | ||
|
|
d2c51f2448 | ||
|
|
13574bfcef | ||
|
|
b4b26d3e31 | ||
|
|
5f5b87ca60 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+1
-1
Submodule deps/plotter updated: bee318870f...cc908efa36.
@@ -0,0 +1,452 @@
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
|
||||
# The math node editor: wires you can draw and delete, and a canvas you can navigate
|
||||
|
||||
The math channel editor works as a *form* and fails as a *graph editor*. Three things were
|
||||
reported — edges cannot be added, edges cannot be deleted, and the canvas cannot be zoomed or
|
||||
panned — and all three are true. The first is a defect with a specific cause, the second has no
|
||||
code path at all, and the third is an absence. Beyond them the editor asks the user to obey a
|
||||
rule the data structure has (a node may only read nodes above it in the list) rather than
|
||||
maintaining that rule itself, and it reports an invalid graph in a modal alert at the end
|
||||
instead of on the canvas as it is being built.
|
||||
|
||||
This redesigns the canvas interaction. Nothing below changes `MathNode`, `MathChannel`, the
|
||||
evaluator in `src/math_channel.cpp`, the session format, or the producer's path.
|
||||
|
||||
## Scope
|
||||
|
||||
In: the canvas view transform, the port and wire hit tests, the gesture state machine
|
||||
(node drag / wire drag / background pan / wheel zoom), edge creation and deletion, a
|
||||
topological re-sort that lets a wire be drawn in either direction, an op picker on the add
|
||||
button, a live validation line that replaces the modal alert, a tidy/auto-layout pass, the
|
||||
`Cancel` button's close path, and the `[math-probe]` assertions for all of it.
|
||||
|
||||
Out: parameters drawn on the node boxes (explicitly declined — the form beside the canvas is
|
||||
where a node's values live). No new ops. No change to `math_channel_valid()`'s rules, to the
|
||||
evaluator, to `MathRuntime`, or to how a derived channel is acquired. No node *library* panel,
|
||||
no copy/paste, no undo.
|
||||
|
||||
## Why edges cannot be added today
|
||||
|
||||
`math_canvas_pressed` (`src/ui.cpp:3455`) handles an output-port hit like this:
|
||||
|
||||
```cpp
|
||||
const int port = math_port_at(d, x, y, which);
|
||||
if (port == -2) {
|
||||
const int node = math_node_at(d, x, y);
|
||||
d->pending_out = (node >= 0 && d->pending_out == node) ? -1 : node;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
`math_port_at` throws away *which* node's output was hit — it returns the sentinel `-2` — and
|
||||
the handler recovers the node with `math_node_at()`, which matches only **inside the box**. The
|
||||
output square is drawn at `x = n.x + kNodeW`, i.e. straddling the right edge: half of it is
|
||||
outside. Its hit radius is `kPort` (±10) while it is drawn at `±kPort * 0.5` (±5), so the
|
||||
clickable region extends 10 px past the edge and `math_node_at()` answers `-1` for every pixel
|
||||
of it beyond the first. `pending_out` is then set to `-1` — a *silent disarm*. Clicking the
|
||||
visible dot's outer half, which is most of what the eye aims at, arms nothing, and the input
|
||||
squares never light up. That is the whole of "it is not possible to add edges": the mechanism
|
||||
exists and the first click of the two-click sequence usually fails.
|
||||
|
||||
It is worth fixing the cause and not only the symptom, because the same re-derivation would
|
||||
break again under the view transform below.
|
||||
|
||||
Edge *deletion* needs no diagnosis: no path in the file ever writes `-1` back into `in_a` or
|
||||
`in_b` except `on_math_node_deleted`, which is deleting the whole node.
|
||||
|
||||
## The view transform
|
||||
|
||||
`MathDialog` gains three doubles:
|
||||
|
||||
```cpp
|
||||
double view_scale = 1.0; ///< canvas pixels per graph unit
|
||||
double view_x = 0.0; ///< canvas pixels: where graph (0,0) is drawn
|
||||
double view_y = 0.0;
|
||||
```
|
||||
|
||||
Graph coordinates are what `MathNode::x`/`y` already are and what the session file stores, so a
|
||||
view change is never persisted — reopening the editor shows the graph at 1:1, which is the
|
||||
right default for a window whose size the user did not choose either.
|
||||
|
||||
Two conversions, and **every** hit test goes through the second:
|
||||
|
||||
```cpp
|
||||
// canvas <- graph: applied once to the cairo context, never per item.
|
||||
cairo_translate(cr, d->view_x, d->view_y);
|
||||
cairo_scale(cr, d->view_scale, d->view_scale);
|
||||
|
||||
// graph <- canvas: the inverse, for a pointer position.
|
||||
void math_to_graph(const MathDialog* d, double cx, double cy, double& gx, double& gy) {
|
||||
gx = (cx - d->view_x) / d->view_scale;
|
||||
gy = (cy - d->view_y) / d->view_scale;
|
||||
}
|
||||
```
|
||||
|
||||
Applying the transform to the context rather than to each item is what keeps the drawing code
|
||||
unchanged: `math_canvas_draw` keeps every coordinate it has today, and the line widths and font
|
||||
sizes scale with the graph, which is what a zoom should do to a diagram.
|
||||
|
||||
**A hit tolerance is a screen distance, not a graph distance.** A port is aimed at with a
|
||||
pointer, so its clickable radius must be the same number of *canvas* pixels at every zoom —
|
||||
which in graph units is `kPort / view_scale`. The same for the wire tolerance below. Getting
|
||||
this backwards makes the ports unhittable when zoomed out, which is exactly when a user is
|
||||
looking for them.
|
||||
|
||||
**Scale is clamped to `[0.25, 4.0]`.** Below a quarter the op names are unreadable and the
|
||||
boxes are smaller than the pointer tolerance; above four a single node fills the canvas. The
|
||||
clamp lives in one place, a `math_set_scale()` helper, because three callers reach it (wheel,
|
||||
Fit, reset).
|
||||
|
||||
### Wheel zoom about the pointer
|
||||
|
||||
A `GtkEventControllerScroll` with `GTK_EVENT_CONTROLLER_SCROLL_VERTICAL`. The anchor is the
|
||||
graph point under the pointer, and it must not move:
|
||||
|
||||
```cpp
|
||||
double gx = 0.0, gy = 0.0;
|
||||
math_to_graph(d, px, py, gx, gy); // before
|
||||
d->view_scale = clamp(d->view_scale * (dy < 0 ? 1.1 : 1.0 / 1.1), 0.25, 4.0);
|
||||
d->view_x = px - gx * d->view_scale; // restore the anchor
|
||||
d->view_y = py - gy * d->view_scale;
|
||||
```
|
||||
|
||||
The pointer position comes from the controller's own `GtkEventController` current event rather
|
||||
than from a tracked motion position: a scroll event carries one, and tracking motion to supply
|
||||
it is a second source of truth that can be stale.
|
||||
|
||||
No modifier is involved. The canvas has no other use for the wheel, so a bare wheel zoom is
|
||||
unambiguous here — unlike the plot, where the wheel's modifiers are already spoken for.
|
||||
|
||||
### Pan, Fit, and reset
|
||||
|
||||
Dragging the empty background pans: `view_x = pan_x0 + dx`, from a `pan_x0` recorded at
|
||||
`drag-begin`, for the same total-offset reason the node drag already documents at
|
||||
`src/ui.cpp:3507`.
|
||||
|
||||
A **Fit** button frames every node:
|
||||
|
||||
```
|
||||
bbox over all math_node_rect() (empty graph -> identity view)
|
||||
scale = clamp(min(w_canvas / (bbox.w + 2*pad), h_canvas / (bbox.h + 2*pad)), 0.25, 4.0)
|
||||
view_* = centre the scaled bbox in the canvas
|
||||
```
|
||||
|
||||
`Ctrl+0` resets to `scale = 1`, `view_x = view_y = 0`. Both are one call to a shared
|
||||
`math_fit_view()` / `math_reset_view()` so the button and the accelerator cannot drift apart.
|
||||
|
||||
## The port and wire hit tests
|
||||
|
||||
`math_port_at` is replaced by one that **names the node it found**:
|
||||
|
||||
```cpp
|
||||
/// The port under a *graph* position. Returns the node index, with `which` set to
|
||||
/// `kPortOut` (-1) for its output or 0/1 for an input; `-1` when no port is under it.
|
||||
int math_port_at(const MathDialog* d, double gx, double gy, double tol, int& which);
|
||||
```
|
||||
|
||||
Two changes beyond the signature. It scans **backward**, topmost first, so it agrees with
|
||||
`math_node_at()` about which of two overlapping nodes owns a shared pixel — a disagreement
|
||||
between the two searches is how a click lands on one node and a drag on another. And the
|
||||
caller never re-derives the owner, which is the defect above made structurally impossible.
|
||||
|
||||
The hit radius stays *larger* than the drawn square: ±`kPort` canvas pixels around the port's
|
||||
centre, against a square drawn at ±`kPort * 0.5` — a 20 px target for a 10 px mark. That is
|
||||
deliberate and, once the owner is no longer re-derived, harmless: a generous radius is the
|
||||
difference between a diagram that can be wired and one that cannot. The asymmetry is written
|
||||
down in a comment at the hit test, because it is what the old defect fed on.
|
||||
|
||||
A wire is picked by distance to the curve it is drawn as. The curve is the same cubic the
|
||||
renderer uses (`src/ui.cpp:3401`), so the pick and the ink cannot disagree:
|
||||
|
||||
```cpp
|
||||
/// The wire under a graph position, as (consumer node, which input), or {-1, 0}.
|
||||
/// The bezier is sampled at 24 points and the nearest segment measured; a curve 40 units of
|
||||
/// tread wide over a node pitch of 160 is smooth at that sampling, and the tolerance is 6
|
||||
/// canvas pixels, so a miss by a pixel is not a wrong edge deleted.
|
||||
MathWireRef math_wire_at(const MathDialog* d, double gx, double gy, double tol);
|
||||
```
|
||||
|
||||
Sampling rather than solving: the exact distance to a cubic is a fifth-degree root-find, and 24
|
||||
samples of a curve whose control points are 40 units out is well inside a 6 px tolerance. The
|
||||
sample count is a named constant so a future wider tread can raise it.
|
||||
|
||||
**Order of precedence on a press**: port, then wire, then node body, then background. A port
|
||||
sits on a node's edge and a wire ends on a port, so each later test must only see what the
|
||||
earlier ones refused.
|
||||
|
||||
## The gesture state machine
|
||||
|
||||
One `GtkGestureDrag` serves four gestures, decided entirely by what the press landed on. A
|
||||
`GtkGestureClick` is kept for the selection-only cases, because a click is a drag of zero
|
||||
length and GTK delivers both; `drag-begin` records the mode and `pressed` only ever *selects*.
|
||||
|
||||
```cpp
|
||||
enum class MathDrag { None, Node, Wire, Pan };
|
||||
MathDrag drag_mode = MathDrag::None;
|
||||
int wire_from = -1; ///< Wire: the node whose output the live wire leaves
|
||||
int wire_detach = -1; ///< Wire: the consumer whose input was detached to start it, or -1
|
||||
int wire_detach_which = 0;
|
||||
double wire_x = 0.0; ///< Wire: the pointer, in graph units, for the preview
|
||||
double wire_y = 0.0;
|
||||
double pan_x0 = 0.0;
|
||||
double pan_y0 = 0.0;
|
||||
MathWireRef selected_wire{-1, 0};
|
||||
```
|
||||
|
||||
`drag-begin`, in precedence order:
|
||||
|
||||
- **An output port** → `Wire`, `wire_from = node`, `wire_detach = -1`.
|
||||
- **A wired input port** → `Wire`, `wire_from = <the node it was wired from>`,
|
||||
`wire_detach = node`, `wire_detach_which = which`, and the input is cleared *immediately*.
|
||||
Dragging an existing wire off its input and dropping it elsewhere is one gesture, and
|
||||
clearing on begin is what makes the preview show the truth: the wire the user is holding.
|
||||
Dropping it on nothing therefore *detaches*, which is the agreed detach gesture, and needs
|
||||
no separate case.
|
||||
- **An unwired input port** → `None` (select the node; there is nothing to drag from an empty
|
||||
input). Wiring is always output→input, so this is not a connect gesture.
|
||||
- **A node body** → `Node`, exactly as today.
|
||||
- **Anything else** → `Pan`.
|
||||
|
||||
`drag-update` moves the node, the live wire's endpoint, or the view, by mode.
|
||||
|
||||
`drag-end` resolves `Wire`:
|
||||
|
||||
- Released on an **input port** of a node that is not `wire_from` → `math_connect()`.
|
||||
- Released on an **input port of `wire_from` itself** → refused, with "a node cannot read
|
||||
itself" on the validation line. A self-edge is the one cycle a topological sort cannot
|
||||
break, and refusing it by name reads better than "the graph has a cycle".
|
||||
- Released anywhere else → nothing further. If the drag began by detaching, the detachment
|
||||
stands.
|
||||
|
||||
`Escape` during a `Wire` drag cancels and **restores** a detached input: a cancel must undo the
|
||||
clear-on-begin, or Escape would be a way to delete an edge by accident.
|
||||
|
||||
A `GtkEventControllerKey` on the canvas handles `Delete`/`BackSpace` (clear `selected_wire`),
|
||||
`Escape` (cancel a drag, else clear the wire selection) and `Ctrl+0` (reset the view). It is
|
||||
added to the canvas and the canvas is given `gtk_widget_set_focusable(TRUE)` plus focus on
|
||||
press, because a drawing area is not focusable by default and a key controller on an unfocused
|
||||
widget never fires. **The key controller must not swallow text**: the dialog's name and unit
|
||||
entries are `GtkEditable`s, and the controller is on the *canvas*, so a key only reaches it
|
||||
while the canvas has focus — the same reasoning as the app-level accelerators' bubble phase,
|
||||
reached by a narrower route.
|
||||
|
||||
## The edge model
|
||||
|
||||
One function owns connection:
|
||||
|
||||
```cpp
|
||||
/// Wire `from`'s output into `to`'s input `which`. Re-sorts the node list when the wire runs
|
||||
/// backwards. Returns false and sets `why` when the wire would make a cycle.
|
||||
bool math_connect(MathDialog* d, int from, int to, int which, std::string* why);
|
||||
```
|
||||
|
||||
and deletion is a one-liner at the call site (`in_a`/`in_b` back to `-1`), because there is
|
||||
nothing to validate in removing an edge: a disconnected input is a state the validation line
|
||||
already names.
|
||||
|
||||
### Backwards wires re-sort the list
|
||||
|
||||
`MathChannel::nodes` is a list walked in order, and the evaluator's correctness rests on a node
|
||||
reading only nodes before it. Today that rule is the *user's* problem: the input ports of nodes
|
||||
above the armed output are not even drawn as armed, so a wire that would run backwards cannot
|
||||
be drawn at all. In a two-column auto-placed graph that is a real obstruction — the node you
|
||||
want to feed is frequently the one that happens to sit earlier in the list.
|
||||
|
||||
The rule becomes the editor's invariant instead. `math_connect()` on a backwards wire
|
||||
(`from > to`) builds the new edge set, runs a topological sort, and commits only if it is
|
||||
acyclic:
|
||||
|
||||
1. Trial edge set = every node's existing inputs, plus the new one.
|
||||
2. Kahn's algorithm over it. Ties broken by **current list index**, so a sort that changes
|
||||
nothing leaves the list alone and a sort that must move something moves as little as
|
||||
possible. (A stable order matters: the node positions on the canvas do not change, so a
|
||||
gratuitous renumber would silently re-point `output` and `selected` for no reason the user
|
||||
can see.)
|
||||
3. On a cycle: nothing is written, `why` is set, and the validation line shows it. The edge is
|
||||
*not* created — this is the one refusal left, and it is the honest one.
|
||||
4. On success: permute `nodes`, then remap through the permutation **everything that holds an
|
||||
index** — `output`, `selected`, every `in_a`/`in_b`, `drag_node`, `selected_wire.node`. A
|
||||
forgotten one is a silent mis-wire, so they are remapped in a single block with a comment
|
||||
naming the set.
|
||||
|
||||
The session format is untouched: what is written is still a list in evaluation order. A file
|
||||
written by an older build still loads, because the sort is an editor operation and an
|
||||
already-ordered list sorts to itself.
|
||||
|
||||
`math_canvas_draw` stops gating a wire on `from < i` once this holds — but keeps the gate as a
|
||||
**defensive skip**, because a `MathChannel` loaded from a file the editor did not write could
|
||||
hold a forward reference, and drawing it would be drawing an edge the evaluator ignores.
|
||||
|
||||
### The armed-port highlight goes away
|
||||
|
||||
`pending_out` and the "input ports of later nodes light up" highlight are both deleted. They
|
||||
exist to serve the two-click protocol and the list-order rule, and neither survives: with a
|
||||
drag gesture the feedback is the live wire following the pointer, and with the re-sort every
|
||||
input is a legal target. What replaces the highlight is a hover accent on the port under the
|
||||
pointer during a `Wire` drag, which says the one thing that still matters — whether releasing
|
||||
here will connect.
|
||||
|
||||
## The live validation line
|
||||
|
||||
`on_math_apply` validates in two steps today (`math_channel_valid()`, then an acquired-source
|
||||
check the validator cannot make) and reports each in a modal `GtkAlertDialog`. A modal alert at
|
||||
the end of a build is the worst time and place to be told; the graph is right there and can say
|
||||
so continuously.
|
||||
|
||||
One extraction:
|
||||
|
||||
```cpp
|
||||
/// Why the graph as it stands could not be saved, or "" when it could. Both checks the Apply
|
||||
/// path made: `math_channel_valid()`'s graph rules and the dialog's own "a Source must name an
|
||||
/// acquired channel", which the validator cannot make because only the dialog sees that list.
|
||||
std::string math_dialog_problem(const MathDialog* d);
|
||||
```
|
||||
|
||||
`math_dialog_sync()` — already the one place that rewrites the form from the model, and already
|
||||
called from every mutation — sets the line's text and `gtk_widget_set_sensitive(apply, problem
|
||||
.empty())`. An insensitive Apply with the reason beside it is a better refusal than a dialog
|
||||
that must be dismissed.
|
||||
|
||||
`on_math_apply` keeps the check and loses the **dialogs**: it calls `math_dialog_problem()` and
|
||||
returns on a non-empty answer, with no `GtkAlertDialog`. The guard has to stay even though the
|
||||
button cannot be clicked while it would fire, because the probe calls `on_math_apply()` directly
|
||||
and must not be able to save a broken graph through it. That `d->apply` pointer is new on
|
||||
`MathDialog`; the button is built after the canvas today, so `math_dialog_sync()`'s first call
|
||||
(at `src/ui.cpp:3972`, after the whole tree) already runs late enough to set it.
|
||||
|
||||
The line is a `GtkLabel` under the canvas, `error` CSS class when non-empty, and
|
||||
`gtk_label_set_ellipsize(END)` with a tooltip carrying the full text — **the layout-must-not-
|
||||
depend-on-content rule applies here as much as to the status bar**: this label sits in a dialog
|
||||
whose canvas is `hexpand`, and a wrapping error message would otherwise widen the dialog on a
|
||||
mere change of graph.
|
||||
|
||||
## The op picker on add
|
||||
|
||||
`add node` always makes a `MathOp::Gain` (`src/ui.cpp:3516`), so adding a low pass is two
|
||||
actions and a dropdown hunt. It becomes a `GtkMenuButton` labelled `add` whose popover holds a
|
||||
flat button per op, built once from `math_op_count()` / `math_op_at(i)` — the accessor order,
|
||||
not the enum order, for the reason `CLAUDE.md` already records about writing an op's index.
|
||||
Each button carries its op index with `g_object_set_data`, so there is no action-name plumbing
|
||||
and nothing to parse.
|
||||
|
||||
The two behaviours that already work are kept: the new node becomes the output when the graph
|
||||
`was_linear`, and placement is the existing grid. Placement gains one thing — a node is placed
|
||||
at the **first free grid cell** rather than at `nodes.size()`, so the slot left by a deletion is
|
||||
reused instead of the grid growing for ever. The overlap rule stands (`CLAUDE.md`: boxes must
|
||||
not overlap, or `math_node_at()`'s topmost-first search returns the wrong one).
|
||||
|
||||
## Tidy / auto-layout
|
||||
|
||||
A `tidy` button lays the graph out left to right by dependency depth:
|
||||
|
||||
```
|
||||
depth(i) = 0 for a Source or an unwired node
|
||||
= 1 + max(depth of its wired inputs) // well-defined: inputs precede i
|
||||
x = 24 + depth * (kNodeW + 48)
|
||||
y = 20 + row_within_depth * (kNodeH + 22)
|
||||
```
|
||||
|
||||
It needs no recursion and no visited set: the list is in evaluation order — the invariant the
|
||||
re-sort now maintains — so the depth of node `i` depends only on nodes before it and a single
|
||||
forward pass computes every depth. Then `math_fit_view()`, because a tidied graph that is off
|
||||
screen is not tidy.
|
||||
|
||||
The column pitch is `kNodeW + 48`, which is 180 against the 160 the current grid uses: 160
|
||||
leaves 28 units for the wire's 40-unit tread, so the curve doubles back on itself between
|
||||
adjacent columns.
|
||||
|
||||
## The `Cancel` button closes rather than destroys
|
||||
|
||||
`src/ui.cpp:3962` connects `Cancel` to `gtk_window_destroy`:
|
||||
|
||||
```cpp
|
||||
g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_destroy), window);
|
||||
```
|
||||
|
||||
This is the defect `CLAUDE.md` documents under the math channel invariants — "a Cancel that
|
||||
*destroyed* the window left `app.math_dialog` pointing at a dead widget: every later
|
||||
`open_math_dialog()` took the 'already open' branch and presented it" — and it is still live.
|
||||
The fix for it reached the Apply path (`src/ui.cpp:3731`) and the probe (`src/ui.cpp:6672`) and
|
||||
never reached the button. It is `gtk_window_close`, one word, and the probe must assert it,
|
||||
which it does not today: it only ever closes programmatically.
|
||||
|
||||
## What `[math-probe]` must assert
|
||||
|
||||
Every gesture above is reachable from the probe, because the handlers take the `MathDialog*`
|
||||
and plain coordinates — which is already how the drag test at `src/ui.cpp:6694` works. The
|
||||
probe is extended with one line per claim; each is an assertion stated as a `want` beside the
|
||||
reading, in the style of the existing lines.
|
||||
|
||||
- `[math-probe] hit: output at edge+1 -> node N which=-1, at edge+9 -> node N which=-1
|
||||
(want N N)` — the silent-disarm defect, read at two offsets inside the hit radius and outside
|
||||
the box. Both must name the node. **This is the one line that would have caught the reported
|
||||
bug**, and it is the reason the hit test returns the node rather than a sentinel.
|
||||
- `[math-probe] wire: connect 0->1.a wired=W (want 0)` then a backwards
|
||||
`[math-probe] wire: backwards connect 2->0.a order=[..] wired=W reordered=D (want 1)` — the
|
||||
re-sort, with the permuted op order printed so a wrong remap is visible as a wrong list.
|
||||
- `[math-probe] wire: self refused=D problem=[..] (want 1)` and
|
||||
`[math-probe] wire: cycle refused=D problem=[..] (want 1)` — the two refusals, with the
|
||||
cycle built by wiring a two-node pair both ways. `refused=1` **and** the edge absent.
|
||||
- `[math-probe] wire: pick at (x,y) -> (node,which)=(N,K) delete -> in=-1 (want -1)` — the
|
||||
pick samples the curve at its midpoint, which is the one place a wrong tolerance or a wrong
|
||||
curve shows.
|
||||
- `[math-probe] detach: drag from in 1.a to empty -> in=-1 (want -1)`, and
|
||||
`[math-probe] detach: escape restores in=F (want F)` — the clear-on-begin and its undo, which
|
||||
is the half that silently deletes an edge if Escape is forgotten.
|
||||
- `[math-probe] view: zoom scale=S anchor kept dx=.. dy=.. (want 0.0 0.0)` — the graph point
|
||||
under the pointer before and after a wheel step, which is the only reading that distinguishes
|
||||
"zoomed about the pointer" from "zoomed about the origin". Both must be zero to a rounding.
|
||||
- `[math-probe] view: fit scale=S box=[..] all_inside=D (want 1)`, and
|
||||
`[math-probe] view: reset scale=S offset=(..) (want 1.000 (0,0))`.
|
||||
- `[math-probe] view: pan dx=.. dy=.. offset=(..) (want ..)` plus a hit test **after** a pan and
|
||||
a zoom — `port at the same screen point -> node N` — because a hit test that forgot the
|
||||
inverse transform passes every test taken at the identity view.
|
||||
- `[math-probe] tidy: depths=[..] columns=[..] overlaps=D (want 0)` — the overlap count is the
|
||||
assertion (`CLAUDE.md`'s own rule), and the depths say the layout followed the graph.
|
||||
- `[math-probe] validate: problem=[..] apply_sensitive=D` before and after wiring the missing
|
||||
input: `(want non-empty 0)` then `(want empty 1)`.
|
||||
- `[math-probe] cancel: dialog=cleared` — the `Cancel` button's `::clicked` emitted directly,
|
||||
then `app.math_dialog` read back. `STILL SET` is the regression.
|
||||
|
||||
Two things the probe deliberately cannot assert, and which are therefore stated as design
|
||||
constraints rather than checks: that the canvas takes **focus** on press (a synthetic press
|
||||
does not run GTK's focus machinery), and that the live wire *looks* like it follows the pointer
|
||||
(a draw is not readable from a probe). The second is bounded by the first assertion set — the
|
||||
endpoint it is drawn from is `wire_x`/`wire_y`, which the drag test writes and can print.
|
||||
|
||||
The existing lines are all kept. `drag:` in particular stays as it is: the total-offset rule is
|
||||
unchanged by any of this, and the gesture that exercises it is still a node-body drag.
|
||||
|
||||
## Documentation
|
||||
|
||||
Three places, following the project's existing split:
|
||||
|
||||
- **`CLAUDE.md`** — the `[math-probe]` bullet under Verification gains the new readings and
|
||||
their `want`s; the math channel invariants gain the **hit-test-names-its-own-node** rule (a
|
||||
port hit must not be re-derived with a box test), the **tolerance-is-a-screen-distance** rule,
|
||||
the **list order is the editor's invariant, maintained by a topological re-sort** rule with
|
||||
the index-remap set named, and a note that the `Cancel` path is now asserted.
|
||||
- **`README.md`** — a subsection under the math channels describing the editor as it now
|
||||
behaves, and the diagnosis of the silent disarm, since README is where "what was actually
|
||||
built and why" lives.
|
||||
- **`src/help/11-math.txt`** gains the four canvas gestures (drag a port to wire, drag a wired
|
||||
input away to detach, click a wire and press Delete, wheel to zoom and drag the background to
|
||||
pan) and the two buttons. It is an edit to an existing page, so `pages` stays 14 and
|
||||
`[help] pages=14 parsed=14` must still hold — the markup is Pango and a page that fails to
|
||||
parse renders a stand-in.
|
||||
|
||||
## Risks
|
||||
|
||||
- **The re-sort touching an index nothing remapped.** The mitigation is that the remap is one
|
||||
block, the probe prints the permuted op order, and `selected`/`output` are both read back.
|
||||
- **The key controller stealing a keystroke from the name entry.** It is on the canvas, which
|
||||
must have focus; the probe cannot see this, so it is checked by hand once, by typing in the
|
||||
name field with a wire selected.
|
||||
- **The wheel controller fighting a future scrolled window.** There is none and this design
|
||||
removes the reason for one, which is worth stating so a later "wrap the canvas in a
|
||||
`GtkScrolledWindow`" is recognised as undoing the zoom rather than adding to it.
|
||||
- **`GtkGestureDrag` and `GtkGestureClick` on one widget both claiming a press.** They already
|
||||
coexist on this canvas; the change is that `pressed` now only selects and never mutates the
|
||||
graph, which removes the ordering question rather than answering it.
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+62
-14
@@ -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;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
+30
-4
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
+85
-19
@@ -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)]
|
||||
# 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]
|
||||
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,26 +106,54 @@ 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,
|
||||
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",
|
||||
"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",
|
||||
"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,
|
||||
"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")
|
||||
"(the mean rate is unchanged); 0 = the ideal grid",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
dt = args.stream_scale / args.rate # stream seconds per sample
|
||||
@@ -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, "
|
||||
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)
|
||||
% (
|
||||
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
|
||||
|
||||
|
||||
Reference in new issue
Block a user