Compare commits
34
Commits
ec93a9831d
..
0.2.1
No files matched your search
@@ -41,6 +41,15 @@ both states, the frame and per-channel sample counts, each plot's painted-frame
|
||||
count, which channel each trace slot holds, and the allocated size and reveal
|
||||
state of both side panels.
|
||||
|
||||
Every size it prints is read from a value the frame tick captured while the window was alive, not
|
||||
from the widget: the dump runs from the destroy handler, where the widget pointer is already freed
|
||||
memory. It used to ask two of them directly, which is where the long-standing "an exit-dump run
|
||||
emits exactly two `Gtk-CRITICAL`s" baseline came from — and a critical was only the lucky reading.
|
||||
`UDPSCOPE_PROBE_HELP_MS` builds and destroys three windows of its own before the dump, so by then
|
||||
the memory had been reused and the same two calls **crashed in the middle of the dump**, which
|
||||
looks like a truncated log rather than like a segfault in a debug line. The dump now makes no
|
||||
`gtk_widget_*` call at all and a clean run carries no critical.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
@@ -63,27 +72,101 @@ frame and the GUI thread takes once per frame around binding the traces.
|
||||
Samples cross the boundary through `plot::data::RingBuffer` (single producer,
|
||||
single consumer), never through the lock.
|
||||
|
||||
## Measurements
|
||||
## The x axis is relative to now
|
||||
|
||||
**A live axis is fixed at `[-window, 0]`, with the newest sample at 0.** That is what a bench
|
||||
scope in roll mode shows and what the window control's figure means: a 2 s window reads `-2s` to
|
||||
`0` and keeps reading that for as long as the stream runs, with the trace flowing right to left
|
||||
under it. The axis used to carry the stream's own clock, so the labels counted the app's uptime
|
||||
upwards (`20:00`, `20:01`, …) and the one number a user actually wants — how long ago a feature
|
||||
happened — had to be worked out by subtraction.
|
||||
|
||||
Every mode now expresses x as *seconds from the thing the view is about*, and the rule is one
|
||||
line: `display_x = stream_t - ScopeModel::stream_at_x0()`, where `stream_at_x0()` is the stream
|
||||
time of display x = 0.
|
||||
|
||||
| display mode | x = 0 is | `stream_at_x0()` |
|
||||
| --- | --- | --- |
|
||||
| `Live`, `Waiting` | the newest acquired sample | the newest stamp |
|
||||
| `Capturing` | the accepted edge | the edge |
|
||||
| `Held` | the event's edge | the snapshot's `x_offset` |
|
||||
| `Paused` | the instant of the freeze | the snapshot's `x_offset` |
|
||||
|
||||
The live answer is written by the frame tick **after** `bind()` and `acquire()`, because it has to
|
||||
be minus the newest sample the *traces* can report: asking the model before they are bound puts
|
||||
the newest data a frame's production to the right of the axis, which at 1 MS/s over a 50 ms window
|
||||
is a third of the screen. It converges in exactly one frame and cannot oscillate — `live_edge()`
|
||||
reports in the epoch currently in force, so `new = old - live_edge()` is `-raw_newest` whatever the
|
||||
old value was.
|
||||
|
||||
A snapshot carries its own offset (`EventSnapshot::x_offset`) rather than leaning on the model's
|
||||
epoch, because its coordinates outlive the epoch that produced them. One consequence is a reversal
|
||||
of an earlier deliberate choice: **a pause snapshot is now rebased** like an event's. Leaving its
|
||||
stamps on the stream's clock would have thrown the frozen picture a whole uptime to the right of an
|
||||
axis that is now newest-relative. `x_offset` keeps the stream clock recoverable, which is what the
|
||||
export's second column reads — and it fixed a latent bug on the way, the export's snapshot branch
|
||||
having added `trigger_time()` to a *paused* view's stamps: an unrelated edge, or none at all before
|
||||
the first latch.
|
||||
|
||||
Two things move in **opposite** directions under a rolling axis, and the asymmetry is deliberate.
|
||||
A **cursor** stays on screen: placed at -1.5 s it keeps meaning "1.5 s ago" and the data flows
|
||||
under it, which is a bench scope's roll behaviour and is free here, the cursors already being
|
||||
stored in display coordinates. A **measurement mark** is glued to the sample it measured, because
|
||||
a mark is a *result* rather than an instrument — so the marks are stored in stream seconds and
|
||||
reconverted every frame (`place_measure_marks()`), which is what makes them drift left with their
|
||||
own data between recomputes. `App::measure_window` is on the stream's clock for a related reason:
|
||||
"has the window moved" is the question that triggers a recompute, and a fixed `[-window, 0]` makes
|
||||
it invisible in display coordinates, so the pane would have computed once and never again.
|
||||
|
||||
**Pause freezes the picture, not the acquisition.** The producer keeps writing the live ring
|
||||
while paused — the same thing it does when the trigger is off — and the plot binds a *snapshot*
|
||||
of the window that was on screen at the pause, with the axes pinned there. That is the same
|
||||
immutable-series path a held trigger event uses (`DisplayMode::Paused` sits beside `Held`); the
|
||||
difference is the anchoring: an event's stamps are edge-relative, a pause's are the stream's own,
|
||||
so the times on the axis do not change when the pause begins. Resuming drops the snapshot and
|
||||
immutable-series path a held trigger event uses (`DisplayMode::Paused` sits beside `Held`).
|
||||
Resuming drops the snapshot and
|
||||
re-frames on the newest data, so the seconds that passed while paused are *there*, not a hole —
|
||||
which is what pausing used to leave, because it stopped the producer outright.
|
||||
which is what pausing used to leave, because it stopped the producer outright. The status bar
|
||||
says how long it has been frozen (`paused · 10 kS/s · 2 s window · for 12 s`), in **wall**
|
||||
seconds: the producer never stopped, so the stream's own clock says nothing about how long the
|
||||
user has been looking at a still picture. It is written through `format_elapsed()`, which steps to
|
||||
a coarser unit instead of growing digits, because the status row sits directly above the plot and
|
||||
a field whose width tracks the wait would relayout it.
|
||||
|
||||
**Sub-second windows read relative.** The x ticks are absolute until the window's span goes
|
||||
under a second (with a band to 1.05 s, so the form cannot flicker while a zoom is nudged), and
|
||||
relative below it: the ticks are then generated on a grid anchored at the window's left edge —
|
||||
so they read `0`, `5ms`, `10ms` instead of `12.345671s`, `12.345672s`, which three significant
|
||||
digits cannot even tell apart — and a caption names that anchor in the axes' top-left corner:
|
||||
`t0 = 12.3456 s`. The labels scale through the prefixed units the SI formatter uses — `500ms`,
|
||||
`500us`, `5us`, `5ns` — so a microsecond window reads in microseconds instead of `0.005ms`. Adding the two is the tick's absolute time. The caption is an annotation
|
||||
inside the axes rather than the axis title on purpose: its text changes with the window, and a
|
||||
title of a changing width would relayout the plot every frame. Everything a user copies out —
|
||||
the cursors' `T0`/`T1`/`dT`, a measurement, an export — stays in absolute stream seconds.
|
||||
## Measurements
|
||||
|
||||
**The x ticks read relative when, and only when, they could not be written absolutely.** A
|
||||
`TickFormat::Time` label carries three significant digits, so the test is the tick **step**
|
||||
against the label's own **resolution at the axis' magnitude**: `12.345671s` and `12.345672s` are
|
||||
the same string, and there the ticks are re-anchored on a grid at the window's left edge — so
|
||||
they read `0`, `5ms`, `10ms` — with a caption naming that anchor, `t0 = 12.3456 s`. Adding the
|
||||
two is the tick's absolute time. The labels scale through the prefixed units the SI formatter
|
||||
uses — `500ms`, `500us`, `5us`, `5ns` — so a microsecond window reads in microseconds instead of
|
||||
`0.005ms`.
|
||||
|
||||
The rule used to be the *span* alone (relative under a second, with a band to 1.05 s), and a
|
||||
span says nothing about whether a label can be written: it was wrong in **both** directions. A
|
||||
2 s window at twenty minutes of uptime is labelled `20:00` on every tick — `m:ss` carries whole
|
||||
seconds — and was left absolute and unreadable. And a **triggered** sub-second window, whose
|
||||
axis is already `[-25ms, +25ms]` about the edge, reads `-20ms`, `-10ms`, `0`, `10ms` perfectly
|
||||
well, yet was re-anchored anyway: the one position a triggered axis exists to show was displaced
|
||||
by a `t0` caption that had nothing to explain. The step is read back from the majors the last
|
||||
layout produced (a function of the range and the axis width, not of the label form, so no
|
||||
feedback loop), the magnitude is the larger of the two endpoints, and the comparison carries a
|
||||
1.05 band leaving the relative form plus a ULP of slack entering it — a step *equal* to the
|
||||
resolution is readable (`1.73s`, `1.74s`, `1.75s`: one in the last digit is exactly enough), so a
|
||||
decision taken on the last bits of two differently computed doubles picked a different form from
|
||||
one frame to the next. The caption is **chrome** — `Plot::set_x_note()`, added to the plotter for
|
||||
it, drawn on the legend's own backplate in the bottom-left corner of the axes — rather than the
|
||||
axis title or an annotation. Not the title, because its text changes with the window and a title
|
||||
of a changing width would relayout the plot every frame; not an annotation, because an
|
||||
annotation's anchor is a *data* coordinate, so the caption crawled across the plot under a pan and
|
||||
drifted under a zoom, which is what it was reported as. The library test is
|
||||
`the_x_note_is_chrome_that_a_pan_or_a_zoom_does_not_move`: the footprint is measured as the pixels
|
||||
a frame with the caption differs from the same frame without one, and a pan to another decade plus
|
||||
a 64× zoom must produce the same box to the pixel. Everything a user reads off the screen is in
|
||||
the *display* coordinate the labels name — the cursors' `T0`/`T1`/`dT`, a measurement's time
|
||||
marks, the crosshair — so live they are negative ages and in trigger mode they are offsets from
|
||||
the edge. Only the **export** carries the stream's own clock as well, in a second column
|
||||
(`abs_seconds`); see *Export*.
|
||||
|
||||
The anchor is taken by the *axis* on every layout (`Axis::origin_follows_range`), not written
|
||||
by the app: the window's edge is derived from the newest sample during the layout, so a value
|
||||
@@ -118,17 +201,58 @@ 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
|
||||
with. The dialog's fields and their wording follow the type — the split row is absent for
|
||||
the plot) and the signal's chip, in the colour every other view names that signal with —
|
||||
through `RowSet::signal_color()`, so a signal the user re-coloured is re-coloured here too and
|
||||
not left at the palette entry. The dialog's fields and their wording follow the type — the split row is absent for
|
||||
`average`, `min` and `max`, the side row only for `pulse duration`, and the "show on plot"
|
||||
line says what would be drawn.
|
||||
|
||||
@@ -175,18 +299,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
|
||||
@@ -334,7 +460,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
|
||||
@@ -577,18 +704,25 @@ is what makes branching — the sum of a raw and a filtered channel — expressi
|
||||
|
||||
The editor is a compact node graph: each node is a box, its inputs are the squares on its left edge
|
||||
and its output the square on its right. Click a box to select it and its parameters appear in the
|
||||
form beside the canvas; click an output square and then an input square to wire them; drag a box to
|
||||
move it. The graph is a *list* walked in order, so a node can only read the ones above it — a cycle
|
||||
is not detected, it is unrepresentable, and an input square is only offered the nodes that qualify.
|
||||
The node carrying the channel's value is the **last one** of a graph built top to bottom (as the
|
||||
orange dot on the canvas says, and settable with *set as output*): it defaulted to the first node —
|
||||
the source — so every operation was computed and discarded, and a gain or a filter produced a
|
||||
channel identical to its input. Numeric fields are seeded with their own identity values too, since
|
||||
form beside the canvas; **drag from an output square to an input square to wire them** (the old
|
||||
two-click protocol is gone); drag a wired input square off its port to detach it; click a wire and
|
||||
press `Delete` to remove it; drag a box to move it. **Wheel zooms about the pointer, drag the
|
||||
background to pan, `f` to fit, `Ctrl+0` to reset.** *tidy*, a button in the editor dialog beside
|
||||
*fit*, lays out all nodes in topological columns. The op is chosen when the node is added — *add
|
||||
node* in the same row of the dialog is a menu button whose popover lists every op — so there is no
|
||||
opless placeholder to build past. The graph is a *list* walked in order, so a node can
|
||||
only read the ones above it; a wire drawn right to left re-sorts the list silently, so the graph
|
||||
stays evaluable without requiring the user to add nodes in order. A cycle is unrepresentable:
|
||||
`math_connect()` checks the trial edge set with Kahn's algorithm before writing anything. The node
|
||||
carrying the channel's value is the **last one** of a graph built top to bottom (as the orange dot
|
||||
on the canvas says, and settable with *set as output*): it defaulted to the first node — the source
|
||||
— so every operation was computed and discarded, and a gain or a filter produced a channel identical
|
||||
to its input. Numeric fields are seeded with their own identity values too, since
|
||||
`gtk_spin_button_new_with_range()` leaves a spin button at its range's minimum: the offset started
|
||||
at -1e6, which turned `x*2` into `x*2 - 2000000`.
|
||||
|
||||
Wiring is checked before the channel is created, and the reason is shown rather than the dialog
|
||||
closing on the work.
|
||||
A live validation line beneath the form says why the graph cannot be saved; Apply is insensitive
|
||||
while it is non-empty. The two modal alert boxes that used to appear are gone.
|
||||
|
||||
A `Source` node lists only the channels the scope is actually **acquiring**: naming a disabled
|
||||
signal would read as no data for ever, and the stream's timestamp signal has no samples at all. The
|
||||
@@ -848,14 +982,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
|
||||
@@ -921,13 +1150,21 @@ connection, the measured rate, the packet/frame/gap counters, what plot 1 holds
|
||||
the buffer's size and span. Three of those are worth calling out:
|
||||
|
||||
- **The badge blinks green at 1 Hz while the pipeline is actually running** — the
|
||||
stream is connected, acquisition is not paused, and in trigger mode the trigger is
|
||||
armed. Anything else leaves it a steady grey, with the reason on its tooltip
|
||||
(`paused`, `trigger disarmed`, `waiting for the stream`). *Blinking* is the signal,
|
||||
not the colour: a steady badge is a stopped pipeline, and that distinction survives
|
||||
a screenshot. It is one glyph whose colour is all that changes, so blinking it
|
||||
stream is connected, a CONFIG has arrived, acquisition is not paused, and in trigger
|
||||
mode the trigger is armed. Anything else leaves it steady, with the reason on its
|
||||
tooltip (`paused`, `trigger disarmed`, `waiting for the stream`). *Blinking* is the
|
||||
signal, not the colour: a steady badge is a stopped pipeline, and that distinction
|
||||
survives a screenshot. It is one glyph whose colour is all that changes, so blinking it
|
||||
cannot change its width — a badge that grew and shrank twice a second would move the
|
||||
label beside it and re-lay out the plot with it.
|
||||
There are two steady colours, and the difference is how far the handshake got.
|
||||
**Grey** is a socket with nothing behind it, or a pipeline the user stopped.
|
||||
**Yellow** is a source that answered CONNECT but has sent **no CONFIG**: there is no
|
||||
signal list, so nothing can be enabled and no DATA frame is owed. The glance line reads
|
||||
`connecting` in both, because neither has the signal list every other state word claims;
|
||||
the colour is what separates "no socket yet" from "a socket and no stream behind it". A
|
||||
blinking green over a source that has sent nothing would claim a working pipeline, which
|
||||
is the one reading the badge exists to prevent.
|
||||
- **`rate 9.99 kS/s` is the rate the buffer actually stored over the last second**, not
|
||||
the rate the CONFIG declared. The two agree unless the requested window is too long
|
||||
to hold at the wire rate, in which case the ingest resamples (see "A window longer
|
||||
@@ -967,6 +1204,20 @@ shape and the column of chips reads as a legend. Because `plot::Plot` fixes a tr
|
||||
colour at `add_trace()`, the index is stored on the row (`Row::color_index`) and a
|
||||
colour change goes through a figure rebuild, which a drop already forces.
|
||||
|
||||
**Clicking the chip of a signal that is on a plot opens a colour chooser** (`GtkColorDialog`,
|
||||
with the alpha slider off — a trace is stroked opaque, so a transparency setting would do
|
||||
nothing), and the colour it returns *overrides* the palette entry for that signal. The chip of
|
||||
a signal on no plot is not clickable: a grey chip names no trace, so there is nothing to
|
||||
recolour. A chosen colour is held by signal **name** (`RowSet::set_signal_color()`), saved in
|
||||
the session file's `[[color]]` tables and resolved — for the trace and for every chip — by the
|
||||
one call `RowSet::signal_color()`, so a chip cannot claim a colour the trace is not drawn in.
|
||||
The way back is **Default colour** in the signal's `⋯` menu, sensitive only while an override
|
||||
exists; it is there rather than on the chip because a second gesture on a one-glyph target is
|
||||
not something anybody would find. The chooser is opened by a `GtkGestureClick` on the existing
|
||||
label rather than by wrapping it in a `GtkButton`, because a button's padding is exactly what
|
||||
made this row twice as tall once (below), and the gesture claims its sequence so a click on the
|
||||
chip cannot become the start of a row drag.
|
||||
|
||||
A `GtkSwitch` and two
|
||||
always-visible buttons made a row about twice as tall (46 px against 26 px now),
|
||||
which is what turned a four-signal stream into a panel you have to scroll; Adwaita's
|
||||
@@ -983,9 +1234,9 @@ that changes nothing still says why. Each plot's y axis has a right-click menu
|
||||
with auto scale, a fixed range, **Fit X axis**, **Fit all**, and one
|
||||
**Remove '<signal>' from plot N** entry per occupied trace slot (a plot with nothing on
|
||||
it says so instead). The two fits are the same pair the overflow menu and the `f` key
|
||||
offer, split by what they touch: **Fit X axis** frames the window on the data the ring
|
||||
holds and leaves every y scale exactly as the user set it, **Fit all** does that and
|
||||
auto-scales the y axes too.
|
||||
offer, split by what they touch: **Fit X axis** puts the x view back on the whole time
|
||||
window and leaves every y scale exactly as the user set it, **Fit all** does that and
|
||||
auto-scales the y axes too. Neither changes the time window itself — see below.
|
||||
|
||||
**The plot does not twitch.** Two independent causes were fixed, both of which
|
||||
showed up as the x axis moving a few pixels every frame — including while paused,
|
||||
@@ -1001,13 +1252,40 @@ which is how the second one was found:
|
||||
what its labels need, so a floor written onto the rows would survive exactly
|
||||
until the next layout. The figure seeds its common gutter — the maximum over
|
||||
the rows, which is also what aligns their axes — with that reservation.
|
||||
The reservation is the widest label `format_si()` writes for an axis with no
|
||||
unit (a sign, three significant figures and a one-glyph prefix), not the
|
||||
wider `%.3g` fallback it used to cover: that form needs a value past 1e24, so
|
||||
it cost a third of the gutter for a label no real signal produces — and since
|
||||
the floor is a monotone maximum over the *measured* need, one that did appear
|
||||
would widen the gutter for good anyway.
|
||||
2. *The status line drove the pane width.* A `GtkLabel`'s minimum width is its
|
||||
text, and the status line is rewritten every frame; as its counters and
|
||||
messages changed width they moved the paned divider, which resized the plot,
|
||||
which re-laid out the axes and their ticks. The label is now ellipsized, so its
|
||||
minimum is “…”; the plot keeps the width it was given.
|
||||
3. *The gutter was a ratchet.* `note_y_gutter()` is handed the gutter the row was
|
||||
**laid out with** — already the maximum of its labels and the floor imposed on
|
||||
the frame before — and it added six pixels of slack to it. Those six pixels came
|
||||
back next frame and were added again, so the left margin climbed a decade of
|
||||
pixels a second until it hit the 200 px cap and stopped: 200 px of an 870 px
|
||||
viewport, with `axes.w changes: 17` beside it, which reads as a settle and is a
|
||||
runaway that ran out of room. The reservation's own padding is the slack, once;
|
||||
nothing is added to a measurement that is partly our own answer.
|
||||
|
||||
The self-check reports both. `viewport.w` must collapse to a single value, and
|
||||
**The two sides are balanced.** The axes rect used to reach the viewport's right
|
||||
edge, because that is all the library's layout had ever reserved — so the
|
||||
rightmost x tick label, *centred* on its tick, had half of itself outside the plot
|
||||
(a live axis is `[-window, 0]`, so there is always a tick exactly on that edge) and
|
||||
the newest sample's ink landed on the frame. `Plot::set_right_gutter()` and
|
||||
`Figure::set_right_gutter()` were added upstream for this: an empty band, served
|
||||
from the same half-viewport allowance as the label and title strips, set once on
|
||||
every row because the rows' axes have to end on a common column as well as start on
|
||||
one. `RowSet` asks for half the widest time label plus the label padding, which is
|
||||
exactly what the last label needs to be drawn whole. With the two fixes above the
|
||||
figure now sits in a 104 px left gutter holding the y labels and a 59 px empty band
|
||||
on the right, where it was 200 px and nothing.
|
||||
|
||||
The self-check reports all of it. `viewport.w` must collapse to a single value, and
|
||||
`axes.x`, `axes.w` and `y-gutter` must settle: the one-shot label reservation
|
||||
lands a few frames after the first measured frame, and because the figure serves
|
||||
its floor out of the label strip the reservation converges over two layouts, so
|
||||
@@ -1018,9 +1296,9 @@ bug. `x-ticks` is content, and varies by one as a live window slides over a roun
|
||||
number:
|
||||
|
||||
```
|
||||
layout over 952 frames: axes.x 67..80, axes.w 546..559, viewport.w 627..627,
|
||||
y-gutter 44..74, x-ticks 20..21, x-desync 0
|
||||
axes.w changes: 2 (last at frame 35, side panel up=1)
|
||||
layout over 583 frames: axes.x 88..104, axes.w 707..782, viewport.w 870..870,
|
||||
y-gutter 88..104, x-ticks 20..41, x-desync 0
|
||||
axes.w changes: 1 (last at frame 1, side panel up=1)
|
||||
```
|
||||
|
||||
Set `UDPSCOPE_PROBE_PAUSE_MS=<ms>` to pause the acquisition part-way through a
|
||||
@@ -1086,6 +1364,16 @@ A third, found the same way, is in the tick generator rather than the shaders:
|
||||
— where the same residue read `3.19e-13ms`. A range whose ticks are *legitimately*
|
||||
that small keeps them.
|
||||
|
||||
4. *One second was labelled `1e+3ms`.* `format_time()` picked its rung by comparing the raw
|
||||
value against each unit's scale and then wrote the mantissa with `%.3g` — so a tick a hair
|
||||
under a rung's top (a 1 s tick computed as 0.9999999 s, which is what differencing a live
|
||||
axis gives) stayed on the rung below and its mantissa *rounded up to 1000*, which `%.3g`
|
||||
writes in exponential form. The fix is to pick the rung by the **rounded** mantissa
|
||||
(`t < u.scale * 0.9995`), which is exactly the `>= 999.5` spill guard `format_si()` in the
|
||||
same file already had; the whole-second branch takes the same slack. The test is
|
||||
`format_time_never_lets_a_field_reach_its_radix`, extended with the boundary of every rung
|
||||
from seconds down to nanoseconds and its negative.
|
||||
|
||||
**The minor grid is texture, the labelled grid is structure.** The subdivisions used to be
|
||||
drawn exactly like the labelled lines — same width, same opacity — so a plot dense enough to
|
||||
need them read as two competing sets of ticks. They are now three things the style states
|
||||
@@ -1112,6 +1400,7 @@ so it is one path, and it costs what the crosshair's own lookup costs.
|
||||
|
||||
Each carries a test upstream (`the_atlas_is_filtered_not_point_sampled`,
|
||||
`grid_hairlines_land_on_whole_pixels`, `the_tick_at_the_origin_reads_zero`,
|
||||
`format_time_never_lets_a_field_reach_its_radix`,
|
||||
`a_sparse_window_between_samples_still_inks_both_margins`), and each
|
||||
was verified to fail with its own fix reverted.
|
||||
|
||||
@@ -1139,10 +1428,64 @@ is what made a dragged cursor spring to where it had been. A ruler is per row: e
|
||||
two different values, and the pane names the row.
|
||||
|
||||
`mix` needs no rulers: it reports the value the trace actually has at each marker, read
|
||||
through the library's `Plot::crosshair_value()` — the two bracketing samples,
|
||||
interpolated — so it is the answer to "what is this signal at these two times" rather
|
||||
than "drag a ruler until it crosses the trace". A readout is `--` when the trace has
|
||||
nothing at that time and `partial` when only one of the two markers is over samples.
|
||||
through the library's `Plot::crosshair_value()` — **the nearer of the two bracketing
|
||||
samples**, never a number between them — so it is the answer to "which datum of this
|
||||
signal is at these two times" rather than "drag a ruler until it crosses the trace". A
|
||||
readout is `--` when the trace has nothing at that time and `partial` when only one of
|
||||
the two markers is over samples.
|
||||
|
||||
The value used to be a linear interpolation between the brackets, which is upstream's
|
||||
old convention and was wrong for this app in two ways at once. The samples here are
|
||||
**already averaged** — ingest decimation is a boxcar over `time_stride` samples (see
|
||||
"Decimation at ingest is an average") — so a number computed *between* two of them is a
|
||||
second, undeclared estimate laid over the first, and nothing on screen or in an export
|
||||
holds it. And at a zoom past the sample spacing it is visibly not the datum the pointer
|
||||
is over: the polyline between two samples is a straight line the renderer drew, and
|
||||
quoting a point on it reports the drawing rather than the data. A tie reads the earlier
|
||||
sample. The fix is upstream (`deps/plotter`, `crosshair_span()`'s `t` is a selector rather
|
||||
than a blend factor), so the library's own hover crosshair changed with the pane — which is
|
||||
the point, since both are the same lookup and a user comparing them must not see two
|
||||
numbers. Upstream keeps the blend as `CrosshairValue::Interpolated` for hosts whose x is a
|
||||
quantity they are willing to interpolate; the nearest sample is the default and udpscope
|
||||
never states the other.
|
||||
|
||||
**The crossing dot is on the sample, not on the marker's column.** The dot is placed
|
||||
through `Plot::crosshair_point()` — the same lookup as the value, returning the quoted
|
||||
sample's own x as well as its y — so the mark the eye follows and the number in the pane
|
||||
are the same datum. The two x's differ by up to half a sample period: nothing at a
|
||||
whole-window view, and the whole width of the panel once a few samples span the axes, which
|
||||
is exactly the state in which a reader is asking "which point is that?". It therefore leaves
|
||||
the dot a little off its own vertical line by design — the line says where the *marker* is
|
||||
and the dot says which *datum* is being quoted, and when the marker is between two samples
|
||||
those are two places. The library's hover crosshair snaps the same way and draws its mark
|
||||
`Style::crosshair_marker_px` across (9 logical px against a trace's own 3 px sample dots),
|
||||
since a snapped mark lands among those dots and at their size would read as one more sample
|
||||
instead of as the answer to the question the hover asks.
|
||||
|
||||
**A zoomed-in trace is drawn on the x its readout quotes, which needed the gather's origin to
|
||||
become the caller's.** Separately from the half-sample offset above — which is a property of
|
||||
*which datum* is quoted — there was an offset in *where the polyline was drawn*, and it grew
|
||||
with the zoom. `plot::data::Series::gather()` stages x as `f32`, which cannot hold an absolute
|
||||
epoch time, so it emits a value relative to an origin the consumer adds back in `f64`. That
|
||||
origin was fixed at `x0()`, the start of the **retained window**: the f32 then had to resolve
|
||||
2^-24 of the whole ring however little of it was on screen — 1.9 µs over 20 s of retained data
|
||||
— while a readout reads `x_at()`, exact `f64`. At a view 10 µs wide across a 716 px axis
|
||||
(14 ns a pixel) that is over a hundred pixels between the drawn sample and the x reported for
|
||||
it, and it halves with every zoom *out*, which is why it read as "zooming in makes the
|
||||
crosshair pick the wrong point".
|
||||
|
||||
The origin is now an argument, and `Renderer::draw_traces` passes the **visible range's
|
||||
minimum** — so the quantisation is 2^-24 of the span on screen, a fixed 4e-5 of a pixel, at
|
||||
every zoom. The fix is upstream (`deps/plotter`), where the parameter was deliberately placed
|
||||
*before* the output buffer so that every one of the library's and this app's call sites was a
|
||||
compile error rather than a silent change of behaviour. One simplification fell out of it:
|
||||
`stage_xy()` took a gather origin and a draw origin, those are now always the same value, and
|
||||
the linear case hands the gathered buffer straight to the line batch with no per-point copy.
|
||||
Two guards: the library's
|
||||
`a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes`, which measures the
|
||||
error in pixels both ways (under 0.05 px with the view's minimum, over 10 px with `x0()`), and
|
||||
this app's `trace ink:` line, which now reports `worst drawn-vs-quoted P px` over the raw-level
|
||||
paints of a whole run.
|
||||
|
||||
The readouts are a **table**: a header naming the columns, then one row per instrument —
|
||||
`| who | A | B | delta |`. The first row is the two times and their difference, and the
|
||||
@@ -1156,7 +1499,16 @@ Every readout is a **measurement**, not a raw number: at most two decimals, scal
|
||||
SI prefix so the digits sit in `1..999`, and the unit the stream declared appended —
|
||||
`10.52 mV`, `-9.8 V`, `dT -398.67 ms`, `1.06 pV`. Two decimals is the resolution the
|
||||
instruments can claim (a marker lands on a sample, and the sample is an `f32`), and a
|
||||
trailing zero is dropped, so `1.50 V` reads `1.5 V`. A row's rulers use its signals' unit
|
||||
trailing zero is dropped, so `1.50 V` reads `1.5 V`. The prefix is applied **whether or not
|
||||
the stream declared a unit**, and that is not a detail: a signal whose CONFIG declares none is
|
||||
ordinary, a *math* channel never has one, and two decimals of an unprefixed small value is
|
||||
`0.00` — the reading was simply gone for an entire class of channels, which went unnoticed
|
||||
because the mock declares `V` for everything (it now takes `--unit ''`, which is the only way
|
||||
a probe can see this). With no unit the prefix is written *tight* against the number —
|
||||
`585.24m`, not `585.24 m` — because the space is there to separate the number from its unit,
|
||||
and a spaced `m` with nothing after it reads as metres. Below `1e-12`, where no prefix can
|
||||
help, the value falls back to three significant digits in whatever form fits rather than to
|
||||
`0.00`. A row's rulers use its signals' unit
|
||||
when they agree on one and print bare digits when they do not: a ruler is on the row's y
|
||||
axis, and two units on one axis make "the" unit a guess. A trace's line carries the
|
||||
signal's **colour chip and its name** — `● sine 616.49 mV -24.57 mV -641.06 mV` — and the
|
||||
@@ -1247,10 +1599,24 @@ logged with both rates and the new anti-alias corner. A window change that leave
|
||||
limitations below); only the shared clock is decimated.
|
||||
|
||||
**Fit to data** (overflow menu, the `f` key, or **Fit all** in a plot's right-click menu)
|
||||
auto-scales every row's y axis and re-frames the x window on the data the ring holds,
|
||||
propagated to every row. **Fit X axis** is the same re-framing without the y scaling, for
|
||||
the common case where the window should be the data's but the levels were placed by hand.
|
||||
The status line acknowledges either (`x fitted to the buffered data (1.25 s)`).
|
||||
auto-scales every row's y axis to the data it holds and puts the x view back on the whole time
|
||||
window, propagated to every row. **Fit X axis** is that x half alone, for the common case where
|
||||
a zoom should be undone but the levels were placed by hand. The status line acknowledges either
|
||||
(`x back to the 2 s window`).
|
||||
|
||||
**Nothing you do on the plot changes the time window** — only the window control does. A fit is
|
||||
the *undo of a zoom*: a 2 s window zoomed into reads 2 s again. That matters because the window
|
||||
is not just the axis. It is also the trigger's event span, how the samples are stored (a window
|
||||
long enough to move the decimation re-plans the rings and **drops the history**), the control's
|
||||
readout and what the session file records — so a wheel notch or a Fit that redefined it would
|
||||
quietly throw the buffer away. A zoom and a pan are view-only by construction; the fits read the
|
||||
window and never write it.
|
||||
|
||||
Which 2 s depends on the state, and the fit restores the whole of whatever the view is allowed to
|
||||
show: live, the newest window at the live edge; and for a held event or a paused view, the pin's
|
||||
full range — the snapshot's own span, which is legitimately *narrower* than the requested window
|
||||
when the ring had not filled it. A fit of a frozen view keeps it frozen rather than throwing it
|
||||
back onto the stream.
|
||||
|
||||
**Shared x axis.** The rows are the rows of **one `plot::Figure`**, drawn by one
|
||||
`plot::gtk4::PlotArea` in figure mode, and they all join a single x link group.
|
||||
@@ -1260,7 +1626,7 @@ them all with nothing to propagate by hand. The live follow is the *group's*
|
||||
(`set_x_link_follow`), not each row's — `Figure::link_x()` refuses a member whose
|
||||
own axis still follows, because a following axis would reinterpret the pushed
|
||||
range as its own span and offset behind its own edge, which is per-row drift by
|
||||
construction. The figure also owns the left gutter and the crosshair, so a
|
||||
construction. The figure also owns both gutters and the crosshair, so a
|
||||
vertical feature lands in the same pixel column in every row.
|
||||
|
||||
The self-check's layout line carries `x-desync N`, the number of frames in which
|
||||
@@ -1311,16 +1677,30 @@ division.
|
||||
over the shared x window, at the **stored** sample rate — never the LOD pyramid's
|
||||
buckets, which are the envelope the renderer drew rather than the acquisition. A
|
||||
signal on several rows is one column; the columns come out in channel order, so the
|
||||
file reads in the same order as the signal panel. The table has two time columns:
|
||||
`time` is the axis as displayed (stream seconds live, `[-pre, +post]` while an event
|
||||
is held) and `stream_time` is always the stream's own clock, so two exports taken in
|
||||
different modes line up.
|
||||
file reads in the same order as the signal panel.
|
||||
|
||||
The table has **two time columns**, which is the whole answer to "relative or absolute":
|
||||
|
||||
- **`time`** is the axis exactly as it was displayed — negative ages ending at 0 live, offsets
|
||||
from the edge in trigger mode — so a row's `time` is the x the user read a value at.
|
||||
- **`abs_seconds`** is the stream's own clock for the same sample: `epoch() + stream_at_x0() +
|
||||
time[i]`, one expression correct in all four display modes (see *The x axis is relative to
|
||||
now*). Two exports taken in different modes therefore line up on this column, and
|
||||
`abs - time` is a constant per file naming where the display's origin sat in the stream.
|
||||
|
||||
Both are `f64` seconds and not the wire's integer, because the wire's integer never exists in
|
||||
this process: `udps_frame_t::values` is a `const double*`, so the time signal has already been
|
||||
widened by the client before udpscope sees it. One caveat follows from that and from `time`
|
||||
being a *derived* coordinate: `abs_seconds` is a sum of two numbers of very different
|
||||
magnitudes, so consecutive differences wobble by a few ULP of the stream's uptime
|
||||
(femtoseconds at any plausible uptime — note that the `f32` defect this used to guard against
|
||||
was *microseconds*, four orders of magnitude up).
|
||||
|
||||
Two formats, picked by the extension the save dialog ends up with:
|
||||
|
||||
| file | what it is |
|
||||
|---|---|
|
||||
| `.npy` | one NumPy structured array, `time`/`stream_time` as `<f8` and every signal as `<f4` — the width the ring stores, so the file invents no precision and is about half an all-`f8` one. `pd.DataFrame(np.load(path))` is the whole reader. |
|
||||
| `.npy` | one NumPy structured array, `time`/`abs_seconds` as `<f8` and every signal as `<f4` — the width the ring stores, so the file invents no precision and is about half an all-`f8` one. `pd.DataFrame(np.load(path))` is the whole reader. |
|
||||
| `.csv` | the same table as text, `%.17g` for the times and `%.9g` for the values: the shortest widths that round-trip each type exactly. |
|
||||
|
||||
Parquet (the original ask) would have meant linking Arrow — tens of megabytes of
|
||||
@@ -1389,7 +1769,8 @@ fails validation renders a one-line stand-in so the window always opens rather t
|
||||
no `Gtk-CRITICAL`.
|
||||
|
||||
**The status bar is two tiers.** The bar itself shows one glance line: the activity badge, the
|
||||
display state (one word from a fixed set), the stored rate and the window. Everything else —
|
||||
display state (one word from a fixed set — `connecting`, `live`, `paused`, `armed`, `capturing`,
|
||||
`event held`, `disarmed`, none wider than ten characters), the stored rate and the window. Everything else —
|
||||
packet and frame counts, gaps, buffer size and span, per-plot trace counts — is behind the ⓘ
|
||||
button at the bar's right-hand end, opening a popover. The two moved there because a bar that
|
||||
carried all of them was too wide to read while watching a signal, and because the bar sits
|
||||
@@ -1410,7 +1791,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
|
||||
@@ -1474,6 +1864,17 @@ status line as `N reconnects` once it is nonzero) it says so in the status line
|
||||
leaving an empty plot to be puzzled over, and prints a `starved:` line to stderr while the
|
||||
state holds.
|
||||
|
||||
That notice is **split on whether a CONFIG ever arrived**, because the two states are
|
||||
different diagnoses and only one of them is about another reader. With a signal list in
|
||||
hand, a stream that stops is the fight above: something took the session away, and the
|
||||
notice says so (`starved:` on stderr). With **no CONFIG at all** nothing was ever taken —
|
||||
there is no signal list, so nothing can be enabled and no DATA frame is owed — and the app
|
||||
says `trying to connect` instead (`connecting:` on stderr), with the glance line reading
|
||||
`connecting` and the badge yellow from the first frame rather than after the three-second
|
||||
stall test. Calling that one "connected, but no data is arriving" blamed a second reader
|
||||
that does not exist; the usual cause is a streamer that is not running, or one serving a
|
||||
different port.
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Multi-element (vector/matrix) signals are acquired with a zero-order hold.**
|
||||
|
||||
Vendored
+1
-1
Submodule deps/plotter updated: 26a7c82a80...cc908efa36.
@@ -0,0 +1,118 @@
|
||||
# Architecture: threads, the data path and the layers
|
||||
|
||||
The two threads and what they share, the four display modes, the display clock, and the layers to keep straight.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md).
|
||||
|
||||
`SPECS.md` is the requirement document. `README.md` documents what was actually built,
|
||||
including deliberate deviations from the spec and known limitations — read it before
|
||||
concluding something is a bug.
|
||||
|
||||
### Threads
|
||||
|
||||
Exactly two: a producer thread in `Acquisition` owns `udps_client_t` and calls
|
||||
`udps_client_poll()`; the GTK main thread owns every widget and GL context. They share
|
||||
`ScopeModel` under `ScopeModel::mutex()`, taken once per DATA frame by the producer and
|
||||
once per frame by the UI (`tick()` in `src/ui.cpp`) around `RowSet::bind()`/`acquire()`.
|
||||
Samples themselves cross via `plot::data::RingBuffer` (single producer, single
|
||||
consumer) and never through the lock. Cross-thread scalars are atomics on `ScopeModel`
|
||||
(`trigger_enabled_`, `trigger_armed_`, `trigger_latches_`, `trigger_time_`,
|
||||
`event_`, `generation_`). Two of them are a pair: the producer writes `capture_edge_`
|
||||
before `trigger_pending_` and those two carry release/acquire, so a consumer that sees a
|
||||
capture open sees which edge it belongs to.
|
||||
|
||||
### Data path
|
||||
|
||||
`ScopeModel` holds **one `BufferStore`** (`live()`): one shared time-base ring plus one
|
||||
value ring per channel, sized by `plan_partition()` to cover `--max-window` seconds
|
||||
within the byte budget. The producer always writes it, whatever the trigger is doing —
|
||||
that is what guarantees a full pre-trigger history on the *first* latch after arming.
|
||||
The spec's second buffer (item 1) is the latched `EventSnapshot`, which is already an
|
||||
immutable copy of the captured window; see README for why two live rings were dropped.
|
||||
|
||||
What a trace binds to is `ScopeModel::display_mode()`, one of four states, and
|
||||
`RowSet::bind()` takes it as an argument rather than recomputing it:
|
||||
|
||||
- `Live` — no trigger: the live ring, on the clock of the **newest sample**
|
||||
(`display_epoch() == -newest`), so the axis is a fixed `[-window, 0]` and x reads as an age.
|
||||
- `Capturing` — an edge has been accepted (so the pre window was full) and the post
|
||||
window is still filling: the live ring, on the coming event's own clock
|
||||
(`display_epoch() == -edge`), inside an axis already pinned to `[-pre, +post]`. The
|
||||
post side fills in view and the marker sits at x = 0 throughout.
|
||||
- `Held` — the frozen `EventSnapshot` of the last event, whose stamps are already edge-relative,
|
||||
so the display epoch is 0.
|
||||
- `Waiting` — armed, nothing captured yet: blank, with the live epoch (there is nothing bound,
|
||||
but the axis is still a live one).
|
||||
|
||||
**Disarm during a capture publishes what the capture got to**, then disarms: the producer
|
||||
cuts the window at the newest sample and that partial snapshot becomes the held event, so
|
||||
the picture on screen at the moment of the Disarm stays on screen. `engine.disarm()` clears
|
||||
`pending_`, which drops the display out of `Capturing`, and with nothing published the view
|
||||
fell back to the *previous* event — or to blank on a first capture. No event fired, so
|
||||
`trigger_latches()` is deliberately not bumped; `ScopeModel::event_seq()` counts published
|
||||
snapshots instead, and the frame tick keys its event framing on *that*. Keep the two
|
||||
distinct: the latch count is what the probes assert "how many events fired" with.
|
||||
|
||||
`Capturing` is a **deliberate departure from SPECS:37** ("data visualisation should not
|
||||
be refreshed till next event"), asked for explicitly; that reading is what the old "never
|
||||
the live ring mid-capture" rule enforced. What survives of the rule is that `Held` binds
|
||||
the snapshot *only* — a held event is never quietly replaced by live data — and that the
|
||||
*axis* does not move during a capture: it is the event's window from the edge onwards,
|
||||
never a sliding live view.
|
||||
|
||||
`display_mode()` must be read **once per frame** and passed around (the tick does this,
|
||||
and hands it to `bind()`). The producer can open or close a capture between any two
|
||||
reads, and two reads in one frame put the bind, the epoch, the pin and the status line on
|
||||
different clocks.
|
||||
|
||||
**The display clock is not the stream clock, and `ScopeModel::stream_at_x0()` is the only
|
||||
conversion.** `display_x = stream_t - stream_at_x0()` in **every** mode, and the offset lives in
|
||||
one of two places: a snapshot carries its own (`EventSnapshot::x_offset`, frozen with the
|
||||
picture) and a live view carries it as minus `display_epoch()`. Anything crossing between the
|
||||
two clocks goes through that one accessor rather than picking the branch itself — the export's
|
||||
`abs_seconds` column, the pause handler putting a display window back on the ring to cut it, the
|
||||
measurement marks — because there are four modes and picking the branch by hand gets one of them
|
||||
wrong. It reads atomics only, so it is safe to call inside an existing `mutex()` guard.
|
||||
Three things about the live epoch specifically:
|
||||
- **It is written after `bind()` and `acquire_all()`, not before.** It must be minus the newest
|
||||
sample the *traces* can report, and asking before the acquire is one frame of production
|
||||
stale — at 1 MS/s over a 50 ms window that is a third of the screen to the right of the axis.
|
||||
- **There is no feedback loop**, even though the newest stamp is read through the epoch that is
|
||||
about to change: `RowSet::live_edge()` reports in the epoch currently in force, so
|
||||
`display_epoch() - live_edge()` is minus the *raw* stamp and the fixed point is reached in one
|
||||
frame rather than converged on.
|
||||
- **`Waiting` takes the live epoch too.** Nothing is bound, but the axis is still a live one, and
|
||||
an epoch of 0 there would make the axis jump on the frame a capture closes.
|
||||
|
||||
**A measurement mark is glued to its sample; a cursor stays on screen.** The two instruments move
|
||||
in opposite directions under a rolling axis and that is deliberate — a bench scope's cursors sit
|
||||
where the user put them while the waveform rolls under, and a measurement's mark names the sample
|
||||
it measured. The cursors need no code: their x *is* a display coordinate. The marks are stored in
|
||||
**stream** seconds and reconverted every frame by `place_measure_marks()`, since a mark left in
|
||||
display coordinates would crawl forward at one window per window. `App::measure_window` is
|
||||
stream-clocked for the same reason, and that one is load-bearing: the recompute trigger is "the
|
||||
window moved by 2 % of its span", and in display coordinates a live window is a fixed
|
||||
`[-window, 0]` — it never moves, so the measurements would never be recomputed again.
|
||||
|
||||
Layers to keep straight:
|
||||
|
||||
- `SignalInfo` — one signal as the CONFIG described it. The unit of enable/disable.
|
||||
- `ChannelInfo` / `ChannelBuffer` — the unit of acquisition and plotting. A
|
||||
`ChannelBuffer` is a `plot::data::Series` over a (value, time) ring pair.
|
||||
- `SlotSeries` (`src/channel.hpp`) — a trace's stable series object, rebound each frame
|
||||
to either a `ChannelBuffer` or an `EventSeries`.
|
||||
- `RowSet` (`src/plot_host.hpp`) — **one `plot::Figure`** of up to `kMaxRows` rows (added
|
||||
and removed one at a time by the layout editor), each a
|
||||
borrowed `plot::Plot*` with `kSlotsPerRow` fixed trace slots. The figure owns
|
||||
everything the rows have to agree on: the x window (one x link group every row joins),
|
||||
the live follow (the *group's* — a member's own axis must be `XFollow::Off`, since
|
||||
`Figure::link_x()` refuses a `Live` member), the left gutter and the crosshair.
|
||||
|
||||
- `ExportTable` (`src/export.hpp`) — the visible window as columns, collected from the
|
||||
snapshot or the live rings and handed to the `.npy`/`.csv` writers. Always the stored
|
||||
samples, never the LOD pyramid's buckets.
|
||||
|
||||
`src/ui.cpp` is the GTK4 window (toolbar, trigger panel, signal panel, plot box) plus
|
||||
the `tick()` frame function that binds, updates the trigger state and rewrites the
|
||||
status line. One `plot::gtk4::PlotArea` in figure mode draws the whole figure. It is by
|
||||
far the largest file.
|
||||
@@ -0,0 +1,102 @@
|
||||
# Build and run
|
||||
|
||||
Configuring, building and launching udpscope, the desktop entry target, the command-line options and the two vendored dependencies.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md).
|
||||
|
||||
## Build and run
|
||||
|
||||
```sh
|
||||
git submodule update --init --recursive # deps/plotter is a submodule
|
||||
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo
|
||||
cmake --build build
|
||||
|
||||
./build/udpscope --server 127.0.0.1 --port 8000
|
||||
./build/udpscope --server 10.0.0.5 --port 8000 --multicast 239.1.2.3 --interface 10.0.0.9
|
||||
```
|
||||
|
||||
The app can be launched from the shell rather than only from a terminal, but only on request:
|
||||
|
||||
```sh
|
||||
cmake --build build --target desktop-entry
|
||||
```
|
||||
|
||||
That writes `org.udpscope.app.desktop` plus both app icons under `$XDG_DATA_HOME` (default
|
||||
`~/.local/share`), runs `desktop-file-validate` as a **hard failure** (when it is on `PATH`;
|
||||
absent, it says so and installs anyway), refreshes `update-desktop-database` and
|
||||
`gtk4-update-icon-cache` when they are on `PATH`, and prints the three paths it wrote — which is
|
||||
the uninstall instructions, since there is no uninstall target. `cmake --build build` alone never
|
||||
touches `$XDG_DATA_HOME`.
|
||||
|
||||
The entry's `Exec` is the built binary's **absolute path in the build tree**, so **moving or
|
||||
deleting the build directory breaks it.** That is the price of having no install step and it is
|
||||
deliberate: the entry then always launches exactly the binary that was last built. No
|
||||
`StartupWMClass` is needed — GTK takes the WM class from the application ID, and the desktop
|
||||
file's basename is that same string, which is what associates a running window with the entry.
|
||||
|
||||
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per
|
||||
buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row
|
||||
count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards).
|
||||
|
||||
Run with no `--server` and udpscope asks which source to open: the sessions it has stored, plus a
|
||||
form for a new host. Every probe below passes `--server`, so none of them sees it.
|
||||
|
||||
`deps/plotter` is built from source with only the GTK4 adapter enabled
|
||||
(`PLOT_BUILD_GTK4`); its tests and examples are forced off. `deps/udpsclient` is a
|
||||
vendored C99 source pair compiled into a static lib.
|
||||
|
||||
**libadwaita ≥ 1.8 is a system dependency** (`pkg_check_modules(ADWAITA ... libadwaita-1>=1.8)`),
|
||||
for two widgets: the shortcut overview (`AdwShortcutsDialog`) and the About dialog
|
||||
(`AdwAboutDialog`). GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and
|
||||
`AdwShortcutsDialog` is its replacement; `GtkAboutDialog` still exists but
|
||||
`adw_about_window_new()` is deprecated in favour of `AdwAboutDialog`, which shows the
|
||||
application icon as the page's own header. Two consequences worth knowing before editing.
|
||||
`adw_init()` is called — in `run_ui()` and, since the chooser runs first and must be styled
|
||||
the same, in `choose_source()` in place of its `gtk_init()` (both are idempotent) — so
|
||||
**the whole app is drawn with libadwaita's stylesheet**, not GTK's: a metric change there
|
||||
reaches every widget, and `layout over N frames` / `axes.w changes` is what says whether
|
||||
the layout still settles. And an `AdwDialog` is **not** a `GtkWindow`: both `show_about()`
|
||||
and `show_shortcuts()` keep an `AdwDialog*`, cleared on `::closed` (there is no
|
||||
`::close-request`) and closed with `adw_dialog_close()`. They share one `on_dialog_closed()`
|
||||
handler keyed by a pointer-to-pointer (`gpointer slot` cast to `AdwDialog**`), so one
|
||||
handler clears either pointer without knowing which dialog it is. Presenting either on this
|
||||
app's plain `GtkApplicationWindow` is supported and warning-free; it was checked.
|
||||
|
||||
`deps/plotter` is upstream's own repository and this app's figure support was added
|
||||
there rather than worked around here: `Figure::set_min_y_gutter()` (a floor under the
|
||||
common left gutter), `Figure::set_right_gutter()` / `Plot::set_right_gutter()` (an empty
|
||||
band on the *other* side, which the layout had no concept of — `axes` reached the
|
||||
viewport's right edge, so the rightmost x tick label was centred on a line it had half of
|
||||
itself outside of; the guard is `figure_rows_share_one_right_gutter`), and in the GTK4
|
||||
adapter `PlotArea`'s **figure mode** —
|
||||
`set_figure()`, one `input::Controller` per row, nearest-row event routing, a
|
||||
figure-wide crosshair — plus `PlotArea::row_at()` for naming the row a pointer is over.
|
||||
|
||||
The render-quality work went there for the same reason: `kTextFS` now **filters** the
|
||||
glyph atlas (a 2×2 bilinear clamped to the instance's own cell) instead of point-sampling
|
||||
it, and `LineProgram::set_snap()` / `u_snap` rounds an axis-aligned segment's ink onto
|
||||
whole pixels for the chrome that frames a plot (grid and axis lines), never for anything
|
||||
drawn at a data position. `generate_ticks()` also stores the tick at the origin as
|
||||
exactly `0.0` — the walk starts at `ceil(min/stride)*stride` and so never lands on zero
|
||||
when the stride is not a power of two, which labelled it `-139a`. `format_time()` picks its
|
||||
unit rung by the **rounded** mantissa (`t < u.scale * 0.9995`, and the same slack on the
|
||||
whole-second branch) — the `>= 999.5` spill guard `format_si()` in the same file always had —
|
||||
because a tick a hair under a rung's top rounds to 1000 at three significant figures and
|
||||
`%.3g` writes that `1e+03`, which is how a one-second tick read `1e+3ms`. All four carry
|
||||
upstream tests — `the_atlas_is_filtered_not_point_sampled`,
|
||||
`grid_hairlines_land_on_whole_pixels`, `the_tick_at_the_origin_reads_zero` and
|
||||
`format_time_never_lets_a_field_reach_its_radix`. The minor
|
||||
grid's look is set there too, not here: `grid_minor` carries 50% alpha, `grid_minor_width` is
|
||||
0.75 (sub-pixel, expressed as coverage at 1x) and `grid_minor_dash_on`/`_off` are 3/3 — a
|
||||
fragment discard, no extra geometry — so the subdivisions read as texture under the labelled
|
||||
grid instead of as a second set of ticks (`the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major`).
|
||||
README records what they cured.
|
||||
|
||||
Changing any of this means committing in the submodule and moving its pin, and running
|
||||
the library's own suite as well as the udpscope probes (see
|
||||
[verification.md](verification.md)):
|
||||
|
||||
```sh
|
||||
cmake --build deps/plotter/build/debug
|
||||
xvfb-run -a deps/plotter/build/debug/tests/plot_tests # expect "0 failed"
|
||||
```
|
||||
@@ -0,0 +1,112 @@
|
||||
# Invariants: the data path — buffers, decimation and the rings
|
||||
|
||||
What rebuilds a buffer, why decimation is an average, and the two rules the time ring's precision and its first stamp impose.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **Any structural change rebuilds the buffer and drops history**: a CONFIG, an
|
||||
enable/disable, a calibration change, or a **change of display window that moves the
|
||||
decimation factor**. Calibration is applied at ingest, which is why it re-partitions.
|
||||
`ScopeModel::set_target_span()` is the window's path in, and it re-plans *only* when
|
||||
`time_stride` actually changes, so walking the window inside one decade is free; it asks
|
||||
`plan_partition()` rather than recomputing the factor, because two expressions for that
|
||||
decision is how the buffer and the view end up disagreeing about the stored rate. It
|
||||
takes `mutex()` itself — do not call it holding the lock.
|
||||
- **The shared clock is decimated to fit the requested window; channels never are.** A
|
||||
window too long to store at the wire rate used to be refused (the control was capped to
|
||||
`Partition::span_s`), which made a 600 s view of a 1 MS/s stream unreachable.
|
||||
`plan_partition()` now picks `time_stride`, a power of two, **after** the growth loop has
|
||||
bought all the ring the budget allows — deciding it earlier over-decimates by whatever
|
||||
factor growth was about to add. Per-channel `stride[i]` stays 1: a stride of
|
||||
`round(dt_i / dt_fastest)` keeps one sample per stride, and when that equals the signal's
|
||||
own period — a 2 kHz sine on a 1 MHz clock, stride 500 — every kept sample sits at the
|
||||
same phase and the channel plots flat.
|
||||
- **Decimation at ingest is an average, never a stride.** `Acquisition::on_data()` reads
|
||||
and accumulates *every* sample and pushes the mean of each group of `time_stride`
|
||||
(`value_acc_`/`value_acc_n_`, and `time_acc_` for the clock). That boxcar's first null is
|
||||
at the stored rate, so content that would fold back is attenuated instead of reappearing
|
||||
as a false low frequency — which is the whole reason decimation was once dropped
|
||||
entirely. Skipping the read for unstored samples re-introduces the aliasing; the
|
||||
`UDPSCOPE_PROBE_WINDOW_MS` run under `--freq 2000` is the guard (the anti-alias check in
|
||||
[probe-readings.md](probe-readings.md)).
|
||||
The stamp pushed is the group's **mean**, not its last member: that is where the boxcar's
|
||||
group delay puts the value, and stamping at the end of the group shifts every trace half
|
||||
a stored sample late. The accumulators are reset whenever `time_stride` or the channel
|
||||
count changes, so a re-plan starts a fresh group rather than finishing one at the wrong
|
||||
length. The LOD min/max pyramid still preserves the extremes *of what is stored*; a spike
|
||||
narrower than a group is now filtered away rather than aliased, which is the correct
|
||||
trade and is why the status line's `rate` field is worth reading.
|
||||
- **The trigger sees the stored (filtered) signal, not the wire signal.** `engine.process()`
|
||||
is fed the averaged value, inside the `if` that pushes it. An edge found on a sample that
|
||||
is not in the buffer would be cut from data that does not contain it.
|
||||
- **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
|
||||
stored relative to an epoch fixed at the first sample ever seen and so grow without bound
|
||||
while the interval they must resolve stays one sample period. `f32`'s ULP is 1.9 µs at 16 s
|
||||
of uptime, 3.8 µs at 32 s, 7.6 µs at 64 s and 61 µs over a 600 s window — so at 1 MS/s two
|
||||
to four consecutive samples shared a stamp after half a minute, eight after a minute, and
|
||||
the LOD envelope over such a plateau drew a zero-width segment, i.e. a vertical spike where
|
||||
the waveform should be. That is "zoom in to ~10 µs and three to five points share a
|
||||
timestamp, and min/max produces artefacts". **Do not widen the values to match**: they are
|
||||
the memory budget, and nothing about them is magnitude-bound this way. Three consequences:
|
||||
`plan_partition()` can no longer count slots in its growth loop (a time slot and a value
|
||||
sample are different sizes, so it weighs the budget in **bytes** — `sizeof(f64)` for the
|
||||
clock plus `sizeof(f32) / stride[i]` per channel — and `bytes_for()` is also what bounds
|
||||
the doubling against overflow); `live_window_locked()` compares its bounds in the ring's own
|
||||
type with **no rounding step**, the old `f32` round-trip having existed only so that a
|
||||
sample sitting exactly on a bound was kept whatever the stream's absolute time was; and
|
||||
`EventSnapshot::stamps` is `f64` because it is cut from this ring and is therefore in the same
|
||||
units, whatever a given snapshot's own origin is (both kinds are rebased now — an event's
|
||||
stamps are edge-relative, a pause's freeze-relative — so neither is magnitude-bound the way the
|
||||
ring is, and the width is for the cut rather than for the coordinate). `RingBuffer<T>` stores
|
||||
slots as `std::atomic<T>` behind a `static_assert(is_always_lock_free)`, which `double`
|
||||
satisfies on every supported target. The guard is the **export**: over a multi-million-row
|
||||
`.npy`, `np.diff(t)` must hold one step to within a few ULP of the column's magnitude (see
|
||||
the export guard in [probe-readings.md](probe-readings.md) for why it is no longer exactly one
|
||||
value). Three steps at 2, 2.5 and
|
||||
3 ULP of *the stream's uptime* — microseconds, not femtoseconds — is this defect.
|
||||
- **A dropped time slot is clamped, never answered with a sentinel.** `acquire()` pins
|
||||
the published window to the value ring's base and both rings keep sliding afterwards —
|
||||
their capacities are equal, so there is no slack — which means the oldest few samples
|
||||
of a saturated window routinely point at time slots the producer has already
|
||||
overwritten. `ChannelBuffer::time_of()` therefore clamps to the oldest *readable*
|
||||
slot: a stamp of `0` would reach `x_bounds()`, and from there `dx()` and `x0()`, which
|
||||
is the renderer's whole index mapping — one unreadable slot inflated `dx()` by the
|
||||
ratio of elapsed stream time to the retained window and clipped the older part of
|
||||
every live view. The library's `StreamSeries` is immune for a reason worth copying:
|
||||
its x is `x0 + i*dx`, a function of the absolute index, so an overwritten slot costs a
|
||||
*y* value, never the geometry. Clamping buys the same bargain here.
|
||||
- **The window's *first* stamp is cached by `acquire()` and never read at paint time.** The
|
||||
clamp above makes an unreadable slot *survivable*; it does not make reading it *safe*, and
|
||||
`absolute(0)` is not an ordinary slot — `acquire()` pins the window to `values_->base()`, so
|
||||
sample 0 is the ring's oldest, the very slot the producer is about to overwrite. `time_of()`
|
||||
checks the bounds and then copies, and when the producer laps the slot in between the stamp it
|
||||
returns is **one whole ring-span too new**. Everything else on a `ChannelBuffer` is read at
|
||||
paint time with no lock, and `x0()` *was* the origin the renderer reconstructed every trace
|
||||
point from (`gather[i] + series.x0()`), read both inside its increasing-suffix filter and again
|
||||
at `stage_xy()` — so one torn read of that one slot produced three pictures, all of them "the
|
||||
waveform is gone while the grid and the axes stay": `neighbours()` tests against `x_at(0)` and
|
||||
refused the whole visible range (the renderer then gathered the *entire* ring at a coarse level
|
||||
and drew it off the axis — `idx=[0..8388608) level=7 cover=320%`); `x0()` changing *during* the
|
||||
filter loop dropped three quarters of the points; and `stage_xy()` shifted the finished polyline
|
||||
a ring-span off its axis. The gather origin is the *view's* minimum now (see the `gather()`
|
||||
invariant), a local of the renderer's, so the second and third of those are structurally
|
||||
impossible — but **the first is not**, `x_at(0)` is still what `neighbours()` tests the visible
|
||||
range against, and that is the one that gathered the whole ring. The cache is load-bearing for
|
||||
that reason; do not read the slot at paint time because two of its three symptoms are gone.
|
||||
That is the "with measurements and cursors active the plot flickers as
|
||||
if there were no data, at no constant frequency" defect. It is **live only** (a snapshot is
|
||||
immutable, so `x0()` is constant for `Held`/`Paused`), it is a race against packet arrival so it
|
||||
is irregular, and the measurements pane makes it both likelier and larger: the pane holds
|
||||
`mutex()` for each `MeasureWindowReader`'s life, so the released producer bursts several packets
|
||||
at once. `acquire()` runs on the consumer under `ScopeModel::mutex()` — the same lock the
|
||||
producer's pushes hold — and the window is pinned on the line above, so that is the one point in
|
||||
the object's life where the slot is safe to read; `window_x0_` is also the *right* answer rather
|
||||
than merely a stable one (it is the stamp sample `window_base` actually carries, where a
|
||||
paint-time clamp answered with whatever slot happened to be oldest *now*). `x_at(0)` is served
|
||||
from it too, for the same reason, and `invalidate()` clears it with the window. The guard is
|
||||
`trace ink:` / `[ink]` (see [probe-readings.md](probe-readings.md)). The same torn read still exists for a small `i > 0`
|
||||
— the oldest frame or so of samples — but there its observable is a *prefix plateau*, which is
|
||||
exactly what the renderer's increasing-suffix filter was written to absorb, so it is a different
|
||||
symptom and deliberately not fixed here.
|
||||
@@ -0,0 +1,161 @@
|
||||
# Invariants: GTK traps, the status bar, the icons and the help
|
||||
|
||||
The GTK signal-signature and dialog-lifetime traps this app has already fallen into, plus the status bar's two tiers, the bundled icons and the help pages.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **The status line's per-second fields are measured once a second, not per frame.**
|
||||
`App::stored_rate`/`declared_rate` and the label's tooltip are refreshed on the same
|
||||
tick as the channel dump; the status line only formats what is already there. The
|
||||
badge's markup is likewise rewritten on a change of phase (twice a second), never on
|
||||
all sixty frames.
|
||||
- **A `notify::<prop>` handler takes three parameters, a `value-changed` handler two.**
|
||||
`GObject::notify` passes `(GObject*, GParamSpec*, gpointer)` while
|
||||
`GtkSpinButton::value-changed` passes `(GtkSpinButton*, gpointer)`, so one callback cannot
|
||||
serve both: on the `notify::` connection the `GParamSpec*` lands in the `user_data` slot
|
||||
and the handler dereferences it as its `App*`. `G_CALLBACK` is a cast, so nothing warns.
|
||||
That is why the trigger panel's spin buttons and its dropdowns have separate thin entry
|
||||
points (`on_trigger_field_changed`, `on_trigger_drop_changed`) onto one
|
||||
`publish_trigger_fields()`: changing the edge or Normal/Single dropdown used to segfault
|
||||
outright. `UDPSCOPE_PROBE_FIELDS_MS` is the guard.
|
||||
- **`GtkDropDown`'s `notify::selected` arrives after the `set_selected()` that caused it
|
||||
returns**, so the `updating_panels` flag — set around a programmatic write and cleared when
|
||||
it returns — does *not* suppress it. Where the write is part of a readout the handler reads
|
||||
back, that is a feedback loop: `sync_window_display()` writes a value rounded for reading
|
||||
into the window entry and picks the unit for it, and the late notify had
|
||||
`on_window_unit_changed()` re-parse that rounded text, turning a 500 s request into
|
||||
499.98 s. The guard is `App::window_sync_unit` — the unit we last synced — and a selection
|
||||
equal to it is ours, since GTK only notifies on a change. Any new control that both writes
|
||||
and reads a `GtkDropDown` needs the same shape; the flag alone is not enough.
|
||||
- **The header bar's buttons are icon-only**, so headless probes must reach them
|
||||
through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via
|
||||
`find_labelled_button()`. The **pane switches are toolbar buttons** too (`cursors_toggle`,
|
||||
`measures_toggle`, `math_toggle` -- packed with `header_bar_pack_end()`, where the **first** packed
|
||||
is the rightmost, and the bar is an *ancestor* rather than the parent because packing parents into
|
||||
an internal box), and the cursors pane owns its own
|
||||
**Reset** button rather than the overflow menu owning one for it. The dump prints
|
||||
`panel controls: … ; toolbar, left to right: …` to say where they are and in what order. The widget
|
||||
tree's child order *is* the visual order left to right, so that line reads as the *reverse* of the
|
||||
pack order -- it is how an arrangement gets checked with no screenshot, and it is what finally
|
||||
settled the pack-order rule above (two comments in `build_toolbar()` disagreed with each other and
|
||||
one of them with reality): it is computed on the first *tick*, because the dump
|
||||
runs after the window is destroyed and asking GTK about a widget then is four criticals, which is
|
||||
how the first version of that line earned them. What stays in the menu is what acts on the *data*
|
||||
(Fit, Export) and the two editors that are not panes (Layout, Signal list). The Arm/Disarm button in the trigger panel still has text.
|
||||
- **Six of this app's icons are its own, and they are a `GResource` compiled into the
|
||||
binary.** No icon theme has a glyph for an oscilloscope trigger, a pair of cursors or a
|
||||
measurement ruler, and the stock names those buttons used to carry said something else
|
||||
entirely: a *record dot* for the trigger and three different **magnifiers** for cursors,
|
||||
measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,math}-symbolic.svg`
|
||||
are the four action icons; beside them in `src/icons/scalable/apps/` are the two
|
||||
**application** icons, `org.udpscope.app.svg` (128px colour artwork) and
|
||||
`org.udpscope.app-symbolic.svg` (16px). The `apps` context is where an icon theme keeps an
|
||||
application's own icon and is where the **in-process** window icon resolves from; it is also
|
||||
the layout that `cmake/install_desktop_entry.cmake` mirrors when it copies the same two files
|
||||
into `$XDG_DATA_HOME/icons/hicolor/scalable/apps` and `.../symbolic/apps` — those installed
|
||||
copies are what makes `Icon=org.udpscope.app` in the desktop entry resolve (a desktop shell
|
||||
cannot read a GResource compiled into the binary).
|
||||
All six are bundled by `src/icons/udpscope.gresource.xml` (CMake runs
|
||||
`glib-compile-resources`) and reached through **one**
|
||||
`gtk_icon_theme_add_resource_path()` in `run_ui()`, right after `adw_init()` — not at the top
|
||||
of `build_toolbar()`, where it used to be: there are two consumers now (the window's own icon,
|
||||
set before the toolbar is built, and the toolbar's buttons) and `run_ui()` is the one place
|
||||
ahead of both. `adw_init()` calls `gtk_init()`, which is what makes
|
||||
`gdk_display_get_default()` valid there.
|
||||
A resource rather than files beside the binary because udpscope is run straight out of the
|
||||
build tree and has no install step. Five authoring rules, each of which fails quietly:
|
||||
* **GTK's symbolic recolour only reaches `fill` on `path`, `rect` and `circle`.** It wraps the
|
||||
SVG in a stylesheet for those selectors, so a `stroke`, a `line` or a `polyline` keeps the
|
||||
colour written in the file and stays dark on a dark theme. **In a symbolic icon everything is
|
||||
therefore a filled `path`** — including anything that wants to look stroked, which is drawn as
|
||||
a closed band (two offset curves joined) instead. The rule is scoped to symbolic icons on
|
||||
purpose: `org.udpscope.app.svg` is 128px colour artwork that is never recoloured, so it strokes
|
||||
its sine freely, which is the sane way to draw a smooth one. Do not "fix" one to match the
|
||||
other.
|
||||
* **Whole-pixel coordinates**, because these are drawn at 16 px far more often than not: the
|
||||
first draft placed features on half-pixels and the whole icon was a grey smear at 1x.
|
||||
* `fill-rule="evenodd"` is how the ruler's body is a hole *and* its ticks still fill -- a tick
|
||||
inside the hole is three crossings deep, which is odd. Nonzero would fill the hole back in.
|
||||
* Overlapping subpaths under the default nonzero rule must be **wound the same way** or they
|
||||
cancel instead of uniting. All of them run clockwise.
|
||||
* **A malformed SVG is a build error, and that is the only place it can be caught.** Every
|
||||
`<file>` in `src/icons/udpscope.gresource.xml` carries `preprocess="xml-stripblanks"`, which
|
||||
makes `glib-compile-resources` pipe it through `xmllint` — so `--` inside an XML comment, which
|
||||
is illegal and which librsvg refuses the whole file for, fails the build with a file, a line
|
||||
and a caret under the offending character (`…app-symbolic.svg:5: parser error : Double hyphen
|
||||
within comment`). It has to be here because **no runtime check can see it**: librsvg refuses
|
||||
the file but the icon *name* still resolves, so `gtk_icon_theme_has_icon()` returns true and
|
||||
the icon draws nothing. `gtk_icon_theme_lookup_icon()` cannot tell either (see the `app icons:` line under
|
||||
Verification), `GMarkup` accepts the double hyphen because it is a lenient subset parser, and
|
||||
`gdk_pixbuf` goes through glycin here, which sandboxes a subprocess per load and refuses this
|
||||
project's own icons outright. The cost is that the `xmllint` binary is a build dependency; it
|
||||
ships with libxml2, which GTK already requires.
|
||||
`gtk_icon_theme_has_icon()` is checked for every toolbar button and reported by the dump (see
|
||||
`toolbar icons:` in [probe-readings.md](probe-readings.md)): a resource path that was never added does not raise an
|
||||
error, it draws `image-missing`.
|
||||
- **A shortcut table row's `accel` is GTK accelerator syntax, and a pointer gesture has
|
||||
none.** `src/shortcuts.cpp`'s strings are handed to `AdwShortcutsItem`, which parses them
|
||||
with `gtk_accelerator_parse()` — so they are `"space"`, `"<Control>e"`, `"<Control>slash"`,
|
||||
`"plus minus"` (a space-separated pair is the "either of these" form the label draws as
|
||||
`+ / -`), never the prose `"Ctrl+E"` or `"+ / -"`. Neither GTK's syntax nor libadwaita's
|
||||
knows a **wheel or a drag**: `"Wheel"` costs a `Failed to parse` warning and draws an empty
|
||||
cell. Those six rows therefore carry `accel = ""` and put the prose in `subtitle`, which is
|
||||
the one field that is free text. A row added with a parse error is otherwise silent — the
|
||||
overview still opens and the row is simply blank — so read the probe run's stderr for
|
||||
`Failed to parse` after touching either table.
|
||||
- **The status bar is two tiers, and the dump reads the detail one.** `format_glance()` writes
|
||||
the bar and `format_detail()` writes both the popover's prose and `App::status_detail`, which
|
||||
is what `exit_probe_step3`/`exit_probe_step2` copy into `dbg_mode_live`/`dbg_mode_triggered`.
|
||||
Those two used to read the *label*, and the label no longer carries `packets`, `gaps`,
|
||||
`plot 1:` or the buffer figures — so a `gtk_label_get_text()` there would silently reduce
|
||||
every documented `mode …:` assertion to four fields. The glance line's state field is **one
|
||||
word from a fixed set** (`connecting`, `live`, `paused`, `armed`, `capturing`, `event held`,
|
||||
`disarmed`) for the layout reason above; a word **wider than the widest already there**, or a
|
||||
field whose width tracks the stream, breaks the row the plot is laid out under. The set may
|
||||
grow — `connecting` was added for the no-CONFIG state — but only on that condition, and the
|
||||
check is the dump's `max_glance_chars`. `connecting` is derived from `have_config` alone and
|
||||
sits **ahead of every other branch**, including the display mode's: with no signal list, every
|
||||
other word claims something that does not exist yet. The `mode` prose branches the same way for
|
||||
the same reason, which is what keeps the two tiers from disagreeing.
|
||||
- **The ⓘ popover's widgets are built once and only their text is set.** It is visible when the
|
||||
tick updates it, so a rebuild is a per-frame relayout of a widget on screen.
|
||||
`update_status_popover()` only ever calls `gtk_label_set_text()`, and `popover_rebuilds` in
|
||||
the dump is the assertion.
|
||||
- **The About dialog, the help window and the shortcut overview are opened by `show_about()`,
|
||||
`show_help()` and `show_shortcuts()`, and each closes with the matching call.** There is
|
||||
exactly **one `GtkWindow`** among the three — the paged help window. It closes with
|
||||
`gtk_window_close()`, which emits `close-request` so the handler that nulls the file-scope
|
||||
pointer runs. `gtk_window_destroy()` does **not** emit that signal and must not be used — the
|
||||
math dialog fell into exactly this trap, and the pointer then points at a dead widget until the
|
||||
next attempt to open the same window. The other two — the About dialog (an `AdwAboutDialog`)
|
||||
and the shortcut overview (an `AdwShortcutsDialog`) — are **`AdwDialog`s and not `GtkWindow`s**:
|
||||
they close with `adw_dialog_close()` and clear on `::closed`, there being no `::close-request`
|
||||
on an `AdwDialog`. Both share one `on_dialog_closed()` handler keyed by a **pointer to the
|
||||
pointer** it must null (`&g_about`, `&g_shortcuts`), which is what keeps one handler honest for
|
||||
two dialogs. One `GtkWidget*` (or `AdwDialog*`) per window, nulled in its own close handler, is
|
||||
the shape that prevents the double-open defect across all three.
|
||||
- **`build_info.cpp` is generated, never committed.** `cmake/write_build_info.cmake` runs at
|
||||
configure time (so `add_executable` can name the file) and again on every build through
|
||||
`udpscope_build_info` (so the commit and the timestamp belong to the binary that is running).
|
||||
`git` absent leaves `kCommit` at `"unknown"` and the build succeeds. The About dialog and the
|
||||
`[help]` probe both read `kVersion`, `kCommit` and `kTimestamp` from it; `settings=` in the
|
||||
probe reads `config_dir()`, which is the path the session file is written to.
|
||||
- **The keyboard zoom's modifiers are not the wheel's.** `+`/`-` with **Ctrl** restricts to x
|
||||
and with **Alt** to y, while the wheel keeps upstream's Shift = x / Ctrl = y convention. The
|
||||
two cannot agree: Shift is unusable on the keyboard path because `key_from()` folds `'+'` and
|
||||
`'='` onto `kKeyZoomIn` — `+` *is* Shift+`=` on common layouts, so a bare zoom-in already
|
||||
arrives with `Mod::Shift` set. Ctrl+Alt together zooms neither axis, which needs no special
|
||||
case. The guards are `deps/plotter`'s own `ctrl_plus_zooms_only_x` and `alt_plus_zooms_only_y`
|
||||
tests, plus `UDPSCOPE_PROBE_ZOOMKEYS_MS`.
|
||||
- **A help page is one `.txt`, one `<file>` line in the GResource manifest, and one `kPages`
|
||||
row.** The bodies are Pango markup in a second GResource (`--c-name=udpscope_help`, so its
|
||||
generated symbols cannot collide with the icons'), validated with `pango_parse_markup()` at
|
||||
load; a page that fails either lookup or parse renders a one-line stand-in and is excluded from
|
||||
`parsed`. `UDPSCOPE_PROBE_HELP_MS`'s `parsed` must equal `pages`.
|
||||
A page may also carry screenshots (`HelpPage::images`, a null-terminated list of resources).
|
||||
They are `GtkPicture`s **with a height request**: one created `can_shrink` asks for *no* height
|
||||
at all inside a box, so without the request it is allocated nothing and the page renders its text
|
||||
with a silent gap where its screenshots should be — which is what the first version did.
|
||||
- **`find_accel()` masks the modifiers before comparing.** Lock, Num-Lock and the pointer buttons
|
||||
all arrive in `GdkModifierType`, so an unmasked compare matches no table row at all once
|
||||
Caps Lock is on.
|
||||
@@ -0,0 +1,235 @@
|
||||
# Invariants: the math channels and the node editor
|
||||
|
||||
A derived channel is an ordinary channel; the engine runs on the producer thread, so its cost is measured in packets.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **A math channel *is* a channel.** `ScopeModel::set_math_channels()` appends one *synthetic*
|
||||
`SignalInfo` (with `math = true`) per math channel after the stream's own, so the panel's switch,
|
||||
the plot binding, the measurements and the export all handle a derived channel without knowing it
|
||||
is derived -- and another math channel is a valid `Source` for exactly the same reason. The
|
||||
*samples* come from the producer: `Acquisition::on_data()` collects each channel's value for the
|
||||
slot and calls `ScopeModel::push_math_sample()` **once per stored slot**, before the deferred
|
||||
latch (`publish_event()` clamps its snapshot to every ring's `produced()`, so a derived sample
|
||||
pushed later would be missing from the capture's newest slot). The math therefore runs on the
|
||||
*stored* (filtered, decimated) samples, which is also why `math_prepare()` gets `slot_dt() *
|
||||
time_stride`: a cutoff the user typed in Hz means the rate the filter will actually run at.
|
||||
`src/math_channel.cpp` is the engine: a graph evaluated as a **list walked in order**, so a node
|
||||
may only read ones *before* it and a cycle is impossible by construction rather than by a check
|
||||
(`math_channel_valid()` states that as a message the dialog shows). `/tmp/check_math.cpp`-style
|
||||
checks verified the biquads against known responses (LP fc=100 Hz: 50 Hz -> 0.97, 2 kHz -> 0.002;
|
||||
BP fc=1 kHz q=2: 200 Hz -> 0.05, 1 kHz -> 0.95, 4 kHz -> 0.05) -- **keep test sines below
|
||||
Nyquist**: at `dt = 1/10000` a "5 kHz" test tone is the Nyquist point itself and says nothing.
|
||||
The editor is the *compact graph*: nodes as boxes on a `GtkDrawingArea`, click to select (its
|
||||
parameters in the form beside it), **drag from an output port to an input port to wire** (the old
|
||||
two-click protocol is gone), drag a box to move it; **wheel zooms about the pointer, background
|
||||
drag pans, `f` fits, `Ctrl+0` resets**.
|
||||
Its widgets are created once and only shown, hidden and set by `math_dialog_sync()`, so no rebuild
|
||||
can invalidate a live callback handle -- the same shape the measurements dialog uses.
|
||||
`UDPSCOPE_PROBE_MATH_MS` defines two graphs and opens the editor (a construction crash test
|
||||
as much as a state check), and the dump prints each derived channel's extremes beside the
|
||||
source's. Read it on the fast mock, where the two assertions that matter are **`gaps=0`** (the
|
||||
math is on the producer thread, so what it can break is packet capture, never the frame rate)
|
||||
and each `math '<name>': ... N samples vs M time slots` being **equal**, with `math x2` at
|
||||
`y=[-2, 2]`, `math lp` at `y=[-0.998, 0.998]` and `math3` at `y=[-2, 2]`. The `0 visible
|
||||
(order breaks 0, first at -1), y=[0, 0]` tail of that line is **not** a failure: it is
|
||||
`visible_samples()` reading row 0's axis after the window was destroyed, and
|
||||
`UDPSCOPE_EXIT_AFTER_MS` arms the trigger first, so the dump can land in `Waiting`. The live
|
||||
`ch N '<name>' ... y=[..]` lines are the race-free reading of the same thing.
|
||||
`math cost: S slots, N ns/slot (T timed), X ms total est` is the per-slot cost; see the
|
||||
math-cost invariant below for why it is sampled, why it is an upper bound, and why a figure
|
||||
anywhere in 78--101 ns/slot is one reading of the same thing.
|
||||
The probe also asserts the new editor mechanics. Each line carries `(want ...)` and the reading
|
||||
taken from a real run is the reference:
|
||||
* `[math-probe] hit: inside node=0 which=-1, past-edge node=0 which=-1 (want 0 -1 0 -1)` — the
|
||||
output square straddles the box edge, so a hit test that recovers the node by a box test misses
|
||||
every pixel past it. `past-edge node=-1` is the silent disarm that was "edges cannot be added".
|
||||
* `[math-probe] cancel: dialog=cleared (want cleared)` — Cancel must `gtk_window_close()`, not
|
||||
`gtk_window_destroy()`. The probe clicks the **button**, because the defect was in the button's
|
||||
connection and a direct handler call cannot see it.
|
||||
* `[math-probe] view: zoom scale=1.210 anchor kept dx=0.0000 dy=0.0000 (want 0.0000 0.0000)` —
|
||||
a wheel zoom that does not keep the graph point under the pointer reads as the canvas sliding
|
||||
away from the hand. Both deltas must be `0.0000`.
|
||||
* `[math-probe] view: pan+zoom hit node=0 which=-1 (want 0 -1)` — hit tests go through
|
||||
`math_to_graph()`. At the identity view a test that forgot the inverse passes anyway, so this
|
||||
one is taken after a pan *and* a zoom.
|
||||
* `[math-probe] view: fit scale=1.591 all_inside=1 (want 1)` and
|
||||
`[math-probe] view: reset scale=1.000 offset=(0.0,0.0) (want 1.000 (0.0,0.0))`.
|
||||
* `[math-probe] view: pan mode=3 dview=30.0 dnode=0.0 (want 3 30.0 0.0)` — a background drag
|
||||
pans the view and moves no node.
|
||||
* `[math-probe] wire: connect mode=2 in_a=0 (want 2 0)`,
|
||||
`[math-probe] detach: emptied_at_begin=-1 in_a=-1 (want -1 -1)`,
|
||||
`[math-probe] detach: escape restores in_a=0 (want 0)`.
|
||||
* `[math-probe] wire: pick node=1 which=0 -> in_a=-1 off_sample=8.4 tol=3.0 (want 1 0 -1,
|
||||
off_sample > tol)` — the pick is taken at `t = 0.52`, **between** samples 12 and 13 of the 24 the
|
||||
curve is walked at, on a wire whose two boxes are several rows apart. `off_sample` is the measured
|
||||
distance from the pick to the nearest sample point and `tol` the pick radius in the same units:
|
||||
with `off_sample` the larger, a hit test that measured to the sample *points* rather than to the
|
||||
*segments* between them would miss. Taken at `t = 0.5` — exactly sample 12 — the line passed for
|
||||
both implementations and asserted nothing, the same mistake as a hit test taken at the identity
|
||||
view.
|
||||
* `[math-probe] wire: backwards ok=1 order=[0:-1,1:0,2:1] output=2 valid=1 why=[] (want 1 ... 1 [])` —
|
||||
the re-sort. `valid=1` is the assertion; the `order=` string is the reading that explains it.
|
||||
* `[math-probe] wire: self refused=1 why=[a node cannot read itself]` and
|
||||
`[math-probe] wire: cycle refused=1 why=[that wire would make a loop]` — and **nothing is
|
||||
written** on either. Both call `math_connect()` directly, so both assert its out-parameter and
|
||||
neither says anything about the UI.
|
||||
* `[math-probe] wire: self through drag-end node=1 problem=[a node cannot read itself] (want the
|
||||
refusal text, not empty)` — the other half, and the one that matters: the refusal has to reach
|
||||
the **validation line**. The UI path used to screen the self case out before `math_connect()` and
|
||||
then drop its `why` on the floor, so the user drew a wire, nothing appeared and nothing was said
|
||||
while the two lines above passed. No synthetic GTK gesture is reachable headlessly, so the probe
|
||||
sets `drag_mode`/`wire_from`/`press_cx`/`press_cy` and calls `math_canvas_drag_end()` — which is
|
||||
still the handler under test. An empty `problem=[]` here is also how the sync **re-entrancy** was
|
||||
found: setting the op dropdown emits `notify::selected`, whose handler syncs, so the form's own
|
||||
write re-entered `math_dialog_sync()` and the inner call rewrote the label the outer had just
|
||||
written. `MathDialog::syncing` guards it.
|
||||
* `[math-probe] validate: broken problem=[..] label=[..] apply=0; fixed problem=[] apply=1 (want non-empty 0; empty 1)`,
|
||||
`[math-probe] validate: no-selection label=[..] (want non-empty)` — the label is written
|
||||
**above** `math_dialog_sync()`'s early return; a stale message when there is no selection to
|
||||
show is the failure mode.
|
||||
* `[math-probe] validate: source list=6 acquired=6 signals=7 (want list==acquired)` — the
|
||||
dropdown lists exactly the acquired channels, not all signals.
|
||||
* `[math-probe] op pick: nodes 3->4 op_index=7 q_row=1 (want 4 7 1)` and
|
||||
`[math-probe] op pick: index round trip=1 (want 1)`.
|
||||
* `[math-probe] add node: after a delete cells=[(24,20) (24,78) (184,78) (184,20)] overlaps=0
|
||||
(want 0)` — the boxes are put back on the grid, the middle one deleted and one added. The new box
|
||||
takes cell 1, `(184,20)`, which the deletion freed; placing by `nodes.size()` would have put it on
|
||||
`(184,78)`, which is occupied, and an overlapped box is an unclickable one. `overlaps` is the
|
||||
assertion, `cells` the reading that explains it.
|
||||
* `[math-probe] tidy: xs=[24,24,204,384] overlaps=0 (want 0)` — four nodes in three depth columns
|
||||
(the delete above left two roots), so two share the first column and the x values repeat. Only
|
||||
`overlaps=0` is the assertion.
|
||||
Four rules here were wrong first and are worth keeping:
|
||||
* **A `Source` may only name an *acquired* channel** -- `ScopeModel::channels()`, which is what the
|
||||
dialog's dropdown lists, **and both places that create a node must take one from there**.
|
||||
`signals().front()` is the stream's *timestamp* signal: it is first in every CONFIG, it is not
|
||||
acquired, and a graph sourced from it validates and then reads NaN for ever. The dialog's
|
||||
default was fixed first and the pane's `+` was not, which is what "[ch] -> [gain x2] produces
|
||||
only nan" was. The dropdown also *reconciles*: a node whose source is not in the list is moved
|
||||
to what the form is showing, rather than the form showing one channel while the node holds
|
||||
another, and while a source that is not acquired is in the graph the **live validation line is
|
||||
non-empty and Apply is insensitive** (the engine's validator is about the graph -- an output,
|
||||
every input wired above it -- and cannot see the acquired list).
|
||||
* **The ingest must skip a derived channel.** `Acquisition::on_data()` iterates every channel and
|
||||
reads each one from the packet, so a math channel with no signal read *zero* and pushed it --
|
||||
while `push_math_sample()` pushed the real value on the same slot. Two pushes per slot into a
|
||||
ring whose time ring advances once: the values alternated between the real one and 0, and once
|
||||
both rings wrapped the value ring had drifted a whole window off the time ring, so the derived
|
||||
trace disappeared. `ChannelInfo::math` is the flag (`channel.math`) and the loop `continue`s on
|
||||
it. **A derived channel's `values(c).produced()` must equal `live().time().produced()`** -- one
|
||||
push per stored slot -- and the dump prints both as `N samples vs M time slots` for exactly
|
||||
this: the arithmetic on the plot can look plausible long after the pairing is wrong.
|
||||
* **`gtk_window_destroy()` does not emit `close-request`.** The math dialog's pointer is cleared
|
||||
by a `close-request` handler, so 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 -- a GTK critical, and nothing on screen, which is "I cannot edit a math channel
|
||||
again". Both paths close (`gtk_window_close()`), which emits the signal, lets the handler clear
|
||||
the pointer, and then destroys. The same lesson as the other GTK traps here: a programmatic
|
||||
destroy is not a user close, and only one of them runs the handlers.
|
||||
* **Boxes must not overlap on the canvas.** The first auto-place staggered nodes by 24 px for a
|
||||
132 px box, so every box sat on the one before it, `math_node_at()`'s topmost-first search always
|
||||
returned the last, and a drag or a click landed on the wrong node -- which reads as the dialog
|
||||
ignoring the pointer. The **add-node grid** places in two columns spaced 160 px apart, rows 58,
|
||||
and `math_add_node()` takes the **first free cell** rather than cell `nodes.size()`: placing by
|
||||
the count reuses an occupied cell as soon as anything has been deleted — delete the middle of
|
||||
three and add one and the new box lands exactly on an existing one. The free-cell search uses the
|
||||
same overlap test the tidy probe asserts with, and falls through to cell `nodes.size()` after 64
|
||||
cells rather than searching for ever. The **tidy layout** uses a column pitch of `kNodeW + 48` =
|
||||
180, which is *wider* than that 160 and is what gives a wire's 40-unit horizontal tread its
|
||||
clearance — at 160 the bezier's control points cross and the wire doubles back on itself. The
|
||||
probe prints the box the drag *began over* for exactly this reason.
|
||||
Offering the whole signal list let a graph name something nobody is acquiring (no data for ever)
|
||||
and put the stream's timestamp signal, which has no samples, in it.
|
||||
* **`prepare_math_locked()` runs from `rebuild_locked()`, so *every* plan change re-prepares the
|
||||
filters**: a CONFIG, an enable/disable, and a window change that moves the decimation (that path
|
||||
is `set_target_span()` -> `rebuild_locked()`). Coefficients are a function of the *stored*
|
||||
interval and the filter's state is the old rate's history, so both are rebuilt together.
|
||||
Preparing only on an edit left the cutoff wrong and the state ringing after any window change --
|
||||
that is what "I tested a low pass and it was a mess" was.
|
||||
* **The graph's output defaults to the *last* node, and a node added to a linear graph becomes
|
||||
it.** The output began at index 0 -- the *source* -- so every operation a user built was
|
||||
computed and then thrown away: a gain of 2 and a low pass both produced a channel equal to its
|
||||
input. The output is drawn as the orange dot and settable explicitly, so an earlier node chosen
|
||||
deliberately is kept; this is the default for a graph that has never been given a choice.
|
||||
* **`gtk_spin_button_new_with_range()` can leave the value at the range's *minimum*.** The offset
|
||||
field began at -1e6, and `on_math_param_changed()` reads the widgets, so `x*2` saved as
|
||||
`x*2 - 2000000`. Every numeric field is seeded with its own identity value; the range-minimum
|
||||
default is never a useful starting point for a gain or a corner frequency.
|
||||
* **The form's rows are grouped per *op*, not per field**: gain and offset are one group because
|
||||
`gain + offset` is one op. The offset was a row of its own that nothing ever hid, which read as
|
||||
a property of every node.
|
||||
* **A `GtkSpinButton` holds zero decimals by default**, so every numeric field sets its digits
|
||||
(4 for gain/offset/clamp, 3 for `fc`/`Q`); without that a 12.5 Hz cutoff was stored as 12 and a
|
||||
gain of 0.25 as 0. And the canvas drag reports the *total* offset from where the gesture began,
|
||||
so adding each update's delta to a value the handler then writes back is a runaway box -- it
|
||||
must record the position at `drag-begin` and ignore a drag that did not begin on a box.
|
||||
* **A port hit must name its own node.** The output square is drawn centred on `n.x + kNodeW`,
|
||||
straddling the box's right edge, with a `±kPort` hit radius against a `±kPort*0.5` drawn
|
||||
half-width — so recovering the node with `math_node_at()`, which matches only inside the box,
|
||||
returns `-1` for every pixel past the edge. `math_port_at()` returns the node directly for
|
||||
that reason.
|
||||
* **Every hit test goes through `math_to_graph()`, and every tolerance is a screen distance
|
||||
divided by `view_scale`.** A tolerance left in graph units makes a port unhittable zoomed out
|
||||
and greedy zoomed in. **A probe that tests a hit at the identity view asserts nothing** — the
|
||||
check has to be taken after a pan and a zoom.
|
||||
* **A drag's mode is decided once, at `drag-begin`, and the precedence is port, box, background.**
|
||||
Asking the box first makes the output square unreachable, which is the same geometry as the
|
||||
bullet above.
|
||||
* **A detach empties the input at `drag-begin`.** That is what makes a release over nothing the
|
||||
detach gesture rather than a second verb, and it is why `Escape` has something to restore.
|
||||
* **A backwards wire re-sorts the list; it is not refused.** The evaluator walks the nodes in
|
||||
order, and until now that made the order the *user's* problem — an earlier node's input was not
|
||||
even drawn as connectable. `math_connect()` runs Kahn's algorithm over the **trial** edge set,
|
||||
so a cycle is found before anything is written, breaks ties by the current list index so a graph
|
||||
already in order is not permuted, and remaps **`output`, `selected`, every `in_a`/`in_b`,
|
||||
`drag_node`, `wire_from`, `wire_detach` and `selected_wire.node`** in one block. A missed index
|
||||
silently renames an operation or re-points a wire. Its `edges_of` masks each input by the op's
|
||||
**arity**: a node changed from binary to unary keeps a stale `in_b`, and counting it makes an
|
||||
indegree that never reaches zero and so reports a loop that is not there.
|
||||
* **The validation line is written above `math_dialog_sync()`'s early return.** An out-of-range
|
||||
selection is exactly the state in which a stale message misleads most: the graph can still be
|
||||
broken and there is no form to read instead.
|
||||
* **The line is written from `math_dialog_sync()` and from nowhere else, it ellipsizes, and its
|
||||
tooltip carries the full text.** Three consequences. (1) A *refusal* — "a node cannot read
|
||||
itself", a cycle — is not a state `math_dialog_problem()` can describe, since it is a thing that
|
||||
did not happen, so `drag-end` leaves it on `MathDialog::refusal`, which the sync shows in
|
||||
preference to the graph's own problem and **consumes as it writes**: the message lasts until the
|
||||
next sync, and every mutation syncs. `Apply`'s sensitivity still follows the graph alone, because
|
||||
a refused wire wrote nothing. (2) The label **ellipsizes rather than wraps**, and the tooltip is
|
||||
set beside the text so the two cannot disagree — layout must not depend on content, and this
|
||||
label sits in a dialog whose canvas is `hexpand`. (3) The sync is **not re-entrant**
|
||||
(`MathDialog::syncing`): writing the op dropdown emits `notify::selected`, whose handler syncs,
|
||||
and the inner call rewrote the label the outer one had just written.
|
||||
* **`math_op_index()`, never a cast.** `math_op_at()`'s order is the dialog's — what a dropdown
|
||||
lists and what a session file stores — and `static_cast<guint>(op)` worked only because the
|
||||
two happen to agree today. The session-file rule in this file already says an op is written as
|
||||
its index through `math_op_at()`; this is the other direction of the same rule.
|
||||
* **A `Delete` key removes the selected *wire* and never falls through to the selected node.**
|
||||
A key whose target depends on an invisible selection state is how a graph gets destroyed by
|
||||
accident; the `delete node` button stays the only way to remove a node.
|
||||
- **The math channels' cost is the *producer's*, and the instrument must not be part of it.**
|
||||
`push_math_sample()` runs once per stored slot -- up to the wire rate -- so what a regression
|
||||
here looks like is `gaps`, never a frame time. Three things were taken off that path and must
|
||||
not come back: a `std::vector` *local* (a heap allocation and free per slot; it is the member
|
||||
`math_values_`, `assign()`d), a `Source` node's channel resolved **by name** per sample (a
|
||||
linear scan with a `std::string` compare per candidate, per node, per slot, behind a
|
||||
type-erased call -- `math_prepare()` resolves it once into `MathNodeRt::src`, and every rebuild
|
||||
re-prepares), and a **`fetch_add` on the cost counters**. That last one was 20-25 % of the
|
||||
measured figure: a `lock xadd` costs about what a two-node graph's whole arithmetic does, so
|
||||
counting the slots cost more than the work being counted. The counters are producer-written
|
||||
only, so each is a relaxed `store()` of a value already in hand (`math_slot_count_` and
|
||||
friends). Two things about the figure itself:
|
||||
* **It is sampled, one slot in `kMathCostSample`, and it is an upper bound.** A slot's work is
|
||||
the same order as one `clock_gettime`, so the measured slot carries a clock read. Timing a
|
||||
*run* of consecutive slots to amortise that away **is wrong and was tried**: the producer gets
|
||||
its slots a packet at a time, so a run long enough to be worth timing straddles a packet
|
||||
boundary now and then and charges the measured slots the **wait for the next packet**. At 2000
|
||||
samples a packet a 64-slot run crosses one ~3 % of the time, and a ~1.5 ms gap spread over 64
|
||||
slots reported **799 ns/slot against the true ~80**. One slot is always inside one packet.
|
||||
* **78--101 ns/slot is one reading.** Four identical-config runs spread that far, so the per-slot
|
||||
path is at the noise floor of this harness and a micro-optimisation cannot be shown to work
|
||||
here. Converting `MathRuntime` from eight parallel `std::vector<double>` to one array of
|
||||
`MathNodeRt` was measured and bought **nothing** -- at six nodes the whole runtime is
|
||||
L1-resident either way. It was kept for the shape, not for a number; do not expect a cache
|
||||
argument to pay at this size.
|
||||
@@ -0,0 +1,110 @@
|
||||
# Invariants: the measurements pane
|
||||
|
||||
Why the pane is refreshed on a cadence, why neither pass may read its window raw, and how a crossing is counted.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **The measurements pane is refreshed on a cadence, and its cost is the *window's*, not the
|
||||
frame's.** A measurement is a statistic of the visible window and a live view moves that window
|
||||
every frame, so "recompute when the window moves" is "recompute every frame" at O(visible
|
||||
samples) *per measurement*. With the pane holding four measurements over a 4.7M-sample window
|
||||
that measured **13.66 ms of tick mean** (baseline 0.41 ms) and cost the run a third of its
|
||||
frames. Three things fixed it, and all three matter: `update_measures()` recomputes at most
|
||||
every `kMeasureRefreshUs` (100 ms; 400 ms when a pass costs more than `kMeasureHeavyUs`), once
|
||||
when the window settles and once per mode change -- and reading *that* backwards is what made
|
||||
it worse first, 457 recomputes in one run, because a pinned window then recomputed every
|
||||
frame; the window is materialized in bounded chunks (never two whole-window f32 temporaries)
|
||||
and the loop holds it **by reference** (copying both vectors per measurement was 300 MB of
|
||||
memcpy a frame); and `measure_stats()` is one shared pass carrying both extremes with their
|
||||
first positions, the sum and the count, so average/min/max/peak-to-peak/amplitude need no
|
||||
pass of their own. **Adding a type must not add a pass** -- that is what `compute_measure()`'s
|
||||
`stats` argument is for.
|
||||
The marks' *ink* is set where they are created (`install()` in `plot_host.cpp`): 0.75 px and
|
||||
dashed 4/4 through `Plot::set_cursor_stroke()`, which is per cursor because the trigger marker
|
||||
(also a cursor) keeps the solid `line_width_px` stroke. A new measure mark must take that
|
||||
stroke -- it is created in the same loop, so it does; a new *instrument* should not.
|
||||
The **marks** the pane draws (`set_measure_marks()` -- full-width `h_ruler`s and full-height
|
||||
cursors, i.e. the "lines across the plot") are re-placed every frame from the result, so a
|
||||
throttled pass is a *stepped* ruler: acceptable, because the ruler and the number beside it
|
||||
come from the same pass and agree. What is *not* acceptable is a ruler that appears and
|
||||
disappears, so an invalid pass falls back to the last valid result for its **level** marks and
|
||||
only drops its **time** marks: a level is a property of the signal (a mean, a top, an
|
||||
amplitude) and outlives the window it was measured in, while a time mark is a position *inside*
|
||||
that window and may not. Both are emitted in **one pass after the numbers, never inside the
|
||||
loop that computes them**: that loop has three `continue`s (an unresolvable signal, a channel
|
||||
with no window) and each one was a way for the rulers to blink off and back. Fixing one and not
|
||||
the others is how that bug survived a round of "fixes". `clamp fires: N` in the dump is the other half of this: `clamp_to_data()`
|
||||
compares with a relative tolerance, because clamping to exactly `window_s_` can leave the
|
||||
group's span an ULP above it and a clamp that fires every frame is a re-frame loop, not a
|
||||
clamp.
|
||||
- **A measurement must not read its window raw.** Both passes go through
|
||||
`plot::data::MinMaxPyramid`, which the producer already maintains at amortised O(1) -- so the
|
||||
pane inherits the summary of the new samples rather than computing one, which is what the
|
||||
"evaluate only on new samples" ask comes to here. Throttling the cadence (above) bounded *how
|
||||
often*; this bounds *how much*, and it is the larger factor: 19.7 ms per recompute to 1.2--1.6
|
||||
ms, reading **0.1 %** of a 2 M-sample window. Pass one (`measure_scan_buckets()`) adds whole
|
||||
level-0 buckets -- 64 samples each, carrying min, max an **exact** sum and a finite count -- and
|
||||
locates each extreme to a *bucket*; one raw `measure_scan()` of that bucket alone then pins it
|
||||
to a sample, two buckets at most whatever the window's size. Pass two
|
||||
(`measure_accumulate_bucket()`) folds a bucket'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
|
||||
`pyramid_.drop_before(values_->base())`, so a channel's pyramid is in the value ring's
|
||||
absolute index space -- the same space `live_window_locked()` answers in, since a channel's
|
||||
stride is always 1. The snapshot's pyramid is built from sample 0 and never dropped from, so
|
||||
its space is the snapshot's own. `MeasureWindowReader::origin_` is what turns one into a
|
||||
window offset, and a mismatch there measures the wrong samples rather than failing.
|
||||
* **The raw path stays, and stays exact**, for the window's partial edge buckets, for the
|
||||
samples produced since this frame's `acquire_all()` (one frame of production, so bounded), and
|
||||
for the straddling buckets. A window shorter than a bucket degrades to O(window) and must
|
||||
still be right.
|
||||
* **Both passes walk the same fetched bucket list** (`App::measure_bucket_scratch`, reused
|
||||
across recomputes -- a 2 M-sample window is 32 k buckets). Fetching twice is a second walk of
|
||||
the pyramid for nothing.
|
||||
`measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say
|
||||
anything.
|
||||
- **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`.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Invariants: the plot, the figure and the layout
|
||||
|
||||
How `plot::Figure`/`plot::Plot` may and may not be driven, what a row's state belongs to, the layout editor's overlay, and the rule that layout must not depend on content.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **`plot::Plot` cannot remove or retarget a trace.** Hence fixed trace slots created
|
||||
up front and rebinding via `SlotSeries`; a *name* change (legend text) is the one
|
||||
thing that forces a rebuild. Cursors are parked outside the axis range instead of
|
||||
removed.
|
||||
- **A `Figure`'s row count is fixed at construction**, so `RowSet::rebuild_all()` builds
|
||||
a *new* figure and the area is handed it (`PlotArea::set_figure()`); it costs no GL
|
||||
state. That makes one path for "a row's traces changed" and "the row count changed",
|
||||
and it is why `rebuild_all()` restates a pinned event window (`pinned_pre_`/
|
||||
`pinned_post_`) instead of re-framing on the live edge — a drop during trigger mode
|
||||
would otherwise throw the frozen view onto the stream, where nothing moves it off
|
||||
again until the next latch. `PlotArea::create()` is handed `App::placeholder_plot`,
|
||||
never a row: the area keeps a reference to the plot it was created with, and a rebuild
|
||||
destroys every row.
|
||||
- **A trace is a slot with a name *and* a channel; a name alone is not one.** `Row::channel[s]`
|
||||
is re-resolved by `RowSet::bind()` every frame (slots are identified by name, see above), and
|
||||
`-1` means the model built no channel for that name. Some names can *never* bind: the stream's
|
||||
**time signal** is skipped by `build_channels_locked()` however often it is enabled, so a slot
|
||||
holding it has a name for ever and data never. The plot showed nothing for it, which is why it
|
||||
went unnoticed, but the cursors pane's `mix` mode drew one line per *named* slot and so gave
|
||||
each phantom a row that could only read `--` — "the pane shows a lot of empty signals that do
|
||||
not exist", six lines over three real traces. Two halves to the rule. (1) Anything that counts
|
||||
traces counts `channel[s] >= 0`, not `!signal_name[s].empty()` — and a per-frame bind means the
|
||||
cursors pane cannot key its rebuild on `generation()` alone, hence `App::cursors_pane_bound`, a
|
||||
bit per row/slot (`kMaxRows * kSlotsPerRow` is 32, so one `std::uint32_t`): a trace that
|
||||
appears without a structural change still gets its line. (2) `apply_layout()` refuses a name
|
||||
that cannot produce a channel (`ScopeModel::is_time_signal()`), because such a name was
|
||||
**sticky**: `collect_layout()` writes `signal_name` back verbatim, so one that got into a
|
||||
session file was re-saved every session for ever. The refusal also heals an existing file on
|
||||
the next save. How the names first got in is unexplained — the drop path already refuses a
|
||||
signal with no channel — most likely an older build or a CONFIG whose time signal was not yet
|
||||
classified as one; the fix is the stickiness and the counting, not a guess at the origin.
|
||||
- **A row's contents are state of the `RowSet`, not of the figure.** `insert_row()` and
|
||||
`remove_row()` shift that state with `carry_row()` and let `rebuild_all()` re-deal the
|
||||
figure handles, so inserting above row 1 moves what row 1 plotted down to row 2 rather
|
||||
than renumbering the rows under the user. `carry_row()` deliberately copies fields
|
||||
instead of assigning a whole `Row`: a `Row` also holds the figure handles (`plot`, the
|
||||
axis and cursor ids) and the `SlotSeries` objects the traces point at, and those belong
|
||||
to the row's *position*. It clears the destination's slot bindings for the same reason —
|
||||
the next `bind()` re-resolves them, and without the clear the frame in between draws the
|
||||
moved row's samples under the new row's legend.
|
||||
Both call `rebuild_all()` themselves, so their callers must re-point the area
|
||||
(`PlotArea::set_figure()`) directly; `relayout_areas()` is no help, since it only acts on
|
||||
a *dirty* row.
|
||||
- **The layout editor's buttons are *separate* overlay children, never one transparent
|
||||
child covering the area.** A `GtkOverlay` child is picked wherever its allocation is, and
|
||||
`gtk_widget_set_can_target(FALSE)` takes a widget's children out of picking with it, so a
|
||||
full-area `GtkFixed` would swallow every pan, drop and hover the editor is not interested
|
||||
in. Each button is therefore a small widget aligned to a corner or the centre and placed
|
||||
by its **margins**, and the overlay is only measured from its main child, so moving them
|
||||
cannot reach the plot's layout.
|
||||
They are placed from `FigureLayout::rows` — the rects the frame was *drawn* from —
|
||||
divided by `PlotArea::device_scale()`, because a `Rect` is framebuffer pixels and a GTK
|
||||
margin is logical ones. Reading the figure's own rects rather than dividing the widget
|
||||
height by the row count is what keeps a `×` on the row it names when the rows stop being
|
||||
an equal division. `sync_layout_editor()` runs every frame on purpose:
|
||||
`gtk_widget_set_margin_*()` returns early on an unchanged value, so a settled layout costs
|
||||
the comparisons and nothing else, and a resize needs no notification.
|
||||
- **Trace slots are identified by signal name, not channel index.** Enabling a signal
|
||||
or a new CONFIG renumbers the channel list, so an index would silently re-point a
|
||||
trace at another signal.
|
||||
- **Layout must not depend on content.** The y gutter is reserved at the widest label
|
||||
`TickFormat::Si` can produce *for an axis with no unit* — a sign, three significant
|
||||
figures and a one-glyph prefix, measured and not counted in characters — and only ever
|
||||
grows; the status label is ellipsized so
|
||||
its text cannot drive the paned divider. Both exist to stop per-frame relayout.
|
||||
The reservation deliberately does **not** cover `format_si()`'s `%.3g` fallback
|
||||
(`-1.23e-45`), which it used to: that form needs a value past 1e24, so it bought a third
|
||||
of the gutter for a label no stream produces, and the floor being a monotone maximum over
|
||||
the *measured* need means one that did appear widens the gutter for good anyway. Erring
|
||||
wide is not free here — it is a third of the plot's width.
|
||||
And **nothing may add slack to the gutter it is told**: `note_y_gutter()`'s argument is
|
||||
`layout().y_axis_width`, which is `max(measured, floor)` — partly our own answer coming
|
||||
back — so a constant added to it compounds. See the ratchet reading under `layout over N frames` in
|
||||
[probe-readings.md](probe-readings.md).
|
||||
The same rule is why the activity badge is one glyph in three colours rather than
|
||||
three glyphs, why the `rate` field collapses to one figure or a range instead of a
|
||||
per-channel list, and why `format_elapsed()` steps to a coarser unit instead of
|
||||
letting the counter grow: each is a status field whose *width* would otherwise depend
|
||||
on the stream, and the status row is directly above the plot.
|
||||
The reservation goes on the **figure** (`Figure::set_min_y_gutter()`, added to the
|
||||
plotter for this), not on the rows: `Figure::layout()` clears every row's
|
||||
`set_min_y_gutter()` to measure what its own labels need, so a floor written onto the
|
||||
rows survives exactly until the next layout.
|
||||
- **The right-hand band is a want, and the left gutter is a measurement.** The two sides of
|
||||
the plot are not symmetric in kind: the left holds the y labels, so it is measured and
|
||||
floored; the right holds nothing, so there is nothing to measure and `RowSet` simply asks
|
||||
for half the widest time label plus the label padding (`right_gutter_px_`, reserved in the
|
||||
same one-shot block). Half a label is the figure that matters: an x tick label is
|
||||
*centred* on its tick and a live axis is `[-window, 0]`, so there is always a tick exactly
|
||||
on the axes' right edge, and with `axes` flush to the viewport half of that label was
|
||||
drawn outside the plot. Both go on the **figure** and for the same reason — the rows have
|
||||
to end on one column as well as start on one — which is why `apply_gutters()` applies the
|
||||
pair and `rebuild_all()` calls it: a new `Figure` carries neither.
|
||||
- **A trace's colour is indexed by *signal*, not by trace slot.** `Row::color_index`
|
||||
stores it and `signal_color_index()` (ui.cpp) computes it from the signal list, so a
|
||||
signal dropped on two rows is one colour and the signal panel's chip names a trace
|
||||
unambiguously. `plot::Plot` fixes a colour at `add_trace()`, so changing one needs a
|
||||
figure rebuild — which `assign()` already forces. A signal on no plot gets
|
||||
`RowSet::unplotted_color()` (grey) rather than no chip, so the panel's rows stay the
|
||||
same height.
|
||||
- **`RowSet::signal_color()` is the only route to a trace's colour.** The user can override the
|
||||
palette entry per signal (clicking its chip; `set_signal_color()` / `clear_signal_color()`,
|
||||
keyed by **name**, because a palette index is positional and a CONFIG renumbers), and that
|
||||
override is exactly the state in which two routes to a colour disagree. So `install()` builds
|
||||
the trace through this call and all three chip sites — the signal panel, the measures pane and
|
||||
the cursors pane — read it too; none of them may index `palette_color()` itself. An override
|
||||
marks every row holding the signal dirty (`mark_signal_dirty()`, not `assign()`: what the slot
|
||||
*holds* did not change) and the rebuild happens on the frame tick via `relayout_pending`,
|
||||
because `relayout_areas()` is the one place that builds a figure and re-points the area at it.
|
||||
`UDPSCOPE_PROBE_COLOR_MS` reads the result back out of `plot::Plot::trace()` and out of the
|
||||
chip's own markup, since asking the resolver twice would assert nothing.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Invariants: readouts, cursors and the value formatter
|
||||
|
||||
What a readout is allowed to quote, how the cursors are read back, and the one formatter both panes share.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **A readout quotes a stored sample, never a value between two of them.** The hover
|
||||
crosshair and the cursors pane's `mix` values are one call, `Plot::crosshair_value()`, and it
|
||||
reports the **nearer** of the two samples bracketing the x (a tie reads the earlier). Upstream
|
||||
interpolated linearly, and that is wrong twice over here: ingest decimation is a *boxcar
|
||||
average* over `time_stride` samples, so a number between two stored samples is a second
|
||||
estimate laid over the first that nothing on screen or in an export holds; and past the sample
|
||||
spacing the polyline is a line the renderer drew, so a point on it reports the drawing. The
|
||||
change is in `deps/plotter`, which keeps the blend as `CrosshairValue::Interpolated` for a host
|
||||
whose x is a quantity it is willing to interpolate — the nearest sample is the **default** and
|
||||
**udpscope never states the other**, so a `set_crosshair_value_mode()` call appearing here is a
|
||||
regression, not a feature.
|
||||
**Both the mark and the number come from one call, `Plot::crosshair_point()`**, which returns
|
||||
the quoted sample's own **x** as well as its y — so every mark this app draws for a readout is
|
||||
*snapped to the sample it quotes*. The library's `CrosshairRow::marker` and the cursors pane's
|
||||
`mix` crossing annotations (`place_crossings()`, which must use `crosshair_point()` and not
|
||||
`crosshair_value()`) are both placed from it. Two consequences that look like defects and are
|
||||
not: the mark **sits beside the polyline at a deep zoom** (one y feeding both the number and the
|
||||
dot is what keeps them from disagreeing), and a crossing dot **sits a little off its own
|
||||
vertical cursor line** — up to half a sample period, invisible at a whole-window view and the
|
||||
width of the panel at a few samples across, which is the only zoom at which anyone is asking
|
||||
which point it is. The line says where the *marker* is; the dot says which *datum* is quoted.
|
||||
The hover mark is also deliberately *larger* than a trace's own sample dots
|
||||
(`Style::crosshair_marker_px` 9 against `marker_px` 3), because a snapped mark lands among them
|
||||
and at their size reads as one more sample. The guards are the library's
|
||||
`crosshair_value_is_the_nearest_sample`, `the_crosshair_marker_snaps_to_the_sample_it_reports`,
|
||||
`crosshair_interpolated_mode_blends_the_two_samples` and
|
||||
`the_crosshair_mark_is_bigger_than_a_sample_dot`, plus `[cursors-probe]`'s `first mark at x=..
|
||||
y=..` agreeing with the pane's first value.
|
||||
- **`format_measure()` is the one formatter for both panes, and its SI prefix does not depend on
|
||||
there being a unit.** The cursors pane's values and the measurements pane's `text=` go through
|
||||
it, so a change reaches both. The prefix ladder used to be inside `if (!unit.empty())`, which
|
||||
made two decimals of a small unitless value read `0.00` — the reading gone, not merely coarse —
|
||||
and that is a whole class of channels, not a corner: `measure_unit()` returns `""` for any signal
|
||||
whose CONFIG declares no unit, and a **math channel never has one**. The regression net could not
|
||||
see it because `tools/mock_udpstreamer.py` declared `unit="V"` for every data signal; it now takes
|
||||
**`--unit ''`**, and that is the flag to run the measures and cursors probes under when touching
|
||||
this (`text=[636.62m]`, `● sine=585.24m`). With no unit the prefix is written **tight** against
|
||||
the number — `1.34m`, not `1.34 m` — because the space exists to separate the number from its
|
||||
unit and a spaced `m` alone reads as metres; with one it stays spaced and the prefix joins the
|
||||
unit (`636.62 mV`). Below `1e-12` the ladder runs out, and `%.2f` would print `0.00` again, so
|
||||
that range falls back to `%.3g`.
|
||||
- **The cursors are read back from the plots only while a gesture is on that row.** `Cursor::x`
|
||||
is the *free* pointer position while a drag runs and the snapped value otherwise -- which is
|
||||
what the app itself wrote a frame earlier, or the snap of it. A tick that reads a row on
|
||||
every frame therefore adopts its own echo back, and -- because the vertical pair is one value
|
||||
shared by every row -- a row that has not been re-written yet hands the *previous* shared
|
||||
time straight back. That is the "drag it and it springs back" defect, and the fix is
|
||||
`RowSet::sync_cursors(int drag_row)`: the tick passes the row whose controller reports
|
||||
`grabbed_cursor() != kInvalid`, and every other frame does nothing. The values are echoed
|
||||
back with `CursorPlacement::Free` for the same reason -- the controller snaps at the end of
|
||||
the gesture (`Controller::end_drag()`), and snapping the echo fights the hand that is still
|
||||
moving the cursor.
|
||||
- **The cursors' on/off is one flag on the `RowSet`, never a copy per row.** It is one
|
||||
piece of state (the toolbar toggle) and the mode is one value; a per-row copy is something
|
||||
`insert_row()`'s `clear_row()`, or any rebuild, can clear behind the user's back, which is
|
||||
how a row added while the cursors were already on came up with its instruments parked
|
||||
while every other row showed them. The same goes for the vertical pair's `x`: it is one
|
||||
stream time for the figure, so it lives on the `RowSet` too, and `Row` keeps only what is
|
||||
genuinely per row (the rulers' values, the traces, the colours).
|
||||
- **Every instrument this app *computed* is out of the pointer's reach.** The trigger marker and
|
||||
the measurement marks are cursors created with `Plot::add_cursor()`/`add_h_ruler()`, and a cursor
|
||||
is grabbable by default -- so each takes `set_cursor_selectable(id, false)`. **Parking it outside
|
||||
the axis is not enough**: the controller holds a marker for the gesture's life and follows the
|
||||
pointer between frames, so a readout parked at `kParked` could still be dragged. The trigger
|
||||
marker says where the scope put the edge and a measurement mark says what a measurement measured;
|
||||
neither is something a user may move. The dump prints `readout instruments locked: N`, which must
|
||||
be 1 -- a drag is not something a probe can perform, so the state is what is asserted.
|
||||
@@ -0,0 +1,160 @@
|
||||
# Invariants: rendering — index mapping, gather, LOD and grading
|
||||
|
||||
How the renderer maps a visible range onto samples, what `gather()` emits and relative to what, and why a dense trace is drawn one pixel column at a time.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **The renderer maps the visible range onto indices through the series' own x, never the
|
||||
`dx()` grid.** `deps/plotter`'s `Renderer::draw_traces` asks `Series::neighbours()` for the
|
||||
bracketing samples. A grid walk is wrong for exactly the series this app plots: `dx()` here is
|
||||
a nominal spacing (the LOD level, a coarse snap) while the real stamps are the time ring, so
|
||||
the window it computes lands inside the visible range and the two end segments — the ones that
|
||||
draw into the left and right margins — are never emitted. That is the "empty margin when
|
||||
zoomed in" defect. The library test is
|
||||
`a_sparse_window_between_samples_still_inks_both_margins` (a stub whose `dx()` lies, with the
|
||||
window strictly between two samples). It is the guard, not the probe: `[trig-probe/idx]` now
|
||||
computes `got` from the same call the renderer makes, so it cannot catch a renderer that went
|
||||
back to the grid — what it still reports is the drift that made the walk wrong (`dx_ratio`).
|
||||
- **`neighbours()` is a binary search over the real stamps in *every* series here, the
|
||||
snapshot's included.** `EventSeries` answered from the `(x - x0()) / dx()` grid — the walk the
|
||||
invariant above condemns: `dx()` is the *mean* spacing of the snapshot's stamps and the stamps
|
||||
are an arrival clock, so grid and clock drift apart by whatever that clock wandered over the
|
||||
capture. The renderer picks its visible index window from this call, so the drift arrived as a
|
||||
**constant offset in seconds** between the axis and the gathered geometry — and a constant
|
||||
number of seconds is a *doubling share of the screen* on every zoom step: 8 ms over a 2 s
|
||||
capture is invisible, and seven halvings later it is 374 px of blank left margin on a 716 px
|
||||
axis, with the ink pushed off the right-hand edge by the same amount. That is the "zooming
|
||||
into a held event clips it more and more from the left while the crosshair still reads the
|
||||
right values" report; the crosshair was right because it reads `x_at()`, a real stamp.
|
||||
`ChannelBuffer` always searched; the two now agree. **A perfectly uniform source cannot show
|
||||
this at all** — three probe runs against the ideal mock were clean — so the guard needs
|
||||
`--clock-jitter` (see `UDPSCOPE_PROBE_EVENTZOOM_MS` above).
|
||||
- **The `Series` grid contract does not hold for `ChannelBuffer`.** x is a streamed
|
||||
timestamp, so `dx()`/`x0()` report a nominal grid (LOD level, coarse snap) while
|
||||
`gather()`, `x_at()`, `neighbours()` and `x_bounds()` use the real time ring.
|
||||
Nothing may walk that grid to find *which* samples a range covers — the renderer did,
|
||||
and it lost the two end segments (see the margin invariant above). `gather()` emits x
|
||||
relative to an origin because absolute epoch seconds do not survive `f32`.
|
||||
- **`gather()` emits x relative to an origin *the caller names*, in every series here**,
|
||||
`EventSeries` included, and the consumer adds that same origin back in `f64`. Two separate
|
||||
rules live here and both were learned from a defect.
|
||||
(1) **It must be relative at all.** An event's stamps are small (edge relative,
|
||||
`[-pre, +post]`), so emitting them absolute does not overflow anything and does not look
|
||||
like a bug — it just draws the whole capture `x0()` = one pre-trigger window to the left of
|
||||
the axis. That was the "half the plot is empty / the marker sits at the end of the data /
|
||||
nothing is drawn but the crosshair reads values" defect: the crosshair reads `x_at()`, which
|
||||
was right all along. `[trig-probe]`'s `data=` vs `drawn=` is the guard.
|
||||
(2) **The origin is the *view's* minimum, not `x0()`.** `x0()` is the start of the
|
||||
**retained window**, so the f32 had to resolve 2^-24 of the whole ring however little of it
|
||||
was on screen: 1.9 µs over 20 s of retained data, which at a view 10 µs wide across a 716 px
|
||||
axis is over a hundred pixels between where a sample is drawn and the x `x_at()` quotes for
|
||||
it — and it halved on every zoom out, so it read as "zooming in makes the crosshair select a
|
||||
point beside the data". `Renderer::draw_traces` passes `x_range.min`, making the error a fixed
|
||||
4e-5 of a pixel at any zoom. The parameter sits **before** `out_xy` deliberately, so a call
|
||||
site that was not thought about is a compile error rather than silent drift. A caller that has
|
||||
no view in mind may still pass `x0()`, and `[trig-probe]`'s `drawn=`/`data=` does exactly that
|
||||
on purpose: it reads the series' *whole* span, which is the widest relative value a gather can
|
||||
be asked to stage. The two guards are upstream's
|
||||
`a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes` and the
|
||||
`trace ink:` line's `drawn-vs-quoted` figure.
|
||||
**A probe that recomputes what the renderer gathers must pass the same origin the renderer
|
||||
does** (`[ink]` and `[zoom-probe]` both pass the axis minimum), or its reconstruction stops
|
||||
being the renderer's and its pixel readings mean nothing.
|
||||
- **Every series bound to a trace must carry a real `MinMaxPyramid`**, including an
|
||||
immutable one. `MinMaxPyramid::level_for()` descends to level 0 — raw samples — on a
|
||||
pyramid with no levels, whatever `n` is, so a series that answers `pyramid()` with an
|
||||
empty one is re-gathered sample by sample on every frame it is bound. That was the
|
||||
"large data is laggy" defect: `EventSeries` had no pyramid, and a held 2 s capture at
|
||||
1 MS/s staged 6 million segments per frame for as long as it stayed on screen. The
|
||||
pyramid lives on `EventSnapshot`, not on `EventSeries`: the snapshot is immutable, so it
|
||||
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
|
||||
[probe-readings.md](probe-readings.md)).
|
||||
* **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.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Invariants: the session file, the export and the source chooser
|
||||
|
||||
What a session persists and in what order, when a save is refused, and when an export is collected.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **The session is the layout.** `LayoutState` persists what makes a reopened scope look like the
|
||||
one that was closed: the window, the rows and their slot signals, the calibration, **the enabled
|
||||
signals**, **the chosen trace colours** (`[[color]]`, `name` + `rgb = "#RRGGBB"` — only the
|
||||
*overrides*, so a stream's new signals still take the positional palette and a saved session does
|
||||
not freeze a colour that is meant to follow the signal list), **the math channels with their
|
||||
graphs** and **the measurements pane**. Signal identity
|
||||
is the *name* throughout (a CONFIG renumbers channels), 'enabled' is read off the channels the
|
||||
model built -- an enabled signal is exactly one that produced a channel -- and `apply_layout()`
|
||||
installs the graphs **before** assigning the rows, since a row may hold a math channel's name and
|
||||
that name is only a signal once its graph is in. Two format rules, both learned the hard way:
|
||||
write an op or a measure *type* as its index through `math_op_at()`/`measure_type_at()`, never as
|
||||
the raw enum value -- those accessors' orders are the dialog's, not the declaration order, so
|
||||
writing one and reading the other silently renames operations. And a top-level key written after
|
||||
a `[[table]]` belongs to *that table* in TOML: `enabled = [...]` emitted after `[[calibration]]`
|
||||
was parsed into the last calibration record and the enable state was lost without a word.
|
||||
`mkdirs()` used to create only components with a trailing slash, so `~/.config/udpscope` was never
|
||||
made and the *first* save failed silently -- worth knowing when a layout change "does not stick".
|
||||
- **The export is collected at click time, not in the file-dialog callback.** The live
|
||||
view keeps scrolling while the chooser is up, so a late collection would write a
|
||||
window the user never saw. The writer is picked by the chosen path's extension.
|
||||
- **The chooser runs before `ScopeModel` exists, and `--server` is what decides.** `Config` carries
|
||||
`server_specified`, set only by the `--server` branch, and `main()` asks only when it is false —
|
||||
so declining costs no buffer and no socket, and `ScopeModel`, `Acquisition`, `RowSet` and
|
||||
`run_ui()` need no notion of "no source yet". It is `--server` alone and not "no arguments":
|
||||
`--multicast` still needs a server to fetch CONFIG from. **Every documented probe passes
|
||||
`--server`**, which is what makes the feature invisible to the regression net — a probe added
|
||||
without it would reach the chooser and hang. `gtk_init()` plus a nested `GMainLoop`, and then
|
||||
`run_ui()`'s `GtkApplication` afterwards in the same process, is a verified combination
|
||||
(`gtk_init()` is idempotent; running your own loop is the documented GTK4 replacement for the
|
||||
removed `gtk_dialog_run()`).
|
||||
- **A session file's name cannot say how its source was reached, so the file says it.**
|
||||
`<host>_<port>.toml` carries neither the multicast group nor the interface, so a chooser row
|
||||
could not reconnect to a multicast source at all. `LayoutState::multicast`/`interface_addr` are
|
||||
written in the **top block, before the first `[[table]]`** — a bare key after a table header is
|
||||
that table's key in TOML, which is exactly how `enabled` was parsed into the last
|
||||
`[[calibration]]` record and lost without a word. A file from an older build has neither key and
|
||||
loads as unicast on the default route. The chooser's list filters to sessions that **name at
|
||||
least one signal**, because a probe run leaves a file per throwaway port and all of them hold
|
||||
`signals = ["", "", "", ""]`; `known_sources()` splits the filename at the **last** `_`, since
|
||||
`sanitize()` turns anything unsafe in a host into one.
|
||||
- **`save_layout_now()` refuses to write while `signals()` is empty.** A run against a source
|
||||
whose streamer was down receives no CONFIG, so every slot name stays empty and the save would
|
||||
overwrite a configured session with `signals = ["", ...]`. The chooser then filters that file
|
||||
out — a source whose streamer was merely down during one run would silently vanish from the
|
||||
only list it can be picked from, for ever. Keeping the old file loses nothing: a run that
|
||||
configured nothing had nothing worth persisting.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Invariants: the time axis and the window control
|
||||
|
||||
What may change the time scale (one function, one caller), how the x labels pick their form, and what a fit restores.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **The window control's label is width-locked and ellipsized** (`kWindowLabelChars`), for
|
||||
the same reason as the status label: it sits in the header bar above the plot, so a label
|
||||
that grew from `1 s` to `123.456 ms` would widen the bar and relayout everything under it
|
||||
on a mere change of window. That is also why the window is typed into a popover rather
|
||||
than shown in the bar.
|
||||
- **No plot interaction changes the time scale; only the window control does.** The *time scale*
|
||||
is more than the axis: `RowSet::window_s_` also feeds `ScopeModel::set_event_span()` (the
|
||||
trigger's pre-trigger share is a share of *this* window), `ScopeModel::set_target_span()`
|
||||
(which may re-plan the partition, move the decimation and **drop the history**), the window
|
||||
control's readout and the session file. A **zoom or a pan** does not touch it by construction —
|
||||
the controller writes the *member* axis and `clamp_to_data()` caps the span at `window_s_`, so
|
||||
nothing has to be written to keep it that way. **Neither do the two fits**: a fit is the
|
||||
*undo of a zoom*, so `RowSet::fit_x()` restores the window that is already set and reads
|
||||
`window_s_` without ever writing it. Both fits used to frame the *data* instead and move
|
||||
`window_s_` alone, which was wrong twice over — a gesture-triggered re-plan of the rings, and
|
||||
an event span left on the *old* window until the next decade step or unit change applied the
|
||||
fitted value for real (one action, two changes of time scale).
|
||||
- **`apply_time_window()` is the only place the time scale changes**, and the window control is
|
||||
its only caller. It clamps to `window_limit()` and moves `set_target_span()`,
|
||||
`set_event_span()`, `RowSet::set_time_window()`, the control and the session save together —
|
||||
the partition **before** the axis, so the axis is framed over the rings that will exist. A fit
|
||||
must not come here for two reasons: it is not entitled to the scale at all, and
|
||||
`set_time_window()` unpins and re-arms the live follow, which would throw a held event or a
|
||||
paused view onto a live edge it has nothing bound to. Paths that change the span for their own
|
||||
reasons (an event pin) call `sync_window_display()` alone, which only restates the control and
|
||||
never moves the plot — calling `apply_time_window()` there would clamp a legitimately short
|
||||
capture up to the requested window.
|
||||
- **The x labels go relative on the tick *step against the label resolution at the axis'
|
||||
magnitude*, never on the span.** `RowSet::update_time_labels()`'s question is only ever "can
|
||||
this step be written at this magnitude": `TickFormat::Time` carries three significant figures
|
||||
below a minute, **whole seconds** through `m:ss`/`h:mm:ss` up to a day, and three figures of
|
||||
days above — so the resolution is a step function of the magnitude, which `time_label_resolution()`
|
||||
states and nothing else may re-derive. The old test was `span < 1.0` and it was wrong in both
|
||||
directions: a 2 s window at twenty minutes of uptime reads `20:00` on every tick and stayed
|
||||
absolute, and a **triggered** sub-second window — whose axis is already `[-25ms, +25ms]` about
|
||||
the edge — went relative and so re-anchored its ticks *away from the trigger*, with a `t0`
|
||||
caption that had nothing to explain. Three details hold it up. The step is read back from the
|
||||
majors the **last layout** produced, which is not a feedback loop (a tick's value depends on the
|
||||
range and the axis' pixel width, never on the label form) but *is* absent before the first
|
||||
layout — and with no ticks the form must be **left alone**, because a step guessed from the span
|
||||
would be latched by the hysteresis below. The hysteresis is a 1.05 band on *leaving* the relative
|
||||
form. And *entering* it needs a ULP of slack (`1 - 1e-9`): a step differenced from two tick
|
||||
values and a resolution from `pow(10, n)` differ in their last bits when they are the same
|
||||
number, and `step == res` is a readable axis (`1.73s`, `1.74s`, `1.75s` — one in the last digit
|
||||
is exactly enough), so the bare comparison picked a different form from one frame to the next.
|
||||
The guard is `[timelabels-probe]`'s `distinct=1`, which asserts what the form is *for* rather
|
||||
than which form a given window gets.
|
||||
- **The `t0 = …` caption is chrome, never an annotation.** It is `Plot::set_x_note()` (added to
|
||||
`deps/plotter` for this), drawn by `Renderer::draw_x_note()` on the legend's own
|
||||
`draw_readout_panel()` backplate in the bottom-left corner of the axes, in **pixels**. It was an
|
||||
`Annotation` first, and an annotation's anchor is a *data* coordinate: the caption therefore
|
||||
crawled across the plot under a pan and drifted under a zoom, which is what "[t0=...s] move
|
||||
slightly around when user zoom/pan" was. Three things follow. The **text is the host's**, because
|
||||
the caption has to name the anchor precisely enough that label + caption is the exact absolute
|
||||
time and `format_time()` would round it to three figures (`"t0 = %.6g s"`); only the **bottom
|
||||
row** of the figure carries one, every other row being set to `""`; and the renderer measures it
|
||||
rather than `Plot::layout()` — it reserves no space and nothing can be picked on it, so there is
|
||||
no rect to read back, and a caption explaining the x labels must not be able to move the layout
|
||||
those labels were measured into. The guards are the library's
|
||||
`the_x_note_is_chrome_that_a_pan_or_a_zoom_does_not_move` (the footprint is the pixels a frame
|
||||
with the caption differs from one without, and a pan plus a 64× zoom must give the same box to
|
||||
the pixel) and `[timelabels-probe]`'s `note=`, which reads `Plot::x_note()` back.
|
||||
- **`fit_x()` restores the *whole of what the view may show*, which is not always `window_s_`.**
|
||||
Live, that is the newest `window_s_` at the live edge (`set_time_window(window_s_)`, the same
|
||||
path a resume takes). Pinned — a held event, a filling capture or a paused view — it is the
|
||||
**pin's** full range, which is the snapshot's own stamps and may be legitimately *narrower*
|
||||
than the requested window. It must write the per-row **member** axes as well as the link group:
|
||||
`hold_pinned_window()` prefers a member that differs from `pinned_stated_`, which is how it
|
||||
tells a real gesture from its own echo, so writing the group alone is reverted to the zoomed
|
||||
range on the next frame. `fit_all()` is the y fit plus this, shared rather than duplicated so
|
||||
the two cannot drift apart.
|
||||
@@ -0,0 +1,109 @@
|
||||
# Invariants: the trigger, the capture and a pinned view
|
||||
|
||||
The four display modes' framing rules: what the event window is counted in, what pins an axis, and what each pin is measured from.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **`clamp_to_data()` caps the span at the *requested window*, never at the retained
|
||||
span.** It runs every frame and cannot distinguish "the user zoomed out past the data"
|
||||
from "the ring has only just started filling", so `window_s_` — what the window control
|
||||
states, capped by `--max-window` — is the bound. Clamping to `retained` alone turned a
|
||||
requested 2 s window into the ~0.02 s the first frames held, permanently: nothing there
|
||||
grows a span back. Loosening it to `max(retained, window_s_)` fixed that and broke the
|
||||
other state instead — while the ring is filling the two are equal, but once it holds
|
||||
more than the window the bound becomes the whole ring, and a wheel zoom-out walked the
|
||||
view into data the window was never set to show. `window_s_` is right in both: it never
|
||||
shrinks a window a caller asked for, and a partly filled buffer is left empty on its
|
||||
left, which is what a scope that just started should look like. `UDPSCOPE_PROBE_PAN_MS`
|
||||
reports it as `zoom ... span=.. window=.. limited=1`.
|
||||
Its pan clamping is likewise gated on the data being wider than the view, or the empty
|
||||
left-hand side drags the live edge off the right-hand edge.
|
||||
- **Signals start disabled** — enabling is what starts acquisition. A trigger on a
|
||||
signal nobody acquires can never fire, so selecting/arming enables it.
|
||||
- **The trigger's event window is separate from the display window**, and
|
||||
`pretrigger_pct` is a share of the *event* span, not of the ring.
|
||||
- **The event window is counted in stream seconds end to end; nothing converts it to
|
||||
a sample count.** `TriggerEngine` latches once the stamps it is fed have advanced
|
||||
past the post window, and `ScopeModel::publish_event()` cuts the snapshot by binary
|
||||
search over the time ring. There used to be a measured samples-per-second estimate
|
||||
in `Acquisition` doing that conversion, and it is precisely why the post window came
|
||||
out empty: any stream whose time signal advances in large steps drove it to its
|
||||
1 S/s floor, the count came out as 1, and the engine latched on the edge sample
|
||||
itself. **Do not reintroduce a rate estimate on this path.**
|
||||
- **A pinned window is *clamped* into the event every frame, and a zoom in is allowed.**
|
||||
While an event is held, a capture is filling, or the view is paused, `tick()` calls
|
||||
`RowSet::hold_pinned_window()`, which caps the span at `[-pinned_pre_, +pinned_post_]`'s and
|
||||
clamps the position into it (and states `Off`, since a following group would re-derive from a
|
||||
live edge). **A zoom in must keep working** -- "not outside the event" is not "not readable",
|
||||
and this function's first version *re-stated* the pin every frame, which made the wheel a no-op
|
||||
in single trigger mode: an acquired event could not be examined at all.
|
||||
**The request is read from the row's own axis, not from the group.** A member's `set_x_range`
|
||||
-- what the controller writes for a gesture -- is *not* adopted into the group while the follow
|
||||
is off, so a clamp that read the group only ever saw its own previous answer (the probe showed
|
||||
`group` and `member` both equal to the pin while the ask was gone). `pinned_stated_` remembers
|
||||
what the clamp last wrote: a member that matches it is our own answer coming back, one that
|
||||
differs is a gesture to honour -- which is also what keeps a *fresh* pin
|
||||
(`pin_event_window()`, `pin_pause_window()`, both of which write the group in the same tick)
|
||||
from being reverted to the member's stale window. Without it nothing bounds a drag or
|
||||
a zoom in those states: the library clamps a *following* group's offset only, and the live
|
||||
clamp is deliberately skipped here — so a gesture walked the frozen view off its axis, over
|
||||
data the scope never had. The two are one branch in `tick()` for that reason. A paused view
|
||||
pins the same way (`pin_pause_window()` sets `pinned_` from the frozen range), so the frozen
|
||||
picture is equally immovable; resuming or leaving trigger mode clears it.
|
||||
- **A pinned view is navigable *within its data*, and that is the bound -- not the request.**
|
||||
Two things had to agree for this, and both were wrong in the same direction:
|
||||
the **pause** pinned on the window it asked for rather than on the snapshot it got (a cut
|
||||
clamped to the ring is shorter), and the **held event** pinned on the snapshot's recorded
|
||||
`pre_s`/`post_s` -- the *request* again -- which a capture shorter than the request exceeds.
|
||||
Either way the shortfall was drawn as an empty margin, and a zoom-in moved the view *into* it,
|
||||
which is what made it look as if zooming caused it. `tick()` now re-states the pin from
|
||||
`RowSet::event_data_window()` (the bound **event series'** own stamps) while an event is held,
|
||||
from the snapshot's stamps on a pause, and `pin_pause_window()`/the pause handler set the axis
|
||||
to it as well; `[pause-probe]` prints `axis=` beside `frozen_x=` and the axis must lie inside.
|
||||
A `Capturing` view is the exception and stays on the *requested* `[-pre, +post]`: its post side
|
||||
is empty on purpose, and that is the capture filling in view, not a margin.
|
||||
- **A pause is pinned from its snapshot's own stamps too, not from the window it requested.** The
|
||||
pause freezes the *visible window*, but `publish_pause_view()` cuts that window out of the ring
|
||||
and the cut is clamped to what the ring holds -- so a window reaching back past the oldest
|
||||
retained sample produces a *shorter* snapshot. Pinning the axis to the request left the shortfall
|
||||
as an **empty margin on the left**, visible before any zoom and made worse by one: zooming in
|
||||
moved the view *into* the empty part, which is what made it look as if zooming caused it. The pin
|
||||
is the snapshot's own stamps now. `[pause-probe]` prints `axis=` beside `frozen_x=`, and while
|
||||
paused **the axis must lie inside `frozen_x`** -- equal at the moment of the pause, a subset
|
||||
afterwards if the probe zoomed in. Before the fix the two disagreed outright:
|
||||
`axis=[-1.44..0.56]` over data that started at `0.0`. (The event pin already worked this way; the
|
||||
pause now agrees.)
|
||||
- **The event axis is pinned from the snapshot's own stamps, not from the request.** A
|
||||
capture may legitimately be shorter than asked for — the ring need not reach back a
|
||||
full pre-trigger share on the first latch after arming, so `snap=[-0.339..1.000]`
|
||||
for a ±1 s request is correct there rather than a defect.
|
||||
- **The frame that *frames* a snapshot is the one that consumes its `event_seq()`.** The
|
||||
tick reads `display_mode()` *before* it takes `mutex()`, while the producer publishes
|
||||
inside that mutex and lowers `trigger_pending` only at the end of its packet — so a tick
|
||||
can read `Capturing`, block on the lock through `publish_event()`, and reach the framing
|
||||
step with a new sequence number it is not bound to. (The other case is benign and was
|
||||
always handled: a publication and the *next* edge in one frame, where the capture has
|
||||
framed its own window.) Advancing `seen_event_seq` on such a frame dropped the pin
|
||||
permanently — the axis kept the capture's requested `[-pre, +post]` over a shorter
|
||||
snapshot and `event_held` stayed false, letting `clamp_to_data()` drag the frozen view —
|
||||
so the test is `seq != seen_event_seq && display == Held` and the pin stays owed until a
|
||||
frame is bound to the snapshot. Visible only at high rates, where the publish is long
|
||||
enough to lose the race often; `[disarm-probe]`'s `axis=` against `snap=` is the guard,
|
||||
which means **running the disarm probe at 10 kS/s alone does not check this.**
|
||||
- **A capture in progress is the one place the axis is pinned from the *request*.** There
|
||||
is no snapshot to measure yet, so `DisplayMode::Capturing` frames
|
||||
`[-(span - post_span_s(span)), +post_span_s(span)]` — computed with the producer's own
|
||||
`TriggerConfig::post_span_s()`, not a second expression that rounds differently, because
|
||||
the marker at x = 0 has to land where the coming cut will put it. The latch then re-pins
|
||||
from the snapshot, and the two agreeing is why the picture does not jump at the latch.
|
||||
- **`clamp_to_data()` must be skipped while `capturing` as well as while `event_held`.**
|
||||
A capture is live data on a pinned axis — the exact combination the clamp was written
|
||||
for and against: it would follow the live edge and drag the frozen window off the event.
|
||||
- **`publish_event()` cuts one absolute index range and uses it for the time ring and
|
||||
every value ring.** That is what makes `stamps` and each `values[c]` equal-length
|
||||
and index-aligned by construction instead of by two truncations that can disagree —
|
||||
`EventSeries::gather()` indexes both at the same `i`.
|
||||
- **`live_window_locked()` is the one live cut, shared by `publish_event()` and the
|
||||
export.** Both need the same absolute index range over the time ring and every value
|
||||
ring, or an export and a capture of the same window would hold different samples. Call
|
||||
it under `mutex()`.
|
||||
@@ -0,0 +1,576 @@
|
||||
# Probe readings: what each line asserts
|
||||
|
||||
The per-line reference for the self-check's output — what every probe and dump line means, the value it must read, and the defect a wrong reading names.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md).
|
||||
|
||||
Two lines of the self-check output are assertions in disguise:
|
||||
|
||||
- `layout over N frames: axes.x A..B, ...` — **`viewport.w` must collapse to a single
|
||||
value and `axes.x`, `axes.w` and `y-gutter` must *settle*.** `note_y_gutter()` is
|
||||
monotone (`plot_host.cpp`), its one-shot widest-label reservation can land a frame or
|
||||
two after the first measured frame, and the figure serves its floor out of the label
|
||||
strip (`y_width = max(measured, floor - y_title_w)`, `plot.cpp`) so the reservation
|
||||
converges over two layouts rather than one. A `y-gutter 88..104` dragging
|
||||
`axes.x 88..104`/`axes.w 707..782` with it is that settle, not a twitch — confirm it
|
||||
with the `changes` line below. A range that keeps moving is the "twitching axis" bug:
|
||||
the plot being re-measured every frame. **A range that keeps *climbing* to 200 is the
|
||||
other one**: `note_y_gutter()` is handed the gutter the row was laid out with, which
|
||||
already includes the floor imposed last frame, so slack added to it is added again every
|
||||
frame and the gutter ratchets to its own 200 px cap — `axes.x 88..200 / y-gutter 88..200
|
||||
/ axes.w changes: 17` on an 870 px viewport is what that reads as, and 17 changes looks
|
||||
like a settle. The reservation's own padding is the only slack there may be.
|
||||
`axes.w` also no longer reaches the viewport's right edge: `apply_gutters()` reserves an
|
||||
empty band there (~59 px of 870), so `viewport.w - axes.x - axes.w` is a *constant*, not
|
||||
a zero. **`x-desync` must read 0**: it counts frames
|
||||
in which any row's x range differed from row 0's by more than a pixel of data, i.e.
|
||||
frames the x link group did not reach. `x-ticks` is content, not layout, and
|
||||
legitimately varies by one as a live
|
||||
window slides over a round number. The companion `axes.w changes: N (last at frame F,
|
||||
side panel up=S)` line separates the two readings: `0`, or a handful near the start,
|
||||
is a settled layout; a count near the frame count is the twitch. Runs far longer than
|
||||
the documented probes have shown one ~320 px step — the side panel's width — that
|
||||
reverts; it is intermittent and unexplained, so read `changes` before calling a range
|
||||
the twitch bug.
|
||||
- `[trig-probe] ... trigger=on ... display=... epoch=...` (stderr,
|
||||
`UDPSCOPE_TRIG_TRACE_MS`) — with `trigger=on`, `display` must read `event(frozen)`,
|
||||
`live(capturing)` or `nothing`, **never plain `live`**, except for the single interval
|
||||
that contains the toggle itself — recognise it by `latches=0` *and* the next probe
|
||||
reading `nothing` or `live(capturing)` with a frozen `axis=`. Plain `live` with the
|
||||
trigger on is the old "the plot keeps scrolling in trigger mode" defect; the named
|
||||
`live(capturing)` is the feature. In that state `epoch=` must equal minus the capture's
|
||||
edge and `axis=` must stay at `[-pre..+post]` across consecutive probes while
|
||||
`data=`'s upper bound climbs towards `+post` — an axis that moves instead means the
|
||||
capture is being treated as a live view (`clamp_to_data()` reaching a pinned window).
|
||||
`epoch=` is **minus the newest acquired stamp** in `live` and `nothing` — it is what makes the
|
||||
live axis `[-window, 0]`, so there it grows (in magnitude) with the stream and a `0.0000` is
|
||||
the defect, not the healthy reading. It is back to `0.0000` only in `event(frozen)`, where the
|
||||
snapshot carries its own origin (`EventSnapshot::x_offset`) and the display epoch has nothing
|
||||
left to do. Note the probe reads
|
||||
`pending=` fresher than `display=` (which is last tick's decision), so a single
|
||||
`pending=0 display=live(capturing)` line is one frame of sampling skew, not a defect.
|
||||
And `snap=[a..b]` must equal `axis=[a..b]`, with `b` positive by roughly
|
||||
the post-trigger share: that is the "post window is empty" defect's observable.
|
||||
**`drawn=[a..b]` must equal `data=[a..b]`** in both modes: `data` is `x_at()` (what
|
||||
the crosshair reads) and `drawn` is `x0() + gather()` (what the renderer maps), so a
|
||||
series that breaks the "gather emits x relative to `x0()`" contract shows up here as
|
||||
the two disagreeing by exactly `x0()` — a trace drawn a whole pre-trigger window left
|
||||
of its axis, invisible once the view is narrower than the shift, while the crosshair
|
||||
keeps reporting correct values.
|
||||
- `[trig-probe/idx] n=.. bounds=[..] dx=.. dx_real=.. dx_ratio=R got=[a..b) want=[c..d)`
|
||||
(stderr, same probe) — **`got` must contain `want`** (`a <= c` and `b >= d`), and
|
||||
`dx_ratio` must stay within about a percent of `1.0`. `got` is the index window
|
||||
`Renderer::draw_traces` gathers *today* — `neighbours()`, one sample of slack either side —
|
||||
and `want` is the same window from the same call, so read this line for the **rate**:
|
||||
`dx_ratio` is the nominal `dx()` against the real last-spacing, and it is the number that
|
||||
used to matter. It above 1 (every index scaled up, `a` past the first visible sample, the
|
||||
samples in between in the buffer and readable by the crosshair but never drawn) was the
|
||||
live view whose older part went blank while its newest part looked fine. That is a *fixed*
|
||||
defect: the renderer no longer walks the grid at all, because on a ring-backed series it
|
||||
also lost the two end segments and left the left and right margins of a zoomed-in view
|
||||
empty. The guard for it is the library's own pixel test
|
||||
(`a_sparse_window_between_samples_still_inks_both_margins`), not this line — a probe that
|
||||
recomputes the renderer's formula cannot see a renderer that changed it. Also
|
||||
read `axis=` in the companion line against the `window control:` line in the dump —
|
||||
a live axis narrower than the requested window is the same symptom from the other
|
||||
cause (see the `clamp_to_data()` invariant in
|
||||
[invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)). Both need a *saturated* ring to
|
||||
show up, so run them under `--buffer-mb 1`, which wraps in a few seconds.
|
||||
- `trigger: edge at X ... want [-a..b] got [-c..d] n=N` (stderr) — `got` must equal
|
||||
`want` and `N` must be exactly the window times the stream's real rate, plus one
|
||||
(2 s at 10 kS/s is `n=20001`, not 19999). `got` shorter than `want` on the *left*
|
||||
means the edge was accepted before the history reached back a full pre window (the
|
||||
gate in `TriggerEngine::process()`); shorter on the *right* means the engine latched
|
||||
early or the cut lost the newest samples. An `N` one or two short of the exact count
|
||||
is the same class of bug, not rounding: `publish_event()` clamps the slice to every
|
||||
value ring's `produced()`, so publishing before each channel of the latching sample
|
||||
has been pushed silently drops it.
|
||||
- `[disarm-probe] was_capturing=1 display=.. seq=A->B events=C->D snap=[a..b] n=N
|
||||
axis=[..]` (stderr, `UDPSCOPE_PROBE_DISARM_MS`) — with `was_capturing=1`, **`display`
|
||||
must read `event(frozen)`** and `snap`/`axis` must be `[-pre..e]` where `e` is how far
|
||||
the post window had filled, with `n` that span times the stream's real rate plus one.
|
||||
`display=nothing` (or a `snap` from the *previous* event) is the "disarming mid-capture
|
||||
makes the event completely disappear" defect. **`seq` must advance by exactly one while
|
||||
`events` does not move**: no event fired, so the partial publish deliberately leaves
|
||||
`trigger_latches()` alone — which is also what keeps the `trigger events: N total, M at
|
||||
the disarm` line in the dump meaning what it says.
|
||||
- `[export-probe] mode=.. axis=[a..b] rows=N cols=M time=[c..d] abs=[..]` (stderr,
|
||||
`UDPSCOPE_EXPORT_MS`) — **`time=` must equal `axis=`** and **`rows=` must be the
|
||||
span times the stream's real rate, plus one** (2 s at 10 kS/s is 20001). The export
|
||||
is the *visible* range at the *stored* rate, so a count far short of that is a table
|
||||
cut from the LOD pyramid, and a `time=` narrower than `axis=` is a window searched on
|
||||
the wrong clock (the live branch puts the window back on the stream's clock with
|
||||
`stream_at_x0()` before searching the ring, and that offset is a whole window while
|
||||
streaming — not the 0 it once was).
|
||||
**`abs - time` must be a single constant across both ends of a line**, and that constant is
|
||||
where the display's origin sits in the stream's own clock: `epoch()` plus the newest stamp in
|
||||
`mode=live`, `epoch()` plus the accepted edge in `mode=event`. That is the one assertion on
|
||||
the second column, and it replaces the old "`stream=` must be the edge ± the window" reading,
|
||||
which only ever held because the display clock *was* the stream clock. A `mode=live` line
|
||||
whose `abs - time` differs between the two ends is the two columns computed from different
|
||||
epochs — the defect `abs_base` exists as a single `const` to prevent.
|
||||
An export taken **mid-capture** reports `mode=live` with `time=[-pre..e]` where `e` is how far
|
||||
the post window has filled — the export is what is on screen, and during a capture that is a
|
||||
partly filled window. Pin the probe outside a capture when checking the `rows=` count.
|
||||
The two `bytes=` lines are the writers: the `.npy` is
|
||||
`64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)`, so a size that is not that is a
|
||||
mispacked record. Read it back with `pd.DataFrame(np.load(path))` after changing
|
||||
either writer.
|
||||
- `[window-probe] <action> label=.. entry=.. unit=U window=W event_span=E cap=C decim=M
|
||||
stored=R aa=F buffered=B` (stderr, `UDPSCOPE_PROBE_WINDOW_MS`) — **`window` must equal
|
||||
`event_span`** on every line (the trigger's event span is the display window; a capture
|
||||
cut to a different length than the axis frames is the "post window is empty" defect's
|
||||
cousin) and **neither may exceed `cap`**. The `unit ms->s` line is the one that catches
|
||||
the feedback loop: changing the unit re-interprets the typed number, so "500" in ms
|
||||
becomes 500 **s exactly**, and a `window=499.98` there means the control re-parsed its own
|
||||
rounded readout (`8.333 min × 60`) instead of the request — see the `window_sync_unit`
|
||||
invariant in [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md). `label` is a reading to three decimals in a natural unit and is
|
||||
*expected* to be lossy (500 s reads `8.333 min`); it is the `window=` figure that has to
|
||||
be exact.
|
||||
For the resampling: **`buffered` must be ≥ `window`** on every line — that is what the
|
||||
decimation is for, and a `buffered` short of the window is the old "the control is capped
|
||||
to what the ring holds" behaviour coming back. `decim` must be a power of two,
|
||||
`stored × decim` must equal the wire rate, and `aa` must be exactly `stored / 2`. Run it
|
||||
under `--buffer-mb 1`, which saturates in a few seconds and makes windows past ~6 s
|
||||
decimate; at 200 MB nothing inside `--max-window` needs to.
|
||||
- **The anti-alias check** is the same probe run against a waveform above the stored
|
||||
Nyquist: `--buffer-mb 1 --freq 2000` with the probe ending on a 60 s window gives
|
||||
`decim=16`, a 625 S/s stored rate and a 312.5 Hz corner, and the channel dump's
|
||||
`ch 0 'sine' ... y=[-0.059, 0.079]` is the assertion — a boxcar of 16 at 10 kS/s passes
|
||||
2 kHz at about 6%. **A `y=[-1, 1]` there means the filter is gone** and the 2 kHz sine has
|
||||
aliased to a full-amplitude 125 Hz one, which looks entirely plausible on screen. This is
|
||||
the only check that distinguishes averaging from striding.
|
||||
- `[cursors-probe] gestureless plot move with no gesture: stored A -> B ignored=D` — a
|
||||
plot-level `set_cursor_x()` with **no gesture behind it must be ignored** (`ignored=1`, and
|
||||
`A == B`): the tick reads a row only while its controller holds a cursor, and this line is the
|
||||
guard for the defect where it adopted its own echo and a dragged cursor sprang back. (The
|
||||
*adoption* half -- a gesture that does move a cursor, and the shared value reaching every row
|
||||
-- is asserted in the `check_host` harness, which can call `sync_cursors(row)` directly; a
|
||||
synthetic press here cannot reach the adapter's own event pump.)
|
||||
- `[cursors-probe] <action> mode=.. on=D pane=D t0=.. t1=.. dt=.. T=[..] lines=N [..]`
|
||||
(stderr, `UDPSCOPE_PROBE_CURSORS_MS`) — walks `vertical` → `horizontal` → `both` →
|
||||
`mix`, then moves a marker *through the plot* (`Plot::set_cursor_x(..., Free)`, which is
|
||||
what a drag does) and reports again. **The times must survive every mode change**: they
|
||||
are the `RowSet`'s one pair, and a mode that parks the markers must not read them back
|
||||
(a parked cursor reports a real number, so a read-back would adopt it as a gesture — the
|
||||
bug that made `T0` read 0). **`lines`** must be 0 for `vertical`, one per row for
|
||||
`horizontal`/`both` (and the time row exists only in the modes that show the vertical
|
||||
pair — `T=[]` in `horizontal` is the assertion that it does not), and one per **bound**
|
||||
trace for `mix` — a slot with a name *and* a channel the model built, never a name alone
|
||||
(see the bound-slot invariant in
|
||||
[invariants-plot-and-layout.md](invariants-plot-and-layout.md); `lines=6 marks=3` over three real traces is that
|
||||
defect). Each line reports as `<name>=<A> <B> <dY>`, so the names are the reading: a row
|
||||
claiming a signal that is not on a plot is the phantom. In `mix` the values must be the
|
||||
traces' own (`--` when a marker is past the newest sample, `partial` when only one
|
||||
marker is over samples). After the move, `t0` must have changed and every row's marker
|
||||
must have followed it (the report's `t0` is the shared value). **`marks`** is the `mix`
|
||||
mode's crossing annotations: **0 in every other mode, and one per bound trace for each
|
||||
marker that is over samples in `mix`** (so a move that brings the second marker into the
|
||||
data doubles it). The `first mark at x=.. y=..` line that follows must agree with the
|
||||
pane's *first* value for the same trace (the pane's lines are `chip name Y1 Y2 dY` --
|
||||
the chip carries the signal's name, since a chip alone leaves the user to match a colour
|
||||
against the plot by eye -- with the values formatted as measurements, two decimals, an SI
|
||||
prefix, the channel's unit, so the comparison is against the first number): both are read through the same
|
||||
`Plot::crosshair_value()`, and that line is the check that they stay the same number.
|
||||
A **ruler** drag is the other direction and the other hazard: the pane's `A`/`B` cells
|
||||
for a ruler row must change when the ruler moves (`after ruler drag` in the probe walks
|
||||
the first ruler to 80% of its y range), because nothing shares a ruler and the read-back
|
||||
in `RowSet::sync_cursors()` is the only thing that can notice. The dump's `cursors in plot
|
||||
0:` line is the other half: it reads the markers back out of
|
||||
the plot (`cursor(id).x/.visible`), which is what the renderer draws from — the pane's
|
||||
numbers and the plot's state agreeing is the difference between "the app placed them" and
|
||||
"the app placed them and they are on screen".
|
||||
- `[measures-probe]` + the dump's `measure N: 'sig' type thr=..% above=D show=D -> ok|invalid value text=[..]
|
||||
[duty=..] [marks=..]` and `measures pane: n=N revealed=D marks_shown=M pane_rows=R pane_w=W
|
||||
pane_h=H`
|
||||
(stderr, `UDPSCOPE_PROBE_MEASURES_MS`) — adds one measurement of every type on the first
|
||||
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. 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
|
||||
its unit, so `frequency` must read `Hz` (not the signal's unit), `period`/`pulse duration`
|
||||
seconds, and the rest the channel's unit. `marks_shown` counts two per shown measurement
|
||||
(a min/max showing both its marks counts two of its own). `measures window:` is the
|
||||
timeliness assertion -- the window the numbers were computed over against the one on screen.
|
||||
**The reading that must hold is `marks_in_window=K/K`**: every *time* mark inside the window
|
||||
on screen. Both ranges are on the **stream's** clock, which is the space `measure_window` and
|
||||
the time marks are stored in -- a live axis is a fixed `[-window, 0]`, so comparing display x
|
||||
would read no drift whatever the data did.
|
||||
`drift` itself is a **single sample of a sawtooth** and is not an assertion on its
|
||||
own: a live view scrolls between recomputes, so it rises to one `kMeasureRefreshUs` of scroll
|
||||
and drops to zero at each pass. The figure is `(|Δmin| + |Δmax|) / span`, so a pure scroll
|
||||
counts **twice** and the top of the sawtooth is `2 × refresh / window`: ~10 % on a 2 s window
|
||||
at the 100 ms cadence, and a few *hundred* percent on a window shorter than the interval (a
|
||||
50 ms window reads 254 % for 63.6 ms of scroll, and its ceiling is 400 %). Compute the ceiling
|
||||
for the window the run actually used before calling a figure large. A `0.0000%`
|
||||
is a dump that landed just after a pass, not a better state. Read it against the recompute count:
|
||||
*more* recomputes with a *higher* drift sample is the sawtooth, while a drift that exceeds one
|
||||
refresh interval of scroll, or marks outside the window, is the defect. A measurement is
|
||||
recomputed when the window moves (2 % of its span) or the held/live state changes, never on a
|
||||
wall-clock timer: a timer is what let a marker sit outside the viewport and a dragged one
|
||||
survive a frame. **`marks_selectable` must be 0**: the
|
||||
measurement marks are created unselectable (`Plot::set_cursor_selectable()`, which the
|
||||
library gained for this), read back out of the plot here -- re-placing a marker every frame
|
||||
does **not** stop a drag, because the controller holds it for the gesture's life and the
|
||||
marker follows the pointer between frames. The library's own controller test
|
||||
(`an_unselectable_cursor_is_neither_hovered_nor_grabbed`) is the other half. The probe's last
|
||||
step still drags a mark *programmatically* and requires the next frame to put it back
|
||||
(`restored=1`), which now checks the placement path rather than the read-only property. The dialog report's `type= thr=
|
||||
side= show=` is the per-type behaviour: `period` and `frequency` must show **neither** the
|
||||
split nor the side row (they compute from the mid level's rising crossings, and an option
|
||||
there is one the user can only get wrong), `average`/`min`/`max` no split either, and only
|
||||
`pulse duration` a side. A measurement whose signal is not acquired, or
|
||||
a window with too few crossings, must read `invalid`/`--` rather than a made-up number
|
||||
(`frequency` on a window holding one edge is the case: `duty` is reported only when a
|
||||
period exists to divide by). The probe then **walks the dialog's lifecycle**: open the edit
|
||||
dialog on measurement 0, click its `Save`, open it again, click `Delete` — `measures=` must
|
||||
be unchanged after the save and one lower after the delete, with the process still alive.
|
||||
That is the check for the two things a dialog of this shape gets wrong: two buttons owning
|
||||
one context (a double free aborts on edit/delete, and only those paths have two buttons) and
|
||||
a `const char*` array built from a `std::vector<std::string>` as it grew (every pointer
|
||||
dangling, the first entry the most visibly). The report's `signals=[..]` and `drop_h` are the second and third:
|
||||
the list must be the **plotted traces** (real names, deduplicated, in signal-list order —
|
||||
*not* every channel, and never a stream's timestamp signal, which is what an acquired-but-
|
||||
unplotted list put first and auto-selected), and `drop_h` must be a combo box's height
|
||||
(tens of pixels — a scrolled-window wrapper asked it to grow to 160).
|
||||
- `[timelabels-probe] <action> relative=D distinct=D window=.. origin=.. note=[..] ticks=[..]
|
||||
first_value=.. first_rel=.. range=[..]` + the dump's `time labels: relative=D distinct=D
|
||||
origin=.. note=[..] x_origin_set=.. ticks=[..] first_rel=..` (stderr,
|
||||
`UDPSCOPE_PROBE_TIMELABELS_MS`) — the x label form across a zoom, driven through
|
||||
`apply_time_window()`: **under the figure's x link group the group owns the window, so a
|
||||
row-level `set_time_window()` does nothing** (a probe that changes the window must use the
|
||||
app's path). **`distinct` must be 1 on every line, and that is the assertion** — it says no two
|
||||
major labels on the axis are the same string, which is the one thing the relative form exists to
|
||||
buy and the only reading that holds whatever the stream's uptime is. `relative=D` is **expected
|
||||
to read 0 on every line now**, and that is a consequence of the newest-relative axis rather than
|
||||
a change to the rule: the form is decided by the tick step against the label resolution at the
|
||||
axis' **magnitude** (see
|
||||
[invariants-time-axis-and-window.md](invariants-time-axis-and-window.md)), and no display mode carries a large magnitude any
|
||||
more — live and waiting are `[-window, 0]`, a capture and a held event are edge-relative, a pause
|
||||
is freeze-relative. The 50 ms window that used to read absolute at 2 s of uptime (`2.13s, 2.14s,
|
||||
2.15s`) and relative at 17 s (`100µs, 10.1ms, 20.1ms`, with the caption) now reads
|
||||
`-40ms, -30ms, -20ms, -10ms` at any uptime. The rule and the caption are kept because they are
|
||||
the answer for *any* axis magnitude and nothing in them is mode-specific — do not delete them on
|
||||
the strength of this reading — but what the probe asserts is `distinct`, not the form. The
|
||||
**caption must be non-empty exactly when `relative=1`**, which here means empty throughout. Run it after
|
||||
`UDPSCOPE_ARM_TRIGGER_MS` as well, which is the case the span rule got wrong: a triggered 50 ms
|
||||
window must read `relative=0 note=[] ticks=[-20ms,-10ms,0,10ms]` — a `t0` caption on an axis
|
||||
that is already edge-relative re-anchors the ticks away from the trigger, which is the one
|
||||
position the axis exists to show. The probe *races the paint* — it reads the axis on a timer,
|
||||
and the ticks it sees are the previous layout's — so its `first_rel` (the first major minus
|
||||
the axis' origin) is a fraction of a step; the dump runs after the last paint and is the
|
||||
race-free reading. Either way the assertion that matters is the *sum*: `first_value` must
|
||||
equal `origin + first_rel` as a *tick label* plus its caption, i.e. the labels and the
|
||||
caption must share one anchor. `x_origin_set` is read back out of the plot; it must be 0 in
|
||||
the absolute form. Note the last two steps both report `window=0.05`: `RowSet::set_time_window()`
|
||||
floors the window there, so the 20 ms step is clamped.
|
||||
- `[fit-probe] <action> pinned=D window=.. event_span=.. x=[..] span=.. y=[..]` (stderr,
|
||||
`UDPSCOPE_PROBE_FIT_MS`) — the two fits, through the **menu handlers** rather than the bare
|
||||
`RowSet` calls, because the notice and the control are the handler's half. A fit is the **undo
|
||||
of an x zoom**, so the probe performs one: it moves the y scale off the data (a fixed
|
||||
`[-100, 100]`), sets a 2 s window through the control's own path, then zooms row 0 to a quarter
|
||||
of it the way a gesture does — on the *member* axis — and fits. Four assertions.
|
||||
**`window` and `event_span` must be unchanged on every line** and equal to the typed 2 s: a fit
|
||||
is a plot interaction and may not restate the time scale (see
|
||||
[invariants-time-axis-and-window.md](invariants-time-axis-and-window.md)), and they are
|
||||
the only two readings that can see it try. The `zoomed` line's `span` must be a quarter of the
|
||||
window, or the fits have nothing to undo and the lines below mean nothing. **`fit x` must put
|
||||
`span` back to the whole window and leave `y` exactly as it was**, and **`fit all` must do the
|
||||
same and move `y` to the data's own range** — a `y` that changed on the `fit x` line is the two
|
||||
paths aliased. `x`'s *position* advances between lines on a live view and is not an assertion:
|
||||
the follow is re-armed on the newest data, which is what the whole window means there.
|
||||
Run it **inside a pause** as well (`UDPSCOPE_PROBE_PAUSE_MS=2000 UDPSCOPE_PROBE_FIT_MS=2200`),
|
||||
which is the other branch and the one that can silently revert: `pinned=1`, and `fit x` must
|
||||
restore the *pin's* full range — the snapshot's own stamps, legitimately narrower than the
|
||||
requested window (`span=1.179900` against `window=2.000000` is correct, not a finding) — and
|
||||
must still read `pinned=1` afterwards. The pin is restored by writing the **members** as well as
|
||||
the group, because `hold_pinned_window()` prefers a member that has moved since `pinned_stated_`
|
||||
— that is how it tells a gesture from its own echo — so a fit that wrote the group alone reads
|
||||
back as the zoomed range one frame later.
|
||||
- `[layout-probe] <action> rows=N buttons=B done=D done_box=[x,y,WxH] rows_y=[..] close=[..] add=[..] traces=[..]`
|
||||
(stderr, `UDPSCOPE_PROBE_LAYOUT_MS`) — **`buttons` must be `2N+1`** while the editor is
|
||||
up (one `×` per row, one `+` per boundary) and **0 on the `editor off` line**: the
|
||||
buttons exist only while the editor does. `done` follows the same rule for the editor's
|
||||
own exit button (1 up, 0 off), and its `done_box` on the `placed` line must sit in the
|
||||
area's top-right corner: `x + W` within a few pixels of the area width and `y` within a
|
||||
few of 0 (the on-switch line reports a box from before the first frame, so read the
|
||||
`placed` one). The last probe step leaves the editor by **clicking that button** — the
|
||||
toolbar toggle is still exercised by the first step — so `buttons=0 done=0` with the
|
||||
rows and traces untouched is the assertion that the way out works. **`traces=` is the real assertion**: an insert
|
||||
or a remove moves the rows' *contents* with them, so `add above 0` must read
|
||||
`[0:-|1:sine,…]` — row 0's signals now on row 1, not renumbered onto the new empty row —
|
||||
and `remove 0` must pull them back to `0:`. A `traces=` where the signals stayed on row 0
|
||||
means `carry_row()` was skipped and the rows were renumbered under the user.
|
||||
Geometrically, each `close=` must equal its row's top from `rows_y` plus the pad, and
|
||||
`add=` must be centred on the boundaries (so strictly increasing, the first at 0 and the
|
||||
last clamped to the area's bottom). The `rows_y` spans must tile: a gap other than
|
||||
`kRowGap` means the figure and the overlay disagree about where a row is, which is how a
|
||||
`×` ends up on the wrong row. Also read `x-desync 0` in the dump — every row the editor
|
||||
adds has to join the one x link group. Run it at `--plots 8` as well: the cap and the
|
||||
floor are the two refusals, and because the probe emits `::clicked` straight at the
|
||||
widget it reaches the handler even when the button is insensitive, so `add above 0` there
|
||||
must leave `rows=` and `traces=` untouched rather than growing a ninth row.
|
||||
- `header: window_button_w=W label_w=L bar_w=B` (stderr, once a second) — **`W` must be
|
||||
a small constant and must not track `B`**: the window control is a header-bar child,
|
||||
and an expanding one is measured into the title's centre box, so it stretched from the
|
||||
label to the title (516 px at a 1270 px bar before the fix, 121 px after). A `W` that
|
||||
grows with the window width is that bug back.
|
||||
- `toolbar icons: trigger=NAME(ok) pause=... cursors=... measures=... math=... sidebar=...
|
||||
menu=...` (in the exit dump, beside `panel controls:`) — the icon name every toolbar button
|
||||
carries, with `gtk_icon_theme_has_icon()`'s verdict. **Every entry must read `(ok)`.** Four
|
||||
of the names are this app's own (`udpscope-trigger-symbolic` and friends, a `GResource`
|
||||
compiled into the binary — see the toolbar-icon invariant in
|
||||
[invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)), and a `(MISSING)` on one of
|
||||
those means the resource path was never registered. That failure is otherwise *silent*: GTK
|
||||
substitutes a missing-image placeholder, the button still works, and every other line of the
|
||||
dump still passes. Changing an icon name is a one-line change in `build_toolbar()` and this
|
||||
is the only check that it names something that exists.
|
||||
- `app icons: org.udpscope.app(ok 128x128) org.udpscope.app-symbolic(ok 16x16)` (in the exit dump,
|
||||
beside `toolbar icons:`) — the application icon, named and present at the size its own root
|
||||
`<svg>` element declares. **Both entries must read `ok` at those sizes.** `ok` is
|
||||
`gtk_icon_theme_has_icon()` *and* the resource bytes being found under the registered path, so a
|
||||
`MISSING` is a resource path that was never registered or a file that was renamed, and a size
|
||||
other than 128 or 16 is a canvas that drifted off the HIG's. **Read it together with
|
||||
`toolbar icons:`, and trust that line further for the registration**: the resource-path
|
||||
registration is one call in `run_ui()` serving both the window's icon and the toolbar's buttons,
|
||||
so a `MISSING` on either line is the same defect — but on a machine where
|
||||
`--target desktop-entry` has been run, *this* line's `has_icon` half is also satisfied by the
|
||||
**installed** copy under `$XDG_DATA_HOME/icons/hicolor`, so a broken registration still reads
|
||||
`ok` here. That was measured: with the resource path deliberately misspelt, `toolbar icons:`
|
||||
reported four `MISSING` (nothing installs the action icons) while `app icons:` stayed `ok`
|
||||
until the two installed SVGs were moved aside, whereupon it read `MISSING 128x128` /
|
||||
`MISSING 16x16`. The line is still worth its place — the *present* half (the resource bytes) and
|
||||
the declared sizes are read from the binary and nothing else can satisfy them — but a developer
|
||||
machine is the one where it is least sensitive.
|
||||
**It deliberately does not claim the icon renders**, because no runtime GTK call can:
|
||||
`gtk_icon_theme_lookup_icon()` never returns `NULL`, its paintable's intrinsic size is the size
|
||||
it was *asked* for, and a valid icon, a malformed one and a name that does not exist at all all
|
||||
snapshot to the same `GSK_TEXTURE_NODE` of the requested bounds — GTK substitutes
|
||||
`image-missing`. A malformed SVG is a *build* error instead; see the icon-authoring rules.
|
||||
- `[sources] N known: HOST:PORT plots=P signals=S [multicast=G] [interface=I] | ...` (stderr,
|
||||
`UDPSCOPE_PROBE_SOURCES=1`) — the startup chooser's discovery, printed and exited **before
|
||||
`gtk_init()`**, so this one needs no display and no `xvfb-run`. **Only sources with a named
|
||||
signal may appear**: a development machine accumulates one session file per probe run on a
|
||||
throwaway port (140 of them when this was written), and the filter in `known_sources()` is
|
||||
what keeps the list the sources a user configured. Order is newest first by file mtime.
|
||||
- `[sources] picked HOST:PORT multicast=G interface=I` (stderr, `UDPSCOPE_PROBE_PICK_MS`) —
|
||||
shows the chooser, then activates row 0 the way a click does and continues into the real
|
||||
application, so a full run with **no `--server`** must reach the scope and emit every other
|
||||
dump line unchanged. `multicast=`/`interface=` coming back non-empty from a stored row is the
|
||||
assertion that a multicast session reconnects to its group, and it needs **no multicast
|
||||
networking**: write the session file by hand and read the line. With
|
||||
`UDPSCOPE_PROBE_NEWHOST=host:port` it drives the form instead — `[sources] form open,
|
||||
connect_sensitive=1` then a `picked` line, with no SIGSEGV, which is as much a crash test of a
|
||||
dialog built and destroyed as it is a state check. An empty list must print `picked nothing:
|
||||
the list is empty` rather than dereferencing a null row.
|
||||
- **The session layout round trip** (`~/.config/udpscope/<host>_<port>.toml`) — run once
|
||||
with a drop and a window change, then again with no interaction: the second run's dump
|
||||
must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the
|
||||
first run's values, and the signals it names must come back *acquired* (a `row 0 slot 0
|
||||
-> channel ... samples=N` line). Delete the file before a run whose layout you did not
|
||||
mean to inherit (`127.0.0.1_9540.toml` and friends accumulate otherwise).
|
||||
- `[udpscope] starved: connected (config=1), reconnects=N, no new frames in the last
|
||||
second ...` (stderr, once per second while the state holds, plus a status-line notice) —
|
||||
"connected but nothing is arriving after a reconnect", which over unicast is the second
|
||||
instance taking the source's single session. Check it does **not** fire against a live
|
||||
source. The status line carries ` | N reconnects` only when N > 0, which is the same
|
||||
counter without the prose.
|
||||
**The same stall test has two outcomes, split on `have_config`, and they are different
|
||||
diagnoses.** With a signal list in hand, the above. With **no CONFIG at all** it prints
|
||||
`[udpscope] connecting: connected (config=0), reconnects=N, no configuration from the
|
||||
stream ...` and the notice reads *trying to connect* — nothing was taken away, because
|
||||
with no signal list nothing can be enabled and no DATA frame is owed, and the old wording
|
||||
blamed a second reader that does not exist. That state is also named *without* waiting for
|
||||
the three-sample stall: the glance word is `connecting` and the badge is **yellow** from the
|
||||
first frame (see `badge frames:`). The way to see it with no second client is to point the
|
||||
app at a UDP port where nothing answers (the client CONNECTs, times out, reconnects), which
|
||||
is now the `config=0` branch and no longer exercises the `starved:` one at all — a check of
|
||||
the unicast-contention wording needs a real second reader, or a source that configures and
|
||||
then goes quiet.
|
||||
- **The export's `time`/`abs_seconds` columns must be strictly increasing and evenly
|
||||
spaced.** Check the written file, not just the probe: `np.diff(t)` with no `<= 0` and
|
||||
`rows / (t[-1] - t[0])` equal to the stored rate (the `ch` line's figure). Duplicate or
|
||||
backwards stamps mean the recorded timeline holds two clocks or a collapsed cadence —
|
||||
the two ways this has broken: a per-frame choice of time source (now sticky, see the
|
||||
README) and a per-sample step derived from the client's `lost` counter (now the
|
||||
stream's measured arrival cadence). A `rows=` count far below the span times the rate
|
||||
is the other signature, that of a table cut from the LOD pyramid.
|
||||
**The step must hold to a few ULP of the column's own magnitude**, and this is the check that
|
||||
needs a long run at a high rate: the old form of this assertion was "`set(np.diff(t))` holds
|
||||
exactly one value", and that was only true while `time` *was* the stored stamp. Both columns
|
||||
are derived now — `time` is `stamp - stream_at_x0()` and `abs_seconds` is that plus a base of a
|
||||
wholly different magnitude — so each subtraction costs a rounding and `set()` legitimately
|
||||
holds a handful of neighbouring doubles. Measured: four values spanning ~1.5 ULP, 6.8e-16 s.
|
||||
The thing that must not come back is the **plateau**: equal stamps, or a diff taking two or
|
||||
three values a few *hundred nanoseconds* apart, is the time ring losing precision with uptime
|
||||
(see the `f64` time-ring invariant in
|
||||
[invariants-data-path.md](invariants-data-path.md)). The figure to compute is the ULP of `t[0]` and of
|
||||
`abs_seconds[0]` — the defect that invariant guards is microseconds, nine orders of magnitude
|
||||
above the arithmetic noise, so the two readings cannot be confused.
|
||||
- `badge frames: lit=L dim=D idle=I connecting=C` — the activity badge's phase per frame. Over
|
||||
a run that acquired anything **both `lit` and `dim` must be non-zero**: that is what "it
|
||||
blinks" means, and a badge stuck lit is indistinguishable from a working one in a
|
||||
screenshot. One or two `idle` frames at the start are the frames before the socket
|
||||
connected; a large `idle` is correct only when the run paused or disarmed (a
|
||||
`UDPSCOPE_PROBE_DISARM_MS` run should show hundreds). **`connecting` must be 0 on a run
|
||||
against a live source** — it is the steady *yellow* phase, "the socket answered but the
|
||||
stream has sent no CONFIG", and a non-zero count against a working mock means a signal list
|
||||
that arrived late or not at all. It is the *only* non-zero phase on a dead-port run
|
||||
(measured: `lit=0 dim=0 idle=90 connecting=446`, the `idle` being the frames before the
|
||||
first CONNECT), which is also the check that a source that has sent nothing never gets the
|
||||
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 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 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 in
|
||||
[invariants-rendering.md](invariants-rendering.md)) — 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.
|
||||
**`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 in [invariants-rendering.md](invariants-rendering.md)), 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 in [invariants-rendering.md](invariants-rendering.md) 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 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=..
|
||||
ink=[..] cover=..% x0=.. x_at=[..] bounds=[..] rel=[..] dx=.. drops=[..]
|
||||
back=..` line per anomalous frame (stderr) — **`collapsed` and `staircase` must both be 0**,
|
||||
and **`drawn-vs-quoted` must stay far under a pixel** (0.0000 is what a clean run reads).
|
||||
This is the only check that can see a frame whose *waveform* vanished: `staged_segments`
|
||||
counts geometry including the grid and the instruments, and `lod_traces` counts a trace whose
|
||||
points are then collapsed, so both read healthy through the flicker. The probe runs on every
|
||||
painted frame that drew a trace and recomputes what the renderer did, through the same calls
|
||||
— `neighbours()` for the index window, the 64-spp gate for the level, `gather()`, then the
|
||||
increasing-suffix filter — and reports where the ink actually landed against the axis.
|
||||
`collapsed` is fewer than two distinct positions left (nothing to draw at all), `staircase`
|
||||
is the filter dropping over three quarters of the points, `short` is ink reaching under a
|
||||
quarter of the axis (reported only once the data spans the window, since a filling ring is
|
||||
*meant* to leave its left side empty). `drawn-vs-quoted` is the other reading, and it is the
|
||||
one that names a *defect of position* rather than of presence: at a raw level a gathered pair
|
||||
**is** sample `first + k`, so the line differences where a sample is drawn
|
||||
(`axis.min + gather[2k]`, the renderer's own reconstruction) against the x a readout quotes
|
||||
for it (`x_at()`, exact f64) and reports the worst of them in pixels. That is the
|
||||
gather-origin invariant in [invariants-rendering.md](invariants-rendering.md) read from the
|
||||
screen's side; it was over a hundred pixels at a
|
||||
deep zoom before the origin became the caller's. It is measured on the raw levels only — above
|
||||
them a gathered point is a bucket's envelope and has no single sample to be off from — so `R`
|
||||
is legitimately below `N`. **`back` ≈ one ring span is the signature** of the torn `x0()` read
|
||||
(see the cached-first-stamp invariant); so is an `ink=` range disjoint from `axis=`, or an
|
||||
`idx=` covering the whole ring with `cover` far over 100%. There was a `kept_fixed_x0` field
|
||||
beside `kept` — the same filter with `x0()` hoisted out of the loop, since the renderer used to
|
||||
re-read `x0()` per point and a ring-backed one advances with the producer. The origin is a
|
||||
local of the renderer's now, so that failure mode is structurally impossible and the field is
|
||||
gone; the `[ink]` walk and the renderer still agree because both add `axis.min`. It is a race,
|
||||
so it needs load and a reason for long frames —
|
||||
`tools/mock_udpstreamer.py --rate 1300000 --batch 2000 --clock-jitter 0.4` with the
|
||||
measurements *and* cursors panes up:
|
||||
`UDPSCOPE_ENABLE_ALL_MS=1000 UDPSCOPE_PROBE_MEASURES_MS=2000 UDPSCOPE_PROBE_CURSORS_MS=3500
|
||||
UDPSCOPE_EXIT_AFTER_MS=35000`, which reported three staircase frames in each of three runs
|
||||
while the defect was live and none after. `frame path:`'s own numbers move with it (`segments`
|
||||
max 156078 → 19681), since a refused `neighbours()` gathers the whole ring.
|
||||
- `traceless paints: N (M after the first bind, last at paint P)` and `worst side/pane min
|
||||
width: W px`, plus `[gen] frame F generation A -> B (bound=.. empty=..)` and `[width-probe]
|
||||
...` lines (stderr) — three cheap per-frame counters that came out of the flicker hunt and
|
||||
each guard a documented invariant from the side the existing lines cannot see. **`M` must be
|
||||
only the frames in a state that draws nothing**: a traceless paint is legitimate in
|
||||
`DisplayMode::Waiting`, which is why the line carries `mode=` — `UDPSCOPE_EXIT_AFTER_MS` arms
|
||||
the trigger before it dumps, so a tail of them at the end of a run is expected. `[gen]` names
|
||||
each buffer rebuild as it happens with the bind state around it: a rebuild drops every ring's
|
||||
history, so the frames after one legitimately draw an empty plot, and `blank view frames`
|
||||
cannot see that (it asks whether the ring holds samples the view has not got, and a rebuilt
|
||||
ring holds none either) — so an unexplained `[gen]` burst is a structural change firing when
|
||||
nothing structural happened. `worst side/pane min width` is the "layout must not depend on
|
||||
content" invariant read from the **demand** side: a `GtkLabel`'s minimum width is its text and
|
||||
a minimum wider than the screen is honoured by growing the toplevel, which hands the plot an
|
||||
allocation wider than the window; `axes.w` sees that a frame later and cannot name the cause,
|
||||
so the panes the tick rewrites are measured directly. The `[width-probe]` lines are the
|
||||
per-change detail behind the `axes.w changes` count, and a step over 100 px dumps the widget
|
||||
tree's wide subtrees — that is what the documented, intermittent ~320 px step wants read.
|
||||
- `[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,
|
||||
`UDPSCOPE_PROBE_MENUS_MS`) — the two popovers the toolbar and the status bar open, measured with
|
||||
the side panel hidden. **`rows_bad` must be 0 and `fits` must be 1.** What it asserts is what a
|
||||
screenshot can only show an eye: every row's label starts at the *same* x (`label_x=26` for all
|
||||
seven — a menu whose checkable rows indent their text and whose others do not is exactly what
|
||||
"the text is not left-aligned" looks like), every shortcut ends at the row's own right edge
|
||||
(`accel_right == W`), no row is over `kMenuRowMaxPx` (30) tall — Adwaita's button minimum is 34,
|
||||
and seven of those is what made the popover read as a stack of dialogs until `.udpscope-menu`
|
||||
trimmed it — and the rows arrive in the order its `kExpected` names. `[menu] status` is the ⓘ
|
||||
bubble: **`pos` must read `top`**, which is set *after* `gtk_menu_button_set_popover()` because a
|
||||
`GtkMenuButton` states a position of its own and the last word wins, and `button_top` must exceed
|
||||
the bubble's height, because "below the button" is off the window's bottom edge — the bubble's
|
||||
top edge stayed at the button with its body clipped away, which reads as an arrow disconnected
|
||||
from its body. Run it **alone**: another probe that opens a window dismisses the popover, and
|
||||
every row then reports `h=0`. It turns autohide off for its own measurement — a popover dismisses
|
||||
itself when it loses the grab, and `xvfb-run` has no window manager to hand it one — and restores
|
||||
it afterwards.
|
||||
- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `<key>: <widget-description>: <before>-><after>` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values).
|
||||
- `[color] '<name>' at row R slot S: trace=#RRGGBB chip_agrees=1 (want 1)`, then `[color] override: trace=#123456 chip_agrees=1`, `[color] session: entries=1 rgb=#123456` and `[color] default: trace=#RRGGBB chip_agrees=1` (stderr, `UDPSCOPE_PROBE_COLOR_MS`) — **all three `chip_agrees` must be 1**, the override line's `trace` must be `#123456`, the session line must read `entries=1 rgb=#123456`, and the default line's `trace` must be back to the first line's. Run it with `UDPSCOPE_ENABLE_ALL_MS` ahead of it, or there is no bound trace and the probe prints `no bound trace to recolour` instead of asserting anything; on the standard mock the first line reads `'sine' at row 0 slot 0: trace=#4FA3FF`. Two things make this an assertion rather than a tautology. The colours are read out of **`plot::Plot::trace().color`** — what the renderer will stroke, reached through `Row::plot` *after* `relayout_areas()`, since a new colour is a new figure — and out of the chip's own `<span foreground="…">` markup, not by asking `RowSet::signal_color()` twice, which would pass against a chip that never reached the trace. And the override is `#123456`, deliberately in no palette slot: an override equal to the entry it replaces passes against an implementation that ignores overrides entirely. The session leg round-trips `collect_layout()` → `save_layout()` → `load_layout()` on a temp path and deletes it, because a probe must not leave a colour in the real session file for a later run to inherit.
|
||||
- `[zoomkeys] ctrl+plus x=[..]->[..] y_held=1 | alt+plus y=[..]->[..] x_held=1` (stderr, `UDPSCOPE_PROBE_ZOOMKEYS_MS`) — **both `_held` must be 1 and both zooms must still happen.** Run it inside a pinned state, where a frame cannot re-frame the other axis for its own reasons: `UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200`. A `_held=0` is the modifier not reaching the plotter's controller; an unchanged range on the *zoomed* axis is the opposite failure, the key doing nothing. Ctrl restricts to x and Alt to y; Ctrl+Alt zooms neither (no special case needed — neither flag alone fires).
|
||||
- `status bar: glance=[● live · 1.09 MS/s · 2 s window] max_glance_chars=53 detail_fields=11 popover_rebuilds=0` (exit dump) — **`max_glance_chars` must stay small** (under about 60) and **`popover_rebuilds` must be 0**. The status row is directly above the plot, so this is the "layout must not depend on content" rule read from the bar's own side: the state field is one word from a fixed set (`connecting`, `live`, `paused`, `armed`, `capturing`, `event held`, `disarmed` — a word added to it must be **no wider than the widest already there**, which is what let `connecting` join at ten characters beside `event held`; the dead-port glance reads `max_glance_chars=51` against a live run's 54), the rate field is `%.3g` (so `10 kS/s`, never `10.0 kS/s`), and the two fields a *state* adds are both elapsed times — trigger mode's `last event … ago` and a pause's `for <elapsed>` — which step units rather than growing digits (`format_elapsed()`). A **paused** glance is not in the dump at all: it is read by `[pause-probe]`'s `glance=` field, since the dump's own line is taken live. `detail_fields` is the popover's label count, a literal, so adding a label without thinking about layout shows up here. The glance rate format matters: `%.3g` drops the trailing zero that a `%.4g` or a fixed-precision format would keep — a rate that reads differently in the dump and in the help page is a format disagreement, and `14-status.txt`'s example must match it.
|
||||
@@ -0,0 +1,180 @@
|
||||
# Verification
|
||||
|
||||
How to run the headless self-check, the two baselines every run is read against, and the environment variables that schedule each probe.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md).
|
||||
|
||||
There is no unit test suite. The only automated check is the app's own headless
|
||||
self-check, driven by env vars and run under a display (`xvfb-run`, llvmpipe) against
|
||||
a live UDPStreamer source:
|
||||
|
||||
```sh
|
||||
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||||
./build/udpscope --server 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
Two baselines every run is read against. A clean build emits **four** warnings and no more: two
|
||||
`-Wmissing-field-initializers` on `MathNode::source` and `-Woverlength-strings` on **both**
|
||||
generated GResource `.c` files — the help pages' and the icons', whose blob crossed C99's
|
||||
4095-byte string-literal limit when the two app icons joined the four action ones (a note about a
|
||||
generated file, which GCC itself supports). The fifth warning that used to appear here was an unused
|
||||
`output_built` in `math_probe()`; it was **deleted** as dead code — redundant with the
|
||||
`output_after_add` already printed on the same line — and that deletion is what dropped the baseline
|
||||
from five to four. Nothing started reading it. And an exit-dump run emits **no** `Gtk-CRITICAL` at
|
||||
all. A fifth warning, or any critical, is a finding.
|
||||
|
||||
An *incremental* build reads **two** of those four: the generated GResource `.c` files are not
|
||||
recompiled when only `src/` changed, so their `-Woverlength-strings` do not reappear.
|
||||
|
||||
That second baseline used to be "exactly two, `gtk_widget_get_height` and `gtk_widget_get_width`,
|
||||
from the dump asking a destroyed widget for its size", and tolerating them was a mistake: the dump
|
||||
runs from the destroy handler, so the widget pointer is **freed memory**, and a critical is only
|
||||
the lucky reading of it. A run that had built and destroyed other windows first —
|
||||
`UDPSCOPE_PROBE_HELP_MS`, which opens three — has had that memory reused, and the same two calls
|
||||
then **SIGSEGV in the middle of the dump**, which reads as a truncated dump rather than as a
|
||||
crash in a debug line. Every size the dump prints is therefore captured on the *tick*, while the
|
||||
window is alive (`App::dbg_*`), and **no `gtk_widget_*` call may appear in the dump** — the
|
||||
`panel controls:` line was already written that way for the same reason, and that comment is the
|
||||
precedent, not an exception.
|
||||
|
||||
- `UDPSCOPE_EXIT_AFTER_MS` — run the trigger-on → go-live probe, close the window,
|
||||
then dump the whole pipeline state to stdout (frame/sample counts, per-plot painted
|
||||
frames, trace bindings, panel sizes, status line in both states).
|
||||
- `UDPSCOPE_ENABLE_ALL_MS`, `UDPSCOPE_ARM_TRIGGER_MS`, `UDPSCOPE_PROBE_DISARM_MS`,
|
||||
`UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes. `UDPSCOPE_PROBE_DISARM_MS` only
|
||||
*starts* its sequence: it switches the trigger on and then polls for a capture to open,
|
||||
so the Disarm lands ~300 ms into one rather than wherever a fixed delay happened to fall.
|
||||
- `UDPSCOPE_PROBE_FIELDS_MS` — change the trigger panel's edge and Normal/Single
|
||||
dropdowns. As much a crash test as a state check (see the GTK signal-signature invariant
|
||||
below), so time it inside a capture: `[fields-probe] ... capturing=1` followed by no
|
||||
SIGSEGV *is* the assertion.
|
||||
- `UDPSCOPE_PROBE_WINDOW_MS` — drive the window control every way a user can: type a
|
||||
number, change the unit under it, step a decade either way.
|
||||
- `UDPSCOPE_PROBE_LAYOUT_MS` — drive the layout editor: switch it on, insert above the
|
||||
first row, remove a row, append one at the bottom, switch it off. Its steps are 200 ms
|
||||
apart on purpose — the buttons are placed from the rects the *last frame* was laid out
|
||||
with, so a synchronous sequence would read every position one action stale. It clicks by
|
||||
emitting `::clicked`, never `gtk_widget_activate()`, which holds a `GtkButton` for its
|
||||
250 ms `ACTIVATE_TIMEOUT` and then emits into a button the rebuild has already destroyed.
|
||||
- `[measure-probe] N measurements on 'X'` (stderr, `UDPSCOPE_PROBE_MEASURE_MS`) — adds one
|
||||
measurement of each statistical type (average, top, peak to peak, amplitude) on the first
|
||||
*channel* and forces a recompute. It must name a **channel**, not `signals().front()`: the
|
||||
first signal of a stream is its timestamp, which is not acquired, and naming it adds four
|
||||
measurements that all read `--` *and* makes a performance run look free (each bails before
|
||||
collecting anything -- that is how the first baseline of the stall below read 0.4 ms).
|
||||
The values are in the exit dump as `measurements: <type>=<value>`; on the fast mock
|
||||
(`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`) a unit sine must read
|
||||
`peak to peak=2`, `amplitude=1`, `average=0` and **`top=0.636620`** -- 2/pi, the half-wave
|
||||
mean of a sine, which is the check that the threshold types are not merely plausible.
|
||||
`[measure-trace] view=[..] <type>=<value>... marks=N level:V/time:V...` is a repeating line
|
||||
(250 ms) from the probe: the pane's numbers beside the *current* window and the marks actually
|
||||
placed, which is the only way a stale result or a mark that should not be drawn shows up as a
|
||||
number. It must never print `marks=0` while measurements with `show_on_plot` are on and the
|
||||
data is there -- that was the rulers blinking off at a mode change.
|
||||
`measure cost: N recomputes, X ms total, Y ms each, data D ms (P%), covered=C buckets=B
|
||||
raw=R (F%), N ns/covered sample` follows it: **read it against a run with
|
||||
`UDPSCOPE_PROBE_MEASURE_MS` unset**, because a frame mean cannot attribute a cost. The last
|
||||
three fields are the assertion, and they are a *ratio*, not a time: `covered` is the samples
|
||||
the window spans, `buckets` the LOD buckets the two walks answered from, and `raw` the samples
|
||||
they still had to read. **`raw` must stay at a fraction of a percent of `covered`** on a clean
|
||||
waveform over a window of millions -- 0.1% is what the pyramid-backed walks give (see the
|
||||
measurement-cost invariant in
|
||||
[invariants-measurements.md](invariants-measurements.md)). 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* 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
|
||||
`[measure-trace]` line, which carries its own `view=`; those lines are also where the
|
||||
level-mark fallback is visible (an invalid pass must still print its `level:` marks).
|
||||
|
||||
- `[pan-probe] asked=[..] was=[..]`, `[pan-probe] after=[..] held=D asked=[..]`, then
|
||||
`[pan-probe] zoom asked=[..] after=[..] span=S window=W limited=L`
|
||||
(stderr, `UDPSCOPE_PROBE_PAN_MS`) — **`held` must be 1**: a gesture must not move a window
|
||||
that is pinned (trigger mode, or a paused view). The probe writes the panned range the way a
|
||||
gesture does — on the *member* axis, which `Figure::layout()` adopts into the group — and
|
||||
reports the axis the frame after it produced. Time it *inside* one pinned state: a pan during
|
||||
a capture whose check lands after the capture has ended is re-framed by the mode change, not
|
||||
by the pin, and reads `held=1` with the hold removed (that is how the first version of this
|
||||
probe fooled me). The pause probe makes a stable pin, so run them together:
|
||||
`UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_PAN_MS=2200` puts the pan and its check inside
|
||||
the pause. With `hold_pinned_window()` disabled it reads `held=0` and the axis sits at the
|
||||
asked range. It then asks for a **zoom-in** (a window a quarter of what is on screen) and
|
||||
reports `group=`/`member=` beside it: **`allowed` must be 1, with both equal to the ask** --
|
||||
that is the single-trigger-mode case, where the wheel has to keep working, and where reading
|
||||
the wrong one of the two made a working clamp look broken. All three verdicts are read from the
|
||||
*tick*, one frame after the ask: a `g_timeout` was the first version and it drifted past the
|
||||
paused/resumed boundary under load. The probe then asks for a **zoom-out** ten times the current span and reports
|
||||
the span the next frame produced: **`limited` must be 1**, i.e. `span <= window`. Run it with
|
||||
a ring that holds *more* than the window (`--buffer-mb 64` past the window's own seconds) —
|
||||
that is the state the old `max(retained, window_s_)` bound leaked in, and it showed up here as
|
||||
`span=2.502550 window=2.000000 limited=0` against `span=2.000000` after the fix.
|
||||
|
||||
- `[zoom-probe] <sweep>/x<step> axis=[..] span=.. n=N idx=[a..b) spp=.. level=L pts=P kept=K
|
||||
back=.. ink=[..] x_at_first=.. gap_px=.. end_px=.. rect=[..]` (stderr,
|
||||
`UDPSCOPE_PROBE_EVENTZOOM_MS`) — arms the trigger in **single** mode, polls for a **held
|
||||
event** (a latch happens when the stream says so; a sweep on a timer has nothing to zoom
|
||||
into), then zooms in eight doublings
|
||||
twice: `ges/` writes the row's own axis the way a gesture does, `win/` drives the window
|
||||
control (`apply_time_window`, which unpins and re-arms the live follow before the pin is
|
||||
restated). Each step's reading is taken in the *tick*, after that frame's pin and clamp — a
|
||||
`g_timeout` would report the ask. It recomputes what `Renderer::draw_traces` is handed, through
|
||||
the same calls: `neighbours()` for `idx`, the 64-spp gate for `level`, then `gather()`.
|
||||
**`gap_px` and `end_px` must stay within a pixel or two at every step** — they are how much of
|
||||
the left and right margin no geometry reaches, and a `gap_px` that *doubles* per step is the
|
||||
"zooming into a held event clips it more and more from the left" defect (374 px of a 716 px
|
||||
axis by `ges/x128`), whose cause is a constant offset in data seconds (see the `neighbours()`
|
||||
invariant in [invariants-rendering.md](invariants-rendering.md)). `kept`/`back` are the renderer's own increasing-suffix filter counted rather
|
||||
than applied: `kept` far below `pts` is a staircase, and a non-zero `back` is a non-monotonic
|
||||
gather. **Run it with `--clock-jitter`** (`tools/mock_udpstreamer.py --rate 1300000 --batch
|
||||
2000 --clock-jitter 0.4`): the ideal mock's stamps sit on a perfect grid, which is exactly
|
||||
what hides this class of bug, and three runs against it reported `gap_px=0.0` throughout while
|
||||
the defect was live. The jitter varies each *packet's* spacing rather than offsetting its
|
||||
start, because the stream's clock has to stay strictly increasing. The `win/` sweep bottoms
|
||||
out at `span=0.05` (`RowSet::set_time_window()`'s floor) and walks to the event's post side,
|
||||
since `set_time_window()` frames the newest span before the pin clamps it — both are expected.
|
||||
- `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are
|
||||
measured on a still picture. It now also asserts what a pause *is*
|
||||
(`[pause-probe] <action> paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..]
|
||||
glance=[..]` on stderr):
|
||||
it presses the **button**, not `Acquisition::set_paused()`, because pausing is a display
|
||||
state as well as an acquisition one and the view snapshot and axis pin live in the
|
||||
handler. Across the three lines **`ring_produced` must grow** (the producer never
|
||||
stops — it fills the live ring exactly as it does with the trigger off), **`event_size`
|
||||
must be non-zero exactly while paused** (the plot is bound to the frozen snapshot, not
|
||||
the ring) and **`frozen_x` must not move between the pause and the resume**; after the
|
||||
resume `event_size` is 0 and the window is back on the newest data. Pausing used to stop
|
||||
the producer, which is why the counts grew by nothing and the resume left a hole.
|
||||
`frozen_x` is the *rebased* snapshot — the pause view's own stamps are relative to the freeze
|
||||
instant now, so the range reads `[-window..0]`-ish rather than stream seconds, and a
|
||||
`frozen_x` in the thousands is the rebase gone.
|
||||
**`glance=` is the assertion for the paused status field**, and this is the only line that can
|
||||
make it: the exit dump's `status bar:` is read live and its two `mode …` lines are the live and
|
||||
triggered states. While paused it must read `paused · <rate> · <window> · for <elapsed>`, with
|
||||
the elapsed figure **growing between the two paused lines** and **absent on the resume line**.
|
||||
It is **wall** time (`g_get_monotonic_time()`), not stream time, because the producer never
|
||||
stops while paused and "how long have I been looking at this" is a question about the user, not
|
||||
about the stream; it is written through `format_elapsed()` so the field steps to a coarser unit
|
||||
instead of growing digits (see the layout rule).
|
||||
- `UDPSCOPE_PROBE_COLOR_MS` — recolour one plotted signal and read the colour back out of the
|
||||
trace and the chip, round-trip it through the session writer, then clear it. Schedule
|
||||
`UDPSCOPE_ENABLE_ALL_MS` before it: with nothing bound it has no trace to recolour and asserts
|
||||
nothing. Its four lines are in
|
||||
[probe-readings.md](probe-readings.md).
|
||||
- `UDPSCOPE_EXPORT_MS` (+ optional `UDPSCOPE_EXPORT_PATH`, default
|
||||
`/tmp/udpscope-export`) — collect the export table and write it both ways, skipping
|
||||
only the file chooser.
|
||||
|
||||
**Run one self-check at a time.** Two concurrent `xvfb-run -a` instances end up on the
|
||||
same display and the second gets no GL context — it dumps `layout over 0 frames` and
|
||||
`valid=-1`, which looks like a pipeline failure and is not one. `tools/mock_udpstreamer.py`
|
||||
also outlives its `--duration`, so kill it after each run or the leftovers pile up and
|
||||
hold ports.
|
||||
|
||||
The probes are the regression net — when changing UI or plot-host behaviour, extend
|
||||
the probe/dump in `src/ui.cpp` rather than adding a separate harness.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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.
|
||||
+5
-5
@@ -189,7 +189,7 @@ void ChannelBuffer::raw(u64 begin, u64 end, f32* out) const {
|
||||
for (u64 i = count; i < end - begin; ++i) out[i] = 0.0f;
|
||||
}
|
||||
|
||||
void ChannelBuffer::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
void ChannelBuffer::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||
plot::data::GatherMode mode) const {
|
||||
const u64 n = pyramid_.size();
|
||||
const u64 b = std::min(begin, n);
|
||||
@@ -197,7 +197,7 @@ void ChannelBuffer::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_
|
||||
if (b >= e) return;
|
||||
|
||||
const u64 origin = pyramid_.window_base();
|
||||
const f64 base = x0(); // x is emitted relative to this (see the header)
|
||||
const f64 base = x_origin; // x is emitted relative to this (see the header)
|
||||
|
||||
const auto emit_raw = [&](u64 from, u64 to) {
|
||||
for (u64 i = from; i < to; ++i) {
|
||||
@@ -302,13 +302,13 @@ u64 SlotSeries::snap_index(f64 x) const {
|
||||
return buffer_ != nullptr ? buffer_->snap_index(x) : 0;
|
||||
}
|
||||
|
||||
void SlotSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
void SlotSeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||
plot::data::GatherMode mode) const {
|
||||
if (event_ != nullptr) {
|
||||
event_->gather(level, begin, end, out_xy, mode);
|
||||
event_->gather(level, begin, end, x_origin, out_xy, mode);
|
||||
return;
|
||||
}
|
||||
if (buffer_ != nullptr) buffer_->gather(level, begin, end, out_xy, mode);
|
||||
if (buffer_ != nullptr) buffer_->gather(level, begin, end, x_origin, out_xy, mode);
|
||||
}
|
||||
|
||||
void SlotSeries::copy_raw(u64 begin, u64 end, f32* out) const {
|
||||
|
||||
+11
-8
@@ -73,16 +73,18 @@ class ChannelBuffer final : public plot::data::Series {
|
||||
/// datagram leaves a gap, so index times must come from the time ring.
|
||||
f64 x_at(u64 i) const override;
|
||||
/// Bracketing samples by binary search over the time ring, which is monotonic,
|
||||
/// so a crosshair value is interpolated between the two samples actually either
|
||||
/// side of the pointer -- a grid walk would land on the wrong pair after a gap.
|
||||
/// so a crosshair reads whichever of the two samples actually either side of the
|
||||
/// pointer is nearer -- a grid walk would land on the wrong pair after a gap.
|
||||
bool neighbours(f64 x, u64& i0, u64& i1) const override;
|
||||
u64 snap_index(f64 x) const override;
|
||||
plot::Range x_bounds() const override;
|
||||
plot::Range y_bounds() const override;
|
||||
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
|
||||
/// x is emitted **relative to `x0()`** (the library's contract): staging an
|
||||
/// absolute epoch time as f32 would quantise it far coarser than a pixel.
|
||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
/// x is emitted **relative to `x_origin`** (the library's contract): staging an
|
||||
/// absolute epoch time as f32 would quantise it far coarser than a pixel, and
|
||||
/// staging it relative to the whole *retained window* quantises it coarser than a
|
||||
/// pixel of a zoomed view -- see `plot::data::Series::gather()`.
|
||||
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override;
|
||||
|
||||
/// `copy_raw`, published so the retargetable trace series can forward the
|
||||
@@ -109,8 +111,9 @@ class ChannelBuffer final : public plot::data::Series {
|
||||
/// slot cannot be overwritten under the read. Everything else on this object is read at
|
||||
/// *paint* time with no lock, and this one slot is the ring's oldest: the one the producer is
|
||||
/// about to overwrite. Reading it there returned a stamp a whole ring-span too new whenever
|
||||
/// the producer lapped it between the bounds check and the copy -- and `x0()` is the origin
|
||||
/// the renderer reconstructs every trace point from (`gather[i] + series.x0()`), so one such
|
||||
/// the producer lapped it between the bounds check and the copy -- and `x0()` is what
|
||||
/// `neighbours()` tests the visible range against, as well as the origin the renderer
|
||||
/// reconstructed every trace point from before the caller named one, so one such
|
||||
/// read moved the entire polyline a ring-span off its axis (an invisible trace) or, caught
|
||||
/// inside the renderer's increasing-suffix filter, made it drop most of the points. That is
|
||||
/// the "live plot flickers as if there were no data" defect. The cached value is also the
|
||||
@@ -163,7 +166,7 @@ class SlotSeries final : public plot::data::Series {
|
||||
plot::Range x_bounds() const override;
|
||||
plot::Range y_bounds() const override;
|
||||
const plot::data::MinMaxPyramid& pyramid() const override;
|
||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override;
|
||||
bool streaming() const noexcept override { return event_ == nullptr && buffer_ != nullptr; }
|
||||
|
||||
|
||||
+28
-18
@@ -26,14 +26,24 @@ using plot::u64;
|
||||
/// live ring buffers switched by a pending flag, plus a display-epoch shift)
|
||||
/// could not stop.
|
||||
struct EventSnapshot {
|
||||
/// Seconds, monotonic -- edge-relative for a trigger event, the stream's own for a
|
||||
/// pause view. **f64, like the live ring it is cut from** (see `BufferStore::time()`):
|
||||
/// an event's stamps are small enough that f32 would nearly do, but a *pause* is
|
||||
/// deliberately not rebased, so its stamps carry the full stream time and quantise at
|
||||
/// the same 3.8 us per 32 s of uptime the live ring did. Keeping the two the same type
|
||||
/// also makes the cut in `publish_event()`/`publish_pause_view()` a straight copy.
|
||||
/// Seconds, monotonic, and **in the coordinates the axis reads**: a trigger event's
|
||||
/// stamps are edge-relative and a pause view's are relative to the instant it froze,
|
||||
/// which is what `x_offset` below records. **f64, like the live ring it is cut from**
|
||||
/// (see `BufferStore::time()`), which also makes the cut in `publish_event()`/
|
||||
/// `publish_pause_view()` a straight copy.
|
||||
std::vector<f64> stamps;
|
||||
std::vector<std::vector<f32>> values; ///< one array per channel, same length
|
||||
/// The stream time of this snapshot's x = 0, so `x_offset + stamps[i]` is the clock the
|
||||
/// data arrived on: the edge for a trigger event, the newest sample at the moment of the
|
||||
/// freeze for a pause view.
|
||||
///
|
||||
/// It exists because a snapshot is the one thing on screen whose coordinates outlive the
|
||||
/// `display_epoch()` that produced them, and `ScopeModel::stream_at_x0()` is what reads
|
||||
/// it: an export has to name the stream's own clock whatever is on screen, and the pause
|
||||
/// handler has to put a *display* window back on the ring to cut it. Before it, the
|
||||
/// export added `trigger_time()` to a pause view's stamps -- an unrelated edge, or none
|
||||
/// at all -- for want of anywhere to record which clock a snapshot was on.
|
||||
double x_offset = 0.0;
|
||||
double pre_s = 0.0; ///< seconds of pre-trigger data kept
|
||||
double post_s = 0.0; ///< seconds of post-trigger data kept
|
||||
std::vector<plot::Range> y; ///< per-channel value bounds, precomputed
|
||||
@@ -108,7 +118,7 @@ class EventSeries final : public plot::data::Series {
|
||||
/// Indices need no rebasing: the 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
|
||||
/// (unlike the ring, whose window slides).
|
||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||
plot::data::GatherMode mode =
|
||||
plot::data::GatherMode::Envelope) const override {
|
||||
if (channel_ >= snap_->values.size()) return;
|
||||
@@ -118,17 +128,17 @@ class EventSeries final : public plot::data::Series {
|
||||
const u64 b = std::min(begin, n);
|
||||
const u64 e = std::min(std::max(end, b), n);
|
||||
if (b >= e) return;
|
||||
// x **relative to x0()**, which is the library's gather contract: the renderer
|
||||
// adds `x0()` back before mapping to the screen. Emitting the absolute stamp made
|
||||
// it add x0 twice, so the whole capture was drawn one `x0()` -- a full
|
||||
// pre-trigger window -- to the left of the axis it had just been measured
|
||||
// against. The event's own stamps are small (edge-relative, so [-pre, +post]),
|
||||
// which is why that shift looked like a plausible picture: with a 50/50 split the
|
||||
// right half of the plot was simply empty, zooming out showed the trace ending at
|
||||
// the x = 0 marker, and zooming in past the shift showed nothing at all while the
|
||||
// crosshair -- which reads `x_at()`, never `gather()` -- went on reporting the
|
||||
// right values.
|
||||
const f64 base = x0();
|
||||
// x **relative to `x_origin`**, which is the library's gather contract: the
|
||||
// renderer adds that same origin back before mapping to the screen. Emitting the
|
||||
// absolute stamp made it add an origin twice, so the whole capture was drawn one
|
||||
// `x0()` -- a full pre-trigger window -- to the left of the axis it had just been
|
||||
// measured against. The event's own stamps are small (edge-relative, so
|
||||
// [-pre, +post]), which is why that shift looked like a plausible picture: with a
|
||||
// 50/50 split the right half of the plot was simply empty, zooming out showed the
|
||||
// trace ending at the x = 0 marker, and zooming in past the shift showed nothing
|
||||
// at all while the crosshair -- which reads `x_at()`, never `gather()` -- went on
|
||||
// reporting the right values.
|
||||
const f64 base = x_origin;
|
||||
const auto rel = [&](u64 i) {
|
||||
return static_cast<f32>(stamps[static_cast<std::size_t>(i)] - base);
|
||||
};
|
||||
|
||||
+23
-19
@@ -38,7 +38,7 @@ std::string sanitise(const std::string& name) {
|
||||
/// two time columns, with a `_2`, `_3` suffix. Two elements of one vector signal can
|
||||
/// easily sanitise to the same string.
|
||||
std::vector<std::string> column_labels(const ExportTable& table) {
|
||||
std::vector<std::string> taken{"time", "stream_time"};
|
||||
std::vector<std::string> taken{"time", "abs_seconds"};
|
||||
std::vector<std::string> out;
|
||||
out.reserve(table.columns.size());
|
||||
for (const ExportTable::Column& col : table.columns) {
|
||||
@@ -106,6 +106,14 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
||||
// What is on screen: a pause's frozen view, or the trigger's held event. Both are immutable
|
||||
// snapshots, so the export is the picture the user is looking at either way.
|
||||
std::shared_ptr<const EventSnapshot> snap = model.display_snapshot();
|
||||
// The absolute clock of display x = 0, so `abs_base + time[i]` is the number the stream
|
||||
// stamped sample i with -- **one expression, correct in all four display modes**, which is the
|
||||
// whole reason `stream_at_x0()` exists. `epoch()` is the stream time of the first sample ever
|
||||
// seen (the rings store stamps relative to it) and `stream_at_x0()` is the offset of the
|
||||
// display's origin within that, whether it comes from a snapshot or from the live epoch. The
|
||||
// snapshot branch used to add `trigger_time()` here, which named an unrelated edge on a
|
||||
// *paused* export, or none at all before the first latch.
|
||||
const f64 abs_base = model.epoch() + model.stream_at_x0();
|
||||
if (snap != nullptr) {
|
||||
// The snapshot is immutable and already index-aligned, so the visible window is two
|
||||
// binary searches over its stamps and the same index range for every channel.
|
||||
@@ -115,15 +123,11 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
||||
if (hi <= lo) return table;
|
||||
const std::size_t first = static_cast<std::size_t>(lo - st.begin());
|
||||
const std::size_t n = static_cast<std::size_t>(hi - lo);
|
||||
// The event's x = 0 is the edge, so the stream clock has to be added back for the
|
||||
// second column -- the one case where the visible x is not the stream's own.
|
||||
const f64 edge = model.trigger_time().load(std::memory_order_relaxed);
|
||||
|
||||
table.time.resize(n);
|
||||
table.stream_time.resize(n);
|
||||
table.abs_seconds.resize(n);
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
table.time[i] = st[first + i];
|
||||
table.stream_time[i] = edge + table.time[i];
|
||||
table.abs_seconds[i] = abs_base + table.time[i];
|
||||
}
|
||||
for (const int c : chans) {
|
||||
const std::size_t ch = static_cast<std::size_t>(c);
|
||||
@@ -141,12 +145,12 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
||||
// the same window hold the same samples. The lock is against a structural change
|
||||
// dropping the rings under the copy, which is what the UI thread has to fear here.
|
||||
std::lock_guard<std::mutex> lock(model.mutex());
|
||||
// The axis reads `display_epoch + stamp`, so the window has to be put back on the
|
||||
// stream's clock before the ring is searched. It is 0 in live mode, but reading it is
|
||||
// what keeps this correct rather than merely true today.
|
||||
const f64 epoch = model.display_epoch();
|
||||
// The axis reads `stamp - stream_at_x0()`, so the window has to be put back on the stream's
|
||||
// clock before the ring is searched -- and that offset is a whole window while streaming now,
|
||||
// not the 0 it once was: the live axis is `[-window, 0]` about the newest sample.
|
||||
const f64 s0 = model.stream_at_x0();
|
||||
u64 first = 0, last = 0;
|
||||
model.live_window_locked(view.min - epoch, view.max - epoch, first, last);
|
||||
model.live_window_locked(view.min + s0, view.max + s0, first, last);
|
||||
const u64 n = last - first;
|
||||
if (n == 0) return table;
|
||||
|
||||
@@ -154,10 +158,10 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
||||
std::vector<f64> stamps(static_cast<std::size_t>(n));
|
||||
store.time().copy_from(first, n, stamps.data());
|
||||
table.time.resize(static_cast<std::size_t>(n));
|
||||
table.stream_time.resize(static_cast<std::size_t>(n));
|
||||
table.abs_seconds.resize(static_cast<std::size_t>(n));
|
||||
for (std::size_t i = 0; i < stamps.size(); ++i) {
|
||||
table.stream_time[i] = stamps[i];
|
||||
table.time[i] = epoch + table.stream_time[i];
|
||||
table.time[i] = stamps[i] - s0;
|
||||
table.abs_seconds[i] = abs_base + table.time[i];
|
||||
}
|
||||
for (const int c : chans) {
|
||||
const std::size_t ch = static_cast<std::size_t>(c);
|
||||
@@ -179,7 +183,7 @@ bool write_npy(const std::string& path, const ExportTable& table, std::string& e
|
||||
// `descr` as a list of (name, format) pairs is a *packed* structured dtype -- NumPy
|
||||
// only inserts alignment padding when asked to (`align=True`) -- so the records below
|
||||
// are field widths back to back with no gaps.
|
||||
std::string descr = "[('time', '<f8'), ('stream_time', '<f8')";
|
||||
std::string descr = "[('time', '<f8'), ('abs_seconds', '<f8')";
|
||||
for (const std::string& label : labels) descr += ", ('" + label + "', '<f4')";
|
||||
descr += "]";
|
||||
std::string header = "{'descr': " + descr + ", 'fortran_order': False, 'shape': (" +
|
||||
@@ -211,7 +215,7 @@ bool write_npy(const std::string& path, const ExportTable& table, std::string& e
|
||||
std::size_t at = 0;
|
||||
std::memcpy(record.data() + at, &table.time[i], sizeof(f64));
|
||||
at += sizeof(f64);
|
||||
std::memcpy(record.data() + at, &table.stream_time[i], sizeof(f64));
|
||||
std::memcpy(record.data() + at, &table.abs_seconds[i], sizeof(f64));
|
||||
at += sizeof(f64);
|
||||
for (const ExportTable::Column& col : table.columns) {
|
||||
const f32 v = i < col.values.size() ? col.values[i] : 0.0f;
|
||||
@@ -239,7 +243,7 @@ bool write_csv(const std::string& path, const ExportTable& table, std::string& e
|
||||
if (!opened(out, path, error)) return false;
|
||||
|
||||
const std::vector<std::string> labels = column_labels(table);
|
||||
std::string line = "time,stream_time";
|
||||
std::string line = "time,abs_seconds";
|
||||
for (const std::string& label : labels) line += "," + label;
|
||||
line += "\n";
|
||||
std::fwrite(line.data(), 1, line.size(), out.f);
|
||||
@@ -252,7 +256,7 @@ bool write_csv(const std::string& path, const ExportTable& table, std::string& e
|
||||
line.clear();
|
||||
std::snprintf(buf, sizeof(buf), "%.17g", table.time[i]);
|
||||
line += buf;
|
||||
std::snprintf(buf, sizeof(buf), "%.17g", table.stream_time[i]);
|
||||
std::snprintf(buf, sizeof(buf), "%.17g", table.abs_seconds[i]);
|
||||
line += ",";
|
||||
line += buf;
|
||||
for (const ExportTable::Column& col : table.columns) {
|
||||
|
||||
+16
-7
@@ -24,13 +24,22 @@ struct ExportTable {
|
||||
std::vector<f32> values; ///< `rows()` long, the ring's own `f32`
|
||||
};
|
||||
|
||||
/// The x the plot shows and the crosshair reads: stream seconds in live mode,
|
||||
/// edge-relative seconds in `[-pre, +post]` while an event is held.
|
||||
/// The x the plot shows and the crosshair reads, which is **relative** in every mode: seconds
|
||||
/// before now while streaming (so the newest sample is 0 and the window is `[-window, 0]`),
|
||||
/// seconds from the edge in `[-pre, +post]` while an event is held, and seconds from the
|
||||
/// instant of the freeze on a paused view.
|
||||
std::vector<f64> time;
|
||||
/// The stream's own clock, so two exports taken in different modes line up. Equal to
|
||||
/// `time` in live mode; `time + trigger_time` while an event is held, which is the one
|
||||
/// case where the visible x is not the stream's.
|
||||
std::vector<f64> stream_time;
|
||||
/// The stream's own absolute clock -- the number the time signal carried, which is what the
|
||||
/// source stamped the samples with -- so two exports taken in different modes, or from
|
||||
/// different runs, line up on one timeline.
|
||||
///
|
||||
/// `f64 seconds` rather than the wire's integer, because the integer does not exist in this
|
||||
/// process: `udps_frame_t::values` is a `const double*`, so the client has already widened
|
||||
/// every value by the time acquisition sees it. At Unix-epoch magnitudes an `f64` resolves
|
||||
/// about 240 ns, which is coarser than a 1 MS/s sample period -- so this column is the
|
||||
/// *provenance* of a row and `time` is the column to difference. Its own `np.diff` is
|
||||
/// legitimately not single-valued for that reason.
|
||||
std::vector<f64> abs_seconds;
|
||||
std::vector<Column> columns;
|
||||
|
||||
std::size_t rows() const noexcept { return time.size(); }
|
||||
@@ -45,7 +54,7 @@ struct ExportTable {
|
||||
/// model access. In trigger mode nothing is locked: the `EventSnapshot` is immutable.
|
||||
ExportTable collect_export(ScopeModel& model, RowSet& rows);
|
||||
|
||||
/// Writes a NumPy `.npy` file holding one structured array: `time` and `stream_time` as
|
||||
/// Writes a NumPy `.npy` file holding one structured array: `time` and `abs_seconds` as
|
||||
/// `<f8`, every signal as `<f4` -- the width the ring actually stores, so the file is
|
||||
/// about half the size of an all-`f8` one and invents no precision.
|
||||
///
|
||||
|
||||
@@ -6,9 +6,15 @@ Acquisition runs on its own thread and writes a ring buffer sized from <tt>--buf
|
||||
the drawing thread reads whatever that ring holds when a frame starts, which is why a trace
|
||||
never makes the window stutter.
|
||||
|
||||
The stream is the clock. Every time you read on screen is a timestamp the source sent, never
|
||||
your machine's, so a stalled source freezes the view instead of scrolling it -- and a source
|
||||
whose clock steps still gets the window you asked for.
|
||||
The stream is the clock. Every interval you read on screen is measured between timestamps the
|
||||
source sent, never your machine's, so a stalled source freezes the view instead of scrolling it
|
||||
-- and a source whose clock steps still gets the window you asked for.
|
||||
|
||||
The time axis is <i>relative to now</i>. While streaming it is a fixed <tt>[-window .. 0]</tt>
|
||||
with the newest sample at 0, so x reads as an age: <tt>-1.5s</tt> is a sample a second and a half
|
||||
old. In trigger mode 0 is the event's edge instead, and a paused view keeps the ages it was
|
||||
frozen with. The source's own clock is still there when you need it -- an export writes it as a
|
||||
second column.
|
||||
|
||||
<b>How to reach it</b>
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ the overflow menu opens the editor where the unit and the calibration live.
|
||||
• A signal's colour follows the <i>signal</i>, not the slot, so the same signal on two rows
|
||||
is one colour and the side panel's chip identifies it unambiguously. A signal on no row
|
||||
gets a grey chip rather than none, so the panel's rows keep their height.
|
||||
• <b>Click a coloured chip to change that signal's colour.</b> A grey chip names no trace, so
|
||||
it is not clickable. The colour is saved with the session; <b>Default colour</b> in the
|
||||
signal's ⋯ menu gives the palette entry back.
|
||||
|
||||
<b>Example</b>
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<b>Rows, layout and drag & drop</b>
|
||||
|
||||
The figure is a stack of rows, 1 to 8 of them, each holding up to 4 traces. Rows share one
|
||||
time axis and one left gutter, so their y labels line up and their windows cannot drift apart.
|
||||
time axis and one pair of side margins, so their y labels line up, their data areas start and
|
||||
end on the same two columns, and their windows cannot drift apart. The left margin holds the y
|
||||
labels; the right one is empty, and is there so the newest tick's label and the newest sample's
|
||||
ink have somewhere to land instead of sitting on the frame.
|
||||
|
||||
<b>How to reach it</b>
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ unit, width-locked so a change of window cannot widen the header bar.
|
||||
value. Stamping at the end of a group would shift every trace half a stored sample late.
|
||||
• Moving the window across a decimation boundary rebuilds the buffer and drops the history.
|
||||
Walking it inside one decade is free.
|
||||
• <b>This control is the only thing that changes the window.</b> A zoom, a pan or a fit moves the
|
||||
view <i>inside</i> it and leaves it alone — which is the point of the previous bullet: a wheel
|
||||
notch that redefined the window could re-plan the buffer and throw your samples away.
|
||||
• The trigger sees the <i>stored</i> (filtered) signal, not the wire signal, because an edge
|
||||
found on a sample that is not in the buffer could not be cut out of it.
|
||||
|
||||
|
||||
@@ -19,12 +19,35 @@ The full table is in the shortcut overview (<tt>Ctrl+/</tt>).
|
||||
• A <i>pinned</i> view is navigable within its own data but cannot leave it. While an event is
|
||||
held, a capture is filling, or the view is paused, a zoom <i>in</i> works normally — "not
|
||||
outside the event" is not "not readable" — but a pan or a zoom out is clamped to the data.
|
||||
• Nothing you do on the plot changes the <i>time window</i> — only the header control does. A
|
||||
zoom or a pan moves the view inside the window; <b>f</b> (and Fit X axis) is the <i>undo</i> of a
|
||||
zoom, so a 2 s window zoomed into reads 2 s again. The window is more than the axis: it is also
|
||||
the trigger's event span and how the samples are stored, so a wheel notch that redefined it
|
||||
would re-plan the buffer and drop your history.
|
||||
• The axis does not scroll — the <i>data</i> does. A live axis is a fixed <tt>[-window .. 0]</tt>
|
||||
with the newest sample at 0, so the labels stand still and the trace rolls under them. The
|
||||
cursors stand still with the labels (they are positions on screen, so a cursor you placed stays
|
||||
where you put it, over newer data each frame), while a measurement's marks travel with the
|
||||
sample they measured and leave the view when it does.
|
||||
• A live view is clamped to the window you asked for, not to what the ring happens to hold.
|
||||
That is why a buffer that has only just started filling leaves its left-hand side empty
|
||||
instead of squeezing the window down to the first few samples.
|
||||
• 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>
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ live ring and puts the window back on the newest data.
|
||||
zoom out is clamped to the snapshot's data.
|
||||
• Pausing is the easiest way to measure a layout or read a cursor precisely, because nothing
|
||||
under the pointer moves between frames.
|
||||
• The status bar says how long you have been paused — wall time, since the stream itself never
|
||||
stopped. It is there so a frozen picture cannot be mistaken for a live one that went quiet.
|
||||
|
||||
<b>Example</b>
|
||||
|
||||
|
||||
+18
-1
@@ -19,12 +19,29 @@ owns a <b>Reset</b> button. Drag a marker to move it. The mode dropdown offers f
|
||||
• <tt>mix</tt> is the reading mode: one line per <i>bound</i> trace, with the trace's own values
|
||||
at each marker and their difference. A slot with a name but no channel behind it is not a
|
||||
trace and gets no line.
|
||||
• A value is the <i>nearest stored sample's</i>, never a number between two samples. Zoom in past
|
||||
the sample spacing and the line between two samples is something the plot drew, not data —
|
||||
so a reading off it would be the drawing. The hover crosshair reads the same way.
|
||||
• Every reading carries a <i>metric prefix</i> so the digits sit in <tt>1..999</tt>:
|
||||
<tt>10.52 mV</tt>, not <tt>0.01 V</tt>. Two decimals is all an instrument landing on an
|
||||
<tt>f32</tt> sample can claim, so without the prefix a small value would read <tt>0.00</tt>
|
||||
and the reading would be gone. A stream that declares no unit gets the prefix anyway, written
|
||||
tight against the number (<tt>585.24m</tt>) — the space separates the number from its unit,
|
||||
and a spaced <tt>m</tt> with nothing after it would read as metres.
|
||||
• A marker past the newest sample reads <tt>--</tt>. With only one marker over data the row
|
||||
reads <tt>partial</tt>: a difference between a number and nothing is not a number.
|
||||
• In <tt>mix</tt> each marker also places a crossing annotation per trace, which is what lets
|
||||
you see on the plot which sample a number came from.
|
||||
you see on the plot which sample a number came from. It is <i>on that sample</i>, so when the
|
||||
marker falls between two it sits a little off its own line: the line is where you put the
|
||||
marker, the dot is the datum being quoted. The hover crosshair's mark snaps the same way, and
|
||||
is drawn larger than a trace's sample dots so it does not read as one more sample.
|
||||
• The time row exists only in the modes that show the vertical pair. <tt>horizontal</tt> has no
|
||||
time row, by design.
|
||||
• A cursor <i>stays where you put it</i> while a live view rolls under it, which is what a bench
|
||||
scope does: its <tt>T0</tt> is an age, so a marker left at <tt>-1.5s</tt> keeps reading the
|
||||
sample a second and a half old as the stream advances. A measurement's marks do the opposite —
|
||||
they travel with the sample they measured — because a cursor is a question you asked and a mark
|
||||
is an answer about one particular sample.
|
||||
|
||||
<b>Example</b>
|
||||
|
||||
|
||||
@@ -24,10 +24,25 @@ 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;
|
||||
every other type carries the channel's own unit.
|
||||
every other type carries the channel's own unit. A value is written with a <i>metric prefix</i>
|
||||
so its digits sit in <tt>1..999</tt> — <tt>636.62 mV</tt>, not <tt>0.64 V</tt> — including when
|
||||
the stream declares no unit at all, which is always the case for a math channel: there the
|
||||
prefix goes tight against the number (<tt>636.62m</tt>), since two decimals of an unprefixed
|
||||
small value is <tt>0.00</tt>.
|
||||
• Cost is bounded by reading the summary pyramid the producer already maintains, not the raw
|
||||
window. The values are still exact: a bucket's sum is a sum.
|
||||
|
||||
|
||||
+19
-9
@@ -5,15 +5,24 @@ nodes, operations, one output — and udpscope then treats the result as an ordi
|
||||
|
||||
<b>How to reach it</b>
|
||||
|
||||
The math button in the toolbar, or <tt>g</tt>. The pane's <tt>+</tt> adds a channel; opening one
|
||||
shows the graph editor: nodes as boxes, click a box to select it and edit its parameters beside
|
||||
the canvas, click an output port then an input port to wire them, drag a box to move it. The
|
||||
orange dot marks the output node.
|
||||
The math button in the toolbar, or <tt>g</tt>. The pane's <tt>+</tt> adds a channel, and opening
|
||||
one shows the graph editor: nodes as boxes, click a box to select it and edit its parameters beside
|
||||
the canvas. Inside the editor, <i>add node</i> is a menu button whose popover picks the op, so the
|
||||
node arrives as the operation you asked for.
|
||||
|
||||
<b>Graph gestures:</b> <b>drag</b> from an output port to an input port to wire them; drag a
|
||||
wired input port off its connection to detach it; click a wire and press <tt>Delete</tt> to remove
|
||||
it; drag a box to move it. <b>Wheel</b> zooms about the pointer. <b>Drag the background</b> to
|
||||
pan. <tt>f</tt> fits all nodes into view; <tt>Ctrl+0</tt> resets the view. <i>tidy</i>, the button
|
||||
beside <i>fit</i> in the editor, lays the graph out in topological columns and frames it. The orange
|
||||
dot marks the output node; click <i>set as output</i> to move it.
|
||||
|
||||
<b>Worth knowing</b>
|
||||
|
||||
• The graph is evaluated as an ordered list, so a node may only read ones <i>above</i> it. A
|
||||
cycle is impossible by construction rather than rejected by a check.
|
||||
wire drawn right to left re-sorts the list silently so the graph stays valid. A cycle is
|
||||
unrepresentable: the connection is refused rather than created, and the line beneath the form
|
||||
says why — a loop, or a node asked to read itself.
|
||||
• A derived channel <i>is</i> a channel. It appears in the signal panel, can be plotted,
|
||||
measured, exported, and used as a source for another math channel.
|
||||
• The math runs on the <b>stored</b> samples — decimated and averaged — so a cutoff you type in
|
||||
@@ -40,10 +49,11 @@ and the last node added becomes the output -- but a graph whose output is left o
|
||||
valid and simply passes its input through, which is the usual reason a gain or a filter appears to
|
||||
have no effect. <i>Set as output</i> moves the dot.
|
||||
|
||||
<b>Save refuses an incomplete graph</b>, and says why: an input that is not connected, no output
|
||||
chosen, or a <i>channel</i> node naming a signal the scope is not acquiring. Only the channels being
|
||||
acquired are offered, because a source that is not acquired reads as no data for ever -- and a graph
|
||||
that reads NaN is harder to diagnose than one that would not save.
|
||||
<b>Apply is insensitive while the graph is incomplete.</b> A line beneath the form says why:
|
||||
an input that is not connected, no output chosen, or a <i>channel</i> node naming a signal the
|
||||
scope is not acquiring. Only the channels being acquired are offered, because a source that is not
|
||||
acquired reads as no data for ever — and a graph that reads NaN is harder to diagnose than one that
|
||||
would not save.
|
||||
|
||||
<b>The filters are real filters.</b> A math channel's samples are produced as the stream arrives,
|
||||
once per stored slot, so a low pass carries the history a filter needs. A cutoff typed in hertz is
|
||||
|
||||
@@ -17,15 +17,17 @@ the format follows the path's extension.
|
||||
• The stored rate is not always the wire rate. A window long enough to need decimation is stored
|
||||
averaged at a lower rate, and the export carries <i>that</i> — read the status bar's ⓘ popover
|
||||
for the figure before trusting a sample count.
|
||||
• Two time columns. <tt>time</tt> is the display timeline (relative to the trigger edge while an
|
||||
event is held) and <tt>stream_time</tt> is the source's own clock. Both are strictly increasing
|
||||
and evenly spaced; duplicate or backwards stamps would mean the file held two clocks.
|
||||
• Two time columns. <tt>time</tt> is the display timeline — negative ages ending at 0 while
|
||||
streaming, offsets from the trigger edge while an event is held — so it is the x you read a
|
||||
value at on screen. <tt>abs_seconds</tt> is the source's own clock for the same sample, which
|
||||
is what lets two exports taken in different modes line up. Both are strictly increasing and
|
||||
evenly spaced; duplicate or backwards stamps would mean the file held two clocks.
|
||||
• An export taken <i>mid-capture</i> writes the partly filled window that is on screen, which is
|
||||
shorter than the event span. Export outside a capture if you want the whole event.
|
||||
|
||||
<b>Example</b>
|
||||
|
||||
A 2 s window at 10 kS/s over 3 traces writes 20001 rows and 5 columns
|
||||
(<tt>time</tt>, <tt>stream_time</tt>, three signals). The <tt>.npy</tt> is
|
||||
(<tt>time</tt>, <tt>abs_seconds</tt>, three signals). The <tt>.npy</tt> is
|
||||
<tt>64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)</tt> bytes. Read it back with
|
||||
<tt>pd.DataFrame(np.load(path))</tt>.
|
||||
+18
-8
@@ -9,11 +9,19 @@ The bar is always visible under the plot. Click <b>ⓘ</b> for the detail popove
|
||||
|
||||
<b>Worth knowing</b>
|
||||
|
||||
• The badge is one glyph in three colours: lit and dim alternating means data is arriving, and a
|
||||
steady idle colour means it is not. A badge that never blinks is not a working badge.
|
||||
• The state field is one word from a fixed set: <tt>live</tt>, <tt>paused</tt>, <tt>armed</tt>,
|
||||
<tt>capturing</tt>, <tt>event held</tt>, <tt>disarmed</tt>. In trigger mode the bar adds
|
||||
exactly one more field, how long ago the last event was.
|
||||
• The badge is one glyph in four colours: green <i>alternating</i> lit and dim means data is
|
||||
arriving, and a steady colour means it is not. A badge that never blinks is not a working badge.
|
||||
Of the two steady colours, <i>grey</i> is no stream at all or a pipeline you stopped (the
|
||||
tooltip says which), and <i>yellow</i> is a source that answered but has sent no configuration.
|
||||
• The state field is one word from a fixed set: <tt>connecting</tt>, <tt>live</tt>,
|
||||
<tt>paused</tt>, <tt>armed</tt>, <tt>capturing</tt>, <tt>event held</tt>, <tt>disarmed</tt>. Two
|
||||
states add one field each: in trigger mode, how long ago the last event was; while paused, how
|
||||
long you have been paused.
|
||||
• <tt>connecting</tt> means there is no <i>signal list</i> yet — either no session or a session
|
||||
with no configuration behind it. Every other word claims a signal list, so until one arrives
|
||||
there is nothing for them to describe: nothing can be enabled and no data is owed.
|
||||
• The paused figure is <i>wall</i> time, not stream time — the producer never stops while paused,
|
||||
so it answers "how long have I been looking at this picture".
|
||||
• Nothing on the bar may change width with the stream, because the bar sits directly above the
|
||||
plot and a wider label would relayout it. That is why the rate collapses to one figure or a
|
||||
range rather than listing per channel, and why an elapsed time steps to a coarser unit instead
|
||||
@@ -22,9 +30,11 @@ The bar is always visible under the plot. Click <b>ⓘ</b> for the detail popove
|
||||
non-zero count means samples are absent from the buffer, not merely late.
|
||||
• <tt>reconnects</tt> counts sessions re-established with the source. Over unicast a climbing
|
||||
count usually means a second client is taking the source's single session.
|
||||
• The starvation notice — <i>connected, but no new frames in the last second</i> — is that same
|
||||
situation seen from the other side. Pointing the app at a port where nothing answers produces
|
||||
it too.
|
||||
• The starvation notice — <i>connected, but no data is arriving</i> — is that same situation seen
|
||||
from the other side: the signal list is here, so something took the stream away. A source that
|
||||
has sent <i>no</i> configuration gets a different notice, <i>trying to connect</i>, because
|
||||
nothing was taken away — pointing the app at a port where nothing answers is that case, and the
|
||||
usual cause is a streamer that is not running or is serving another port.
|
||||
• <tt>stored</tt> versus <tt>wire</tt> in the popover is the decimation. They differ exactly
|
||||
when the window is too long to store at full rate.
|
||||
|
||||
|
||||
+27
-1
@@ -239,13 +239,14 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
||||
st.window_s = 0.0;
|
||||
st.rows.clear();
|
||||
st.calibration.clear();
|
||||
st.colors.clear();
|
||||
st.enabled.clear();
|
||||
st.math.clear();
|
||||
st.measures.clear();
|
||||
|
||||
std::string line;
|
||||
// What the last `[[...]]` header opened, so keys land in the right record.
|
||||
enum class Section { Top, Plot, Calibration, Math, Measure } section = Section::Top;
|
||||
enum class Section { Top, Plot, Calibration, Color, Math, Measure } section = Section::Top;
|
||||
int plot_count = 0;
|
||||
bool plot_section_seen = false;
|
||||
while (std::getline(in, line)) {
|
||||
@@ -259,6 +260,9 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
||||
} else if (t == "[[calibration]]") {
|
||||
section = Section::Calibration;
|
||||
st.calibration.emplace_back();
|
||||
} else if (t == "[[color]]") {
|
||||
section = Section::Color;
|
||||
st.colors.emplace_back();
|
||||
} else if (t == "[[math]]") {
|
||||
section = Section::Math;
|
||||
st.math.emplace_back();
|
||||
@@ -378,6 +382,23 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
} else if (section == Section::Color && !st.colors.empty()) {
|
||||
if (key == "name") {
|
||||
st.colors.back().name = strip_quote(value);
|
||||
} else if (key == "rgb") {
|
||||
// `"#RRGGBB"`, so the file says what it means to a reader hand-editing it -- a bare decimal
|
||||
// would be the one number in this file nobody could check by eye. Anything unparseable
|
||||
// leaves the entry at 0, and `apply_layout()` drops an entry with no name anyway.
|
||||
const std::string hex = strip_quote(value);
|
||||
const std::size_t at = hex.find('#');
|
||||
try {
|
||||
st.colors.back().rgb =
|
||||
static_cast<std::uint32_t>(std::stoul(
|
||||
at == std::string::npos ? hex : hex.substr(at + 1), nullptr, 16)) &
|
||||
0x00FFFFFFu;
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!plot_section_seen && plot_count > 0) {
|
||||
@@ -422,6 +443,11 @@ bool save_layout(const std::string& path, const LayoutState& state) {
|
||||
o << "\n[[calibration]]\nname = " << toml_escape(cal.name) << "\ngain = " << cal.gain
|
||||
<< "\noffset = " << cal.offset << "\n";
|
||||
}
|
||||
for (const auto& color : state.colors) {
|
||||
char rgb[16];
|
||||
std::snprintf(rgb, sizeof(rgb), "\"#%06X\"", color.rgb & 0x00FFFFFFu);
|
||||
o << "\n[[color]]\nname = " << toml_escape(color.name) << "\nrgb = " << rgb << "\n";
|
||||
}
|
||||
for (const MathChannel& m : state.math) {
|
||||
o << "\n[[math]]\nname = " << toml_escape(m.name) << "\nunit = " << toml_escape(m.unit)
|
||||
<< "\nenabled = " << (m.enabled ? "true" : "false") << "\noutput = " << m.output
|
||||
|
||||
@@ -32,6 +32,15 @@ struct LayoutState {
|
||||
};
|
||||
std::vector<Calibration> calibration;
|
||||
|
||||
/// The trace colours the user chose, `0xRRGGBB`. Only the *overrides*: a signal with no entry
|
||||
/// takes the positional palette entry, which is what keeps a stream's new signals coloured
|
||||
/// without a session having to name them. Saved by name like every identity here.
|
||||
struct Color {
|
||||
std::string name;
|
||||
std::uint32_t rgb = 0;
|
||||
};
|
||||
std::vector<Color> colors;
|
||||
|
||||
/// Signals the session had enabled. Enabling is what starts acquisition, so this is what makes a
|
||||
/// reopened scope look like the one that was closed -- and it is saved by *name*, like every other
|
||||
/// identity here, because a CONFIG renumbers the channels.
|
||||
|
||||
+16
-4
@@ -106,6 +106,13 @@ MathOp math_op_at(int index) {
|
||||
}
|
||||
}
|
||||
|
||||
int math_op_index(MathOp op) {
|
||||
for (int i = 0; i < math_op_count(); ++i) {
|
||||
if (math_op_at(i) == op) return i;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int math_op_arity(MathOp op) {
|
||||
switch (op) {
|
||||
case MathOp::Source: return 0;
|
||||
@@ -215,15 +222,15 @@ double math_step(const MathChannel& channel, MathRuntime& rt, const float* value
|
||||
return rts[static_cast<std::size_t>(out)].value;
|
||||
}
|
||||
|
||||
bool math_channel_valid(const MathChannel& channel, std::string* why) {
|
||||
const std::size_t n = channel.nodes.size();
|
||||
bool math_channel_valid_nodes(const std::vector<MathNode>& nodes, int output, std::string* why) {
|
||||
const std::size_t n = nodes.size();
|
||||
const auto fail = [&](const std::string& text) {
|
||||
if (why != nullptr) *why = text;
|
||||
return false;
|
||||
};
|
||||
if (n == 0) return fail("empty graph");
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
const MathNode& node = channel.nodes[i];
|
||||
const MathNode& node = nodes[i];
|
||||
if (node.op == MathOp::Source && node.source.empty()) {
|
||||
return fail("node " + std::to_string(i + 1) + ": no channel chosen");
|
||||
}
|
||||
@@ -238,10 +245,15 @@ bool math_channel_valid(const MathChannel& channel, std::string* why) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (channel.output < 0 || static_cast<std::size_t>(channel.output) >= n) {
|
||||
if (output < 0 || static_cast<std::size_t>(output) >= n) {
|
||||
return fail("no output chosen");
|
||||
}
|
||||
if (why != nullptr) why->clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool math_channel_valid(const MathChannel& channel, std::string* why) {
|
||||
return math_channel_valid_nodes(channel.nodes, channel.output, why);
|
||||
}
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -30,6 +30,10 @@ enum class MathOp {
|
||||
const char* math_op_name(MathOp op);
|
||||
int math_op_count();
|
||||
MathOp math_op_at(int index);
|
||||
/// An op's position in `math_op_at()`'s order. That order is the *dialog's* -- what a dropdown or a
|
||||
/// menu lists, and what a session file stores -- and it is deliberately not promised to be the
|
||||
/// enum's declaration order, so a caller that needs a position must ask rather than cast.
|
||||
int math_op_index(MathOp op);
|
||||
/// Inputs the op takes: 0 for `Source`, 1 for the unary ones, 2 for the arithmetic.
|
||||
int math_op_arity(MathOp op);
|
||||
/// Which of the per-node parameters an op actually reads, so a dialog can show only those rows --
|
||||
@@ -117,4 +121,9 @@ double math_step(const MathChannel& channel, MathRuntime& rt, const float* value
|
||||
/// input it needs connected to a node that exists (with `Source` nodes naming something).
|
||||
bool math_channel_valid(const MathChannel& channel, std::string* why = nullptr);
|
||||
|
||||
/// The same check over a bare node list and output index, for a caller that has a graph but not yet
|
||||
/// a `MathChannel` -- the editor, and the headless probe that drives it.
|
||||
bool math_channel_valid_nodes(const std::vector<MathNode>& nodes, int output,
|
||||
std::string* why = nullptr);
|
||||
|
||||
} // namespace udpscope
|
||||
+63
-15
@@ -28,8 +28,27 @@ void enter_side(MeasureLevel& acc, std::size_t e, double t) {
|
||||
acc.open[e] = true;
|
||||
}
|
||||
|
||||
/// The hysteresis band's half-width, as a share of the window's peak-to-peak. Measured rather than
|
||||
/// chosen: 5 % reads the mock's 0.1 interferer correctly and fails at 0.2 (`period=0.075` for a
|
||||
/// true 0.2), while 10 % holds to an interferer amplitude of 0.3 -- a third of the waveform -- and
|
||||
/// to white noise at sigma=0.05. Above that the band starts eating a real waveform's own extremes
|
||||
/// on anything less square than a sine.
|
||||
constexpr double kHysteresisFrac = 0.10;
|
||||
|
||||
} // namespace
|
||||
|
||||
void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct) {
|
||||
const double span = s.hi - s.lo;
|
||||
acc.level = s.lo + pct * span;
|
||||
// Half the room to the nearer extreme is the clamp. A threshold at 90 % has a tenth of the span
|
||||
// above it, so a fixed 10 % band would put `level + h` *above every sample*: the walk then never
|
||||
// decides the upper side and the measurement reads nothing at all, which is strictly worse than
|
||||
// a noisy reading. Measured on a 3.3 V logic signal with ringing at thr=90 %: `enters=0` with a
|
||||
// fixed band, `enters=4 period=0.200000` with the clamp.
|
||||
const double room = std::min(s.hi - acc.level, acc.level - s.lo);
|
||||
acc.hysteresis = std::max(0.0, std::min(kHysteresisFrac * span, 0.5 * room));
|
||||
}
|
||||
|
||||
void measure_scan(const MeasureChunk& chunk, MeasureStats& s) {
|
||||
// The chunk's own extremes first, in a loop that touches nothing but the values: *where* an
|
||||
// extreme is costs an index, and the caller turns the two that survive into stamps once at the
|
||||
@@ -130,17 +149,27 @@ void measure_accumulate(const MeasureChunk& chunk,
|
||||
for (std::size_t i = 0; i < chunk.count; ++i) {
|
||||
const double v = static_cast<double>(chunk.value[i]);
|
||||
if (!std::isfinite(v)) continue;
|
||||
// The *sums* split at the bare level: `Top` and `Bottom` are defined as the mean above and
|
||||
// below the threshold, and a band here would move a documented exact reading.
|
||||
const int s = v > acc.level ? 1 : 0;
|
||||
acc.sum[static_cast<std::size_t>(s)] += v;
|
||||
++acc.count[static_cast<std::size_t>(s)];
|
||||
// A sample *at* the level is on neither side, so it decides no crossing and the walk keeps
|
||||
// the side it last decided. That is what stops a signal which merely touches the level from
|
||||
// counting every sample as an edge.
|
||||
if (v == acc.level) continue;
|
||||
if (acc.have_side && s != (acc.side ? 1 : 0)) {
|
||||
enter_side(acc, static_cast<std::size_t>(s), time_of(chunk.offset + i));
|
||||
// The *edge* decision reads the band instead. A sample inside it is on neither side, so it
|
||||
// decides no crossing and the walk keeps the side it last decided -- which is what stops a
|
||||
// signal that merely wobbles about the level from counting every wobble as an edge. With
|
||||
// `hysteresis == 0` this is the single-level test, `v == level` skip included.
|
||||
int d;
|
||||
if (v > acc.level + acc.hysteresis) {
|
||||
d = 1;
|
||||
} else if (v < acc.level - acc.hysteresis) {
|
||||
d = 0;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
acc.side = s != 0;
|
||||
if (acc.have_side && d != (acc.side ? 1 : 0)) {
|
||||
enter_side(acc, static_cast<std::size_t>(d), time_of(chunk.offset + i));
|
||||
}
|
||||
acc.side = d != 0;
|
||||
acc.have_side = true;
|
||||
}
|
||||
}
|
||||
@@ -153,21 +182,40 @@ bool measure_accumulate_bucket(const MeasureBucket& k,
|
||||
// A non-finite sample in the bucket would make the edge's position a guess: the raw walk takes
|
||||
// the first *finite* sample of the new side, and an aggregate cannot say which that is.
|
||||
if (k.count != k.span) return false;
|
||||
// Strictly on one side, so every sample decides the same side and none is *at* the level --
|
||||
// which is what makes this exact rather than an approximation of the walk.
|
||||
// The *sums* need only the bare level: strictly on one side of it, so every sample decides the
|
||||
// same bin and none is *at* it, which is what makes the fold exact rather than an approximation.
|
||||
const bool above = k.lo > acc.level;
|
||||
const bool below = k.hi < acc.level;
|
||||
if (!above && !below) return false;
|
||||
const std::size_t e = above ? 1u : 0u;
|
||||
// The band is deliberately *not* folded into the test above. Tightening the whole condition to
|
||||
// `level +/- h` refuses every bucket lying inside the band -- a tenth of the amplitude either side
|
||||
// of the mid level is where a waveform moves fastest, so those are exactly the buckets a crossing
|
||||
// passes through -- which hands the raw walk a share of the window rather than a bucket or two per
|
||||
// crossing, and breaks the "`raw` stays at a fraction of a percent of `covered`" invariant.
|
||||
// Instead: a bucket on one side of the level can always be *binned*, and only a *side change*
|
||||
// whose position is inside the bucket is refused.
|
||||
const double top = acc.level + acc.hysteresis;
|
||||
const double bot = acc.level - acc.hysteresis;
|
||||
// Does the bucket reach past the band at all? One wholly inside it decides no side, exactly as a
|
||||
// raw sample inside it decides none, so the walk state is left alone.
|
||||
const bool reaches = above ? (k.hi > top) : (k.lo < bot);
|
||||
const bool change = reaches && acc.have_side && e != (acc.side ? 1u : 0u);
|
||||
// A bucket that *enters* a side while straddling its own threshold holds the crossing somewhere
|
||||
// inside itself, and only the raw walk can say where. One wholly past the threshold holds it at
|
||||
// the bucket's first sample, exactly, which is where the raw walk would have found it.
|
||||
const bool fully_outside = above ? (k.lo > top) : (k.hi < bot);
|
||||
// Nothing may be folded before this point: a refusal hands the bucket to `measure_accumulate()`,
|
||||
// which adds the same samples again. Sums added above the refusal double-counted every opened
|
||||
// bucket, which read as `top` 0.614304 on a unit sine against the 2/pi it must be.
|
||||
if (change && !fully_outside) return false;
|
||||
acc.sum[e] += k.sum;
|
||||
acc.count[e] += k.count;
|
||||
if (acc.have_side && e != (acc.side ? 1u : 0u)) {
|
||||
// The edge is the bucket's *first* sample: everything before it was on the other side and
|
||||
// everything in the bucket is on this one, so that is where the raw walk would have found it.
|
||||
enter_side(acc, e, time_of(k.offset));
|
||||
if (change) enter_side(acc, e, time_of(k.offset));
|
||||
if (reaches) {
|
||||
acc.side = e != 0;
|
||||
acc.have_side = true;
|
||||
}
|
||||
acc.side = e != 0;
|
||||
acc.have_side = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
+30
-4
@@ -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);
|
||||
|
||||
+42
-4
@@ -515,8 +515,12 @@ void ScopeModel::reset_live_history_locked() {
|
||||
}
|
||||
}
|
||||
|
||||
void ScopeModel::publish_pause_view(double lo_s, double hi_s) {
|
||||
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_);
|
||||
BufferStore& src = live();
|
||||
u64 first = 0;
|
||||
@@ -525,13 +529,18 @@ void ScopeModel::publish_pause_view(double lo_s, double hi_s) {
|
||||
const u64 n = last - first;
|
||||
snap->stamps.resize(static_cast<std::size_t>(n));
|
||||
if (n > 0) {
|
||||
// The stamps go in *as the ring holds them*: the stream's own seconds, which is the
|
||||
// coordinate the axis was already reading. Nothing is rebased, so a pause does not move the
|
||||
// dates under the reader the way a trigger event's edge-relative axis deliberately does.
|
||||
// The stamps come out of the ring on the stream's own clock and are rebased onto
|
||||
// `x_offset`, which is the coordinate the axis was *already* reading: a live view is
|
||||
// newest-relative, so leaving them absolute would throw the frozen picture a whole uptime
|
||||
// to the right of the axis at the instant the user pressed pause. Nothing is lost by the
|
||||
// shift -- `x_offset` is exactly what makes the stream's time recoverable, which is what
|
||||
// `stream_at_x0()` reads it for.
|
||||
src.time().copy_from(first, n, snap->stamps.data());
|
||||
for (f64& t : snap->stamps) t -= 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];
|
||||
@@ -540,17 +549,44 @@ void ScopeModel::publish_pause_view(double lo_s, double hi_s) {
|
||||
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();
|
||||
plot::data::RingBuffer<f64>& tsrc = src.time();
|
||||
|
||||
@@ -599,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 {
|
||||
|
||||
+76
-10
@@ -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_; }
|
||||
|
||||
@@ -290,16 +321,17 @@ class ScopeModel {
|
||||
void live_window_locked(double lo_s, double hi_s, u64& first, u64& last);
|
||||
/// The frozen view taken when the user paused, or null while running. A pause is a
|
||||
/// snapshot of the *visible window* in exactly the sense a trigger event is -- an immutable
|
||||
/// series the plot binds to -- and the difference is the anchoring: an event's stamps are
|
||||
/// edge-relative, a pause's are the stream's own, so the axis does not move when the pause
|
||||
/// begins and the reader keeps the times they were already reading.
|
||||
/// series the plot binds to -- and both are anchored the same way: the stamps are the
|
||||
/// coordinates the axis was already reading, with `x_offset` recording which instant that
|
||||
/// x = 0 was. So the axis does not move when the pause begins.
|
||||
std::shared_ptr<const EventSnapshot> pause_view() const noexcept {
|
||||
return pause_view_.load(std::memory_order_acquire);
|
||||
}
|
||||
/// Snapshots the live ring over `[lo_s, hi_s]` (stream seconds, the display epoch already
|
||||
/// removed) as the pause view. The acquisition thread keeps writing, so this is a copy: what
|
||||
/// is frozen is the picture, not the data.
|
||||
void publish_pause_view(double lo_s, double hi_s);
|
||||
/// Snapshots the live ring over `[lo_s, hi_s]` (stream seconds, i.e. the display window put
|
||||
/// back on the stream's clock) as the pause view, rebased onto `x_offset` so its stamps are
|
||||
/// the x the axis is already showing. The acquisition thread keeps writing, so this is a
|
||||
/// copy: what is frozen is the picture, not the data.
|
||||
void publish_pause_view(double lo_s, double hi_s, double x_offset);
|
||||
void clear_pause_view() noexcept { pause_view_.store(nullptr, std::memory_order_release); }
|
||||
|
||||
/// Whether the user paused acquisition. Lives here rather than on `Acquisition` because the
|
||||
@@ -378,12 +410,30 @@ class ScopeModel {
|
||||
return event() != nullptr ? DisplayMode::Held : DisplayMode::Waiting;
|
||||
}
|
||||
|
||||
/// The x origin the plot uses: 0 for "seconds since the stream started", or minus
|
||||
/// the trigger instant while in trigger mode, so the axis reads seconds from the
|
||||
/// event. Applied to the live store's channels.
|
||||
/// The offset the **live** channels report their x with: `display_x = stream_t + epoch`.
|
||||
/// Minus the newest stored sample's stamp while streaming, so the axis reads
|
||||
/// `[-window, 0]` and the newest data is at 0; minus the trigger instant while a capture
|
||||
/// is filling, so the axis reads seconds from the event. It is 0 while a *snapshot* is on
|
||||
/// screen (a held event, a paused view), because then the live channels are bound to
|
||||
/// nothing and the snapshot's own `x_offset` is what says which clock x = 0 is --
|
||||
/// `stream_at_x0()` is the accessor that does not have to know which of the two it is.
|
||||
f64 display_epoch() const noexcept { return display_epoch_.load(std::memory_order_relaxed); }
|
||||
void set_display_epoch(f64 e) noexcept;
|
||||
|
||||
/// The stream time of the display's x = 0, so `stream_t = display_x + stream_at_x0()` and
|
||||
/// `display_x = stream_t - stream_at_x0()` in **every** display mode.
|
||||
///
|
||||
/// One accessor for the two places the offset can live: a snapshot carries its own
|
||||
/// (`EventSnapshot::x_offset`, frozen with the picture), and a live view carries it as
|
||||
/// minus the display epoch. Everything that has to cross between the two clocks goes
|
||||
/// through here -- the export's absolute column, and the pause handler putting a display
|
||||
/// window back on the ring to cut it -- rather than picking the branch itself and getting
|
||||
/// one of the four modes wrong.
|
||||
f64 stream_at_x0() const noexcept {
|
||||
if (std::shared_ptr<const EventSnapshot> snap = display_snapshot()) return snap->x_offset;
|
||||
return -display_epoch();
|
||||
}
|
||||
|
||||
// --- stream state ---------------------------------------------------------
|
||||
StreamStats& stats() noexcept { return stats_; }
|
||||
const StreamStats& stats() const noexcept { return stats_; }
|
||||
@@ -472,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_;
|
||||
|
||||
+176
-80
@@ -30,10 +30,23 @@ constexpr plot::u32 kRulerBColor = 0x8FA8C8u;
|
||||
/// The measurements' own colour, distinct from both cursor rulers so a readout mark cannot be
|
||||
/// mistaken for something the user placed.
|
||||
constexpr plot::u32 kMeasureColor = 0x9BE8A0u;
|
||||
/// The window-start caption's colour: quiet chrome, brighter than a grid line.
|
||||
constexpr plot::u32 kTimeNoteColor = 0xC8C8C8u;
|
||||
constexpr plot::u32 kTriggerColor = 0xFF5B5Bu;
|
||||
|
||||
/// The smallest difference a `TickFormat::Time` label can *write* at the magnitude `t` -- what
|
||||
/// the last digit of a label at that magnitude is worth. `format_time` writes three significant
|
||||
/// figures below a minute (of the SI-prefixed mantissa under a second, of the seconds above it),
|
||||
/// whole seconds through `m:ss`/`h:mm:ss` up to a day, and three figures of days above. So the
|
||||
/// resolution is a step function of the *magnitude* and not of the span -- which is the whole
|
||||
/// reason a relative axis is ever needed, and the reason a span threshold alone gets it wrong in
|
||||
/// both directions.
|
||||
double time_label_resolution(double t) {
|
||||
t = std::fabs(t);
|
||||
if (!std::isfinite(t) || !(t > 0.0)) return 0.0;
|
||||
if (t >= 86400.0) return 86400.0 * std::pow(10.0, std::floor(std::log10(t / 86400.0)) - 2.0);
|
||||
if (t >= 60.0) return 1.0;
|
||||
return std::pow(10.0, std::floor(std::log10(t)) - 2.0);
|
||||
}
|
||||
|
||||
constexpr double kParked = -1.0e12; ///< hides an instrument without removing it
|
||||
/// The measurement marks' stroke: thinner than the chrome that frames the plot (a trace is 1.5,
|
||||
/// the axis lines and the trigger marker `Style::line_width_px`), and broken, so a readout over
|
||||
@@ -56,6 +69,40 @@ plot::Color RowSet::palette_color(int index) {
|
||||
|
||||
plot::Color RowSet::unplotted_color() { return plot::Color::hex(kUnplottedColor); }
|
||||
|
||||
plot::Color RowSet::signal_color(const std::string& signal, int palette_index) const {
|
||||
const auto it = signal_colors_.find(signal);
|
||||
if (it != signal_colors_.end()) return plot::Color::hex(it->second);
|
||||
return palette_color(palette_index);
|
||||
}
|
||||
|
||||
void RowSet::mark_signal_dirty(const std::string& signal) {
|
||||
for (int i = 0; i < kMaxRows; ++i) {
|
||||
const Row& r = row(i);
|
||||
for (int s = 0; s < kSlotsPerRow; ++s) {
|
||||
if (r.signal_name[static_cast<std::size_t>(s)] != signal) continue;
|
||||
row_dirty_[static_cast<std::size_t>(i)] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void RowSet::set_signal_color(const std::string& signal, plot::u32 rgb) {
|
||||
if (signal.empty()) return;
|
||||
// Masked to 24 bits: the palette is `0xRRGGBB` and `Color::hex()` reads it as such, so an alpha
|
||||
// byte left in the high octet by a caller converting from a `GdkRGBA` would be silently ignored
|
||||
// by the plot and silently written to the session file.
|
||||
const plot::u32 want = rgb & 0x00FFFFFFu;
|
||||
const auto it = signal_colors_.find(signal);
|
||||
if (it != signal_colors_.end() && it->second == want) return;
|
||||
signal_colors_[signal] = want;
|
||||
mark_signal_dirty(signal);
|
||||
}
|
||||
|
||||
void RowSet::clear_signal_color(const std::string& signal) {
|
||||
if (signal_colors_.erase(signal) == 0) return;
|
||||
mark_signal_dirty(signal);
|
||||
}
|
||||
|
||||
RowSet::RowSet() { rebuild_all(); }
|
||||
|
||||
void RowSet::set_row_count(int rows) {
|
||||
@@ -112,13 +159,13 @@ bool RowSet::remove_row(int at) {
|
||||
void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
|
||||
r.plot = &p;
|
||||
r.axis_x = r.plot->add_x_axis(
|
||||
{"time", {-1.0, 1.0}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
|
||||
{"", {-1.0, 1.0}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
|
||||
r.axis_y = r.plot->add_y_axis(
|
||||
{"value", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
|
||||
{"", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
|
||||
for (int s = 0; s < kSlotsPerRow; ++s) {
|
||||
const std::size_t i = static_cast<std::size_t>(s);
|
||||
r.plot->add_trace(
|
||||
{&r.slots[i], palette_color(r.color_index[i]), 1.2f, r.axis_x, r.axis_y, r.name[i]});
|
||||
r.plot->add_trace({&r.slots[i], signal_color(r.signal_name[i], r.color_index[i]), 1.2f,
|
||||
r.axis_x, r.axis_y, r.name[i]});
|
||||
}
|
||||
r.cursor_a = r.plot->add_cursor(0.0, plot::Color::hex(kCursorAColor));
|
||||
r.cursor_b = r.plot->add_cursor(0.0, plot::Color::hex(kCursorBColor));
|
||||
@@ -155,11 +202,6 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
|
||||
r.plot->set_cursor_stroke(r.measure_v[mi], kMeasureStrokeWidth,
|
||||
plot::Vec2{kMeasureDashOn, kMeasureDashOff});
|
||||
}
|
||||
// The window-start caption for the relative x labels: an annotation, hidden until the
|
||||
// labels are relative. Inside the axes on purpose -- its text changes with the window, and
|
||||
// an axis title of a changing width would relayout the plot every frame.
|
||||
r.time_origin_note = r.plot->add_annotation(
|
||||
plot::Annotation{0.0, 0.0, "", plot::Color::hex(kTimeNoteColor), r.axis_x, r.axis_y, false});
|
||||
r.trigger_marker = r.plot->add_cursor(kParked, plot::Color::hex(kTriggerColor));
|
||||
// A marker, not an instrument: it must sit exactly on the edge's time, so its
|
||||
// snap is Free and the controller never rounds it onto a sample.
|
||||
@@ -191,7 +233,7 @@ void RowSet::rebuild_all() {
|
||||
}
|
||||
figure_->link_x(static_cast<plot::u32>(i), x_group_);
|
||||
}
|
||||
if (y_gutter_px_ > 0.0) apply_y_gutter();
|
||||
apply_gutters();
|
||||
// A new figure starts with no window at all, so the one the rows were showing has to
|
||||
// be restated -- as the pin it was, if an event is on screen: assigning a trace or
|
||||
// changing the row count while a capture is displayed must not throw the view back
|
||||
@@ -396,18 +438,30 @@ void RowSet::place_crossings() {
|
||||
if (ida >= r.plot->annotation_count() || idb >= r.plot->annotation_count()) continue;
|
||||
plot::Annotation a = r.plot->annotation(ida);
|
||||
plot::Annotation b = r.plot->annotation(idb);
|
||||
double xa = 0.0;
|
||||
double ya = 0.0;
|
||||
double xb = 0.0;
|
||||
double yb = 0.0;
|
||||
const bool has_a = mix && r.plot->crosshair_value(static_cast<plot::u32>(s), cursor_a_x_, ya);
|
||||
const bool has_b = mix && r.plot->crosshair_value(static_cast<plot::u32>(s), cursor_b_x_, yb);
|
||||
// `crosshair_point()`, not `crosshair_value()`: the number beside this dot is a *stored
|
||||
// sample's*, so the dot belongs on that sample and not on the marker's own column. The
|
||||
// two differ by up to half a sample period, which is nothing at a whole-window view and
|
||||
// the whole width of the panel once the view is zoomed to a handful of samples -- the
|
||||
// state in which a reader is asking "which point is that?" in the first place. It leaves
|
||||
// the dot a little off its vertical line by design: the line says *where the marker is*
|
||||
// and the dot says *which datum is being quoted*, and when the marker is between two
|
||||
// samples those are not the same place.
|
||||
const bool has_a =
|
||||
mix && r.plot->crosshair_point(static_cast<plot::u32>(s), cursor_a_x_, xa, ya);
|
||||
const bool has_b =
|
||||
mix && r.plot->crosshair_point(static_cast<plot::u32>(s), cursor_b_x_, xb, yb);
|
||||
a.visible = has_a;
|
||||
b.visible = has_b;
|
||||
if (has_a) {
|
||||
a.x = cursor_a_x_;
|
||||
a.x = xa;
|
||||
a.y = ya;
|
||||
}
|
||||
if (has_b) {
|
||||
b.x = cursor_b_x_;
|
||||
b.x = xb;
|
||||
b.y = yb;
|
||||
}
|
||||
r.plot->set_annotation(ida, a);
|
||||
@@ -497,12 +551,48 @@ bool RowSet::update_time_labels() {
|
||||
const double span = view.span();
|
||||
if (!(span > 0.0) || !std::isfinite(span)) return false;
|
||||
|
||||
// Sub-second windows read relative: `TickFormat::Time` writes three significant digits, so
|
||||
// `12.345671s` and `12.345672s` are the same string and a 1 ms step around a multi-second
|
||||
// time cannot be labelled at all. The band is the switch's hysteresis -- entering below a
|
||||
// second, leaving only above 1.05 -- so a span sitting on the boundary cannot flicker
|
||||
// between two forms while a zoom is nudged.
|
||||
const bool want_relative = relative_x_ ? span < 1.05 : span < 1.0;
|
||||
// A relative axis is the answer to one problem only: a tick step the label form cannot write.
|
||||
// `TickFormat::Time` carries three significant digits, so a 1 ms step around a 12 s stamp
|
||||
// writes `12.3s` on every tick -- there the ticks have to be re-anchored and the anchor named.
|
||||
// The test is therefore the *step against the resolution at the axis' own magnitude*, never the
|
||||
// span: the two part company in both directions, and both cases are real here. A 2 s window at
|
||||
// twenty minutes of uptime is labelled `20:00` throughout and needs the anchor although its
|
||||
// span is wide; a 50 ms trigger window spanning [-25ms, +25ms] is labelled `-20ms .. +20ms`
|
||||
// perfectly well and needs nothing, although its span is narrow. That second case is what made
|
||||
// a triggered sub-second view carry a `t0` caption *and* re-anchor its ticks away from the
|
||||
// edge, which is the one position a triggered axis exists to show.
|
||||
// The 1.05 band is the switch's hysteresis, so a step sitting on the boundary cannot flicker
|
||||
// between two forms while a zoom is nudged, and the `1 - 1e-9` is a slack the *entry* needs for
|
||||
// its own reason: a step read back from two tick values and a resolution from `pow(10, n)`
|
||||
// differ in their last bits even when they are the same number, and `step == res` is a readable
|
||||
// axis (`1.73s, 1.74s, 1.75s` -- one in the last digit is exactly enough), so a decision taken
|
||||
// on those bits picked a different form from one frame to the next.
|
||||
const double mag = std::max(std::fabs(view.min), std::fabs(view.max));
|
||||
const double res = time_label_resolution(mag);
|
||||
// The step the labels are actually written at: the majors the *last* layout produced. They are
|
||||
// a function of the range and the axis' pixel width, not of the label form, so reading them
|
||||
// back here is no feedback loop.
|
||||
double step = 0.0;
|
||||
{
|
||||
const plot::Axis& ax = first.plot->x_axis(first.axis_x);
|
||||
double prev = 0.0;
|
||||
bool have = false;
|
||||
for (const plot::Tick& t : ax.ticks) {
|
||||
if (!t.major) continue;
|
||||
if (have) {
|
||||
step = t.value - prev;
|
||||
break;
|
||||
}
|
||||
prev = t.value;
|
||||
have = true;
|
||||
}
|
||||
}
|
||||
// No ticks yet -- the frames before the first layout. The form is a property of the labels, so
|
||||
// with no labels there is nothing to decide, and keeping the current one is right where
|
||||
// guessing a step from the span is not: a guess that entered the relative form would be
|
||||
// *latched* there by the hysteresis above.
|
||||
const bool want_relative =
|
||||
step > 0.0 ? step < res * (relative_x_ ? 1.05 : 1.0 - 1.0e-9) : relative_x_;
|
||||
const bool changed = want_relative != relative_x_;
|
||||
relative_x_ = want_relative;
|
||||
|
||||
@@ -518,27 +608,25 @@ bool RowSet::update_time_labels() {
|
||||
r.plot->set_x_axis_origin_from_range(r.axis_x, relative_x_);
|
||||
if (!relative_x_) r.plot->set_x_axis_origin(r.axis_x, 0.0);
|
||||
// The caption belongs with the labels, which a figure draws once, on the bottom row (the
|
||||
// one whose x axis is the figure's). Every row gets it parked.
|
||||
// one whose x axis is the figure's). Every other row gets an empty one, which draws nothing.
|
||||
const bool bottom = i == rows_ - 1;
|
||||
if (r.time_origin_note >= r.plot->annotation_count()) continue;
|
||||
plot::Annotation note = r.plot->annotation(r.time_origin_note);
|
||||
note.visible = relative_x_ && bottom;
|
||||
if (note.visible) {
|
||||
// Inside the axes, just above the axis line, at their left edge: the reader adds this
|
||||
// to a tick, so it sits beside the ticks.
|
||||
const plot::Range yr = r.plot->y_range(r.axis_y);
|
||||
note.x = view.min;
|
||||
note.y = yr.min + 0.04 * (yr.span() > 0.0 ? yr.span() : 1.0);
|
||||
char text[64];
|
||||
// The anchor the *drawn* labels were built with: the axis' own origin, which the last
|
||||
// layout set from its range. Reading it back (rather than keeping our own copy) is what
|
||||
// makes the reader's addition exact -- label plus caption is that tick's absolute time.
|
||||
const double shown = r.plot->x_axis_origin(r.axis_x);
|
||||
x_origin_ = shown;
|
||||
std::snprintf(text, sizeof(text), "t0 = %.6g s", shown);
|
||||
note.text = text;
|
||||
if (!relative_x_ || !bottom) {
|
||||
r.plot->set_x_note("");
|
||||
continue;
|
||||
}
|
||||
r.plot->set_annotation(r.time_origin_note, note);
|
||||
char text[64];
|
||||
// The anchor the *drawn* labels were built with: the axis' own origin, which the last
|
||||
// layout set from its range. Reading it back (rather than keeping our own copy) is what
|
||||
// makes the reader's addition exact -- label plus caption is that tick's absolute time.
|
||||
const double shown = r.plot->x_axis_origin(r.axis_x);
|
||||
x_origin_ = shown;
|
||||
std::snprintf(text, sizeof(text), "t0 = %.6g s", shown);
|
||||
// Chrome, not an annotation: an annotation is anchored to a *data* coordinate, so the caption
|
||||
// crawled across the plot as the view panned and drifted as it zoomed -- and it came with the
|
||||
// annotation's own pin cross, which pointed at nothing. `set_x_note()` places it in the axes'
|
||||
// bottom-left corner in pixels, on the legend's backplate, which is where the labels it
|
||||
// qualifies are.
|
||||
r.plot->set_x_note(text);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
@@ -646,20 +734,26 @@ void RowSet::unpin_pause_window() {
|
||||
}
|
||||
|
||||
void RowSet::fit_x() {
|
||||
Row& first = row(0);
|
||||
if (!first.plot || !figure_) return;
|
||||
first.plot->auto_fit_x();
|
||||
const plot::Range fitted = first.plot->x_range(first.axis_x);
|
||||
const double span = fitted.span();
|
||||
if (!std::isfinite(span) || !(span > 0.0)) return;
|
||||
// The same two paths `fit_all()` takes: a held event owns its window and states it
|
||||
// through the link group, a live view re-arms the follow and frames the newest span.
|
||||
if (pinned_) {
|
||||
window_s_ = span;
|
||||
figure_->set_x_link_range(x_group_, fitted);
|
||||
} else {
|
||||
set_time_window(span);
|
||||
if (!figure_) return;
|
||||
if (!pinned_) {
|
||||
// Live: the same path the window control and a resume take, with the window it already has.
|
||||
// That re-arms the follow and parks the newest `window_s_` at the live edge, which is the
|
||||
// whole of what a live view may show.
|
||||
set_time_window(window_s_);
|
||||
return;
|
||||
}
|
||||
// Pinned (a held event or a paused view): the pin's full range, and the pin stays. The
|
||||
// *members* have to be written as well as the group, because `hold_pinned_window()` prefers a
|
||||
// member that has moved since our own last statement -- that is how it tells a gesture from its
|
||||
// own echo -- so writing the group alone would be reverted to the zoomed-in member next frame.
|
||||
const plot::Range pin{-pinned_pre_, pinned_post_};
|
||||
if (!(pin.span() > 0.0) || !std::isfinite(pin.min) || !std::isfinite(pin.max)) return;
|
||||
for (int i = 0; i < rows_; ++i) {
|
||||
Row& r = row(i);
|
||||
if (r.plot) r.plot->set_x_range(r.axis_x, pin);
|
||||
}
|
||||
pinned_stated_ = pin;
|
||||
figure_->set_x_link_range(x_group_, pin);
|
||||
}
|
||||
|
||||
void RowSet::sync_cursors(int drag_row) {
|
||||
@@ -778,30 +872,22 @@ void RowSet::fit_all() {
|
||||
Row& r = row(i);
|
||||
if (r.plot) r.plot->auto_fit_y(r.axis_y);
|
||||
}
|
||||
Row& first = row(0);
|
||||
if (!first.plot || !figure_) return;
|
||||
// Row 0 fits its own x to the data it holds; that span becomes the shared window, and
|
||||
// stating it through the group is what puts every row on it.
|
||||
first.plot->auto_fit_x();
|
||||
const plot::Range fitted = first.plot->x_range(first.axis_x);
|
||||
const double span = fitted.span();
|
||||
if (!std::isfinite(span) || !(span > 0.0)) return;
|
||||
if (pinned_) {
|
||||
// Fitting a captured event: the window is the snapshot's own `[-pre, +post]` span,
|
||||
// and `set_time_window()` would re-arm the follow and re-frame on a live edge that
|
||||
// trigger mode has nothing bound to.
|
||||
window_s_ = span;
|
||||
figure_->set_x_link_range(x_group_, fitted);
|
||||
} else {
|
||||
set_time_window(span);
|
||||
}
|
||||
// The x half is `fit_x()`'s exactly -- the zoom undone, the time window untouched -- and
|
||||
// sharing it is what keeps the two fits from drifting apart.
|
||||
fit_x();
|
||||
}
|
||||
|
||||
void RowSet::note_y_gutter(double need_px) {
|
||||
double want = std::min(need_px + 6.0, 200.0);
|
||||
// No slack added here, and that is load-bearing: `need_px` is the gutter the row was
|
||||
// *laid out with*, which is already the maximum of its labels and the floor imposed last
|
||||
// frame -- so a few pixels added to it are added again next frame. The `+ 6.0` this used
|
||||
// to carry made the gutter a ratchet that climbed 6 px a frame until it hit the cap
|
||||
// below, which is the whole reason the left margin was `200` of an 870 px viewport (and
|
||||
// the `axes.w changes: 17` beside it). The label padding the reservation below adds is
|
||||
// the slack, once.
|
||||
double want = std::min(need_px, 200.0);
|
||||
// Reserve room for the widest label the formatter can produce, once, with the
|
||||
// font the labels are actually drawn in (the embedded font is monospaced, so a
|
||||
// character count is an exact width).
|
||||
// font the labels are actually drawn in.
|
||||
if (!y_gutter_reserved_) {
|
||||
for (int i = 0; i < rows_; ++i) {
|
||||
Row& r = row(i);
|
||||
@@ -809,10 +895,19 @@ void RowSet::note_y_gutter(double need_px) {
|
||||
const plot::render::TextBackend* text = r.plot->text_backend();
|
||||
if (text == nullptr) continue;
|
||||
const plot::render::Style& style = r.plot->style();
|
||||
const double advance = double(text->measure("0123456789")) / 10.0;
|
||||
const double pad = double(style.device(style.grid_label_pad));
|
||||
// "-1.23e-45" is the longest label `format_si()`'s fallback can print.
|
||||
want = std::max(want, 9.0 * advance + 2.0 * pad + 2.0);
|
||||
// A sign, three significant figures and a one-glyph SI prefix: the widest label
|
||||
// `format_si()` writes for an axis with no unit, measured rather than counted in
|
||||
// characters. Its `%.3g` fallback is wider (`-1.23e-45`) and was what this reserved,
|
||||
// which cost a *third* of the gutter for a label no signal on a `Si` axis produces --
|
||||
// it needs a value past 1e24, and `note_y_gutter()` is a monotone maximum over the
|
||||
// measured need, so one that did appear widens the gutter for good anyway.
|
||||
want = std::max(want, double(text->measure("-1.23m")) + 2.0 * pad + 2.0);
|
||||
// The other side, from the same metrics. An x tick label is *centred* on its tick and
|
||||
// the newest tick sits on the axes' right edge (a live axis is [-window, 0]), so half
|
||||
// the widest time label is what the rightmost one needs to be drawn at all -- and that
|
||||
// is also the band which keeps the newest sample's ink off the frame.
|
||||
right_gutter_px_ = 0.5 * double(text->measure("-1.23ms")) + pad;
|
||||
y_gutter_reserved_ = true;
|
||||
break;
|
||||
}
|
||||
@@ -820,9 +915,10 @@ void RowSet::note_y_gutter(double need_px) {
|
||||
if (want > y_gutter_px_) y_gutter_px_ = want;
|
||||
}
|
||||
|
||||
void RowSet::apply_y_gutter() {
|
||||
if (!figure_ || !(y_gutter_px_ > 0.0)) return;
|
||||
figure_->set_min_y_gutter(static_cast<f32>(y_gutter_px_));
|
||||
void RowSet::apply_gutters() {
|
||||
if (!figure_) return;
|
||||
if (y_gutter_px_ > 0.0) figure_->set_min_y_gutter(static_cast<f32>(y_gutter_px_));
|
||||
figure_->set_right_gutter(static_cast<f32>(right_gutter_px_));
|
||||
}
|
||||
|
||||
void RowSet::set_trigger_marker(double x, bool visible) {
|
||||
|
||||
+79
-24
@@ -9,6 +9,7 @@
|
||||
#include <plot/plot/plot.hpp>
|
||||
|
||||
#include <array>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
@@ -106,11 +107,6 @@ class RowSet {
|
||||
/// it keeps its position whatever the cursor toggle says.
|
||||
plot::u32 trigger_marker = 0;
|
||||
|
||||
/// The caption naming the window's start while the x labels are relative: an annotation
|
||||
/// inside the axes, so its text can change every frame without moving the layout (an axis
|
||||
/// *title* would, and it would be a different width each time).
|
||||
plot::u32 time_origin_note = 0;
|
||||
|
||||
/// The measurements' readout marks: a pool created with the plot (a `Plot` cannot add or
|
||||
/// drop a cursor after the fact) and parked unless a measurement is using the slot.
|
||||
std::array<plot::u32, kMeasureMarks> measure_h{};
|
||||
@@ -183,6 +179,14 @@ class RowSet {
|
||||
void set_time_window(double seconds);
|
||||
double time_window() const noexcept { return window_s_; }
|
||||
|
||||
/// The newest x any row's live traces hold, which is what the link group follows -- and, since
|
||||
/// it is reported in the display epoch currently in force, also what the frame tick subtracts
|
||||
/// from that epoch to park the newest sample at x = 0. It must be read **after** `bind()` and
|
||||
/// `acquire()`: it asks the bound series, so before them it answers for the previous frame's
|
||||
/// window, and a frame's production at 1 MS/s over a 50 ms view is a third of the screen.
|
||||
/// False when no row holds a streaming trace.
|
||||
bool live_edge(double& newest) const;
|
||||
|
||||
void set_y_auto(int row, bool on);
|
||||
void set_y_range(int row, double lo, double hi);
|
||||
|
||||
@@ -196,7 +200,13 @@ class RowSet {
|
||||
void place_crossings();
|
||||
CursorMode cursor_mode() const noexcept { return cursor_mode_; }
|
||||
|
||||
/// The two vertical cursors' stream times; shared by every row.
|
||||
/// The two vertical cursors' positions, in the **display** x the axis reads -- not stream
|
||||
/// seconds, which they coincided with only while `display_epoch()` was always 0. A live view
|
||||
/// is newest-relative now, so a cursor left at -1.5 s keeps meaning "1.5 s ago" and the data
|
||||
/// flows under it, which is what a bench scope's roll mode does and is the behaviour that was
|
||||
/// asked for. They are read back out of the plots and written to them unchanged, so there is
|
||||
/// nothing to convert; anything that needs the stream's own clock goes through
|
||||
/// `ScopeModel::stream_at_x0()`. Shared by every row.
|
||||
double cursor_a_x() const noexcept { return cursor_a_x_; }
|
||||
double cursor_b_x() const noexcept { return cursor_b_x_; }
|
||||
/// One row's ruler values, or 0 for a row that does not exist.
|
||||
@@ -298,11 +308,25 @@ class RowSet {
|
||||
void set_data_pin(double lo, double hi);
|
||||
void unpin_pause_window();
|
||||
|
||||
/// Frames the x window on the data row 0 holds, leaving every y axis alone. The x
|
||||
/// counterpart of `fit_all()`, for the one thing the y axes cannot say: how wide the
|
||||
/// window should be.
|
||||
/// Undoes an x zoom: puts the view back on the whole time window, leaving every y axis alone.
|
||||
/// The x counterpart of `fit_all()`.
|
||||
///
|
||||
/// **It does not change the time window** -- `window_s_` is read, never written. A fit is a
|
||||
/// plot interaction, and the window is also the trigger's event span, the buffer partition and
|
||||
/// the control's readout, so a fit that *framed the data* instead would redefine all of that
|
||||
/// from a gesture (and re-plan the rings, dropping the history). What a user asks for here is
|
||||
/// the zoom undone: a 2 s window zoomed into reads 2 s again.
|
||||
///
|
||||
/// Which 2 s depends on the state, and both cases restore the *whole* of what the view is
|
||||
/// allowed to show: live, the newest window at the live edge; pinned (a held event or a paused
|
||||
/// view), the pin's full range, with the pin kept -- `set_time_window()` would clear it and
|
||||
/// re-frame on a live edge that a frozen view has nothing bound to.
|
||||
void fit_x();
|
||||
|
||||
/// Whether a window is pinned: a held event, a filling capture or a paused view. The condition
|
||||
/// `fit_x()` branches on, which is why the fit probe reports it.
|
||||
bool window_pinned() const noexcept { return pinned_; }
|
||||
|
||||
/// Adopts the instruments a gesture is moving, on `drag_row` (the row whose controller holds
|
||||
/// a cursor), and nothing otherwise.
|
||||
///
|
||||
@@ -336,10 +360,9 @@ class RowSet {
|
||||
/// 0, so nothing outside the pre+post window is visible.
|
||||
void pin_event_window(double pre_s, double post_s);
|
||||
|
||||
/// Fits every row's y axis to the data it holds, and re-frames the shared x window
|
||||
/// on what row 0 retains (the same thing the plot's own `f` key does). The window
|
||||
/// the toolbar reports becomes the fitted span, so the dropdown can say what is
|
||||
/// actually on screen.
|
||||
/// Fits every row's y axis to the data it holds, and puts x back on the whole time window --
|
||||
/// `fit_x()`'s x half exactly, so the two cannot drift apart. Like it, **this does not change
|
||||
/// the time window**: the y axes are fitted to their data, x is merely un-zoomed.
|
||||
void fit_all();
|
||||
|
||||
/// Reports the left gutter the rows currently need, and reserves at least the width
|
||||
@@ -351,18 +374,22 @@ class RowSet {
|
||||
/// left and right by a few pixels every frame. Since `set_min_y_gutter()` is a
|
||||
/// floor and the library still takes the larger of the two, a floor smaller than
|
||||
/// the labels cannot stop that; the reservation therefore covers the widest label
|
||||
/// `format_si()` can produce (three significant digits, a sign and a prefix, or the
|
||||
/// `%.3g` fallback) plus the label padding, measured once with the plot's own font.
|
||||
/// Both reservations only ever grow, so the gutter is constant in practice.
|
||||
/// `format_si()` writes for an axis with no unit -- a sign, three significant figures
|
||||
/// and a one-glyph prefix -- plus the label padding, measured once with the plot's own
|
||||
/// font. Both reservations only ever grow, so the gutter is constant in practice.
|
||||
///
|
||||
/// The same metrics fix the **right** gutter, which is a want and not a measurement:
|
||||
/// see `apply_gutters()`.
|
||||
void note_y_gutter(double need_px);
|
||||
|
||||
/// Applies the stable gutter as the figure's floor. Call once per frame, before the
|
||||
/// frame is drawn.
|
||||
/// Applies the stable left gutter as the figure's floor, and the right gutter beside
|
||||
/// it. Call once per frame, before the frame is drawn.
|
||||
///
|
||||
/// It has to be the *figure's* floor and not each row's: `Figure::layout()` clears
|
||||
/// every row's `set_min_y_gutter()` to measure what its own labels need, so a floor
|
||||
/// written onto the rows survives exactly until the next layout.
|
||||
void apply_y_gutter();
|
||||
/// Both have to be the *figure's* and not each row's: `Figure::layout()` clears every
|
||||
/// row's `set_min_y_gutter()` to measure what its own labels need, so a floor written
|
||||
/// onto the rows survives exactly until the next layout, and the rows have to end on
|
||||
/// one column as well as start on one.
|
||||
void apply_gutters();
|
||||
|
||||
/// The palette entry `index`, wrapped. Keyed by the *signal*, so that the colour a
|
||||
/// trace is drawn in is the colour the signal list's chip shows, on every row the
|
||||
@@ -373,7 +400,30 @@ class RowSet {
|
||||
/// names beside it shifted sideways as signals were placed.
|
||||
static plot::Color unplotted_color();
|
||||
|
||||
/// The colour a signal is drawn in: the one the user chose for it if they chose one, the
|
||||
/// positional palette entry otherwise.
|
||||
///
|
||||
/// **The one resolver**, used by `install()` for the trace and by the panels for their chips.
|
||||
/// A chip exists to say which trace on screen a panel row is about, so a chip that resolved
|
||||
/// a colour by a different route than the trace is the single failure the chip cannot
|
||||
/// survive -- and an override is exactly the state in which two routes would disagree.
|
||||
plot::Color signal_color(const std::string& signal, int palette_index) const;
|
||||
/// Gives `signal` the colour `rgb` (0xRRGGBB), by *name*: a palette entry is positional, so an
|
||||
/// override held by index would move to another signal on the next CONFIG. Marks every row that
|
||||
/// plots it dirty, because a trace's colour is fixed at `add_trace()` -- a new colour is a new
|
||||
/// figure, which is the same path `assign()` already takes.
|
||||
void set_signal_color(const std::string& signal, plot::u32 rgb);
|
||||
/// Back to the palette entry. A no-op, and no rebuild, when there was no override.
|
||||
void clear_signal_color(const std::string& signal);
|
||||
/// Every override, for the session file. Ordered by name, so a saved session's colour
|
||||
/// tables do not reshuffle between writes.
|
||||
const std::map<std::string, plot::u32>& signal_colors() const noexcept { return signal_colors_; }
|
||||
|
||||
private:
|
||||
/// Marks every row with `signal` in a slot for rebuild. Not `assign()`: nothing about what
|
||||
/// the slot *holds* changed, only the colour its trace has to be built with.
|
||||
void mark_signal_dirty(const std::string& signal);
|
||||
|
||||
/// Moves one row's *persistent* state -- what it plots, in which colours, where its
|
||||
/// cursors are -- onto another row. Deliberately not a `Row` assignment: a `Row` also
|
||||
/// carries the figure handles (`plot`, the axis and cursor ids) and the `SlotSeries`
|
||||
@@ -391,8 +441,6 @@ class RowSet {
|
||||
/// at the end of the gesture (`Controller::end_drag()`), and a value read out of the plot
|
||||
/// mid-drag has already been through that act.
|
||||
void place_cursors(Row& r, plot::CursorPlacement placement = plot::CursorPlacement::Snapped);
|
||||
/// The newest x any row's live traces hold, which is what the group follows.
|
||||
bool live_edge(double& newest) const;
|
||||
|
||||
std::unique_ptr<plot::Figure> figure_;
|
||||
/// The one x link group every visible row belongs to. Groups are numbered from 1.
|
||||
@@ -411,8 +459,15 @@ class RowSet {
|
||||
/// gesture to be honoured; one that matches it is just the clamp's own answer arriving back.
|
||||
plot::Range pinned_stated_{0.0, 0.0};
|
||||
double y_gutter_px_ = 0.0;
|
||||
/// The empty band kept to the right of the data, device pixels. Nothing is drawn in it,
|
||||
/// so it is reserved once from the font's metrics and never measured or grown.
|
||||
double right_gutter_px_ = 0.0;
|
||||
bool y_gutter_reserved_ = false;
|
||||
std::array<Row, static_cast<std::size_t>(kMaxRows)> storage_{};
|
||||
/// The colours the user chose, by signal name (see `set_signal_color()`). Sparse on purpose:
|
||||
/// an absent entry means "the palette decides", which is what keeps a new CONFIG's signals
|
||||
/// coloured without anything having to seed them.
|
||||
std::map<std::string, plot::u32> signal_colors_;
|
||||
CursorMode cursor_mode_ = CursorMode::Vertical;
|
||||
bool cursors_on_ = false;
|
||||
bool relative_x_ = false;
|
||||
|
||||
@@ -48,6 +48,11 @@ std::string format_glance(const StatusSnapshot& s) {
|
||||
out += " · ";
|
||||
out += s.window_text;
|
||||
out += " window";
|
||||
if (s.paused) {
|
||||
// Directly after the state word's own field rather than at the end, because it qualifies
|
||||
// *that* word: "paused · 10 kS/s · 2 s window · for 12 s" reads as one statement.
|
||||
out += " · for " + format_elapsed(s.paused_for_s);
|
||||
}
|
||||
if (s.trigger_on) {
|
||||
out += " · ";
|
||||
if (s.have_event) {
|
||||
@@ -74,6 +79,7 @@ std::string format_detail(const StatusSnapshot& s) {
|
||||
s.plot1_traces, s.plot1_samples, s.buffer_mb, s.span_s,
|
||||
static_cast<unsigned long long>(s.time_stride));
|
||||
std::string out = buf;
|
||||
if (s.paused) out += " | paused for " + format_elapsed(s.paused_for_s);
|
||||
if (s.trigger_on) {
|
||||
out += s.have_event ? " | last event " + format_elapsed(s.since_event_s) + " ago"
|
||||
: " | no event yet";
|
||||
|
||||
+18
-5
@@ -11,9 +11,12 @@ namespace udpscope {
|
||||
/// two cannot disagree. No GTK here on purpose: this is the one part of the status bar that
|
||||
/// can be reasoned about as text.
|
||||
struct StatusSnapshot {
|
||||
/// One word from the fixed set: "live", "paused", "armed", "capturing", "event held",
|
||||
/// "disarmed". It is a word from a *fixed* set because the bar sits directly above the plot
|
||||
/// and a field whose width tracked the stream would relayout it every frame.
|
||||
/// One word from the fixed set: "connecting", "live", "paused", "armed", "capturing",
|
||||
/// "event held", "disarmed". It is a word from a *fixed* set because the bar sits directly
|
||||
/// above the plot and a field whose width tracked the stream would relayout it every frame --
|
||||
/// so a word added here must be no wider than the widest already in it ("connecting" and
|
||||
/// "event held" are both ten characters). "connecting" is the state before a CONFIG has
|
||||
/// arrived, in which there is no signal list and so nothing any other word here can describe.
|
||||
const char* state_word = "live";
|
||||
/// The long prose the old single-line bar carried, kept for the detail string and the dump.
|
||||
std::string state_detail;
|
||||
@@ -44,10 +47,20 @@ struct StatusSnapshot {
|
||||
bool have_event = false;
|
||||
double since_event_s = 0.0;
|
||||
int reconnects = 0;
|
||||
|
||||
/// How long the view has been frozen, in **wall** seconds, and whether it is. The producer
|
||||
/// never stops while paused -- it keeps filling the live ring, which is what makes the resume
|
||||
/// seamless -- so the stream's own clock says nothing about how long the user has been looking
|
||||
/// at a still picture. Written through `format_elapsed()` like the event age, for the same
|
||||
/// reason: it steps to a coarser unit instead of growing digits, and the bar is directly above
|
||||
/// the plot.
|
||||
bool paused = false;
|
||||
double paused_for_s = 0.0;
|
||||
};
|
||||
|
||||
/// The bar: "live · 10 kS/s · 2 s window", plus " · event 4.2 s ago" in trigger mode and
|
||||
/// nothing else ever. The badge glyph is a separate widget and is not part of this string.
|
||||
/// The bar: "live · 10 kS/s · 2 s window", plus " · event 4.2 s ago" in trigger mode, " · for
|
||||
/// 12 s" while paused, and nothing else ever. The badge glyph is a separate widget and is not
|
||||
/// part of this string.
|
||||
std::string format_glance(const StatusSnapshot& s);
|
||||
|
||||
/// The popover's prose and the exit dump's line, field for field as the old single-line bar
|
||||
|
||||
+2038
-309
File diff suppressed because it is too large.
Load diff
+104
-34
@@ -50,26 +50,42 @@ def signal_desc(name, *, rate, time_mode, time_idx, unit, rows=1, cols=1, dims=0
|
||||
return bytes(d)
|
||||
|
||||
|
||||
def build_config(rate, time_mode=TIME_FULL_ARRAY):
|
||||
def build_config(rate, time_mode=TIME_FULL_ARRAY, unit="V"):
|
||||
sigs = [
|
||||
# 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("sine", rate=rate, time_mode=time_mode, time_idx=0, unit="V"),
|
||||
signal_desc("square", rate=rate, time_mode=time_mode, time_idx=0, unit="V"),
|
||||
signal_desc("ramp", rate=rate, time_mode=time_mode, time_idx=0, unit="V"),
|
||||
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),
|
||||
]
|
||||
payload = struct.pack("<I", len(sigs)) + b"".join(sigs)
|
||||
payload += bytes([PUBLISH_ACCUMULATE])
|
||||
return header(PKT_CONFIG, 0, len(payload)) + payload
|
||||
|
||||
|
||||
def build_data(counter, t0, dt, batch, freq, time_mode=TIME_FULL_ARRAY):
|
||||
def build_data(counter, t0, dt, batch, freq, time_mode=TIME_FULL_ARRAY, noise=0.0):
|
||||
stamps = [t0 + i * dt for i in range(batch)]
|
||||
sine = [math.sin(2.0 * math.pi * freq * t) for t in stamps]
|
||||
square = [1.0 if math.sin(2.0 * math.pi * freq * 0.5 * t) >= 0.0 else -1.0 for t in stamps]
|
||||
# The interferer is a tone at 100x the fundamental with a randomly scaled amplitude, which
|
||||
# is the shape that is hardest on anything counting crossings: near the sine's own mid level
|
||||
# the fundamental's slope is 2*pi*f while the interferer's is 100x that, so the signal
|
||||
# recrosses the level several times per true crossing. `--noise 0` is the ideal waveform, and
|
||||
# it is the default because the documented probe assertions are *exact* (`peak to peak=2`,
|
||||
# `top=0.636620`) and any interferer moves the extremes.
|
||||
if noise > 0.0:
|
||||
sine = [
|
||||
math.sin(2.0 * math.pi * freq * t)
|
||||
+ random.random() * noise * math.sin(2.0 * math.pi * 100.0 * freq * t)
|
||||
for t in stamps
|
||||
]
|
||||
else:
|
||||
sine = [math.sin(2.0 * math.pi * freq * t) for t in stamps]
|
||||
square = [
|
||||
1.0 if math.sin(2.0 * math.pi * freq * 0.5 * t) >= 0.0 else -1.0 for t in stamps
|
||||
]
|
||||
ramp = [(freq * t) % 1.0 * 2.0 - 1.0 for t in stamps]
|
||||
# In FIRST_SAMPLE mode only element 0 of the time signal is meaningful: the producer
|
||||
# stamps the first sample of the batch and says nothing about the rest. A scalar
|
||||
@@ -90,26 +106,58 @@ def main():
|
||||
ap.add_argument("--batch", type=int, default=100, help="samples per packet")
|
||||
ap.add_argument("--freq", type=float, default=5.0, help="waveform frequency, Hz")
|
||||
ap.add_argument("--duration", type=float, default=0.0, help="0 = forever")
|
||||
ap.add_argument("--stream-scale", type=float, default=1.0,
|
||||
help="stream seconds per wall second: 0.1 mimics a producer whose own "
|
||||
"clock advances ten times slower than the wire delivers samples")
|
||||
ap.add_argument("--declared-rate", type=float, default=-1.0,
|
||||
help="sampling_rate to put in CONFIG; -1 = the real rate, 0 = 'unknown'")
|
||||
ap.add_argument("--time-mode", choices=("full", "first"), default="full",
|
||||
help="how the stream carries its clock: 'full' stamps every sample "
|
||||
"(UDPS_TIME_FULL_ARRAY), 'first' stamps only the batch's first "
|
||||
"sample (UDPS_TIME_FIRST_SAMPLE) and leaves the viewer to space the "
|
||||
"rest by the declared cadence -- the shape a real MARTe2-style "
|
||||
"producer with a single u64 clock has")
|
||||
ap.add_argument("--clock-jitter", type=float, default=0.0,
|
||||
help="per-packet stamp jitter, as a fraction of one packet's span: a device "
|
||||
"that stamps each batch from its own clock does not land on a perfect "
|
||||
"grid, so the stream's timestamps wobble about one. Non-cumulative "
|
||||
"(the mean rate is unchanged); 0 = the ideal grid")
|
||||
ap.add_argument(
|
||||
"--stream-scale",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help="stream seconds per wall second: 0.1 mimics a producer whose own "
|
||||
"clock advances ten times slower than the wire delivers samples",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--declared-rate",
|
||||
type=float,
|
||||
default=-1.0,
|
||||
help="sampling_rate to put in CONFIG; -1 = the real rate, 0 = 'unknown'",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--time-mode",
|
||||
choices=("full", "first"),
|
||||
default="full",
|
||||
help="how the stream carries its clock: 'full' stamps every sample "
|
||||
"(UDPS_TIME_FULL_ARRAY), 'first' stamps only the batch's first "
|
||||
"sample (UDPS_TIME_FIRST_SAMPLE) and leaves the viewer to space the "
|
||||
"rest by the declared cadence -- the shape a real MARTe2-style "
|
||||
"producer with a single u64 clock has",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--unit",
|
||||
default="V",
|
||||
help="the unit the data signals declare in CONFIG; '' declares none, which "
|
||||
"is an ordinary stream (and what every math channel looks like) and is "
|
||||
"the only way a probe can see the unitless readout formatting",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--noise",
|
||||
type=float,
|
||||
default=0.0,
|
||||
help="amplitude of an interferer added to `sine`: a tone at 100x the fundamental, "
|
||||
"scaled by a fresh uniform random factor per sample. 0 = the ideal sine, which is "
|
||||
"what the exact probe assertions (peak to peak = 2, top = 2/pi) are read against; "
|
||||
"0.1 is the figure the measurement hysteresis is sized against",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--clock-jitter",
|
||||
type=float,
|
||||
default=0.0,
|
||||
help="per-packet stamp jitter, as a fraction of one packet's span: a device "
|
||||
"that stamps each batch from its own clock does not land on a perfect "
|
||||
"grid, so the stream's timestamps wobble about one. Non-cumulative "
|
||||
"(the mean rate is unchanged); 0 = the ideal grid",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
dt = args.stream_scale / args.rate # stream seconds per sample
|
||||
period = args.batch / args.rate # wall seconds per packet
|
||||
dt = args.stream_scale / args.rate # stream seconds per sample
|
||||
period = args.batch / args.rate # wall seconds per packet
|
||||
declared = args.rate if args.declared_rate < 0.0 else args.declared_rate
|
||||
time_mode = TIME_FIRST_SAMPLE if args.time_mode == "first" else TIME_FULL_ARRAY
|
||||
|
||||
@@ -117,9 +165,19 @@ def main():
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(("0.0.0.0", args.port))
|
||||
sock.settimeout(0.001)
|
||||
print("mock udpstreamer: waiting for CONNECT on :%d (%.0f S/s wire, %d per packet, "
|
||||
"stream clock x%.3g, declared %.0f S/s, time-mode %s)"
|
||||
% (args.port, args.rate, args.batch, args.stream_scale, declared, args.time_mode), flush=True)
|
||||
print(
|
||||
"mock udpstreamer: waiting for CONNECT on :%d (%.0f S/s wire, %d per packet, "
|
||||
"stream clock x%.3g, declared %.0f S/s, time-mode %s)"
|
||||
% (
|
||||
args.port,
|
||||
args.rate,
|
||||
args.batch,
|
||||
args.stream_scale,
|
||||
declared,
|
||||
args.time_mode,
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
|
||||
peer = None
|
||||
counter = 1
|
||||
@@ -138,8 +196,11 @@ def main():
|
||||
t_stream = 0.0
|
||||
started = time.monotonic()
|
||||
next_send = started
|
||||
sock.sendto(build_config(declared, time_mode), peer)
|
||||
print("mock udpstreamer: CONNECT from %s:%d, CONFIG sent" % peer, flush=True)
|
||||
sock.sendto(build_config(declared, time_mode, args.unit), peer)
|
||||
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
|
||||
@@ -162,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