30 Commits
Author SHA1 Message Date
Martino FerrariandClaude Opus 4.6 96ee35da25 docs: a design for a node editor whose wires can be drawn, deleted and navigated
The reported "cannot add edges" has a specific cause: the output port's hit
radius reaches past the box it belongs to, and the press handler re-derives the
owning node with a box test, so clicking the visible dot's outer half sets
pending_out to -1 and silently disarms. Edge deletion has no code path at all.

The design replaces the two-click protocol with a drag gesture, makes the list
order the editor's own invariant via a topological re-sort rather than a rule the
user must obey, adds a view transform with every hit test routed through its
inverse, and trades the modal alert on Apply for a live validation line. It also
records that the Cancel button still calls gtk_window_destroy -- the dangling
dialog pointer CLAUDE.md documents as fixed was only fixed on the Apply path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 22:26:31 +02:00
Martino FerrariandClaude Opus 4.6 f307d27e41 docs: a pause publish costs no packets, and the look is checked with frame path
An A/B of the same fast-mock run with and without a pause reads gaps=0 both
ways, the paused one having published in 86 ms -- so the gaps=73 recorded
yesterday as "a pause costs packet gaps, unexplained" was incidental load on
one run. It never scaled with the publish length and has not reproduced.

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 02:02:52 +02:00
Martino FerrariandClaude Opus 4.6 62df049f53 fix: the plot's two side margins are balanced, and the dump outlives its widgets
With the axis titles gone the left gutter was 200 px of an 870 px viewport and the right
side had no margin at all, so the newest tick's label was drawn half outside the plot and
the newest sample's ink landed on the frame.

Two causes on the left. The reservation covered `format_si()`'s `%.3g` fallback
(`-1.23e-45`), which no signal on an `Si` axis produces -- the widest real label for a
unitless axis is `-1.23m` -- and `note_y_gutter()` added 6 px of slack to a figure that is
already the maximum of the labels and last frame's floor, making the gutter a ratchet that
climbed to its 200 px cap in ~19 frames. It settles at 104 px now, with `axes.w changes: 1`
where it read 17.

The right side did not exist: a plot's `axes` rect reached the viewport's right edge by
construction. `deps/plotter` gains `Plot::set_right_gutter()` and `Figure::set_right_gutter()`
-- a want rather than a measurement, since nothing is drawn there, imposed figure-wide so
the rows end on one column as they already start on one. udpscope asks for half the widest
time label plus the label padding (59 px), x labels being centred on their tick.

Separately: the exit dump asked two destroyed widgets for their size, which is where the
"exactly two Gtk-CRITICALs" baseline came from. The pointer is freed memory by then, so a
run that had destroyed other windows first (UDPSCOPE_PROBE_HELP_MS opens three) segfaulted
in the middle of the dump instead. Both sizes are captured on the tick; a clean run now
carries no critical.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 22:34:10 +02:00
Martino Ferrari 811c6d80f0 fix: a source that has sent no CONFIG reads connecting, not connected
A stream that answered CONNECT but has sent no CONFIG has no signal list, so
nothing can be enabled and no DATA frame is owed. Calling that "connected, but
no data is arriving: another reader may hold the source" blamed a second reader
that does not exist, and the badge blinked green over it -- a working pipeline
is exactly what it did not have.

The glance word is `connecting` (ten characters, no wider than `event held`,
so the fixed-set layout rule still holds) and the badge takes a fourth, steady
yellow phase, both from the first frame rather than after the three-second
stall test. The stall notice itself now splits on `have_config`: the unicast
contention wording where a signal list exists, "trying to connect" where it
does not, with `[udpscope] connecting:` on stderr beside the old `starved:`.
2026-10-05 22:08:13 +02:00
Martino FerrariandClaude Opus 4.6 b313cac46e feat: the x axis is relative to now, and an export carries both clocks
Three asked-for changes, one commit because they are one coordinate system.

The live axis is a fixed [-window, 0] with the newest sample at 0, so x reads
as an age and the labels stand still while the trace rolls under them. That
makes the display clock distinct from the stream clock for the first time, so
`ScopeModel::stream_at_x0()` becomes the single conversion between them
(`display_x = stream_t - stream_at_x0()` in all four modes) and a snapshot
carries its own origin in `EventSnapshot::x_offset`. The pause view is now
rebased like an event's, reversing an earlier deliberate choice: a frozen
absolute-stamped picture would sit a whole uptime right of a newest-relative
axis. That also fixed a latent bug, the export adding `trigger_time()` to a
*paused* view's stamps.

The status bar says how long you have been paused -- wall time, since the
producer never stops, through `format_elapsed()` so the field cannot widen.

The export writes two time columns: `time`, the axis as displayed, and
`abs_seconds`, the stream's own clock for the same sample. f64 seconds and
not the wire's integer, because `udps_frame_t::values` is a `const double*`
and the integer never exists in this process.

Cursors and measurement marks move in opposite directions under the rolling
axis, deliberately: a cursor is a position on screen and stays put, a mark
names a sample and travels with it. `App::measure_window` is stream-clocked
for a sharper reason -- a display-coordinate live window never moves, so the
"the window moved" recompute would never fire again.

Moves the deps/plotter pin to 58473db, which stops a time label's mantissa
reaching its own radix: a one-second tick differenced off a live axis read
`1e+3ms`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 21:49:12 +02:00
Martino FerrariandClaude Opus 4.6 2f4530c128 fix: a zoomed trace is drawn on the x its readout quotes
gather() staged x as f32 relative to the series' own x0() -- the start of
the whole retained window -- so the quantisation was a fraction of the
ring rather than of the view: 1.9 us at 20 s of retained data, which at a
10 us view across a 716 px axis is over 100 px between where a sample is
drawn and the x a readout quotes for it (crosshair_point() reads x_at()
in exact f64). It halved on every zoom out, which is what made it read as
"zooming in makes the crosshair select a point beside the data".

The origin is now the caller's (plotter c2c8c59): the renderer passes the
visible range's minimum, so the error is a fixed 2^-24 of what is on
screen -- about 4e-5 px over a 700 px axis -- at any zoom. The parameter
sits before out_xy so every call site is a compile error rather than a
silent change of behaviour.

The renderer no longer reads series.x0() at paint time at all, which
removes two of the three symptoms of the torn window_x0_ read by
construction; the ink probe's now-vacuous kept_fixed_x0 walk goes with
them, replaced by a drawn-vs-quoted pixel figure. neighbours() still
tests the visible range against x_at(0), so the cache stays load-bearing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 18:49:54 +02:00
Martino FerrariandClaude Opus 4.6 39f30d913f fix: the t0 caption is chrome anchored to the axis, not an annotation in the data
The caption that names the relative x labels' anchor was an `Annotation`, whose anchor
is a data coordinate -- so it crawled across the plot under a pan and drifted under a
zoom, which is how it was reported. It is now `Plot::set_x_note()`: the bottom row's
caption, drawn by the renderer in the axes' bottom-left corner in pixels, on the
legend's own backplate.

The text stays the host's, because only the host knows that label + caption has to be
the exact absolute time (`%.6g`, where `format_time()` would round to three figures).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 18:21:28 +02:00
Martino FerrariandClaude Opus 4.6 e2985aa159 fix: the x labels go relative on what a label can write, not on the span
A `t0 = ...` caption appeared on every sub-second window, including a
triggered one whose axis is already relative to the edge -- so the ticks
were re-anchored away from the one position a triggered axis exists to
show, and the caption had nothing to explain.

The form is now decided by the tick step against `TickFormat::Time`'s own
resolution at the axis' magnitude (three significant figures below a
minute, whole seconds through `m:ss` to a day, three figures of days
above). The span said nothing about that and was wrong both ways: a 2 s
window at twenty minutes of uptime reads `20:00` on every tick and stayed
absolute.

The step is read back from the last layout's majors (a tick value depends
on the range and the axis width, not on the label form); with no ticks yet
the form is left alone rather than guessed, since the hysteresis would
latch a guess. Entering the relative form carries a ULP of slack, because
`step == res` is readable and a bare comparison of two differently
computed doubles flipped the form between frames.

`[timelabels-probe]` and the dump gain `distinct=`, which asserts that no
two major labels read the same -- the thing the relative form is for, and
the only reading that holds whatever the stream's uptime is.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 18:01:41 +02:00
Martino FerrariandClaude Opus 4.6 b505706d92 fix: a readout keeps its metric prefix when the stream declares no unit
The SI prefix ladder in format_measure() was gated on a non-empty unit, so two
decimals of a small unitless value printed `0.00` -- the reading gone, not merely
coarse. That is a whole class of channels rather than a corner: a CONFIG need not
declare a unit, and a math channel never has one. Below 1e-12 the same loss
happened even with a unit, so that range falls back to three significant digits.

With no unit the prefix goes tight against the number (`585.24m`), since the space
exists to separate the number from its unit and a spaced `m` alone reads as metres.

The mock declared `unit="V"` for every data signal, which is why no probe could see
this; it now takes `--unit ''`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 17:28:51 +02:00
Martino FerrariandClaude Opus 4.6 3d7e214295 fix: a fit undoes an x zoom instead of redefining the time window
The previous commit took "fit x sets the window properly" to mean a fit should
frame the data and propagate that as the new time scale. It should not: a fit is
a plot interaction, and the window is also the trigger's event span and how the
samples are stored, so a fit that framed the data could re-plan the rings and
drop the history on a menu click. What a fit means here is the undo of a zoom --
a 2 s window zoomed into reads 2 s again.

`fit_x()` therefore reads `window_s_` and never writes it. Live it takes the
window-control path with the window already set, which re-arms the follow and
parks the newest span at the live edge. Pinned -- a held event, a filling capture
or a paused view -- it restores the pin's full range, which is the snapshot's own
span and may be narrower than the requested window, and it writes the per-row
member axes as well as the link group: `hold_pinned_window()` prefers a member
that differs from `pinned_stated_`, so writing the group alone is reverted to the
zoomed range one frame later. `apply_window_scale()` is folded back into
`apply_time_window()`, which the window control is again the only caller of.

The probe now zooms in before fitting, since otherwise there is nothing to undo,
and reports `pinned=`. `window` and `event_span` must be unchanged on every line.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 16:26:37 +02:00
Martino FerrariandClaude Opus 4.6 9888fd9977 fix: only a window change sets the time scale; a fit states the whole of it
The time scale is more than the axis: it is also the trigger's event span, the
buffer partition and the control's readout. A zoom or a pan was already clean --
the controller writes the member axis and `clamp_to_data()` caps the span -- but
both fits moved `window_s_` alone and only restated the control. The event span
then stayed on the previous window until the next decade step or unit change
applied the fitted value for real, so one action changed the time scale twice.

`apply_window_scale()` is split out of `apply_time_window()` for the fits to use:
it moves the partition, the event span, the control and the session, and leaves
the view alone, because `set_time_window()` re-arms the live follow and would
throw a held event or a paused view back onto the stream. The fits report the
span they framed, `fit_all()` delegates its x half to `fit_x()` so the two cannot
drift, and the probe drives the menu handlers rather than `RowSet` so it sees the
half that lives there. `[fit-probe]` gained `event_span=`, which must equal
`window=` on every line.

Also moves the plotter pin to 03c41ad (three build warnings, no behaviour).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 16:12:59 +02:00
Martino Ferrari 1f6738031a fix: a readout mark sits on the sample it quotes, not on the pointer's column
The value was already the nearest stored sample's, but every mark drawn for it was
placed at the *marker's* x with that sample's y -- so the dot the eye follows and the
number in the pane were a different datum whenever the marker fell between two samples.
Half a sample period is nothing across a whole window and the width of the panel once a
few samples span the axes, which is the only zoom at which anyone is asking which point
a number came from.

`place_crossings()` now goes through the plotter's new `Plot::crosshair_point()`, which
returns the quoted sample's x alongside its y -- one lookup for the mark and the number,
so they cannot name different samples. The dot consequently sits a little off its own
vertical cursor line: the line is where the marker is, the dot is the datum being quoted,
and between two samples those are two places.

Pin moves to the upstream commit that does the same for the library's own hover crosshair
and draws its mark larger than a trace's sample dots, and that keeps the old blend
reachable as `CrosshairValue::Interpolated` -- which udpscope never states.

Verified: plotter suite 370 cases 0 failed; `[cursors-probe]` lines=3 marks=6 with
`first mark at x=3.292400 y=-0.525175` against the pane's `-525.17 mV`; `[help]`
pages=14 parsed=14 closed ok.
2026-10-05 14:41:02 +02:00
Martino FerrariandClaude Opus 4.6 d03c9fb443 fix: a cursor readout quotes the nearest stored sample, not a value between two
The crosshair and the cursors pane's mix values are one library call, and it
interpolated linearly between the two samples bracketing the pointer. That is
wrong twice over here. Ingest decimation is a boxcar average over `time_stride`
samples, so a number computed between two stored samples is a second, undeclared
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 quoting a
point on it reports the drawing rather than the data.

The fix is upstream and unconditional (plotter c028f48): `crosshair_span()`'s `t`
selects the nearer end instead of blending, with a tie reading the earlier
sample. `CrosshairRow::marker` is placed from the same y, so the dot follows the
quoted sample.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 13:45:21 +02:00
Martino FerrariandClaude Opus 4.6 ec93a9831d fix: store the stream clock as f64, so a stamp's precision does not decay with uptime
The time ring was f32 like the values, holding each stamp relative to an epoch
fixed at the first sample ever seen -- so the number stored grew for as long as
the app ran while the interval it had to resolve stayed one sample period. At
1 MS/s the f32 ULP reaches the sample period after about sixteen seconds, so two
to four consecutive samples rounded onto one stamp after half a minute and eight
after a minute. On screen that is a plateau the LOD envelope draws as a
zero-width segment -- a vertical spike where the waveform should be -- and in an
export it was three distinct diffs at 2, 2.5 and 3 ULP instead of one clock.

A value's precision is a property of the signal; a timestamp's is a property of
how long the scope has been running. Only the clock widens: the values are the
memory budget. That makes the two slices different sizes per slot, so
plan_partition() weighs its growth loop in bytes rather than in slots (and
bytes_for() is what bounds the doubling); live_window_locked() loses the f32
round-trip of its bounds, which existed only to keep a sample sitting exactly on
one; and EventSnapshot::stamps is f64 because a pause view is not rebased and so
carries full stream time.

Verified against the fast mock: np.diff over two million exported rows now holds
exactly one step (1e-06, 1999999 times), no duplicate or backwards stamps, and
the 200 MB ring still spans 8.39 s. Every documented probe assertion unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 10:41:57 +02:00
Martino FerrariandClaude Opus 4.6 eb4372242c fix: time a FIRST_SAMPLE stream by its cadence, not by a second stamp
A UDPS_TIME_FIRST_SAMPLE time signal carries one meaningful value -- the
batch's first sample -- but a scalar signal in Accumulate mode still occupies
num_samples slots on the wire, which a producer fills by repeating that
anchor. Deriving the step from value_of(n - 1) therefore came out as zero, so
every sample of a frame landed on one x, the degeneracy guard tripped, and the
whole stream was timed by packet arrival instead. On a 1 MS/s source that
arrival clock is not monotonic within a frame: the export's time column held
90559 order breaks.

Space the batch by the cadence, exactly as the LAST_SAMPLE branch beside it
already dates its own batch backwards. The export's time column is now
strictly increasing (0 duplicates, 0 backwards steps) and reads the declared
1 MS/s.

mock_udpstreamer gains --time-mode {full,first}; it could only emit
FULL_ARRAY, so nothing exercised this branch. In 'first' it repeats the anchor
across the batch's wire slots -- the shape that produced the defect -- while
still generating the waveforms from the real per-sample stamps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 10:24:05 +02:00
Martino Ferrari 1fc2a3afe2 improving doc 2026-10-05 00:28:10 +02:00
Martino FerrariandClaude Sonnet 4.6 02c53bb7a0 docs: fix five accuracy findings in the libadwaita and icon documentation
- CLAUDE.md: the libadwaita dependency paragraph now names both AdwDialog
  widgets (AdwShortcutsDialog and AdwAboutDialog), explains the shared
  on_dialog_closed() handler keyed by pointer-to-pointer, and covers
  both show_about() and show_shortcuts() in the AdwDialog sentence.
- CLAUDE.md: the icon-authoring invariant's explanation of how Icon= in
  the desktop entry resolves now names the installed copies under
  $XDG_DATA_HOME/icons/hicolor/scalable/apps and .../symbolic/apps,
  not the GResource (a desktop shell cannot read a compiled-in resource).
- CLAUDE.md: [help] probe example byte count corrected from 1381 to 1379
  (measured: wc -c src/help/01-overview.txt = 1379).
- README.md + udpscope.gresource.xml: "file, line and column" corrected
  to "file and line number (plus a caret under the offending character)"
  to match xmllint's actual output; also removes -- from the XML comment
  where it was illegal.
- README.md: desktop-file-validate described as a hard failure "when the
  tool is on PATH", matching cmake/install_desktop_entry.cmake lines 44-54.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-04 21:13:22 +02:00
Martino FerrariandClaude Opus 4.6 e2be597698 docs: the app icon, AdwAboutDialog and the desktop entry
Records the app icons: assertion and why it stops short of claiming the icon
renders -- no runtime GTK call can tell a malformed SVG from a good one, which
is why the manifest validates through xmllint at build time instead. Scopes the
'filled paths only' authoring rule to symbolic icons, since the colour icon
strokes its sine and is never recoloured. Revises the windows invariant to one
GtkWindow and two AdwDialogs, and documents the desktop-entry target with the
build-path caveat it comes with.

Two findings from the regression run are recorded as well. The icons' generated
GResource crossed C99's 4095-byte string-literal limit when the app icons joined
the action ones, so the build's warning baseline is five, not four. And the
app icons: line's has_icon() half is satisfied by an installed theme copy, so on
a machine that has run --target desktop-entry it reads ok even with the resource
path broken; toolbar icons: is the sensitive line for that.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-04 21:05:35 +02:00
Martino FerrariandClaude Opus 4.6 f2978fe742 fix: guard unset HOME in desktop-entry cmake script and correct plan verification
Two code-review findings:

1. Add a guard in cmake/install_desktop_entry.cmake that fails visibly when both
   XDG_DATA_HOME and HOME are unset or empty, preventing silent installation to
   the filesystem root in minimal CI sandboxes.

2. Replace the plan's tail-based verification with grep, because cmake's
   FATAL_ERROR message and ninja's "build stopped" lines push the actual
   rejection message out of the last four lines. The grep command reliably finds
   the expected "desktop-file-validate rejected" string.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-04 20:50:42 +02:00
Martino FerrariandClaude Opus 4.6 a055325831 feat: a desktop entry, installed by an opt-in CMake target
The entry's Exec is the built binary's absolute path, so it always launches what
was last built -- and moving the build directory breaks it, which is the price
of having no install step. An install() rule would make a second copy that can
diverge; a documented cp would need the binary on PATH, which it is not.

The target writes the entry and both icons under XDG_DATA_HOME, refreshes the
desktop and icon caches when the tools are there, and runs
desktop-file-validate as a hard failure -- the shell ignores a malformed entry
in silence, so the failure belongs at the moment the entry is asked for. It
prints the three paths it wrote, which is the uninstall instructions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-04 20:46:28 +02:00
Martino Ferrari 33eee0c8d0 feat: the About dialog on AdwAboutDialog, showing the app icon
AdwAboutDialog puts the application icon in its own header, which is what the
app now has one for, and adw_about_window_new() is deprecated in favour of it.
It is an AdwDialog and not a GtkWindow, so it clears on ::closed and closes with
adw_dialog_close() -- the same trap the shortcut overview documents, and both
dialogs now share one handler keyed by the pointer to clear.

Two gains while mapping the fields: system_information becomes debug_info, which
renders as a Troubleshooting page with a copy button, and the string behind it
becomes a std::string rather than a char[1024] that truncated a long settings
path in silence.

The probe now reads the icon name back out of the open dialog, so it is asserted
to carry the icon rather than merely to open.
2026-10-04 20:40:05 +02:00
Martino Ferrari 6deb53c458 feat: show the app icon on the window, and assert it is really there
The resource-path registration moves from build_toolbar() up into run_ui(),
because the window's icon is now a second consumer of it and this is the one
place ahead of both. Whichever consumer ran first would otherwise resolve its
name to nothing, with no error -- GTK draws a missing-image placeholder.

The new 'app icons:' dump line is the guard, computed on the first tick beside
'toolbar icons:'. It reports named-and-present at the size the file declares,
and deliberately does not claim the file renders: no runtime GTK call can see a
malformed SVG, which is why the manifest validates at build time instead.
2026-10-04 20:32:52 +02:00
Martino Ferrari f3d68cfdd4 feat: an app icon in colour and symbolic form, validated at build time
Both are drawn to the GNOME HIG: a 128px instrument face that leaves its
margins, carries one 4px front profile for depth and no gradients or shadows,
and a 16px symbolic reduction of the same metaphor in fills only. The colour
icon strokes its sine because it is never recoloured; the symbolic one cannot.

Every file in the manifest now preprocesses through xmllint, because a
malformed SVG is the one icon failure no runtime check can see: librsvg refuses
the file while the name still resolves, so the icon draws nothing and
has_icon() says it is fine.
2026-10-04 20:27:07 +02:00
Martino Ferrari 69eab02729 docs: the implementation plan for the app icon, AdwAboutDialog and desktop entry
Five tasks, each ending in a commit: the two SVGs with build-time xmllint
validation, the window icon plus the registration move and the app icons:
assertion, the AdwAboutDialog migration, the opt-in desktop-entry target, and
the documentation with the full regression run.

Written assertion-first, since the project has no unit suite: each task's first
step adds the probe or dump line and watches it read MISSING. Both negative
tests are real -- one breaks an SVG comment and requires the build to fail, the
other breaks Type= and requires the target to FATAL_ERROR.
2026-10-04 20:24:10 +02:00
Martino FerrariandClaude Opus 4.6 e17c650ea7 docs: correct the spec's icon verification to a build-time check
The spec asserted that a paintable's intrinsic size reads 0x0 for an SVG
librsvg refuses. It does not: gtk_icon_theme_lookup_icon() never returns NULL
and always reports the requested size, and a valid icon, a malformed one and an
absent name all snapshot to the same texture node. GMarkup accepts the double
hyphen, and gdk-pixbuf goes through glycin here and refuses our own icons.

So the malformed file is caught by preprocess="xml-stripblanks", which runs
xmllint during glib-compile-resources and fails the build with file, line and
column. The runtime line keeps its format but asserts only what it can: the
name resolves, the resource is present, and the root element declares the size.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-04 20:11:42 +02:00
Martino FerrariandClaude Opus 4.6 7816f11c81 docs: spec for the app icon, AdwAboutDialog and desktop entry
Records the decisions taken before design — the application ID stays
org.udpscope.app, the icon is the instrument face, and the desktop entry is an
opt-in CMake target rather than an install() rule — along with the HIG
constraints the artwork is built to and the two silent failures the new
verification line has to tell apart.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-04 20:07:04 +02:00
45 changed files with 5563 additions and 804 deletions

No files matched your search

+736 -121
View File
File diff suppressed because it is too large. Load diff
+30 -2
View File
@@ -39,7 +39,7 @@ if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(udpsclient PRIVATE -Wall -Wextra) target_compile_options(udpsclient PRIVATE -Wall -Wextra)
endif() endif()
# --- icons: four symbolic SVGs compiled into the binary ----------------------- # --- icons: six SVGs compiled into the binary --------------------------------
# There is no stock icon for an oscilloscope trigger, a pair of cursors or a measurement # There is no stock icon for an oscilloscope trigger, a pair of cursors or a measurement
# ruler, so this app draws its own. They go in a GResource rather than beside the binary # ruler, so this app draws its own. They go in a GResource rather than beside the binary
# because udpscope is run straight out of the build tree and has no install step: a # because udpscope is run straight out of the build tree and has no install step: a
@@ -48,7 +48,8 @@ endif()
find_program(GLIB_COMPILE_RESOURCES NAMES glib-compile-resources REQUIRED) find_program(GLIB_COMPILE_RESOURCES NAMES glib-compile-resources REQUIRED)
set(UDPSCOPE_ICON_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/icons) set(UDPSCOPE_ICON_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/icons)
file(GLOB UDPSCOPE_ICON_FILES CONFIGURE_DEPENDS file(GLOB UDPSCOPE_ICON_FILES CONFIGURE_DEPENDS
${UDPSCOPE_ICON_DIR}/scalable/actions/*.svg) ${UDPSCOPE_ICON_DIR}/scalable/actions/*.svg
${UDPSCOPE_ICON_DIR}/scalable/apps/*.svg)
add_custom_command( add_custom_command(
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
COMMAND ${GLIB_COMPILE_RESOURCES} COMMAND ${GLIB_COMPILE_RESOURCES}
@@ -125,3 +126,30 @@ add_dependencies(udpscope udpscope_build_info)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(udpscope PRIVATE -Wall -Wextra -Wpedantic) target_compile_options(udpscope PRIVATE -Wall -Wextra -Wpedantic)
endif() endif()
# --- the desktop entry: opt-in, never part of `cmake --build` ------------------
# `Exec` is the built binary's absolute path in the build tree, so the entry always launches
# exactly the binary that was last built -- and moving or deleting the build directory breaks
# it. That is the price of having no install step, and it is the right one here: an install()
# rule would create a second copy of the app that can diverge from the build tree, and a
# documented `cp` would need the binary on PATH, which it is not.
#
# configure_file() cannot evaluate a generator expression and file(GENERATE) cannot substitute
# an @VAR@, so the two run in sequence: the first writes the genex out literally, the second
# evaluates it. This is the documented idiom.
set(UDPSCOPE_EXEC "$<TARGET_FILE:udpscope>")
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/data/org.udpscope.app.desktop.in
${CMAKE_CURRENT_BINARY_DIR}/desktop-entry.in @ONLY)
file(GENERATE OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/org.udpscope.app.desktop
INPUT ${CMAKE_CURRENT_BINARY_DIR}/desktop-entry.in)
# No ALL: this runs only when it is named, so `cmake --build build` is unaffected.
add_custom_target(desktop-entry
COMMAND ${CMAKE_COMMAND}
-DENTRY=${CMAKE_CURRENT_BINARY_DIR}/org.udpscope.app.desktop
-DICON=${UDPSCOPE_ICON_DIR}/scalable/apps/org.udpscope.app.svg
-DSYMBOLIC=${UDPSCOPE_ICON_DIR}/scalable/apps/org.udpscope.app-symbolic.svg
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/install_desktop_entry.cmake
COMMENT "Installing the udpscope desktop entry into XDG_DATA_HOME"
VERBATIM)
add_dependencies(desktop-entry udpscope)
+570 -68
View File
@@ -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 count, which channel each trace slot holds, and the allocated size and reveal
state of both side panels. 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 ## 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, Samples cross the boundary through `plot::data::RingBuffer` (single producer,
single consumer), never through the lock. 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 **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* 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 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 immutable-series path a held trigger event uses (`DisplayMode::Paused` sits beside `Held`).
difference is the anchoring: an event's stamps are edge-relative, a pause's are the stream's own, Resuming drops the snapshot and
so the times on the axis do not change when the pause begins. Resuming drops the snapshot and
re-frames on the newest data, so the seconds that passed while paused are *there*, not a hole — 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 ## Measurements
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 — **The x ticks read relative when, and only when, they could not be written absolutely.** A
so they read `0`, `5ms`, `10ms` instead of `12.345671s`, `12.345672s`, which three significant `TickFormat::Time` label carries three significant digits, so the test is the tick **step**
digits cannot even tell apart — and a caption names that anchor in the axes' top-left corner: against the label's own **resolution at the axis' magnitude**: `12.345671s` and `12.345672s` are
`t0 = 12.3456 s`. The labels scale through the prefixed units the SI formatter uses — `500ms`, the same string, and there the ticks are re-anchored on a grid at the window's left edge — so
`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 they read `0`, `5ms`, `10ms` — with a caption naming that anchor, `t0 = 12.3456 s`. Adding the
inside the axes rather than the axis title on purpose: its text changes with the window, and a two is the tick's absolute time. The labels scale through the prefixed units the SI formatter
title of a changing width would relayout the plot every frame. Everything a user copies out — uses — `500ms`, `500us`, `5us`, `5ns` — so a microsecond window reads in microseconds instead of
the cursors' `T0`/`T1`/`dT`, a measurement, an export — stays in absolute stream seconds. `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 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 by the app: the window's edge is derived from the newest sample during the layout, so a value
@@ -118,13 +201,53 @@ measurement.
`period` and `frequency` deliberately take **no options**: the engine counts *rising* `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 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 only make that count wrong — a split near either extreme leaves the hysteresis band below no room
counting both edges halves the period. 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 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 signal in this window, and a level in volts would have to be typed for every signal.
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. ### 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 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 the plot) and the signal's chip, in the palette colour every other view names that signal
@@ -175,18 +298,20 @@ Both walks go through it:
the window's size. The two stamps the scan needed are read at the end rather than at every 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. 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`) - **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 folds a bucket's sums 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 in it falls in the same bin and none is *at* the level, so the fold is exact. It then asks
bucket's first sample, which is where the raw walk would have found it. Only the buckets that separately whether the bucket *enters* a side of the hysteresis band: one wholly past a threshold
**straddle** a level are opened, which on a clean waveform is one per crossing. 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 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 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 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 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 noisy enough to cross a threshold everywhere, degrades back towards the O(window) cost and nothing
changes. The dump's `measure cost:` line reports `covered`, `buckets` and `raw` for exactly else changes. The dump's `measure cost:` line reports `covered`, `buckets` and `raw` for exactly
this: `raw` near `covered` means the pyramid bought nothing. this: `raw` near `covered` means the pyramid bought nothing.
### What the math channels cost ### What the math channels cost
@@ -252,6 +377,80 @@ The same reason the window control is a fixed-width label in the header bar
not expand: a header-bar child that expands is measured into the title's centre not expand: a header-bar child that expands is measured into the title's centre
box, so the control stretched to the title and every window resize moved it. box, so the control stretched to the title and every window resize moved it.
## The icon, the About dialog and the desktop entry
**The icon is the instrument face**: a dark panel carrying one bold green trace. That is the
clearest metaphor available for this program -- it is what the thing being imitated looks like --
and it is the kind of physical object the GNOME HIG's top-and-front depth language was written
for, so the guidelines apply as written rather than by analogy. Its silhouette is also what makes
it survive being scaled down, which is why two alternatives were dropped: a bare waveform (no
silhouette at all, so nothing is left of it in a dock) and a colour version of the trigger glyph
the toolbar already carries (a *control*, not the application, and it would mean two icons that
have to stay in step).
It is built to the HIG's own numbers. A 128px canvas the artwork does not fill (it spans
x 12..116, y 22..106), every feature on the 2px grid, and **one** depth treatment: a 4px front
profile along the bottom, clipped to the chassis silhouette so it does not protrude past the
rounded corners. The rest is flat colour -- no gradients, no shadows -- because every surface
here is straight, and the palette is GNOME's (Dark 3 chassis, Dark 2 profile, Dark 4 screen,
Green 1 trace).
**Two things were deliberately left out.** There is no graticule: the grid lines a real scope
face carries cannot survive 32px, and the trace carries the metaphor without them. And the trace
is one cycle -- a single crest, a single trough -- because at 32px the screen is about 22x16 px
and anything busier reads as a smudge.
The two forms are authored differently on purpose. The colour icon **strokes** its sine, which is
the sane way to draw a smooth curve; the 16px symbolic one cannot, because GTK's symbolic
recolour wraps the file in a stylesheet that reaches `fill` on `path`, `rect` and `circle` only,
so a stroked line keeps the colour written in the file and stays dark on a dark theme. Its wave is
therefore a closed 2px band -- a sine along the top edge and the same sine 2px lower along the
bottom. Do not "fix" one to match the other.
Both are compiled into the binary as a `GResource` under `scalable/apps/`, for the same reason
the four action icons are: udpscope is run straight out of its build tree and has no install step.
The one consequence worth knowing is that a **malformed** SVG cannot be caught at runtime -- the
icon *name* still resolves, so GTK reports the icon as present and draws nothing -- which is why
every `<file>` in the manifest carries `preprocess="xml-stripblanks"` and the build fails with a
file and line number (plus a caret under the offending character) instead.
**The About dialog is an `AdwAboutDialog`** rather than a `GtkAboutDialog`, and that migration is
what the app has an icon *for*: the dialog shows it in its own header, which `GtkAboutDialog`
would too but only after being handed one, and nothing else in the program had asked for a
128px rendering. Two further gains came with it. The debug information is a **Troubleshooting
page with a copy button**, where `system_information` on the old dialog was inert text a user
reporting a problem had to retype. And the string it shows is a `std::string`, which removed a
`char[1024]` that truncated a long settings path in silence -- the one failure mode of a fixed
buffer that no one notices until the path matters.
What it cost: an `AdwAboutDialog` is an `AdwDialog` and **not** a `GtkWindow`, so its close path
differs from the help window sitting beside it -- `adw_dialog_close()` and `::closed`, rather than
`gtk_window_close()` and `::close-request`. That is the same trap the shortcut overview already
had, and the two now share one `::closed` handler keyed by the pointer it must null. What was
deliberately left out: credits (one author, already named), release notes (there are no releases)
and an issue-tracker URL (the website link reaches the repository, which carries the tracker).
**The desktop entry is opt-in.** `cmake --build build` never touches `$XDG_DATA_HOME`; asking for
it does:
```sh
cmake --build build --target desktop-entry
```
That writes `org.udpscope.app.desktop` and both app icons under `$XDG_DATA_HOME` (default
`~/.local/share`), validates the entry with `desktop-file-validate` as a hard failure when the tool is on `PATH`, refreshes
`update-desktop-database` and `gtk4-update-icon-cache` if they are on `PATH`, and prints the three
paths it wrote. Those three lines **are** the uninstall instructions: there is no uninstall
target, because three `rm`s the build already told you about are less to maintain than a target
that has to guess what a previous version installed.
The caveat is the entry's `Exec`, which is the built binary's **absolute path in the build tree**:
moving or deleting `build/` breaks the launcher. That is the price of having no install step, and
it is the right trade here -- the entry then always launches exactly the binary that was last
built, which for a program under development is what you want. 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.
## How the spec maps onto the code ## How the spec maps onto the code
**One buffer of `N+1` slices, plus frozen event snapshots.** `ScopeModel` **One buffer of `N+1` slices, plus frozen event snapshots.** `ScopeModel`
@@ -260,7 +459,8 @@ ring per channel (N enabled signals -- see the limitation on multi-element signa
below). The spec's second buffer is the latched `EventSnapshot`, which is already 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 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 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 `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 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 than the fastest channel's, it is decimated by the integer ratio
@@ -269,7 +469,9 @@ clock is resampled too (`Partition::time_stride`, an averaging decimation — se
"A window longer than the buffer is resampled"). Because the split is by "A window longer than the buffer is resampled"). Because the split is by
*time span* rather than by bytes, every channel and the time base retain the same *time span* rather than by bytes, every channel and the time base retain the same
history -- which is what makes one shared time slice meaningful. With no history -- which is what makes one shared time slice meaningful. With no
decimation the two rules coincide and every slice is the same size. decimation the two rules coincide and every slice holds the same number of samples
(not the same number of bytes: the shared clock is `f64` and the values are `f32`, so
the growth loop weighs the budget in bytes -- see "The time ring is `f64`" below).
The ring is always filling, whatever the trigger is doing. The pre-trigger history The ring is always filling, whatever the trigger is doing. The pre-trigger history
has to already be there when an edge arrives; while acquisition followed the has to already be there when an edge arrives; while acquisition followed the
@@ -382,11 +584,12 @@ the post window had been waiting for. That cost one sample of twenty thousand on
The axis is still pinned to what the snapshot actually captured, read off its own The axis is still pinned to what the snapshot actually captured, read off its own
stamps rather than off the request: the two can differ by a sample at each end, and stamps rather than off the request: the two can differ by a sample at each end, and
pinning the request would claim coverage the data does not have. The window's bounds pinning the request would claim coverage the data does not have. The cut's bounds are
are rounded to the time ring's own `f32` before being compared against it, so a sample compared in the ring's own type, which is now the `f64` they arrive in — see "The time
sitting exactly on a bound is kept whatever the stream's absolute time happens to be — ring is `f64`" below. While the ring was `f32` the bounds had to be *rounded to it*
in f64 that decision fell to how the stamp rounded, which cost a sample at 10 kS/s and before comparing, or a sample sitting exactly on a bound fell to how its stamp
a third of the capture at 1 S/s. rounded, which cost a sample at 10 kS/s and a third of the capture at 1 S/s. That
rule is gone with its cause.
**Where the capture is drawn was a separate bug from what it contained**, and it **Where the capture is drawn was a separate bug from what it contained**, and it
outlived the fixes above. The plotting library's `gather()` emits x *relative to* outlived the fixes above. The plotting library's `gather()` emits x *relative to*
@@ -717,6 +920,40 @@ signature. The same torn read still exists for a small `i > 0` — the oldest fr
renderer's increasing-suffix filter exists to absorb, so it is left alone rather than fixed renderer's increasing-suffix filter exists to absorb, so it is left alone rather than fixed
speculatively. speculatively.
**The time ring is `f64`; the value rings stay `f32`.** It was `f32` like everything else,
with the stamps stored relative to an epoch fixed at the first sample ever seen — so the
number being stored grew for as long as the app ran while the interval it had to resolve
stayed one sample period. `f32` carries 24 bits of mantissa, which is a *relative*
precision, so the ULP at 16 s of uptime is 1.9 µs, at 32 s 3.8 µs, at 64 s 7.6 µs and over
a 600 s window 61 µs. At 1 MS/s the sample period is 1 µs, so after half a minute two to
four consecutive samples rounded onto the same stamp, after a minute eight, and so on
without bound. That is the "zoom in to about 10 µs and there are three to five points with
the same timestamp" report, and the export showed it unambiguously: `np.diff(t)` over two
million rows held **three** distinct steps, 0.953 µs, 1.192 µs and 1.431 µs — exactly
2, 2.5 and 3 ULP of the magnitude the stamps had reached, a stair instead of a clock. The
min/max artefacts are the same cause read through the LOD pyramid: a bucket of samples
sharing one x is a zero-width segment, so an envelope drew as a vertical spike at a
plateau's edge rather than as the waveform.
Nothing was wrong with the arithmetic, so no amount of care at a call site could have
fixed it; the type was. **A value's precision is a property of the signal and a
timestamp's is a property of how long the scope has been running** — 24 bits is plenty for
a sample and runs out on a clock. Widening only the clock keeps the cost where it belongs:
the rings are the memory budget, and the values are the overwhelming majority of them. The
one consequence worth knowing is that `plan_partition()`'s growth loop can no longer count
slots, because a time slot and a value sample are now different sizes — it weighs the
budget in **bytes** (`sizeof(f64)` for the shared clock plus `sizeof(f32) / stride[i]` per
channel) and the power-of-two rounding absorbs the rest: a three-channel stream at
`--buffer-mb 200` retains the same 8.39 s span it did before. `EventSnapshot::stamps` is
`f64` for the same reason rather than for symmetry — an *event's* stamps are rebased onto
its edge and so are small, but a *pause* view is deliberately not rebased and carries full
stream time, which quantised at exactly the rate the live ring did.
`RingBuffer<T>` stores its slots as `std::atomic<T>` behind a
`static_assert(is_always_lock_free)`, so this was a question worth asking rather than
assuming: `std::atomic<double>` is lock-free on every target this app builds for, and the
assertion is a compile-time guard rather than a silent fallback.
**A held event carries its own LOD pyramid**, and the lack of one is what "the plot gets **A held event carries its own LOD pyramid**, and the lack of one is what "the plot gets
laggy with large data" was. The renderer picks a level per trace per frame with laggy with large data" was. The renderer picks a level per trace per frame with
`MinMaxPyramid::level_for()`, which descends to level 0 — *raw samples* — on a pyramid `MinMaxPyramid::level_for()`, which descends to level 0 — *raw samples* — on a pyramid
@@ -737,21 +974,128 @@ 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 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 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 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 build runs under `ScopeModel::mutex()`. That stall is now measured rather than estimated —
packet gaps. 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 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 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 and whether the LOD path was taken at all. A segment count in the millions is this defect
returning. 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 **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 *while watching a signal*: the time window, pause/resume, the trigger toggle, the
side-panel toggle and an overflow menu. Those side-panel toggle and an overflow menu. Those
four buttons are icons with tooltips; nine text buttons was a wall of words to read four buttons are icons with tooltips; nine text buttons was a wall of words to read
past every time you wanted the one you needed. The overflow menu holds **Fit to past every time you wanted the one you needed. The overflow menu holds **Fit to data**, the
data**, the **Layout editor**, **Cursors**, **Reset cursors**, **Layout editor**, the **Signal list** toggle, **Export visible range...**, **Keyboard
**Export visible range…** and the **Signal list** toggle — the things you set up once. The side column and the two panel toggles stay hidden until a shortcuts**, **Help** and **About udpscope**, in that order — the shape a GNOME menu has, and
drawn as one: every row's label starts at the same left edge (and the rows that can be checked
keep that column whether or not they are checked), a shortcut sits at the row's right edge, a row
is ~18 px tall rather than Adwaita's 34, and the groups are separated by rules. The ⓘ on the
status bar opens its detail **above** the button: below is off the window's bottom edge, and a
bubble whose body is clipped away while its top edge stays at the button is what "the arrow is
detached from the body" looks like.
The **help window** (`F1`) carries the same screenshots as `docs/`: the overview page shows the
main window and a held capture above its text, and the trigger and math pages carry theirs. A
page's images are `GtkPicture`s **with a height request** — a shrinkable one asks for no height at
all inside a box, so without it the page renders its text with a silent gap where the screenshots
should be. The side column and the two panel toggles stay hidden until a
CONFIG arrives — there is nothing to configure before the stream describes itself — CONFIG arrives — there is nothing to configure before the stream describes itself —
and the first CONFIG reveals the signal panel (the trigger panel stays behind its and the first CONFIG reveals the signal panel (the trigger panel stays behind its
own toggle). own toggle).
@@ -798,13 +1142,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 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 - **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 stream is connected, a CONFIG has arrived, acquisition is not paused, and in trigger
armed. Anything else leaves it a steady grey, with the reason on its tooltip mode the trigger is armed. Anything else leaves it steady, with the reason on its
(`paused`, `trigger disarmed`, `waiting for the stream`). *Blinking* is the signal, tooltip (`paused`, `trigger disarmed`, `waiting for the stream`). *Blinking* is the
not the colour: a steady badge is a stopped pipeline, and that distinction survives signal, not the colour: a steady badge is a stopped pipeline, and that distinction
a screenshot. It is one glyph whose colour is all that changes, so blinking it 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 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. 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 - **`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 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 to hold at the wire rate, in which case the ingest resamples (see "A window longer
@@ -860,9 +1212,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 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 **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 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 offer, split by what they touch: **Fit X axis** puts the x view back on the whole time
holds and leaves every y scale exactly as the user set it, **Fit all** does that and window and leaves every y scale exactly as the user set it, **Fit all** does that and
auto-scales the y axes too. 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 **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, showed up as the x axis moving a few pixels every frame — including while paused,
@@ -878,13 +1230,40 @@ which is how the second one was found:
what its labels need, so a floor written onto the rows would survive exactly 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 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 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 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 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, 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 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. 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 `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 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 its floor out of the label strip the reservation converges over two layouts, so
@@ -895,9 +1274,9 @@ bug. `x-ticks` is content, and varies by one as a live window slides over a roun
number: number:
``` ```
layout over 952 frames: axes.x 67..80, axes.w 546..559, viewport.w 627..627, layout over 583 frames: axes.x 88..104, axes.w 707..782, viewport.w 870..870,
y-gutter 44..74, x-ticks 20..21, x-desync 0 y-gutter 88..104, x-ticks 20..41, x-desync 0
axes.w changes: 2 (last at frame 35, side panel up=1) 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 Set `UDPSCOPE_PROBE_PAUSE_MS=<ms>` to pause the acquisition part-way through a
@@ -963,6 +1342,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* — where the same residue read `3.19e-13ms`. A range whose ticks are *legitimately*
that small keeps them. 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 **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 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 need them read as two competing sets of ticks. They are now three things the style states
@@ -989,6 +1378,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`, 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`, `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 `a_sparse_window_between_samples_still_inks_both_margins`), and each
was verified to fail with its own fix reverted. was verified to fail with its own fix reverted.
@@ -1016,10 +1406,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. 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 `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, through the library's `Plot::crosshair_value()` — **the nearer of the two bracketing
interpolated — so it is the answer to "what is this signal at these two times" rather samples**, never a number between them — so it is the answer to "which datum of this
than "drag a ruler until it crosses the trace". A readout is `--` when the trace has signal is at these two times" rather than "drag a ruler until it crosses the trace". A
nothing at that time and `partial` when only one of the two markers is over samples. 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 — 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 `| who | A | B | delta |`. The first row is the two times and their difference, and the
@@ -1033,7 +1477,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 — 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 `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 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 when they agree on one and print bare digits when they do not: a ruler is on the row's y
axis, and two units on one axis make "the" unit a guess. A trace's line carries the axis, and two units on one axis make "the" unit a guess. A trace's line carries the
signal's **colour chip and its name** — `● sine 616.49 mV -24.57 mV -641.06 mV` — and the signal's **colour chip and its name** — `● sine 616.49 mV -24.57 mV -641.06 mV` — and the
@@ -1124,10 +1577,24 @@ logged with both rates and the new anti-alias corner. A window change that leave
limitations below); only the shared clock is decimated. 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) **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, auto-scales every row's y axis to the data it holds and puts the x view back on the whole time
propagated to every row. **Fit X axis** is the same re-framing without the y scaling, for window, propagated to every row. **Fit X axis** is that x half alone, for the common case where
the common case where the window should be the data's but the levels were placed by hand. a zoom should be undone but the levels were placed by hand. The status line acknowledges either
The status line acknowledges either (`x fitted to the buffered data (1.25 s)`). (`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 **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. `plot::gtk4::PlotArea` in figure mode, and they all join a single x link group.
@@ -1137,7 +1604,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 (`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 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 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. 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 The self-check's layout line carries `x-desync N`, the number of frames in which
@@ -1188,16 +1655,30 @@ division.
over the shared x window, at the **stored** sample rate — never the LOD pyramid's 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 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 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: file reads in the same order as the signal panel.
`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 The table has **two time columns**, which is the whole answer to "relative or absolute":
different modes line up.
- **`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: Two formats, picked by the extension the save dialog ends up with:
| file | what it is | | 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. | | `.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 Parquet (the original ask) would have meant linking Arrow — tens of megabytes of
@@ -1266,7 +1747,8 @@ fails validation renders a one-line stand-in so the window always opens rather t
no `Gtk-CRITICAL`. no `Gtk-CRITICAL`.
**The status bar is two tiers.** The bar itself shows one glance line: the activity badge, the **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 ⓘ 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 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 carried all of them was too wide to read while watching a signal, and because the bar sits
@@ -1287,7 +1769,16 @@ now — they are listed so they are not mistaken for anything else:
latched `EventSnapshot` — an immutable copy of the captured window, which is 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 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 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 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 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 *budget*, and the power-of-two ring rounding means the final
@@ -1351,6 +1842,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 leaving an empty plot to be puzzled over, and prints a `starved:` line to stderr while the
state holds. 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 ## Limitations
- **Multi-element (vector/matrix) signals are acquired with a zero-order hold.** - **Multi-element (vector/matrix) signals are acquired with a zero-order hold.**
+69
View File
@@ -0,0 +1,69 @@
# SPDX-License-Identifier: MIT
#
# The `desktop-entry` target's body. Copies the generated entry and the two app icons into
# $XDG_DATA_HOME, validates the entry, refreshes the caches that make the shell notice, and
# prints every path it wrote.
#
# This runs only when the target is named. The project deliberately has no install step -- that
# is the stated reason its icons are a GResource -- and a target that does nothing until asked
# preserves that while still producing a working entry.
#
# Expects -DENTRY=, -DICON= and -DSYMBOLIC= on the command line.
if(DEFINED ENV{XDG_DATA_HOME} AND NOT "$ENV{XDG_DATA_HOME}" STREQUAL "")
set(data_home "$ENV{XDG_DATA_HOME}")
else()
set(data_home "$ENV{HOME}/.local/share")
endif()
# Fail visibly if both fallback paths are unusable. The script needs a directory to install into;
# a missing $HOME in a sandboxed CI environment should not silently install to the filesystem root.
if(NOT DEFINED ENV{XDG_DATA_HOME} OR "$ENV{XDG_DATA_HOME}" STREQUAL "")
if(NOT DEFINED ENV{HOME} OR "$ENV{HOME}" STREQUAL "")
message(FATAL_ERROR "XDG_DATA_HOME and HOME are both unset or empty; cannot determine install directory")
endif()
endif()
set(apps_dir "${data_home}/applications")
set(icon_dir "${data_home}/icons/hicolor/scalable/apps")
set(sym_dir "${data_home}/icons/hicolor/symbolic/apps")
# The icons are copied from the source tree rather than extracted from the GResource: the same
# two files serve both the in-binary resource and the installed theme, so there is one copy to
# edit. `file(COPY)` creates the directories.
file(COPY "${ENTRY}" DESTINATION "${apps_dir}")
file(COPY "${ICON}" DESTINATION "${icon_dir}")
file(COPY "${SYMBOLIC}" DESTINATION "${sym_dir}")
get_filename_component(entry_name "${ENTRY}" NAME)
get_filename_component(icon_name "${ICON}" NAME)
get_filename_component(sym_name "${SYMBOLIC}" NAME)
# Validated here rather than left to the shell, which ignores a malformed entry in silence: the
# failure belongs at the moment the entry is asked for.
find_program(DESKTOP_FILE_VALIDATE desktop-file-validate)
if(DESKTOP_FILE_VALIDATE)
execute_process(COMMAND "${DESKTOP_FILE_VALIDATE}" "${apps_dir}/${entry_name}"
RESULT_VARIABLE validate_result)
if(NOT validate_result EQUAL 0)
message(FATAL_ERROR "desktop-file-validate rejected ${apps_dir}/${entry_name}")
endif()
message(STATUS "desktop-file-validate: ok")
else()
message(STATUS "desktop-file-validate: not on PATH, entry not validated")
endif()
find_program(UPDATE_DESKTOP_DATABASE update-desktop-database)
if(UPDATE_DESKTOP_DATABASE)
execute_process(COMMAND "${UPDATE_DESKTOP_DATABASE}" "${apps_dir}")
endif()
find_program(GTK4_UPDATE_ICON_CACHE gtk4-update-icon-cache)
if(GTK4_UPDATE_ICON_CACHE)
execute_process(COMMAND "${GTK4_UPDATE_ICON_CACHE}" -f -t "${data_home}/icons/hicolor")
endif()
# Printed because there is no uninstall target: undoing this is these three paths.
message(STATUS "wrote ${apps_dir}/${entry_name}")
message(STATUS "wrote ${icon_dir}/${icon_name}")
message(STATUS "wrote ${sym_dir}/${sym_name}")
+11
View File
@@ -0,0 +1,11 @@
[Desktop Entry]
Type=Application
Name=udpscope
GenericName=Oscilloscope
Comment=An oscilloscope for UDPStreamer sources
Exec=@UDPSCOPE_EXEC@
Icon=org.udpscope.app
Terminal=false
Categories=Development;Science;Electronics;
Keywords=oscilloscope;UDP;signal;waveform;trigger;
StartupNotify=true
+1 -1
+201
View File
@@ -0,0 +1,201 @@
# udpscope — user guide
A viewer for a UDPStreamer source: it connects, decodes the CONFIG, plots the signals you enable,
and lets you freeze, measure and derive from them. This is the *user* guide; `README.md` documents
what was built and why, and `SPECS.md` is the requirement document.
![the main window](img/main-window.png)
*The main window with a source streaming: the signal panel on the right, the plot in the middle, the
status line directly under it, and the toolbar above. The window control — `2 s` — is the first thing
on the left.*
## 1. Connecting
```sh
udpscope # asks which source to open
udpscope --server 10.0.0.5 --port 8000 # connect straight away
udpscope --server 239.1.2.3 --port 8000 --multicast 239.1.2.3 --interface 10.0.0.9
```
With no `--server` the app shows a chooser: the sessions it has stored, each naming its source, plus
a form for a new host. A source opened from the list reconnects to the multicast group and interface
it was stored with.
**A unicast source serves one client at a time.** A second viewer's `CONNECT` displaces the first, and
they take turns — the status line says so when it happens. Watching one source from several instances
needs `--multicast <group>` with `--interface <local address>`.
Every launch is its own process, so running two scopes side by side is fine.
## 2. The status line
Directly under the plot, it reads `● <state> · <rate> · <window>`, and the ⓘ beside it opens the
detail: packets, frames, gaps, per-channel sample counts, the buffer's span and its decimation.
- **The state word is one word** from a fixed set: `live`, `paused`, `armed`, `capturing`,
`event held`, `disarmed`. `no data` appears in the rate's place when nothing is arriving.
- **The rate is the *stored* rate**, which is the wire rate only while the whole requested window
fits in the buffer. A window wider than the budget allows is bought by averaging groups of samples,
so the rate falls and the status line says so — that is the anti-alias filter doing its job.
- **`connected, but no data is arriving`** appears after three seconds in which no frame arrived while
connected: the usual cause is the unicast point above. It clears the moment data returns.
- **`N reconnects`** is appended when the client has had to re-establish the session.
## 3. Signals
The signal panel lists every signal the CONFIG described, with a switch each. **Signals start
disabled — enabling one is what starts acquiring it**, and a signal nobody enables costs nothing.
Drag a signal from the panel onto a plot row to add a trace; drag it again to another row to plot the
same signal twice. The colour is per *signal*, so the same signal is the same colour wherever it
appears.
The panel also holds the per-signal calibration: gain and offset, applied at ingest, so the
measurements and the export carry the same numbers the plot shows.
## 4. The window and navigation
The header's window control (`2 s`) sets the time span on screen; the popover takes a number and a
unit, with a decade stepper either side. The trigger's event window is a share of *this* span, so a
window change moves the trigger with it.
- **Wheel** zooms about the pointer; **Shift+wheel** on x only, **Ctrl+wheel** y only.
- **Ctrl `+` / `-`** zooms x, **Alt `+` / `-`** y, about the plot centre.
- **Drag** pans, **right-drag** too (the right button also grabs a cursor at the pointer).
- **Ctrl+drag** frames x to a box, **Alt+drag** y.
- **Fit to data** (menu) auto-scales both axes and frames the whole buffered range.
The view is bounded by the **time window**, not by the buffer: zooming out stops at the span you set,
and panning stops where the data starts. When the buffer holds *less* than the window — right after a
signal is enabled — the window is left empty on its left, which is what a scope that has just started
should look like.
## 5. Trigger
![a capture held](img/trigger-capture.png)
*Single mode with a capture held: the axis is the event's own `[-pre, +post]`, the trigger marker
sits at the edge, and the status line reads `event held`.*
The trigger panel has the edge, the pre/post split (as a share of the event span), Normal or Single,
and Arm/Disarm. Selecting a signal for the trigger enables it.
- **While a capture fills** the plot keeps drawing live data on the event's clock, so the post side
fills in view; the axis does not move.
- **A held event is navigable within its own data** — zooming in works, panning stops at the capture's
edges — and it is never quietly replaced by live data.
- **Disarming mid-capture** publishes what the capture got and holds that, so the picture on screen
when you disarm is the picture you keep.
- The event window is counted in **stream seconds** end to end, so a source whose clock advances in
jumps still gets the window it asked for.
## 6. Pause
Pause freezes the *picture*, not the acquisition: the producer keeps filling the buffer behind it, and
the frozen view is a snapshot of the window that was on screen. Resuming shows the data that arrived
meanwhile — there is no hole. A paused view is held like a held event, so zooming in works and panning
does not walk it off its snapshot.
## 7. Cursors
The cursors toolbar toggle switches on two instruments per row:
- **vertical** — two times, shared by every row;
- **horizontal** — two rulers per row;
- **both** — the two together;
- **mix** — the two times, read off each *trace* where they cross it.
The pane beside it reports each trace's values at the markers, and **Reset** puts them back at the
window's edges. Values read `--` when a marker is past the newest sample and `partial` when only one
marker is over data.
## 8. Measurements
The measurements pane computes statistics of the **visible window** of a signal: average, top,
bottom, min, max, peak to peak, amplitude, pulse duration, period and frequency. `+` adds one, the
row's pencil edits it, and **show on plot** draws its result as a ruler (levels) or markers (times).
`top` and `bottom` take a *split* as a share of the window's own range — the percentage travels
between signals where a threshold in volts would not. `period` and `frequency` deliberately have no
split: they count rising crossings of the mid level, one edge per cycle, and a split there is a
setting you can only get wrong.
The values are refreshed on a cadence — at most every 100 ms while the window is scrolling, once when
it stops, and once per mode change — so a live view cannot cost you frames. The pane's cost is
reported in the exit dump with the app's self-check.
## 9. Math channels
![the math channel editor](img/math-editor.png)
*The compact node editor: each node is a box, its inputs the squares on the left edge and its output
the square on the right. The orange dot marks the node that carries the channel's value.*
A math channel is derived from the others and is then an ordinary channel: enable it and it is
acquired, plotted, measured and exported like a streamed signal, and another math channel can use it
as a source.
Operations: `channel` (a source), `gain + offset`, `abs`, `sqrt`, `clamp`, `low pass`, `high pass`,
`band pass` (biquads, corner in Hz against the stored rate), `sum`, `difference`, `product`, `ratio`.
- **Click** a node to select it; its parameters appear in the form beside the canvas.
- **Click an output square, then an input square**, to wire them. An input is only offered the nodes
above it — the graph is a list walked in order, so a cycle cannot be drawn.
- **Drag** a box to move it. **add node**, **delete node** and **set as output** are below the form.
- The **output is the last node** of a graph you build top to bottom, and the orange dot says which;
*set as output* changes it. A graph whose output is the source is valid and just passes the input
through — that is the usual reason an operation "does nothing".
- **Save** refuses an incomplete graph and says why: an unconnected input, no output, or a source that
is not a channel the scope is acquiring.
The samples are produced at ingest, once per stored slot, so the filters carry real history — a low
pass is the filter you asked for rather than a window recomputed every frame.
## 10. Sessions
Everything above is saved per source in `~/.config/udpscope/<host>_<port>.toml`: the window, the rows
and which signal each slot holds, the calibration, **which signals were enabled**, **the math channels
with their graphs** and **the measurements**. Reopening the same source brings the scope back as you
left it.
The file is plain TOML and safe to edit — a malformed line is skipped rather than refusing the whole
file. Deleting it starts from the defaults.
## 11. Export
**Export visible range…** writes the plotted signals over the visible x range at the *stored* rate to
`.npy` or `.csv`, picked by the file name's extension. The table is collected when you click, not when
the chooser closes, so a live view cannot scroll under the dialog. The `.npy` is a 2-D array: one row
per sample, `time`, `stream_time`, then one column per signal.
## 12. Keyboard
| key | action |
|---|---|
| `space` | pause / resume |
| `a` | arm / disarm the trigger |
| `t` | the trigger panel |
| `l` | the layout editor |
| `g` | go live |
| `f` | fit to data |
| `d` | add the first free signal to the focused row |
| `c` | cursors on/off |
| `m` | measurements pane |
| `e` | export |
| `?` | shortcuts |
`Ctrl+E` exports, `Ctrl+Q` quits, `Ctrl+plus`/`Ctrl+minus` zoom x and `Alt+plus`/`Alt+minus` zoom y.
A keystroke is ignored while a text field has focus, so typing in the window control never arms the
trigger.
## 13. Troubleshooting
| symptom | cause |
|---|---|
| `connected, but no data is arriving` | another client holds a unicast source — use `--multicast` |
| the plot is empty, the status reads `no data` | nothing is enabled: enable a signal in the panel |
| the trace is blank while the trigger is armed | `Waiting` — armed and nothing latched yet |
| the axis has a margin at the right | a capture is filling: the post side arrives as it is acquired |
| a math channel equals its input | its output node is the source — see §9 |
| a very wide window shows a low rate | the ingest's anti-alias filter, not a fault |
| a level reads `--` | the measurement is invalid in this window (e.g. a period with one edge) |
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,252 @@
# App icon, Adwaita About dialog, and desktop entry
Date: 2026-10-04
## Goal
Three related pieces of desktop integration udpscope currently lacks:
1. An application icon drawn to the GNOME HIG, in both full colour and symbolic form.
2. An About dialog that shows it, built on `AdwAboutDialog` rather than `GtkAboutDialog`.
3. A `.desktop` entry, so the app can be launched from the shell rather than only from a terminal.
## Decisions taken before design
**The application ID stays `org.udpscope.app`.** GNOME convention is that the application ID, the
icon name and the desktop file's basename are one string, so that string is `org.udpscope.app`
throughout: the icon is named `org.udpscope.app`, the desktop file is
`org.udpscope.app.desktop`, and `gtk_application_new()` is left exactly as it is. The alternative
(moving to `it.ferrarinas.Udpscope`, the reverse-DNS of the project's actual forge domain) was
offered and declined. Nothing else in the tree depends on the ID — the config directory is
`~/.config/udpscope` and the app is `G_APPLICATION_NON_UNIQUE` — so this choice costs nothing
beyond the naming itself.
**The icon is the instrument face**, not a bare waveform and not a colour version of the existing
trigger icon. A dark panel carrying one bold trace is the clearest available metaphor, it is the
kind of physical object the HIG's top-and-front depth language was written for, and its silhouette
survives being scaled down better than either alternative.
**The desktop entry is installed by an opt-in CMake target**, not by `install()` rules and not by
documented `cp` commands. The project deliberately has no install step — that is the stated reason
its icons are a GResource — and a target that does nothing until asked preserves that while still
producing a working entry. `install()` rules were rejected because they would create a second copy
of the app that can diverge from the build tree; manual `cp` was rejected because `Exec` would need
the binary on `PATH`, which it is not.
## The HIG constraints this is built to
Taken from <https://developer.gnome.org/hig/guidelines/app-icons.html>:
- Nominal canvas **128×128**, which the artwork should *not* fill.
- A **2px grid** for all detail.
- Depth comes from combining the top and front of an object: a front profile along the bottom,
**no taller than 2 detail units (4 nominal px)**.
- Flat colour on straight surfaces; **gradients reserved for curved surfaces**.
- Shadows **avoided** where possible.
- The standard GNOME palette.
- A **symbolic version is required**, reusing the same metaphor, following the UI-icon (single
colour) rules.
- The icon must still read at **64px** and **32px**.
## Component 1 — the artwork
Two new source files, both added to the existing `src/icons/udpscope.gresource.xml` under the
`scalable/apps/` tree (the icons already there are `scalable/actions/`).
### `src/icons/scalable/apps/org.udpscope.app.svg`
Full colour, 128×128, every coordinate on the 2px grid:
| element | geometry | palette colour |
|---|---|---|
| chassis | rounded rect (12, 22)–(116, 106), radius 8 | Dark 3 `#3d3846` |
| front profile | the bottom band of that same silhouette, y 102–106 | Dark 2 `#5e5c64` |
| screen | rounded rect (20, 30)–(108, 94), radius 4 | Dark 4 `#241f31` |
| trace | one sine cycle, centred y = 62, amplitude 18, over x 24–104, 6 px thick | Green 1 `#8ff0a4` |
The chassis is **one** rounded rect and the front profile is the bottom 4px *of that silhouette*,
drawn as a path that follows its bottom corners — not a separate full-width rectangle underneath it.
A plain rect from (12, 102) to (116, 106) would protrude past the radius-8 corners, which at 128px
reads as a misdrawn bump and at 32px as a dirty edge.
That band is exactly the HIG's 4px maximum and is the only depth treatment: no gradients (every
surface here is straight, so flat colour is what the HIG asks for) and no shadows.
The artwork occupies x 12–116 and y 22–106, leaving 12px side margins and 22px top and bottom, so it
does not fill the canvas. Every coordinate above is even.
Two deliberate omissions. There is **no graticule** — the grid lines a real scope face carries
cannot survive 32px, and the trace already carries the metaphor without them. And the trace is
**one** cycle, a single trough and a single crest, rather than several: at 32px the screen is about
22×16 px, and anything busier reads as a smudge.
### `src/icons/scalable/apps/org.udpscope.app-symbolic.svg`
16×16, the same metaphor reduced to a screen frame with a wave inside it. Unlike the colour icon
this is **filled paths only, with an `evenodd` hole for the screen's interior**, because GTK's
symbolic recolour only reaches `fill` on `path`, `rect` and `circle` — a stroke keeps the colour
written in the file and stays dark on a dark theme. Whole-pixel coordinates throughout.
**This asymmetry is the trap worth documenting:** CLAUDE.md's existing icon-authoring rules say
"everything is a filled `path`" without qualifying it, because every icon in the tree so far has
been symbolic. A 128px colour icon is never recoloured, so it may stroke freely — and stroking is
the sane way to draw a smooth sine. The rule becomes "fills only in a symbolic icon".
## Component 2 — the About dialog
`adw_about_dialog_new()` returns an `AdwDialog*`. An `AdwDialog` is **not** a `GtkWindow`, which is
the trap CLAUDE.md already documents for the shortcuts overview, so in `src/help_window.cpp`:
- `g_about` changes from `GtkWidget*` to `AdwDialog*`.
- It is cleared on `::closed`, not `::close-request` (`AdwDialog` has no such signal).
- It closes with `adw_dialog_close()`, not `gtk_window_close()`.
- It is presented with `adw_dialog_present()`.
- Both `AdwDialog`s can share one `::closed` handler keyed by a pointer-to-pointer, leaving
`on_window_close` to the help window alone.
`close_help_windows()` therefore becomes one `GtkWindow` (help) plus two `AdwDialog`s (About,
shortcuts), and both its doc comment and the CLAUDE.md invariant that says "two of the three are
`GtkWindow`s" have to be revised to "one of the three".
Field mapping:
| `GtkAboutDialog` | `AdwAboutDialog` |
|---|---|
| `program_name` | `application_name` |
| `version` | `version` |
| `comments` | `comments` |
| `license_type` | `license_type` |
| `system_information` | `debug_info` |
| — | `application_icon` = `"org.udpscope.app"` |
| — | `developer_name`, `website` |
Two gains are taken while mapping. `debug_info` renders as a Troubleshooting page with a copy
button, where `system_information` was inert text. And the string behind it is built as a
`std::string` rather than the `char details[1024]` a previous review flagged for silent truncation
on a long settings path — removing the ceiling rather than widening it, in code already being
rewritten.
Credits, release notes and an issue URL are deliberately **not** added; nothing asked for them.
One ordering detail: `gtk_icon_theme_add_resource_path()` currently runs at the top of
`build_toolbar()`. The main window's own icon is now set from a name in the same resource, so the
registration moves up into `run_ui()`, before both `gtk_window_set_icon_name()` and the
`build_toolbar()` call — otherwise whichever of the two runs first resolves its name to nothing.
## Component 3 — the desktop entry
### `data/org.udpscope.app.desktop.in`
```
[Desktop Entry]
Type=Application
Name=udpscope
GenericName=Oscilloscope
Comment=An oscilloscope for UDPStreamer sources
Exec=@UDPSCOPE_EXEC@
Icon=org.udpscope.app
Terminal=false
Categories=Development;Science;Electronics;
Keywords=oscilloscope;UDP;signal;waveform;trigger;
StartupNotify=true
```
`@UDPSCOPE_EXEC@` is configured to the built binary's absolute path in the build tree. The
consequence to accept knowingly: **moving or deleting the build directory breaks the entry.** That
is the price of having no install step, and it is the right price here because 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 already matches it, which is what associates a running window with this entry.
### The `desktop-entry` target
A custom target driving `cmake/install_desktop_entry.cmake`, which writes three files under
`$XDG_DATA_HOME` (defaulting to `~/.local/share`):
- `applications/org.udpscope.app.desktop`
- `icons/hicolor/scalable/apps/org.udpscope.app.svg`
- `icons/hicolor/symbolic/apps/org.udpscope.app-symbolic.svg`
The two SVGs are copied from `src/icons/scalable/apps/` in the source tree, not extracted from the
GResource: the same two files serve both the in-binary resource and the installed icon theme, so
there is one copy to edit.
It then refreshes `update-desktop-database` and `gtk4-update-icon-cache` if either is on `PATH`, runs
`desktop-file-validate` if it is available, and prints every path it wrote.
`cmake --build build` is unaffected: the target runs only when named. No uninstall target is
provided — the three paths are printed, so undoing it is three `rm`s.
## Verification
Two silent failures are possible, and they fail differently.
A GResource path that was never registered draws `image-missing` while every other line of the dump
passes; the existing `toolbar icons:` line already guards that class with
`gtk_icon_theme_has_icon()`. But a **malformed SVG is worse**: librsvg refuses the whole file,
`has_icon()` still returns true because the *name* resolves, and the icon draws nothing. `has_icon`
cannot see it — and these SVGs have already been bitten once by librsvg, which rejects a file for a
`--` inside an XML comment.
The two are therefore caught in two different places, because **no runtime GTK call can see the
malformed one.** This was measured rather than assumed, and three candidate checks were tried and
rejected:
- `gtk_icon_theme_lookup_icon()` never returns `NULL` and its paintable's intrinsic size is the
size that was *asked for*, so it reads `128x128` for a valid icon, a malformed one and a name
that does not exist at all. The same three cases snapshot to an identical `GSK_TEXTURE_NODE` of
the requested bounds, since GTK substitutes `image-missing`. There is nothing to compare.
- `GMarkup` accepts `<!-- a -- b -->` without complaint. It is a deliberately lenient subset
parser, so it cannot stand in for the strict one that refuses the file.
- `gdk_pixbuf_new_from_file()` on an SVG goes through glycin on this system, which sandboxes a
subprocess per load, emits fontconfig errors, and refuses this project's existing icons outright
for reasons of its own. Not a usable oracle.
So the malformed file is caught at **build time**, which is better than a probe anyway: every
`<file>` line in `src/icons/udpscope.gresource.xml` gains `preprocess="xml-stripblanks"`, which
makes `glib-compile-resources` pipe each SVG through `xmllint`. A double hyphen inside a comment
then **fails the build** with the file, line and column, instead of drawing nothing at runtime.
The four existing icons already pass it. The cost is that the `xmllint` binary becomes a build
dependency; it ships with libxml2, which GTK already requires.
The runtime line then asserts what a runtime check genuinely can:
```
app icons: org.udpscope.app(ok 128x128) org.udpscope.app-symbolic(ok 16x16)
```
`ok` is `gtk_icon_theme_has_icon()` **and** the resource bytes being present under the registered
path, and the size is the one the file's root `<svg>` element declares, read out of those bytes.
**Both entries must read `ok` at those sizes.** That covers the unregistered-path case, a renamed
or mis-pathed file, and a canvas size that drifted off the HIG's 128 — and it is computed on the
first tick beside `toolbar icons:`, not in the dump, which runs after the window is destroyed.
Alongside it:
- The `[help]` probe gains `about icon=org.udpscope.app`, read back through
`adw_about_dialog_get_application_icon()`, so the dialog is asserted to carry the icon rather than
merely to open.
- That probe's existing `closed ok` becomes a stronger crash test, because About is now an
`AdwDialog` closed by a different call than the help window beside it. It must still produce no
`Gtk-CRITICAL` beyond the two already baselined.
- `desktop-file-validate` runs inside the `desktop-entry` target, so a malformed entry fails at the
moment it is asked for rather than being silently ignored by the shell later.
## Documentation
- **CLAUDE.md**: the `app icons:` dump line as a documented assertion, and the `xml-stripblanks`
build-time check beside the five existing icon-authoring rules — the sixth rule is that the
malformed-SVG failure is a *build* error now and no runtime check can replace it; the windows invariant revised
to one `GtkWindow` and two `AdwDialog`s; the `desktop-entry` target and the build-path caveat; and
the icon-authoring rules amended so "filled paths only" is scoped to symbolic icons.
- **README.md**: the feature narrative — the icon's HIG constraints and the two omissions
(graticule, multiple cycles) with the 32px reasoning, the move to `AdwAboutDialog` and what it
gained, and how to get the desktop entry.
## Out of scope
- An AppStream `metainfo.xml`. Nothing asked for it, and it is only required for store submission.
- Redrawing the four existing action icons.
- A nightly/devel icon variant.
- An uninstall target for the desktop entry.
@@ -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.
+10 -3
View File
@@ -183,9 +183,16 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
used_stream_time = true; used_stream_time = true;
break; break;
case UDPS_TIME_FIRST_SAMPLE: { case UDPS_TIME_FIRST_SAMPLE: {
// Only element 0 is meaningful here: the producer stamps the batch's first
// sample and says nothing about the rest, so the cadence has to space them --
// exactly as LAST_SAMPLE below dates its batch backwards from its anchor.
// Deriving a step from `value_of(n - 1)` instead cannot work: a scalar signal
// in Accumulate mode still occupies `num_samples` slots on the wire, and a
// producer fills them by repeating the anchor, so the step came out as zero
// and every sample of the frame landed on one x. The degeneracy guard below
// then threw the whole stream onto the arrival clock.
const double anchor = value_of(0); const double anchor = value_of(0);
const double step = n > 1 ? (value_of(n - 1) - anchor) / double(n - 1) : period; for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = anchor + double(k) * cadence;
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = anchor + double(k) * step;
used_stream_time = true; used_stream_time = true;
break; break;
} }
@@ -511,7 +518,7 @@ void Acquisition::on_data(const udps_frame_t* frame) {
time_acc_ += rel; time_acc_ += rel;
++time_acc_n_; ++time_acc_n_;
if (tick % g == g - 1) { if (tick % g == g - 1) {
store.time().push(static_cast<f32>(time_acc_ / static_cast<double>(time_acc_n_))); store.time().push(time_acc_ / static_cast<double>(time_acc_n_));
time_acc_ = 0.0; time_acc_ = 0.0;
time_acc_n_ = 0; time_acc_n_ = 0;
} }
+13 -13
View File
@@ -24,7 +24,7 @@ const plot::data::MinMaxPyramid& null_pyramid() {
// --- ChannelBuffer ---------------------------------------------------------- // --- ChannelBuffer ----------------------------------------------------------
ChannelBuffer::ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>> values, ChannelBuffer::ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>> values,
std::shared_ptr<const plot::data::RingBuffer<f32>> times, u64 stride, std::shared_ptr<const plot::data::RingBuffer<f64>> times, u64 stride,
f64 epoch, f64 nominal_dt) f64 epoch, f64 nominal_dt)
: values_(std::move(values)), times_(std::move(times)), stride_(stride < 1 ? 1 : stride), : values_(std::move(values)), times_(std::move(times)), stride_(stride < 1 ? 1 : stride),
epoch_(epoch), dt_(nominal_dt) {} epoch_(epoch), dt_(nominal_dt) {}
@@ -64,11 +64,11 @@ void ChannelBuffer::acquire() noexcept {
void ChannelBuffer::invalidate() noexcept { void ChannelBuffer::invalidate() noexcept {
published_ = 0; published_ = 0;
window_x0_ = 0.0f; window_x0_ = 0.0;
pyramid_.clear(); pyramid_.clear();
} }
f32 ChannelBuffer::time_of(u64 absolute_index) const noexcept { f64 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
// Channel sample `j` is produced on the tick that also pushes time slot // Channel sample `j` is produced on the tick that also pushes time slot
// `j*stride + stride - 1` (see `Acquisition::on_data`): the producer stamps the // `j*stride + stride - 1` (see `Acquisition::on_data`): the producer stamps the
// time ring every `time_stride` ticks and the channel every `stride` of those, // time ring every `time_stride` ticks and the channel every `stride` of those,
@@ -102,10 +102,10 @@ f32 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
// whole trace's geometry collapsing. // whole trace's geometry collapsing.
const u64 base = times_->base(); const u64 base = times_->base();
const u64 produced = times_->produced(); const u64 produced = times_->produced();
if (produced == 0) return 0.0f; if (produced == 0) return 0.0;
u64 slot = index < base ? base : index; u64 slot = index < base ? base : index;
if (slot >= produced) slot = produced - 1; if (slot >= produced) slot = produced - 1;
f32 out = 0.0f; f64 out = 0.0;
times_->copy_from(slot, 1, &out); times_->copy_from(slot, 1, &out);
return out; return out;
} }
@@ -121,7 +121,7 @@ f64 ChannelBuffer::dx() const {
f64 ChannelBuffer::x0() const { f64 ChannelBuffer::x0() const {
if (pyramid_.size() == 0) return epoch_; if (pyramid_.size() == 0) return epoch_;
return epoch_ + f64(window_x0_); return epoch_ + window_x0_;
} }
f64 ChannelBuffer::x_at(u64 i) const { f64 ChannelBuffer::x_at(u64 i) const {
@@ -131,7 +131,7 @@ f64 ChannelBuffer::x_at(u64 i) const {
// ring-span too new made it refuse the whole visible range -- which left the renderer // ring-span too new made it refuse the whole visible range -- which left the renderer
// gathering the entire ring at a coarse level and drawing it off the axis. // gathering the entire ring at a coarse level and drawing it off the axis.
if (i == 0) return x0(); if (i == 0) return x0();
return epoch_ + f64(time_of(absolute(i))); return epoch_ + time_of(absolute(i));
} }
bool ChannelBuffer::neighbours(f64 x, u64& i0, u64& i1) const { bool ChannelBuffer::neighbours(f64 x, u64& i0, u64& i1) const {
@@ -171,7 +171,7 @@ u64 ChannelBuffer::snap_index(f64 x) const {
plot::Range ChannelBuffer::x_bounds() const { plot::Range ChannelBuffer::x_bounds() const {
const u64 n = pyramid_.size(); const u64 n = pyramid_.size();
if (n == 0) return plot::Range{epoch_, epoch_}; if (n == 0) return plot::Range{epoch_, epoch_};
return plot::Range{x0(), epoch_ + f64(time_of(absolute(n - 1)))}; return plot::Range{x0(), epoch_ + time_of(absolute(n - 1))};
} }
plot::Range ChannelBuffer::y_bounds() const { plot::Range ChannelBuffer::y_bounds() const {
@@ -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; 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 { plot::data::GatherMode mode) const {
const u64 n = pyramid_.size(); const u64 n = pyramid_.size();
const u64 b = std::min(begin, n); 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; if (b >= e) return;
const u64 origin = pyramid_.window_base(); 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) { const auto emit_raw = [&](u64 from, u64 to) {
for (u64 i = from; i < to; ++i) { 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; 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 { plot::data::GatherMode mode) const {
if (event_ != nullptr) { if (event_ != nullptr) {
event_->gather(level, begin, end, out_xy, mode); event_->gather(level, begin, end, x_origin, out_xy, mode);
return; 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 { void SlotSeries::copy_raw(u64 begin, u64 end, f32* out) const {
+17 -12
View File
@@ -34,8 +34,10 @@ using plot::u64;
/// undecimated case and the common one. /// undecimated case and the common one.
class ChannelBuffer final : public plot::data::Series { class ChannelBuffer final : public plot::data::Series {
public: public:
/// `values` is f32 and `times` is f64: see `BufferStore::time()` for why the clock
/// alone needs the wider type.
ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>> values, ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>> values,
std::shared_ptr<const plot::data::RingBuffer<f32>> times, u64 stride, f64 epoch, std::shared_ptr<const plot::data::RingBuffer<f64>> times, u64 stride, f64 epoch,
f64 nominal_dt); f64 nominal_dt);
ChannelBuffer(const ChannelBuffer&) = delete; ChannelBuffer(const ChannelBuffer&) = delete;
@@ -71,16 +73,18 @@ class ChannelBuffer final : public plot::data::Series {
/// datagram leaves a gap, so index times must come from the time ring. /// datagram leaves a gap, so index times must come from the time ring.
f64 x_at(u64 i) const override; f64 x_at(u64 i) const override;
/// Bracketing samples by binary search over the time ring, which is monotonic, /// Bracketing samples by binary search over the time ring, which is monotonic,
/// so a crosshair value is interpolated between the two samples actually either /// so a crosshair reads whichever of the two samples actually either side of the
/// side of the pointer -- a grid walk would land on the wrong pair after a gap. /// 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; bool neighbours(f64 x, u64& i0, u64& i1) const override;
u64 snap_index(f64 x) const override; u64 snap_index(f64 x) const override;
plot::Range x_bounds() const override; plot::Range x_bounds() const override;
plot::Range y_bounds() const override; plot::Range y_bounds() const override;
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; } const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
/// x is emitted **relative to `x0()`** (the library's contract): staging an /// 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. /// absolute epoch time as f32 would quantise it far coarser than a pixel, and
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, /// 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; plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override;
/// `copy_raw`, published so the retargetable trace series can forward the /// `copy_raw`, published so the retargetable trace series can forward the
@@ -94,10 +98,10 @@ class ChannelBuffer final : public plot::data::Series {
/// Absolute (channel) index of retained-window index `i`. /// Absolute (channel) index of retained-window index `i`.
u64 absolute(u64 i) const noexcept { return pyramid_.window_base() + i; } u64 absolute(u64 i) const noexcept { return pyramid_.window_base() + i; }
/// Timestamp of channel sample `absolute_index`, in seconds since `epoch_`. /// Timestamp of channel sample `absolute_index`, in seconds since `epoch_`.
f32 time_of(u64 absolute_index) const noexcept; f64 time_of(u64 absolute_index) const noexcept;
std::shared_ptr<const plot::data::RingBuffer<f32>> values_; std::shared_ptr<const plot::data::RingBuffer<f32>> values_;
std::shared_ptr<const plot::data::RingBuffer<f32>> times_; std::shared_ptr<const plot::data::RingBuffer<f64>> times_;
u64 stride_ = 1; u64 stride_ = 1;
f64 epoch_ = 0.0; f64 epoch_ = 0.0;
f64 dt_ = 1.0; f64 dt_ = 1.0;
@@ -107,14 +111,15 @@ class ChannelBuffer final : public plot::data::Series {
/// slot cannot be overwritten under the read. Everything else on this object is read at /// 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 /// *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 /// 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 producer lapped it between the bounds check and the copy -- and `x0()` is what
/// the renderer reconstructs every trace point from (`gather[i] + series.x0()`), so one such /// `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 /// 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 /// 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 /// the "live plot flickers as if there were no data" defect. The cached value is also the
/// *right* answer rather than merely a stable one: it is the stamp sample `window_base` /// *right* answer rather than merely a stable one: it is the stamp sample `window_base`
/// actually carries, where the clamp answered with whatever slot happened to be oldest now. /// actually carries, where the clamp answered with whatever slot happened to be oldest now.
f32 window_x0_ = 0.0f; f64 window_x0_ = 0.0;
plot::data::MinMaxPyramid pyramid_; plot::data::MinMaxPyramid pyramid_;
}; };
@@ -161,7 +166,7 @@ class SlotSeries final : public plot::data::Series {
plot::Range x_bounds() const override; plot::Range x_bounds() const override;
plot::Range y_bounds() const override; plot::Range y_bounds() const override;
const plot::data::MinMaxPyramid& pyramid() 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; plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override;
bool streaming() const noexcept override { return event_ == nullptr && buffer_ != nullptr; } bool streaming() const noexcept override { return event_ == nullptr && buffer_ != nullptr; }
+29 -13
View File
@@ -26,8 +26,24 @@ using plot::u64;
/// live ring buffers switched by a pending flag, plus a display-epoch shift) /// live ring buffers switched by a pending flag, plus a display-epoch shift)
/// could not stop. /// could not stop.
struct EventSnapshot { struct EventSnapshot {
std::vector<f32> stamps; ///< edge-relative seconds, monotonic /// 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 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 pre_s = 0.0; ///< seconds of pre-trigger data kept
double post_s = 0.0; ///< seconds of post-trigger data kept double post_s = 0.0; ///< seconds of post-trigger data kept
std::vector<plot::Range> y; ///< per-channel value bounds, precomputed std::vector<plot::Range> y; ///< per-channel value bounds, precomputed
@@ -102,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 /// 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 /// dropped from it, so `window_base()` is 0 and a window index *is* the absolute one
/// (unlike the ring, whose window slides). /// (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 mode =
plot::data::GatherMode::Envelope) const override { plot::data::GatherMode::Envelope) const override {
if (channel_ >= snap_->values.size()) return; if (channel_ >= snap_->values.size()) return;
@@ -112,17 +128,17 @@ class EventSeries final : public plot::data::Series {
const u64 b = std::min(begin, n); const u64 b = std::min(begin, n);
const u64 e = std::min(std::max(end, b), n); const u64 e = std::min(std::max(end, b), n);
if (b >= e) return; if (b >= e) return;
// x **relative to x0()**, which is the library's gather contract: the renderer // x **relative to `x_origin`**, which is the library's gather contract: the
// adds `x0()` back before mapping to the screen. Emitting the absolute stamp made // renderer adds that same origin back before mapping to the screen. Emitting the
// it add x0 twice, so the whole capture was drawn one `x0()` -- a full // absolute stamp made it add an origin twice, so the whole capture was drawn one
// pre-trigger window -- to the left of the axis it had just been measured // `x0()` -- a full pre-trigger window -- to the left of the axis it had just been
// against. The event's own stamps are small (edge-relative, so [-pre, +post]), // measured against. The event's own stamps are small (edge-relative, so
// which is why that shift looked like a plausible picture: with a 50/50 split the // [-pre, +post]), which is why that shift looked like a plausible picture: with a
// right half of the plot was simply empty, zooming out showed the trace ending at // 50/50 split the right half of the plot was simply empty, zooming out showed the
// the x = 0 marker, and zooming in past the shift showed nothing at all while the // trace ending at the x = 0 marker, and zooming in past the shift showed nothing
// crosshair -- which reads `x_at()`, never `gather()` -- went on reporting the // at all while the crosshair -- which reads `x_at()`, never `gather()` -- went on
// right values. // reporting the right values.
const f64 base = x0(); const f64 base = x_origin;
const auto rel = [&](u64 i) { const auto rel = [&](u64 i) {
return static_cast<f32>(stamps[static_cast<std::size_t>(i)] - base); return static_cast<f32>(stamps[static_cast<std::size_t>(i)] - base);
}; };
+28 -24
View File
@@ -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 /// two time columns, with a `_2`, `_3` suffix. Two elements of one vector signal can
/// easily sanitise to the same string. /// easily sanitise to the same string.
std::vector<std::string> column_labels(const ExportTable& table) { 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; std::vector<std::string> out;
out.reserve(table.columns.size()); out.reserve(table.columns.size());
for (const ExportTable::Column& col : table.columns) { for (const ExportTable::Column& col : table.columns) {
@@ -106,24 +106,28 @@ 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 // 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. // snapshots, so the export is the picture the user is looking at either way.
std::shared_ptr<const EventSnapshot> snap = model.display_snapshot(); 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) { if (snap != nullptr) {
// The snapshot is immutable and already index-aligned, so the visible window is two // 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. // binary searches over its stamps and the same index range for every channel.
const std::vector<f32>& st = snap->stamps; const std::vector<f64>& st = snap->stamps;
const auto lo = std::lower_bound(st.begin(), st.end(), static_cast<f32>(view.min)); const auto lo = std::lower_bound(st.begin(), st.end(), view.min);
const auto hi = std::upper_bound(st.begin(), st.end(), static_cast<f32>(view.max)); const auto hi = std::upper_bound(st.begin(), st.end(), view.max);
if (hi <= lo) return table; if (hi <= lo) return table;
const std::size_t first = static_cast<std::size_t>(lo - st.begin()); 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); 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.time.resize(n);
table.stream_time.resize(n); table.abs_seconds.resize(n);
for (std::size_t i = 0; i < n; ++i) { for (std::size_t i = 0; i < n; ++i) {
table.time[i] = static_cast<f64>(st[first + 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) { for (const int c : chans) {
const std::size_t ch = static_cast<std::size_t>(c); const std::size_t ch = static_cast<std::size_t>(c);
@@ -141,23 +145,23 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
// the same window hold the same samples. The lock is against a structural change // 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. // dropping the rings under the copy, which is what the UI thread has to fear here.
std::lock_guard<std::mutex> lock(model.mutex()); std::lock_guard<std::mutex> lock(model.mutex());
// The axis reads `display_epoch + stamp`, so the window has to be put back on the // The axis reads `stamp - stream_at_x0()`, so the window has to be put back on the stream's
// stream's clock before the ring is searched. It is 0 in live mode, but reading it is // clock before the ring is searched -- and that offset is a whole window while streaming now,
// what keeps this correct rather than merely true today. // not the 0 it once was: the live axis is `[-window, 0]` about the newest sample.
const f64 epoch = model.display_epoch(); const f64 s0 = model.stream_at_x0();
u64 first = 0, last = 0; 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; const u64 n = last - first;
if (n == 0) return table; if (n == 0) return table;
BufferStore& store = model.live(); BufferStore& store = model.live();
std::vector<f32> stamps(static_cast<std::size_t>(n)); std::vector<f64> stamps(static_cast<std::size_t>(n));
store.time().copy_from(first, n, stamps.data()); store.time().copy_from(first, n, stamps.data());
table.time.resize(static_cast<std::size_t>(n)); 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) { for (std::size_t i = 0; i < stamps.size(); ++i) {
table.stream_time[i] = static_cast<f64>(stamps[i]); table.time[i] = stamps[i] - s0;
table.time[i] = epoch + table.stream_time[i]; table.abs_seconds[i] = abs_base + table.time[i];
} }
for (const int c : chans) { for (const int c : chans) {
const std::size_t ch = static_cast<std::size_t>(c); 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 // `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 // only inserts alignment padding when asked to (`align=True`) -- so the records below
// are field widths back to back with no gaps. // 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')"; for (const std::string& label : labels) descr += ", ('" + label + "', '<f4')";
descr += "]"; descr += "]";
std::string header = "{'descr': " + descr + ", 'fortran_order': False, 'shape': (" + 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::size_t at = 0;
std::memcpy(record.data() + at, &table.time[i], sizeof(f64)); std::memcpy(record.data() + at, &table.time[i], sizeof(f64));
at += 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); at += sizeof(f64);
for (const ExportTable::Column& col : table.columns) { for (const ExportTable::Column& col : table.columns) {
const f32 v = i < col.values.size() ? col.values[i] : 0.0f; 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; if (!opened(out, path, error)) return false;
const std::vector<std::string> labels = column_labels(table); 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; for (const std::string& label : labels) line += "," + label;
line += "\n"; line += "\n";
std::fwrite(line.data(), 1, line.size(), out.f); 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(); line.clear();
std::snprintf(buf, sizeof(buf), "%.17g", table.time[i]); std::snprintf(buf, sizeof(buf), "%.17g", table.time[i]);
line += buf; line += buf;
std::snprintf(buf, sizeof(buf), "%.17g", table.stream_time[i]); std::snprintf(buf, sizeof(buf), "%.17g", table.abs_seconds[i]);
line += ","; line += ",";
line += buf; line += buf;
for (const ExportTable::Column& col : table.columns) { for (const ExportTable::Column& col : table.columns) {
+16 -7
View File
@@ -24,13 +24,22 @@ struct ExportTable {
std::vector<f32> values; ///< `rows()` long, the ring's own `f32` 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, /// The x the plot shows and the crosshair reads, which is **relative** in every mode: seconds
/// edge-relative seconds in `[-pre, +post]` while an event is held. /// 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; std::vector<f64> time;
/// The stream's own clock, so two exports taken in different modes line up. Equal to /// The stream's own absolute clock -- the number the time signal carried, which is what the
/// `time` in live mode; `time + trigger_time` while an event is held, which is the one /// source stamped the samples with -- so two exports taken in different modes, or from
/// case where the visible x is not the stream's. /// different runs, line up on one timeline.
std::vector<f64> stream_time; ///
/// `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::vector<Column> columns;
std::size_t rows() const noexcept { return time.size(); } 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. /// model access. In trigger mode nothing is locked: the `EventSnapshot` is immutable.
ExportTable collect_export(ScopeModel& model, RowSet& rows); 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 /// `<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. /// about half the size of an all-`f8` one and invents no precision.
/// ///
+70 -18
View File
@@ -1,26 +1,78 @@
<b>What udpscope is</b> <b>What udpscope is</b>
udpscope plots a live UDPStreamer source. One window holds up to 8 rows of plots, each udpscope plots a live UDPStreamer source. One window holds up to 8 rows of plots, each with up
with up to 4 traces; every row shares one time axis, so the whole figure scrolls together. to 4 traces, and every row shares one time axis, so the whole figure scrolls together.
Acquisition runs on its own thread and writes a ring buffer sized from --buffer-mb; the Acquisition runs on its own thread and writes a ring buffer sized from <tt>--buffer-mb</tt>;
drawing thread reads whatever that ring holds when a frame starts. 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 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> <b>How to reach it</b>
Start the app with a source (<tt>udpscope --server 127.0.0.1 --port 8000</tt>), or start it Start the app with a source (<tt>udpscope --server 127.0.0.1 --port 8000</tt>), or start it with
with no <tt>--server</tt> and pick one from the chooser. The signals arrive disabled; turn no <tt>--server</tt> and pick one from the chooser, which lists the sessions you have opened
one on in the side panel (<tt>s</tt>) and drag it onto a row. before. A unicast source serves one client at a time: if a second viewer connects, they take
turns and the status bar says so. To watch one source from several instances, use
<tt>--multicast</tt> with the group and <tt>--interface</tt> with the local address.
The signals arrive <i>disabled</i>. Nothing is acquired until you turn one on in the signal
list, and enabling is what starts acquisition -- so a freshly opened scope with an idle side
panel is working correctly, not failing to connect.
<b>The window</b>
• <b>The header bar</b> -- left to right: the time window (<tt>2 s</tt>), the trigger and the
pause, then the pane switches -- signal list, cursors, measurements, math, side panel -- and
the menu, which holds Fit to data, the layout editor, Export, Keyboard shortcuts, Help and
About.
• <b>The side panel</b> -- the trigger, cursors, measurements and math panes at the top, then
the signal list: one row per signal with its colour, an enable switch, its name and its unit.
Signals are dragged from here onto a plot row.
• <b>The plot</b> -- up to 8 rows, each with its own y axis and up to 4 traces, all on one
shared time axis. Right-click a row for its own menu (fit, add a signal, remove a trace).
• <b>The status bar</b>, directly above the plot's bottom edge -- the state word, the stored
sample rate and the window, and the ⓘ beside it for packets, gaps and the buffer's own
figures. The second screenshot above is what a held capture looks like: <tt>event held</tt>,
the axis pinned to the event, and the trigger marker at the edge.
<b>The first five minutes</b>
1. <b>Connect.</b> With no <tt>--server</tt>, pick a stored session or type a host and port.
2. <b>Enable a signal</b> in the signal list. The plot stays empty for the first fraction of a
second while the first window fills -- the buffer holds only what has arrived.
3. <b>Drag it onto a row.</b> Drop a second signal on another row, or the same signal again for
a second view of it. The colour is per signal, so it is the same one wherever it appears.
4. <b>Set the window</b> with the control in the header bar: type a number, pick a unit, or step
a decade. The trigger's event window is a share of this span, so a window change moves it.
5. <b>Pause</b> (<tt>space</tt>) to look at a still picture; the producer keeps filling the
buffer behind it, and resuming shows what arrived meanwhile rather than a hole.
6. <b>Arm the trigger</b> (<tt>a</tt>) and wait for a latch: the view freezes on one event, and
you can zoom in on it. Disarming mid-capture keeps what the capture got.
7. <b>Measure it</b> -- the measurements pane computes a statistic of the visible window, and
<b>show on plot</b> marks it as a ruler or a pair of markers.
8. <b>Save the data</b> with Export visible range, which writes the plotted signals over the
window at the stored rate to <tt>.npy</tt> or <tt>.csv</tt>.
<b>Worth knowing</b> <b>Worth knowing</b>
• Nothing is acquired until a signal is enabled. Enabling is what starts acquisition, so a • A signal nobody enables costs nothing -- no buffer, no socket work, no measurements.
freshly opened scope with an idle side panel is working correctly. • The window remembers itself. Rows, traces, the time window, calibration, which signals were
• The stream carries its own clock. Every time on screen is a stream timestamp, never the enabled, the math channels with their graphs, and the measurements are written per host and
local wall clock, which is why a stalled source freezes the view instead of scrolling it. port, and reloaded the next time you open that source.
• The window remembers itself. Rows, traces, the time window, calibration, math channels • A very wide window on a fast stream is drawn from averaged groups of samples, so the rate in
and measurements are written to a session file per host and port, and reloaded next time. the status bar falls: that is the anti-alias filter, not a fault -- striding would fold a fast
signal back as a slow one, which looks entirely plausible on screen.
<b>Example</b> • Every instrument the app computed -- the trigger marker, the measurement marks -- is locked to
the pointer's reach. The cursors are yours to drag; those are not.
A 3-signal source at 10 kS/s with a 200 MB buffer holds 1677 s of history. Enable all three,
drop them on row 1, and the status bar reads <tt>● live · 10 kS/s · 2 s window</tt>.
+4 -1
View File
@@ -1,7 +1,10 @@
<b>Rows, layout and drag &amp; drop</b> <b>Rows, layout and drag &amp; drop</b>
The figure is a stack of rows, 1 to 8 of them, each holding up to 4 traces. Rows share one 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> <b>How to reach it</b>
+3
View File
@@ -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. 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. • Moving the window across a decimation boundary rebuilds the buffer and drops the history.
Walking it inside one decade is free. 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 • 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. found on a sample that is not in the buffer could not be cut out of it.
+23
View File
@@ -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 • 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 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. 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. • 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 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. 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 • 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 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. 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> <b>Example</b>
+2
View File
@@ -20,6 +20,8 @@ live ring and puts the window back on the newest data.
zoom out is clamped to the snapshot's 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 • Pausing is the easiest way to measure a layout or read a cursor precisely, because nothing
under the pointer moves between frames. 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> <b>Example</b>
+18 -1
View File
@@ -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 • <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 at each marker and their difference. A slot with a name but no channel behind it is not a
trace and gets no line. 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 • 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. 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 • 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 • The time row exists only in the modes that show the vertical pair. <tt>horizontal</tt> has no
time row, by design. 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> <b>Example</b>
+16 -1
View File
@@ -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 • 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 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. 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 • 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. 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; • <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 • 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. window. The values are still exact: a bucket's sum is a sum.
+16
View File
@@ -33,3 +33,19 @@ orange dot marks the output node.
same source passes 50 Hz at 0.97 and 2 kHz at 0.002; a 1 kHz band pass with Q=2 passes 200 Hz at same source passes 50 Hz at 0.97 and 2 kHz at 0.002; a 1 kHz band pass with Q=2 passes 200 Hz at
0.05, 1 kHz at 0.95 and 4 kHz at 0.05. Each derived channel must hold exactly as many samples as 0.05, 1 kHz at 0.95 and 4 kHz at 0.05. Each derived channel must hold exactly as many samples as
the shared clock has time slots. the shared clock has time slots.
<b>Why an operation sometimes does nothing</b>. A graph's <i>output</i> is the node whose value
becomes the channel, and it is drawn as the orange dot. A new graph is built source, then operation,
and the last node added becomes the output -- but a graph whose output is left on the source is
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>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
against the <i>stored</i> rate, which is the wire rate until a wide window makes the buffer decimate
-- the status line's rate is that number, and the filters are rebuilt whenever it changes.
+6 -4
View File
@@ -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 • 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 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. 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 • Two time columns. <tt>time</tt> is the display timeline — negative ages ending at 0 while
event is held) and <tt>stream_time</tt> is the source's own clock. Both are strictly increasing streaming, offsets from the trigger edge while an event is held — so it is the x you read a
and evenly spaced; duplicate or backwards stamps would mean the file held two clocks. 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 • 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. shorter than the event span. Export outside a capture if you want the whole event.
<b>Example</b> <b>Example</b>
A 2 s window at 10 kS/s over 3 traces writes 20001 rows and 5 columns 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>64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)</tt> bytes. Read it back with
<tt>pd.DataFrame(np.load(path))</tt>. <tt>pd.DataFrame(np.load(path))</tt>.
+18 -8
View File
@@ -9,11 +9,19 @@ The bar is always visible under the plot. Click <b>ⓘ</b> for the detail popove
<b>Worth knowing</b> <b>Worth knowing</b>
• The badge is one glyph in three colours: lit and dim alternating means data is arriving, and a • The badge is one glyph in four colours: green <i>alternating</i> lit and dim means data is
steady idle colour means it is not. A badge that never blinks is not a working badge. arriving, and a steady 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>, Of the two steady colours, <i>grey</i> is no stream at all or a pipeline you stopped (the
<tt>capturing</tt>, <tt>event held</tt>, <tt>disarmed</tt>. In trigger mode the bar adds tooltip says which), and <i>yellow</i> is a source that answered but has sent no configuration.
exactly one more field, how long ago the last event was. • 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 • 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 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 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. 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 • <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. 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 • The starvation notice — <i>connected, but no data is arriving</i> — is that same situation seen
situation seen from the other side. Pointing the app at a port where nothing answers produces from the other side: the signal list is here, so something took the stream away. A source that
it too. 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 • <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. when the window is too long to store at full rate.
+7
View File
@@ -23,5 +23,12 @@
<file>12-export.txt</file> <file>12-export.txt</file>
<file>13-sessions.txt</file> <file>13-sessions.txt</file>
<file>14-status.txt</file> <file>14-status.txt</file>
<!-- The screenshots live with the user documentation (docs/); the resource aliases
them under `img/` so a help page can name one by resource path. The path is relative
to this manifest's directory. -->
<file alias="img/main-window.png">../../docs/img/main-window.png</file>
<file alias="img/trigger-capture.png">../../docs/img/trigger-capture.png</file>
<file alias="img/math-editor.png">../../docs/img/math-editor.png</file>
</gresource> </gresource>
</gresources> </gresources>
+93 -33
View File
@@ -13,13 +13,14 @@
namespace udpscope { namespace udpscope {
namespace { namespace {
/// Nulled by the `close-request` handler so a second open cannot present a dead widget. /// Nulled by each window's own close handler, so a second open cannot present a dead widget.
/// One pointer per window, file-scope because there is one application. /// One pointer per window, file-scope because there is one application.
GtkWidget* g_about = nullptr; ///
/// The help window is a `GtkWindow`. The other two are `AdwDialog`s, which are **not**
/// `GtkWindow`s: they have no `::close-request`, so they clear on `::closed` and close with
/// `adw_dialog_close()`.
GtkWidget* g_help = nullptr; GtkWidget* g_help = nullptr;
AdwDialog* g_about = nullptr;
/// The shortcut overview is an `AdwDialog`, not a window, so it has its own pointer and
/// its own clearing signal (`::closed` rather than `::close-request`).
AdwDialog* g_shortcuts = nullptr; AdwDialog* g_shortcuts = nullptr;
gboolean on_window_close(GtkWindow* window, gpointer slot) { gboolean on_window_close(GtkWindow* window, gpointer slot) {
@@ -28,32 +29,49 @@ gboolean on_window_close(GtkWindow* window, gpointer slot) {
return FALSE; // let the default handler destroy it return FALSE; // let the default handler destroy it
} }
void on_shortcuts_closed(AdwDialog* dialog, gpointer user) { /// Shared by both `AdwDialog`s: the pointer to clear is the `user_data`, so there is one
/// handler rather than one per dialog.
void on_dialog_closed(AdwDialog* dialog, gpointer slot) {
(void)dialog; (void)dialog;
(void)user; *static_cast<AdwDialog**>(slot) = nullptr;
g_shortcuts = nullptr;
} }
struct HelpPage { struct HelpPage {
const char* section; ///< the group heading in the sidebar const char* section; ///< the group heading in the sidebar
const char* title; ///< the row's label and the page's heading const char* title; ///< the row's label and the page's heading
const char* resource; ///< a path under /org/udpscope/help const char* resource; ///< a path under /org/udpscope/help
/// The screenshots shown above the text, as resource paths and a null terminator. A page
/// with none is the common case and the reason this is a list rather than one field: the
/// overview carries two, the window and a held capture.
const char* const* images = nullptr;
}; };
/// Adding a page is one .txt file, one <file> line in the manifest and one row here. /// Adding a page is one .txt file, one <file> line in the manifest and one row here.
/// The order here is the order the sidebar shows; `section` groups consecutive rows. /// The order here is the order the sidebar shows; `section` groups consecutive rows.
/// How tall a page's screenshots are drawn. They are scaled to fit this *height* and centre
/// themselves in it, so a 1500x950 capture is readable without a horizontal scrollbar.
constexpr int kHelpShotHeight = 260;
/// The screenshots each page carries, in the order it shows them.
constexpr const char* kOverviewShots[] = {"/org/udpscope/help/img/main-window.png",
"/org/udpscope/help/img/trigger-capture.png",
nullptr};
constexpr const char* kTriggerShots[] = {"/org/udpscope/help/img/trigger-capture.png",
nullptr};
constexpr const char* kMathShots[] = {"/org/udpscope/help/img/math-editor.png", nullptr};
constexpr HelpPage kPages[] = { constexpr HelpPage kPages[] = {
{"Getting started", "Overview", "/org/udpscope/help/01-overview.txt"}, {"Getting started", "Overview", "/org/udpscope/help/01-overview.txt", kOverviewShots},
{"Getting started", "Choosing a source", "/org/udpscope/help/02-source.txt"}, {"Getting started", "Choosing a source", "/org/udpscope/help/02-source.txt"},
{"Getting started", "Signals and calibration", "/org/udpscope/help/03-signals.txt"}, {"Getting started", "Signals and calibration", "/org/udpscope/help/03-signals.txt"},
{"Getting started", "Rows, layout and drag & drop", "/org/udpscope/help/04-layout.txt"}, {"Getting started", "Rows, layout and drag & drop", "/org/udpscope/help/04-layout.txt"},
{"Viewing", "Time window", "/org/udpscope/help/05-window.txt"}, {"Viewing", "Time window", "/org/udpscope/help/05-window.txt"},
{"Viewing", "Navigating the plot", "/org/udpscope/help/06-navigate.txt"}, {"Viewing", "Navigating the plot", "/org/udpscope/help/06-navigate.txt"},
{"Viewing", "Pause and play", "/org/udpscope/help/07-pause.txt"}, {"Viewing", "Pause and play", "/org/udpscope/help/07-pause.txt"},
{"Viewing", "Trigger", "/org/udpscope/help/08-trigger.txt"}, {"Viewing", "Trigger", "/org/udpscope/help/08-trigger.txt", kTriggerShots},
{"Analysis", "Cursors", "/org/udpscope/help/09-cursors.txt"}, {"Analysis", "Cursors", "/org/udpscope/help/09-cursors.txt"},
{"Analysis", "Measurements", "/org/udpscope/help/10-measurements.txt"}, {"Analysis", "Measurements", "/org/udpscope/help/10-measurements.txt"},
{"Analysis", "Math channels", "/org/udpscope/help/11-math.txt"}, {"Analysis", "Math channels", "/org/udpscope/help/11-math.txt", kMathShots},
{"Output and state", "Saving and export", "/org/udpscope/help/12-export.txt"}, {"Output and state", "Saving and export", "/org/udpscope/help/12-export.txt"},
{"Output and state", "Sessions and settings", "/org/udpscope/help/13-sessions.txt"}, {"Output and state", "Sessions and settings", "/org/udpscope/help/13-sessions.txt"},
{"Output and state", "Status bar and diagnostics", "/org/udpscope/help/14-status.txt"}, {"Output and state", "Status bar and diagnostics", "/org/udpscope/help/14-status.txt"},
@@ -92,37 +110,69 @@ void on_help_row_selected(GtkListBox*, GtkListBoxRow* row, gpointer body_label)
bool ok = false; bool ok = false;
const std::string body = load_page(kPages[idx], &ok); const std::string body = load_page(kPages[idx], &ok);
gtk_label_set_markup(GTK_LABEL(body_label), body.c_str()); gtk_label_set_markup(GTK_LABEL(body_label), body.c_str());
// The screenshots above it, if this page has any. The column is the label's previous sibling
// inside the page's box, which is what lets this handler stay a matter of the label alone --
// and refilling it is a page *switch*, not a frame, so rebuilding it here is cheap and is
// also the only way to be sure the pictures are this page's and not the last one's.
GtkWidget* label = GTK_WIDGET(body_label);
GtkWidget* parent = gtk_widget_get_parent(label);
GtkWidget* shots = parent != nullptr ? gtk_widget_get_first_child(parent) : nullptr;
if (shots != nullptr && GTK_IS_BOX(shots)) {
while (GtkWidget* old = gtk_widget_get_first_child(shots)) {
gtk_box_remove(GTK_BOX(shots), old);
}
for (const char* const* img = kPages[idx].images; img != nullptr && *img != nullptr; ++img) {
GtkWidget* shot = gtk_picture_new_for_resource(*img);
gtk_picture_set_can_shrink(GTK_PICTURE(shot), TRUE);
// A height, and `CONTAIN` (the default) to fit the image inside it: a picture that may
// shrink asks for no height at all in a box, so without this it is allocated nothing and
// the page shows its text with a gap where the screenshots should be.
gtk_widget_set_size_request(shot, -1, kHelpShotHeight);
gtk_box_append(GTK_BOX(shots), shot);
}
gtk_widget_set_visible(shots, gtk_widget_get_first_child(shots) != nullptr);
}
} }
} // namespace } // namespace
void show_about(GtkWindow* parent, const std::string& session_path) { void show_about(GtkWindow* parent, const std::string& session_path) {
if (g_about != nullptr) { if (g_about != nullptr) {
gtk_window_present(GTK_WINDOW(g_about)); adw_dialog_present(g_about, GTK_WIDGET(parent));
return; return;
} }
// GtkAboutDialog is not deprecated in GTK 4 (unlike GtkShortcutsWindow), so the // `AdwAboutDialog` rather than `GtkAboutDialog`: it shows the application icon as the page's
// stock dialog is used here and the overview is hand-rolled. // own header, which is the whole reason the app has one, and `adw_about_window_new()` is
GtkWidget* dialog = gtk_about_dialog_new(); // deprecated in favour of it. It is an `AdwDialog`, so it is modal on what it is presented
// on and needs no transient-for or modal call of its own.
AdwDialog* dialog = adw_about_dialog_new();
g_about = dialog; g_about = dialog;
GtkAboutDialog* about = GTK_ABOUT_DIALOG(dialog); AdwAboutDialog* about = ADW_ABOUT_DIALOG(dialog);
gtk_about_dialog_set_program_name(about, "udpscope"); adw_about_dialog_set_application_name(about, "udpscope");
gtk_about_dialog_set_version(about, build_info::kVersion); adw_about_dialog_set_application_icon(about, "org.udpscope.app");
gtk_about_dialog_set_comments(about, "An oscilloscope for UDPStreamer sources."); adw_about_dialog_set_developer_name(about, "Martino Ferrari");
gtk_about_dialog_set_license_type(about, GTK_LICENSE_MIT_X11); adw_about_dialog_set_version(about, build_info::kVersion);
adw_about_dialog_set_comments(about, "An oscilloscope for UDPStreamer sources.");
adw_about_dialog_set_license_type(about, GTK_LICENSE_MIT_X11);
adw_about_dialog_set_website(about, "https://git.ferrarinas.it/martino/udpscope");
char details[1024]; // `debug_info` rather than `system_information`: it renders as a Troubleshooting page with a
std::snprintf(details, sizeof(details), // copy button, where the old field was inert text a user had to retype. A `std::string`
"Commit: %s\nBuilt: %s\nSettings: %s\nSession file: %s", // rather than a fixed buffer, because a long settings path used to truncate it silently.
build_info::kCommit, build_info::kTimestamp, config_dir().c_str(), const std::string debug = "Commit: " + std::string(build_info::kCommit) +
session_path.c_str()); "\nBuilt: " + build_info::kTimestamp +
gtk_about_dialog_set_system_information(about, details); "\nSettings: " + config_dir() +
"\nSession file: " + session_path;
adw_about_dialog_set_debug_info(about, debug.c_str());
gtk_window_set_transient_for(GTK_WINDOW(dialog), parent); g_signal_connect(dialog, "closed", G_CALLBACK(on_dialog_closed), &g_about);
gtk_window_set_modal(GTK_WINDOW(dialog), TRUE); adw_dialog_present(dialog, GTK_WIDGET(parent));
g_signal_connect(dialog, "close-request", G_CALLBACK(on_window_close), &g_about); }
gtk_window_present(GTK_WINDOW(dialog));
const char* about_icon_name() {
if (g_about == nullptr) return "";
return adw_about_dialog_get_application_icon(ADW_ABOUT_DIALOG(g_about));
} }
HelpReport help_self_check() { HelpReport help_self_check() {
@@ -168,7 +218,17 @@ void show_help(GtkWindow* parent) {
GtkWidget* body_scroll = gtk_scrolled_window_new(); GtkWidget* body_scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(body_scroll), GTK_POLICY_NEVER, gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(body_scroll), GTK_POLICY_NEVER,
GTK_POLICY_AUTOMATIC); GTK_POLICY_AUTOMATIC);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(body_scroll), body); // The page's own column: its screenshots above its text. Built once and *refilled* by the page
// switch, which is why no picture is made here -- one `GtkPicture` per image of the page.
GtkWidget* shot_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
gtk_widget_set_margin_start(shot_box, 18);
gtk_widget_set_margin_end(shot_box, 18);
gtk_widget_set_margin_top(shot_box, 14);
gtk_widget_set_visible(shot_box, false);
GtkWidget* page_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_box_append(GTK_BOX(page_box), shot_box);
gtk_box_append(GTK_BOX(page_box), body);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(body_scroll), page_box);
gtk_widget_set_hexpand(body_scroll, TRUE); gtk_widget_set_hexpand(body_scroll, TRUE);
GtkWidget* list = gtk_list_box_new(); GtkWidget* list = gtk_list_box_new();
@@ -278,13 +338,13 @@ void show_shortcuts(GtkWindow* parent) {
gestures[i].description); gestures[i].description);
} }
g_signal_connect(dialog, "closed", G_CALLBACK(on_shortcuts_closed), nullptr); g_signal_connect(dialog, "closed", G_CALLBACK(on_dialog_closed), &g_shortcuts);
adw_dialog_present(dialog, GTK_WIDGET(parent)); adw_dialog_present(dialog, GTK_WIDGET(parent));
} }
void close_help_windows() { void close_help_windows() {
if (g_help != nullptr) gtk_window_close(GTK_WINDOW(g_help)); if (g_help != nullptr) gtk_window_close(GTK_WINDOW(g_help));
if (g_about != nullptr) gtk_window_close(GTK_WINDOW(g_about)); if (g_about != nullptr) adw_dialog_close(g_about);
if (g_shortcuts != nullptr) adw_dialog_close(g_shortcuts); if (g_shortcuts != nullptr) adw_dialog_close(g_shortcuts);
} }
+13 -6
View File
@@ -8,10 +8,16 @@
namespace udpscope { namespace udpscope {
/// The About dialog: version, commit, build timestamp and where the session files live. /// The About dialog: the app icon, version, commit, build timestamp and where the session
/// Modal on `parent`. `session_path` is this run's own session file. /// files live. An `AdwAboutDialog`, so it is modal on `parent` by being presented on it, and
/// it is an `AdwDialog` rather than a `GtkWindow`. `session_path` is this run's own session
/// file; it goes on the Troubleshooting page, which has a copy button.
void show_about(GtkWindow* parent, const std::string& session_path); void show_about(GtkWindow* parent, const std::string& session_path);
/// The icon name the open About dialog carries, or "" when it is not open. The probe reads it
/// so the dialog is asserted to *show* the app icon rather than merely to open.
const char* about_icon_name();
/// The paged help window. Modal on nothing — it is a reference, so it stays usable while /// The paged help window. Modal on nothing — it is a reference, so it stays usable while
/// the scope runs. Already open: presented. /// the scope runs. Already open: presented.
void show_help(GtkWindow* parent); void show_help(GtkWindow* parent);
@@ -33,10 +39,11 @@ HelpReport help_self_check();
/// deprecated as of GTK 4.18 and this project builds warning-free. /// deprecated as of GTK 4.18 and this project builds warning-free.
void show_shortcuts(GtkWindow* parent); void show_shortcuts(GtkWindow* parent);
/// Closes whichever of the three windows are open. The About dialog and the help window are /// Closes whichever of the three windows are open. One of them is a GtkWindow -- the help
/// GtkWindows and close with gtk_window_close(), so their close-request handlers run; the /// window, closed with gtk_window_close(), which emits close-request so its handler runs. The
/// shortcut overview is an AdwDialog and closes with adw_dialog_close(), which emits ::closed. /// other two are AdwDialogs, which are not GtkWindows: they close with adw_dialog_close(),
/// Either way the file-scope pointer is nulled by the handler. Used by the probe. /// which emits ::closed. Either way the file-scope pointer is nulled by the handler, which is
/// what prevents a second open presenting a dead widget. Used by the probe.
void close_help_windows(); void close_help_windows();
} // namespace udpscope } // namespace udpscope
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!--
The symbolic form of the app icon: the same metaphor reduced to a screen frame with one
wave inside it, following the UI-icon rules rather than the app-icon ones.
Filled paths only, with no stroke anywhere. GTK's symbolic recolour wraps the file in a
stylesheet that reaches `fill` on `path`, `rect` and `circle` only, so a stroked line keeps
the colour written here and stays dark on a dark theme. The wave is therefore a closed
2px band: a sine along its top edge and the same sine 2px lower along its bottom, which is
how a stroke's look is had from a fill.
`fill-rule="evenodd"` over three subpaths. The frame is the outer rect minus the inner one,
so the screen's interior is two crossings deep and empty; the wave inside it is three, which
is odd, so it fills. Whole-pixel coordinates throughout, because this is drawn at 16px far
more often than not and a half-pixel feature is a grey smear at 1x.
-->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill="#2e3436" fill-rule="evenodd"
d="M 1 2 H 15 V 14 H 1 Z
M 2 3 H 14 V 13 H 2 Z
M 3 7 C 5 3 6 3 8 7 C 10 11 11 11 13 7 L 13 9 C 11 13 10 13 8 9 C 6 5 5 5 3 9 Z"/>
</svg>
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!--
The application icon: an oscilloscope's face, which is the clearest metaphor available and
the kind of physical object the HIG's top-and-front depth language was written for.
Drawn to <https://developer.gnome.org/hig/guidelines/app-icons.html>. The artwork spans
x 12..116 and y 22..106, so it does not fill the 128px canvas, and every feature sits on
the 2px grid. Depth is the 4px front profile along the bottom and nothing else: that is
the HIG's maximum of 2 detail units, and since every surface here is straight, the rest
is flat colour with no gradients and no shadows.
The profile is the bottom 4px of the chassis silhouette, clipped to it. A plain rect from
(12, 102) to (116, 106) would protrude past the radius-8 corners, which reads as a
misdrawn bump at 128px and as a dirty edge at 32px; the clip follows the corners exactly
and keeps every coordinate even, which hand-computing the arc would not.
Two deliberate omissions. There is no graticule: the grid lines a real scope face carries
cannot survive 32px, and the trace carries the metaphor without them. And the trace is
one cycle, a single crest and a single trough, because at 32px the screen is about
22x16 px and anything busier reads as a smudge.
Colours are the GNOME palette: Dark 3 chassis, Dark 2 profile, Dark 4 screen, Green 1 trace.
-->
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<defs>
<clipPath id="chassis-silhouette">
<rect x="12" y="22" width="104" height="84" rx="8"/>
</clipPath>
</defs>
<rect x="12" y="22" width="104" height="84" rx="8" fill="#3d3846"/>
<g clip-path="url(#chassis-silhouette)">
<rect x="12" y="102" width="104" height="4" fill="#5e5c64"/>
</g>
<rect x="20" y="30" width="88" height="64" rx="4" fill="#241f31"/>
<path d="M 24 62 C 37 38 51 38 64 62 C 77 86 91 86 104 62"
fill="none" stroke="#8ff0a4" stroke-width="6" stroke-linecap="round"/>
</svg>
+18 -8
View File
@@ -2,16 +2,26 @@
<!-- SPDX-License-Identifier: MIT --> <!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari --> <!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- <!--
The four icons this app had to draw for itself, compiled into the binary so there is The icons this app had to draw for itself, compiled into the binary so there is still
still exactly one file to run. `gtk_icon_theme_add_resource_path()` wants the tree an exactly one file to run. `gtk_icon_theme_add_resource_path()` wants the tree an icon theme
icon theme has, so the prefix is followed by `<size>/<context>`; `scalable` is the one has, so the prefix is followed by `<size>/<context>`; `scalable` is the one that means
that means "an SVG, use it at any size". "an SVG, use it at any size", and `apps` is where an application icon belongs.
Every file carries `preprocess="xml-stripblanks"`, which makes glib-compile-resources pipe
it through xmllint. That is the only thing that catches a malformed SVG: librsvg refuses
such a file outright, but the icon *name* still resolves, so `gtk_icon_theme_has_icon()`
returns true and the icon simply draws nothing. No runtime GTK call can tell the two apart
(`lookup_icon()` never returns NULL and always reports the size it was asked for), so the
check belongs here, where a malformed SVG fails the build with a file and line number
(plus a caret under the offending character) instead.
--> -->
<gresources> <gresources>
<gresource prefix="/org/udpscope/icons"> <gresource prefix="/org/udpscope/icons">
<file>scalable/actions/udpscope-trigger-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/udpscope-trigger-symbolic.svg</file>
<file>scalable/actions/udpscope-cursors-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/udpscope-cursors-symbolic.svg</file>
<file>scalable/actions/udpscope-measure-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/udpscope-measure-symbolic.svg</file>
<file>scalable/actions/udpscope-math-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/udpscope-math-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app.svg</file>
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app-symbolic.svg</file>
</gresource> </gresource>
</gresources> </gresources>
+63 -15
View File
@@ -28,8 +28,27 @@ void enter_side(MeasureLevel& acc, std::size_t e, double t) {
acc.open[e] = true; 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 } // 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) { 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 // 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 // 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) { for (std::size_t i = 0; i < chunk.count; ++i) {
const double v = static_cast<double>(chunk.value[i]); const double v = static_cast<double>(chunk.value[i]);
if (!std::isfinite(v)) continue; 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; const int s = v > acc.level ? 1 : 0;
acc.sum[static_cast<std::size_t>(s)] += v; acc.sum[static_cast<std::size_t>(s)] += v;
++acc.count[static_cast<std::size_t>(s)]; ++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 *edge* decision reads the band instead. A sample inside it is on neither side, so it
// the side it last decided. That is what stops a signal which merely touches the level from // decides no crossing and the walk keeps the side it last decided -- which is what stops a
// counting every sample as an edge. // signal that merely wobbles about the level from counting every wobble as an edge. With
if (v == acc.level) continue; // `hysteresis == 0` this is the single-level test, `v == level` skip included.
if (acc.have_side && s != (acc.side ? 1 : 0)) { int d;
enter_side(acc, static_cast<std::size_t>(s), time_of(chunk.offset + i)); 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; 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 // 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. // the first *finite* sample of the new side, and an aggregate cannot say which that is.
if (k.count != k.span) return false; if (k.count != k.span) return false;
// Strictly on one side, so every sample decides the same side and none is *at* the level -- // The *sums* need only the bare level: strictly on one side of it, so every sample decides the
// which is what makes this exact rather than an approximation of the walk. // 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 above = k.lo > acc.level;
const bool below = k.hi < acc.level; const bool below = k.hi < acc.level;
if (!above && !below) return false; if (!above && !below) return false;
const std::size_t e = above ? 1u : 0u; 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.sum[e] += k.sum;
acc.count[e] += k.count; acc.count[e] += k.count;
if (acc.have_side && e != (acc.side ? 1u : 0u)) { if (change) enter_side(acc, e, time_of(k.offset));
// The edge is the bucket's *first* sample: everything before it was on the other side and if (reaches) {
// everything in the bucket is on this one, so that is where the raw walk would have found it. acc.side = e != 0;
enter_side(acc, e, time_of(k.offset)); acc.have_side = true;
} }
acc.side = e != 0;
acc.have_side = true;
return true; return true;
} }
+30 -4
View File
@@ -152,6 +152,20 @@ void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, Measu
/// must adding a second measurement of the same type at the same level. /// must adding a second measurement of the same type at the same level.
struct MeasureLevel { struct MeasureLevel {
double level = 0.0; 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 /// 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. /// `bottom` has always reported), index 1 the side above it.
double sum[2] = {0.0, 0.0}; double sum[2] = {0.0, 0.0};
@@ -170,6 +184,14 @@ struct MeasureLevel {
bool open[2] = {false, false}; 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 /// 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 /// 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. /// 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 /// 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. /// *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 /// False means the caller must feed the bucket's raw samples to `measure_accumulate()` for this
/// the edge's position a guess -- and the caller must feed its raw samples to /// level instead. That is the only place a measurement still reads raw samples in bulk, and on a
/// `measure_accumulate()` for this level instead. That is the only place a measurement still /// clean waveform it is a bucket or two per crossing. Three things make it happen: the bucket
/// reads raw samples in bulk, and on a clean waveform it is a bucket per crossing. /// 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, bool measure_accumulate_bucket(const MeasureBucket& bucket,
const std::function<double(std::size_t)>& time_of, const std::function<double(std::size_t)>& time_of,
MeasureLevel& level); MeasureLevel& level);
+69 -33
View File
@@ -42,12 +42,6 @@ constexpr std::uint64_t kMathCostSample = 1024;
} // namespace } // namespace
u64 Partition::total_samples() const noexcept {
u64 total = time_capacity;
for (u64 c : value_capacity) total += c;
return total;
}
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes, Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
double target_span_s) { double target_span_s) {
Partition p; Partition p;
@@ -76,11 +70,14 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
for (std::size_t i = 0; i < n; ++i) p.stride[i] = 1; for (std::size_t i = 0; i < n; ++i) p.stride[i] = 1;
const u64 budget_bytes = static_cast<u64>(std::max(1.0, buffer_bytes)); const u64 budget_bytes = static_cast<u64>(std::max(1.0, buffer_bytes));
const u64 budget = budget_bytes / sizeof(f32); // Weighted in *bytes*, not in slots: the shared clock is f64 and the values are f32
double weight = 1.0; // the time slice itself // (see `BufferStore::time()`), so a time slot costs twice a value sample and the two
for (u64 s : p.stride) weight += 1.0 / static_cast<double>(s); // slices are not interchangeable. Counting both as one f32 is what would make
// `--buffer-mb` quietly understate the time ring by its own size.
double weight = static_cast<double>(sizeof(f64)); // the time slice itself
for (u64 s : p.stride) weight += static_cast<double>(sizeof(f32)) / static_cast<double>(s);
const u64 slots = budget / static_cast<u64>(std::llround(weight)); const u64 slots = static_cast<u64>(static_cast<double>(budget_bytes) / weight);
u64 time_capacity = pow2_floor(std::max<u64>(slots, 128)); u64 time_capacity = pow2_floor(std::max<u64>(slots, 128));
if (time_capacity == 0) { if (time_capacity == 0) {
throw std::runtime_error("buffer too small for one time-base sample per channel"); throw std::runtime_error("buffer too small for one time-base sample per channel");
@@ -102,11 +99,13 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
return std::max<u64>(pow2_floor(ticks / stride), 1); return std::max<u64>(pow2_floor(ticks / stride), 1);
}; };
const auto bytes_for = [&](u64 ticks) { const auto bytes_for = [&](u64 ticks) {
u64 total = ticks; u64 total = ticks * sizeof(f64);
for (u64 s : p.stride) total += capacity_for(ticks, s); for (u64 s : p.stride) total += capacity_for(ticks, s) * sizeof(f32);
return total * sizeof(f32); return total;
}; };
while (time_capacity * 2 <= budget && bytes_for(time_capacity * 2) <= budget_bytes) { // `bytes_for` bounds the doubling on its own: it is at least `2 * ticks * sizeof(f64)`,
// so a pass means `2 * ticks` is well inside the budget and cannot overflow.
while (bytes_for(time_capacity * 2) <= budget_bytes) {
time_capacity *= 2; time_capacity *= 2;
} }
p.time_capacity = time_capacity; p.time_capacity = time_capacity;
@@ -138,7 +137,7 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
BufferStore::BufferStore(const Partition& part, f64 span_s) BufferStore::BufferStore(const Partition& part, f64 span_s)
: part_(part), : part_(part),
time_(std::make_shared<plot::data::RingBuffer<f32>>(part.time_capacity)) { time_(std::make_shared<plot::data::RingBuffer<f64>>(part.time_capacity)) {
(void)span_s; (void)span_s;
const std::size_t n = part.value_capacity.size(); const std::size_t n = part.value_capacity.size();
values_.reserve(n); values_.reserve(n);
@@ -452,7 +451,7 @@ BufferStore& ScopeModel::live() { return *store_; }
void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& last) { void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& last) {
BufferStore& src = live(); BufferStore& src = live();
plot::data::RingBuffer<f32>& tsrc = src.time(); plot::data::RingBuffer<f64>& tsrc = src.time();
// The slice is one absolute index range, found in the time ring and then shared by // The slice is one absolute index range, found in the time ring and then shared by
// every value ring. Sharing it is what makes a cut's stamps and each of its channels // every value ring. Sharing it is what makes a cut's stamps and each of its channels
@@ -460,16 +459,15 @@ void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& l
// truncations that could disagree. // truncations that could disagree.
const u64 tbase = tsrc.produced() - tsrc.size(); const u64 tbase = tsrc.produced() - tsrc.size();
const u64 tend = tsrc.produced(); const u64 tend = tsrc.produced();
// The bounds are rounded to the ring's own `f32` before they are compared against // The bounds are compared in the ring's own type, which is the `f64` they arrive in,
// it. The window arrives in f64 and the stamps are stored as f32, so an f64 // so a bound that lands exactly on a sample includes it. This used to need rounding
// comparison decides a sample that sits exactly on a bound by which way its stamp // *down* to the stored f32 first: an f64 comparison against an f32 stamp decided such
// happened to round -- the same nominal window then keeps or drops its first sample // a sample by which way its stamp happened to round, and the same nominal window kept
// depending on how large the stream time is (`f32(38.001) < 38.001` but // or dropped its first sample depending on the magnitude of the stream time
// `f32(18.001) > 18.001`). One sample is invisible at 10 kS/s and a third of the // (`f32(38.001) < 38.001` but `f32(18.001) > 18.001`). The ring is f64 now and the
// capture at 1 S/s. Comparing in the stored type makes a bound that lands on a // question does not arise.
// sample include it, always. const f64 t_lo = lo_s;
const f32 t_lo = static_cast<f32>(lo_s); const f64 t_hi = hi_s;
const f32 t_hi = static_cast<f32>(hi_s);
// Stream time is non-decreasing, so the bounds are a binary search. Searching the // Stream time is non-decreasing, so the bounds are a binary search. Searching the
// stamps is the whole point: the window is in seconds and this ring is the only // stamps is the whole point: the window is in seconds and this ring is the only
@@ -496,7 +494,7 @@ void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& l
last = a; last = a;
for (std::size_t c = 0; c < channels_.size(); ++c) { for (std::size_t c = 0; c < channels_.size(); ++c) {
plot::data::RingBuffer<f32>& from = src.values(c); plot::data::RingBuffer<f32>& from = src.values(c); // values stay f32
first = std::max(first, from.produced() - from.size()); first = std::max(first, from.produced() - from.size());
last = std::min(last, from.produced()); last = std::min(last, from.produced());
} }
@@ -517,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>(); auto snap = std::make_shared<EventSnapshot>();
snap->x_offset = x_offset;
std::lock_guard<std::mutex> lock(mutex_); std::lock_guard<std::mutex> lock(mutex_);
BufferStore& src = live(); BufferStore& src = live();
u64 first = 0; u64 first = 0;
@@ -527,13 +529,18 @@ void ScopeModel::publish_pause_view(double lo_s, double hi_s) {
const u64 n = last - first; const u64 n = last - first;
snap->stamps.resize(static_cast<std::size_t>(n)); snap->stamps.resize(static_cast<std::size_t>(n));
if (n > 0) { if (n > 0) {
// The stamps go in *as the ring holds them*: the stream's own seconds, which is the // The stamps come out of the ring on the stream's own clock and are rebased onto
// coordinate the axis was already reading. Nothing is rebased, so a pause does not move the // `x_offset`, which is the coordinate the axis was *already* reading: a live view is
// dates under the reader the way a trigger event's edge-relative axis deliberately does. // 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()); src.time().copy_from(first, n, snap->stamps.data());
for (f64& t : snap->stamps) t -= x_offset;
} }
snap->values.resize(channels_.size()); snap->values.resize(channels_.size());
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0}); 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) { for (std::size_t c = 0; c < channels_.size(); ++c) {
const u64 stride = std::max<u64>(1, part_.stride[c]); const u64 stride = std::max<u64>(1, part_.stride[c]);
auto& vals = snap->values[c]; auto& vals = snap->values[c];
@@ -542,19 +549,46 @@ 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()); if (take > 0) src.values(c).copy_from(first + n - take, take, vals.data());
f32 lo = vals.empty() ? 0.0f : vals.front(); f32 lo = vals.empty() ? 0.0f : vals.front();
f32 hi = lo; 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) { for (f32 v : vals) {
lo = std::min(lo, v); lo = std::min(lo, v);
hi = std::max(hi, v); hi = std::max(hi, v);
pyr.append(v);
} }
snap->y[c] = plot::Range{lo, hi}; snap->y[c] = plot::Range{lo, hi};
} }
pause_view_.store(std::move(snap), std::memory_order_release); 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) { 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>(); 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(); BufferStore& src = live();
plot::data::RingBuffer<f32>& tsrc = src.time(); plot::data::RingBuffer<f64>& tsrc = src.time();
u64 first = 0, last = 0; u64 first = 0, last = 0;
live_window_locked(edge - std::max(0.0, pre_s), edge + std::max(0.0, post_s), first, last); live_window_locked(edge - std::max(0.0, pre_s), edge + std::max(0.0, post_s), first, last);
@@ -566,7 +600,7 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
// Edge-relative from here on: the frozen event reads [-pre, +post] with the // Edge-relative from here on: the frozen event reads [-pre, +post] with the
// edge at exactly 0, with no axis-level shift any other code path could // edge at exactly 0, with no axis-level shift any other code path could
// disagree with. // disagree with.
for (auto& t : snap->stamps) t -= static_cast<f32>(edge); for (auto& t : snap->stamps) t -= edge;
} }
snap->values.resize(channels_.size()); snap->values.resize(channels_.size());
@@ -601,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 // Bumped after the snapshot is in place, so a consumer that sees the new sequence
// number sees the snapshot it counts. // number sees the snapshot it counts.
event_seq_.fetch_add(1, std::memory_order_release); 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 { void BufferStore::set_epoch(f64 epoch) noexcept {
+88 -17
View File
@@ -93,8 +93,6 @@ struct Partition {
double base_dt = 1.0; ///< seconds per base tick double base_dt = 1.0; ///< seconds per base tick
double span_s = 0.0; ///< stream seconds one buffer covers double span_s = 0.0; ///< stream seconds one buffer covers
u64 total_samples() const noexcept;
u64 total_bytes() const noexcept { return total_samples() * sizeof(f32); }
/// Seconds between two time-base slots (the sample interval of every channel). /// Seconds between two time-base slots (the sample interval of every channel).
double slot_dt() const noexcept { return base_dt * static_cast<double>(time_stride); } double slot_dt() const noexcept { return base_dt * static_cast<double>(time_stride); }
/// The rate samples are actually stored at, in samples per second. /// The rate samples are actually stored at, in samples per second.
@@ -125,8 +123,15 @@ class BufferStore {
/// axis read seconds from the trigger event. /// axis read seconds from the trigger event.
void set_epoch(f64 epoch) noexcept; void set_epoch(f64 epoch) noexcept;
plot::data::RingBuffer<f32>& time() noexcept { return *time_; } /// The shared clock, in **f64**. The values are f32 and the clock is not: a stamp is
const plot::data::RingBuffer<f32>& time() const noexcept { return *time_; } /// `stream_time - epoch` and the epoch is fixed at the first sample ever seen, so the
/// magnitude grows without bound over a run's life while the interval that has to be
/// resolved stays the sample period. f32 has 24 bits of mantissa, so its ULP at 32 s is
/// 3.8 us -- four consecutive samples of a 1 MS/s stream then share one stamp, and at
/// 600 s uptime 61 of them do. A value's precision is a property of the signal and 24
/// bits is plenty; a *timestamp's* is a property of how long the scope has been running.
plot::data::RingBuffer<f64>& time() noexcept { return *time_; }
const plot::data::RingBuffer<f64>& time() const noexcept { return *time_; }
plot::data::RingBuffer<f32>& values(std::size_t channel) { return *values_[channel]; } plot::data::RingBuffer<f32>& values(std::size_t channel) { return *values_[channel]; }
ChannelBuffer& channel(std::size_t channel) { return *channels_[channel]; } ChannelBuffer& channel(std::size_t channel) { return *channels_[channel]; }
/// Shared handle to a channel view: the caller keeps the storage alive even if /// Shared handle to a channel view: the caller keeps the storage alive even if
@@ -141,7 +146,7 @@ class BufferStore {
private: private:
Partition part_; Partition part_;
std::shared_ptr<plot::data::RingBuffer<f32>> time_; std::shared_ptr<plot::data::RingBuffer<f64>> time_;
std::vector<std::shared_ptr<plot::data::RingBuffer<f32>>> values_; std::vector<std::shared_ptr<plot::data::RingBuffer<f32>>> values_;
std::vector<std::shared_ptr<ChannelBuffer>> channels_; std::vector<std::shared_ptr<ChannelBuffer>> channels_;
}; };
@@ -219,6 +224,37 @@ class ScopeModel {
std::uint64_t math_cost_ns() const noexcept { std::uint64_t math_cost_ns() const noexcept {
return math_cost_ns_.load(std::memory_order_relaxed); 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<SignalInfo>& signals() const noexcept { return signals_; }
const std::vector<ChannelInfo>& channels() const noexcept { return channels_; } const std::vector<ChannelInfo>& channels() const noexcept { return channels_; }
@@ -278,23 +314,24 @@ class ScopeModel {
/// The live buffer's absolute index range covering `[lo_s, hi_s]` in stream seconds, /// The live buffer's absolute index range covering `[lo_s, hi_s]` in stream seconds,
/// clamped to what the time ring and *every* value ring still hold. One range for the /// clamped to what the time ring and *every* value ring still hold. One range for the
/// time ring and all the channels, which is what keeps a cut index-aligned by /// time ring and all the channels, which is what keeps a cut index-aligned by
/// construction; the bounds are compared in the ring's own `f32`, so a bound that /// construction; the bounds are compared in the ring's own type, which is now the
/// lands on a sample includes it whatever the magnitude of the stream time. /// `f64` they arrive in, so a bound that lands on a sample includes it exactly.
/// ///
/// Caller holds `mutex()` (the acquisition thread already does when it latches). /// Caller holds `mutex()` (the acquisition thread already does when it latches).
void live_window_locked(double lo_s, double hi_s, u64& first, u64& last); 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 /// 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 /// 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 /// series the plot binds to -- and both are anchored the same way: the stamps are the
/// edge-relative, a pause's are the stream's own, so the axis does not move when the pause /// coordinates the axis was already reading, with `x_offset` recording which instant that
/// begins and the reader keeps the times they were already reading. /// x = 0 was. So the axis does not move when the pause begins.
std::shared_ptr<const EventSnapshot> pause_view() const noexcept { std::shared_ptr<const EventSnapshot> pause_view() const noexcept {
return pause_view_.load(std::memory_order_acquire); return pause_view_.load(std::memory_order_acquire);
} }
/// Snapshots the live ring over `[lo_s, hi_s]` (stream seconds, the display epoch already /// Snapshots the live ring over `[lo_s, hi_s]` (stream seconds, i.e. the display window put
/// removed) as the pause view. The acquisition thread keeps writing, so this is a copy: what /// back on the stream's clock) as the pause view, rebased onto `x_offset` so its stamps are
/// is frozen is the picture, not the data. /// the x the axis is already showing. The acquisition thread keeps writing, so this is a
void publish_pause_view(double lo_s, double hi_s); /// 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); } 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 /// Whether the user paused acquisition. Lives here rather than on `Acquisition` because the
@@ -373,12 +410,30 @@ class ScopeModel {
return event() != nullptr ? DisplayMode::Held : DisplayMode::Waiting; return event() != nullptr ? DisplayMode::Held : DisplayMode::Waiting;
} }
/// The x origin the plot uses: 0 for "seconds since the stream started", or minus /// The offset the **live** channels report their x with: `display_x = stream_t + epoch`.
/// the trigger instant while in trigger mode, so the axis reads seconds from the /// Minus the newest stored sample's stamp while streaming, so the axis reads
/// event. Applied to the live store's channels. /// `[-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); } f64 display_epoch() const noexcept { return display_epoch_.load(std::memory_order_relaxed); }
void set_display_epoch(f64 e) noexcept; 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 --------------------------------------------------------- // --- stream state ---------------------------------------------------------
StreamStats& stats() noexcept { return stats_; } StreamStats& stats() noexcept { return stats_; }
const StreamStats& stats() const noexcept { return stats_; } const StreamStats& stats() const noexcept { return stats_; }
@@ -467,6 +522,22 @@ class ScopeModel {
std::atomic<std::uint64_t> math_slots_{0}; std::atomic<std::uint64_t> math_slots_{0};
std::atomic<std::uint64_t> math_timed_{0}; std::atomic<std::uint64_t> math_timed_{0};
std::atomic<std::uint64_t> math_cost_ns_{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_; std::vector<bool> enabled_;
Partition part_; Partition part_;
+140 -78
View File
@@ -30,10 +30,23 @@ constexpr plot::u32 kRulerBColor = 0x8FA8C8u;
/// The measurements' own colour, distinct from both cursor rulers so a readout mark cannot be /// The measurements' own colour, distinct from both cursor rulers so a readout mark cannot be
/// mistaken for something the user placed. /// mistaken for something the user placed.
constexpr plot::u32 kMeasureColor = 0x9BE8A0u; 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; 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 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 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 /// the axis lines and the trigger marker `Style::line_width_px`), and broken, so a readout over
@@ -112,9 +125,9 @@ bool RowSet::remove_row(int at) {
void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) { void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
r.plot = &p; r.plot = &p;
r.axis_x = r.plot->add_x_axis( 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( 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) { for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t i = static_cast<std::size_t>(s); const std::size_t i = static_cast<std::size_t>(s);
r.plot->add_trace( r.plot->add_trace(
@@ -155,11 +168,6 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
r.plot->set_cursor_stroke(r.measure_v[mi], kMeasureStrokeWidth, r.plot->set_cursor_stroke(r.measure_v[mi], kMeasureStrokeWidth,
plot::Vec2{kMeasureDashOn, kMeasureDashOff}); 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)); 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 // 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. // snap is Free and the controller never rounds it onto a sample.
@@ -191,7 +199,7 @@ void RowSet::rebuild_all() {
} }
figure_->link_x(static_cast<plot::u32>(i), x_group_); 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 // 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 // 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 // changing the row count while a capture is displayed must not throw the view back
@@ -396,18 +404,30 @@ void RowSet::place_crossings() {
if (ida >= r.plot->annotation_count() || idb >= r.plot->annotation_count()) continue; if (ida >= r.plot->annotation_count() || idb >= r.plot->annotation_count()) continue;
plot::Annotation a = r.plot->annotation(ida); plot::Annotation a = r.plot->annotation(ida);
plot::Annotation b = r.plot->annotation(idb); plot::Annotation b = r.plot->annotation(idb);
double xa = 0.0;
double ya = 0.0; double ya = 0.0;
double xb = 0.0;
double yb = 0.0; double yb = 0.0;
const bool has_a = mix && r.plot->crosshair_value(static_cast<plot::u32>(s), cursor_a_x_, ya); // `crosshair_point()`, not `crosshair_value()`: the number beside this dot is a *stored
const bool has_b = mix && r.plot->crosshair_value(static_cast<plot::u32>(s), cursor_b_x_, yb); // 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; a.visible = has_a;
b.visible = has_b; b.visible = has_b;
if (has_a) { if (has_a) {
a.x = cursor_a_x_; a.x = xa;
a.y = ya; a.y = ya;
} }
if (has_b) { if (has_b) {
b.x = cursor_b_x_; b.x = xb;
b.y = yb; b.y = yb;
} }
r.plot->set_annotation(ida, a); r.plot->set_annotation(ida, a);
@@ -497,12 +517,48 @@ bool RowSet::update_time_labels() {
const double span = view.span(); const double span = view.span();
if (!(span > 0.0) || !std::isfinite(span)) return false; if (!(span > 0.0) || !std::isfinite(span)) return false;
// Sub-second windows read relative: `TickFormat::Time` writes three significant digits, so // A relative axis is the answer to one problem only: a tick step the label form cannot write.
// `12.345671s` and `12.345672s` are the same string and a 1 ms step around a multi-second // `TickFormat::Time` carries three significant digits, so a 1 ms step around a 12 s stamp
// time cannot be labelled at all. The band is the switch's hysteresis -- entering below a // writes `12.3s` on every tick -- there the ticks have to be re-anchored and the anchor named.
// second, leaving only above 1.05 -- so a span sitting on the boundary cannot flicker // The test is therefore the *step against the resolution at the axis' own magnitude*, never the
// between two forms while a zoom is nudged. // span: the two part company in both directions, and both cases are real here. A 2 s window at
const bool want_relative = relative_x_ ? span < 1.05 : span < 1.0; // 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_; const bool changed = want_relative != relative_x_;
relative_x_ = want_relative; relative_x_ = want_relative;
@@ -518,27 +574,25 @@ bool RowSet::update_time_labels() {
r.plot->set_x_axis_origin_from_range(r.axis_x, relative_x_); 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); 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 // 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; const bool bottom = i == rows_ - 1;
if (r.time_origin_note >= r.plot->annotation_count()) continue; if (!relative_x_ || !bottom) {
plot::Annotation note = r.plot->annotation(r.time_origin_note); r.plot->set_x_note("");
note.visible = relative_x_ && bottom; continue;
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;
} }
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; return changed;
} }
@@ -646,20 +700,26 @@ void RowSet::unpin_pause_window() {
} }
void RowSet::fit_x() { void RowSet::fit_x() {
Row& first = row(0); if (!figure_) return;
if (!first.plot || !figure_) return; if (!pinned_) {
first.plot->auto_fit_x(); // Live: the same path the window control and a resume take, with the window it already has.
const plot::Range fitted = first.plot->x_range(first.axis_x); // That re-arms the follow and parks the newest `window_s_` at the live edge, which is the
const double span = fitted.span(); // whole of what a live view may show.
if (!std::isfinite(span) || !(span > 0.0)) return; set_time_window(window_s_);
// The same two paths `fit_all()` takes: a held event owns its window and states it return;
// 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);
} }
// 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) { void RowSet::sync_cursors(int drag_row) {
@@ -778,30 +838,22 @@ void RowSet::fit_all() {
Row& r = row(i); Row& r = row(i);
if (r.plot) r.plot->auto_fit_y(r.axis_y); if (r.plot) r.plot->auto_fit_y(r.axis_y);
} }
Row& first = row(0); // The x half is `fit_x()`'s exactly -- the zoom undone, the time window untouched -- and
if (!first.plot || !figure_) return; // sharing it is what keeps the two fits from drifting apart.
// Row 0 fits its own x to the data it holds; that span becomes the shared window, and fit_x();
// 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);
}
} }
void RowSet::note_y_gutter(double need_px) { 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 // 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 // font the labels are actually drawn in.
// character count is an exact width).
if (!y_gutter_reserved_) { if (!y_gutter_reserved_) {
for (int i = 0; i < rows_; ++i) { for (int i = 0; i < rows_; ++i) {
Row& r = row(i); Row& r = row(i);
@@ -809,10 +861,19 @@ void RowSet::note_y_gutter(double need_px) {
const plot::render::TextBackend* text = r.plot->text_backend(); const plot::render::TextBackend* text = r.plot->text_backend();
if (text == nullptr) continue; if (text == nullptr) continue;
const plot::render::Style& style = r.plot->style(); 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)); const double pad = double(style.device(style.grid_label_pad));
// "-1.23e-45" is the longest label `format_si()`'s fallback can print. // A sign, three significant figures and a one-glyph SI prefix: the widest label
want = std::max(want, 9.0 * advance + 2.0 * pad + 2.0); // `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; y_gutter_reserved_ = true;
break; break;
} }
@@ -820,9 +881,10 @@ void RowSet::note_y_gutter(double need_px) {
if (want > y_gutter_px_) y_gutter_px_ = want; if (want > y_gutter_px_) y_gutter_px_ = want;
} }
void RowSet::apply_y_gutter() { void RowSet::apply_gutters() {
if (!figure_ || !(y_gutter_px_ > 0.0)) return; if (!figure_) return;
figure_->set_min_y_gutter(static_cast<f32>(y_gutter_px_)); 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) { void RowSet::set_trigger_marker(double x, bool visible) {
+51 -24
View File
@@ -106,11 +106,6 @@ class RowSet {
/// it keeps its position whatever the cursor toggle says. /// it keeps its position whatever the cursor toggle says.
plot::u32 trigger_marker = 0; 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 /// 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. /// drop a cursor after the fact) and parked unless a measurement is using the slot.
std::array<plot::u32, kMeasureMarks> measure_h{}; std::array<plot::u32, kMeasureMarks> measure_h{};
@@ -183,6 +178,14 @@ class RowSet {
void set_time_window(double seconds); void set_time_window(double seconds);
double time_window() const noexcept { return window_s_; } 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_auto(int row, bool on);
void set_y_range(int row, double lo, double hi); void set_y_range(int row, double lo, double hi);
@@ -196,7 +199,13 @@ class RowSet {
void place_crossings(); void place_crossings();
CursorMode cursor_mode() const noexcept { return cursor_mode_; } 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_a_x() const noexcept { return cursor_a_x_; }
double cursor_b_x() const noexcept { return cursor_b_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. /// One row's ruler values, or 0 for a row that does not exist.
@@ -298,11 +307,25 @@ class RowSet {
void set_data_pin(double lo, double hi); void set_data_pin(double lo, double hi);
void unpin_pause_window(); void unpin_pause_window();
/// Frames the x window on the data row 0 holds, leaving every y axis alone. The x /// Undoes an x zoom: puts the view back on the whole time window, leaving every y axis alone.
/// counterpart of `fit_all()`, for the one thing the y axes cannot say: how wide the /// The x counterpart of `fit_all()`.
/// window should be. ///
/// **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(); 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 /// Adopts the instruments a gesture is moving, on `drag_row` (the row whose controller holds
/// a cursor), and nothing otherwise. /// a cursor), and nothing otherwise.
/// ///
@@ -336,10 +359,9 @@ class RowSet {
/// 0, so nothing outside the pre+post window is visible. /// 0, so nothing outside the pre+post window is visible.
void pin_event_window(double pre_s, double post_s); 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 /// Fits every row's y axis to the data it holds, and puts x back on the whole time window --
/// on what row 0 retains (the same thing the plot's own `f` key does). The window /// `fit_x()`'s x half exactly, so the two cannot drift apart. Like it, **this does not change
/// the toolbar reports becomes the fitted span, so the dropdown can say what is /// the time window**: the y axes are fitted to their data, x is merely un-zoomed.
/// actually on screen.
void fit_all(); void fit_all();
/// Reports the left gutter the rows currently need, and reserves at least the width /// Reports the left gutter the rows currently need, and reserves at least the width
@@ -351,18 +373,22 @@ class RowSet {
/// left and right by a few pixels every frame. Since `set_min_y_gutter()` is a /// 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 /// 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 /// 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 /// `format_si()` writes for an axis with no unit -- a sign, three significant figures
/// `%.3g` fallback) plus the label padding, measured once with the plot's own font. /// and a one-glyph prefix -- plus the label padding, measured once with the plot's own
/// Both reservations only ever grow, so the gutter is constant in practice. /// 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); void note_y_gutter(double need_px);
/// Applies the stable gutter as the figure's floor. Call once per frame, before the /// Applies the stable left gutter as the figure's floor, and the right gutter beside
/// frame is drawn. /// 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 /// Both have to be the *figure's* and not each row's: `Figure::layout()` clears every
/// every row's `set_min_y_gutter()` to measure what its own labels need, so a floor /// row's `set_min_y_gutter()` to measure what its own labels need, so a floor written
/// written onto the rows survives exactly until the next layout. /// onto the rows survives exactly until the next layout, and the rows have to end on
void apply_y_gutter(); /// 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 /// 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 /// trace is drawn in is the colour the signal list's chip shows, on every row the
@@ -391,8 +417,6 @@ class RowSet {
/// at the end of the gesture (`Controller::end_drag()`), and a value read out of the plot /// 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. /// mid-drag has already been through that act.
void place_cursors(Row& r, plot::CursorPlacement placement = plot::CursorPlacement::Snapped); 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_; std::unique_ptr<plot::Figure> figure_;
/// The one x link group every visible row belongs to. Groups are numbered from 1. /// The one x link group every visible row belongs to. Groups are numbered from 1.
@@ -411,6 +435,9 @@ class RowSet {
/// gesture to be honoured; one that matches it is just the clamp's own answer arriving back. /// 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}; plot::Range pinned_stated_{0.0, 0.0};
double y_gutter_px_ = 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; bool y_gutter_reserved_ = false;
std::array<Row, static_cast<std::size_t>(kMaxRows)> storage_{}; std::array<Row, static_cast<std::size_t>(kMaxRows)> storage_{};
CursorMode cursor_mode_ = CursorMode::Vertical; CursorMode cursor_mode_ = CursorMode::Vertical;
+6
View File
@@ -48,6 +48,11 @@ std::string format_glance(const StatusSnapshot& s) {
out += " · "; out += " · ";
out += s.window_text; out += s.window_text;
out += " window"; 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) { if (s.trigger_on) {
out += " · "; out += " · ";
if (s.have_event) { 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, s.plot1_traces, s.plot1_samples, s.buffer_mb, s.span_s,
static_cast<unsigned long long>(s.time_stride)); static_cast<unsigned long long>(s.time_stride));
std::string out = buf; std::string out = buf;
if (s.paused) out += " | paused for " + format_elapsed(s.paused_for_s);
if (s.trigger_on) { if (s.trigger_on) {
out += s.have_event ? " | last event " + format_elapsed(s.since_event_s) + " ago" out += s.have_event ? " | last event " + format_elapsed(s.since_event_s) + " ago"
: " | no event yet"; : " | no event yet";
+18 -5
View File
@@ -11,9 +11,12 @@ namespace udpscope {
/// two cannot disagree. No GTK here on purpose: this is the one part of the status bar that /// two cannot disagree. No GTK here on purpose: this is the one part of the status bar that
/// can be reasoned about as text. /// can be reasoned about as text.
struct StatusSnapshot { struct StatusSnapshot {
/// One word from the fixed set: "live", "paused", "armed", "capturing", "event held", /// One word from the fixed set: "connecting", "live", "paused", "armed", "capturing",
/// "disarmed". It is a word from a *fixed* set because the bar sits directly above the plot /// "event held", "disarmed". It is a word from a *fixed* set because the bar sits directly
/// and a field whose width tracked the stream would relayout it every frame. /// 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"; const char* state_word = "live";
/// The long prose the old single-line bar carried, kept for the detail string and the dump. /// The long prose the old single-line bar carried, kept for the detail string and the dump.
std::string state_detail; std::string state_detail;
@@ -44,10 +47,20 @@ struct StatusSnapshot {
bool have_event = false; bool have_event = false;
double since_event_s = 0.0; double since_event_s = 0.0;
int reconnects = 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 /// The bar: "live · 10 kS/s · 2 s window", plus " · event 4.2 s ago" in trigger mode, " · for
/// nothing else ever. The badge glyph is a separate widget and is not part of this string. /// 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); 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 /// The popover's prose and the exit dump's line, field for field as the old single-line bar
+877 -253
View File
File diff suppressed because it is too large. Load diff
+115 -31
View File
@@ -25,7 +25,7 @@ MAGIC = 0x53504455
PKT_DATA, PKT_CONFIG, PKT_ACK, PKT_CONNECT, PKT_DISCONNECT = 0, 1, 2, 3, 4 PKT_DATA, PKT_CONFIG, PKT_ACK, PKT_CONNECT, PKT_DISCONNECT = 0, 1, 2, 3, 4
T_FLOAT64 = 9 T_FLOAT64 = 9
QUANT_NONE = 0 QUANT_NONE = 0
TIME_PACKET, TIME_FULL_ARRAY = 0, 1 TIME_PACKET, TIME_FULL_ARRAY, TIME_FIRST_SAMPLE = 0, 1, 2
PUBLISH_ACCUMULATE = 1 PUBLISH_ACCUMULATE = 1
NO_TIME_SIGNAL = 0xFFFFFFFF NO_TIME_SIGNAL = 0xFFFFFFFF
@@ -50,28 +50,51 @@ def signal_desc(name, *, rate, time_mode, time_idx, unit, rows=1, cols=1, dims=0
return bytes(d) return bytes(d)
def build_config(rate): def build_config(rate, time_mode=TIME_FULL_ARRAY, unit="V"):
sigs = [ sigs = [
# The stream's clock: referenced by every data signal, so udpscope treats # The stream's clock: referenced by every data signal, so udpscope treats
# it as the time base rather than a plottable channel. # it as the time base rather than a plottable channel. Its *own* time_mode
signal_desc("time", rate=0.0, time_mode=TIME_FULL_ARRAY, # is what udpscope switches on, so `--time-mode first` has to land here.
time_idx=NO_TIME_SIGNAL, unit="s"), signal_desc(
signal_desc("sine", rate=rate, time_mode=TIME_FULL_ARRAY, time_idx=0, unit="V"), "time", rate=0.0, time_mode=time_mode, time_idx=NO_TIME_SIGNAL, unit="s"
signal_desc("square", rate=rate, time_mode=TIME_FULL_ARRAY, time_idx=0, unit="V"), ),
signal_desc("ramp", rate=rate, time_mode=TIME_FULL_ARRAY, time_idx=0, unit="V"), 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 = struct.pack("<I", len(sigs)) + b"".join(sigs)
payload += bytes([PUBLISH_ACCUMULATE]) payload += bytes([PUBLISH_ACCUMULATE])
return header(PKT_CONFIG, 0, len(payload)) + payload return header(PKT_CONFIG, 0, len(payload)) + payload
def build_data(counter, t0, dt, batch, freq): 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)] stamps = [t0 + i * dt for i in range(batch)]
sine = [math.sin(2.0 * math.pi * freq * t) for t in stamps] # The interferer is a tone at 100x the fundamental with a randomly scaled amplitude, which
square = [1.0 if math.sin(2.0 * math.pi * freq * 0.5 * t) >= 0.0 else -1.0 for t in stamps] # 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] 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
# signal in Accumulate mode still occupies `batch` slots on the wire (the client
# reads `num_samples` values for any 1-element signal), so the anchor is repeated --
# which is exactly the shape that made udpscope derive a step of zero.
wire_time = [t0] * batch if time_mode == TIME_FIRST_SAMPLE else stamps
payload = struct.pack("<QI", int(t0 * 1e9), batch) payload = struct.pack("<QI", int(t0 * 1e9), batch)
for arr in (stamps, sine, square, ramp): for arr in (wire_time, sine, square, ramp):
payload += struct.pack("<%dd" % batch, *arr) payload += struct.pack("<%dd" % batch, *arr)
return header(PKT_DATA, counter, len(payload)) + payload return header(PKT_DATA, counter, len(payload)) + payload
@@ -83,29 +106,78 @@ def main():
ap.add_argument("--batch", type=int, default=100, help="samples per packet") 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("--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("--duration", type=float, default=0.0, help="0 = forever")
ap.add_argument("--stream-scale", type=float, default=1.0, ap.add_argument(
help="stream seconds per wall second: 0.1 mimics a producer whose own " "--stream-scale",
"clock advances ten times slower than the wire delivers samples") type=float,
ap.add_argument("--declared-rate", type=float, default=-1.0, default=1.0,
help="sampling_rate to put in CONFIG; -1 = the real rate, 0 = 'unknown'") help="stream seconds per wall second: 0.1 mimics a producer whose own "
ap.add_argument("--clock-jitter", type=float, default=0.0, "clock advances ten times slower than the wire delivers samples",
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 " ap.add_argument(
"grid, so the stream's timestamps wobble about one. Non-cumulative " "--declared-rate",
"(the mean rate is unchanged); 0 = the ideal grid") 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() args = ap.parse_args()
dt = args.stream_scale / args.rate # stream seconds per sample dt = args.stream_scale / args.rate # stream seconds per sample
period = args.batch / args.rate # wall seconds per packet period = args.batch / args.rate # wall seconds per packet
declared = args.rate if args.declared_rate < 0.0 else args.declared_rate 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
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", args.port)) sock.bind(("0.0.0.0", args.port))
sock.settimeout(0.001) sock.settimeout(0.001)
print("mock udpstreamer: waiting for CONNECT on :%d (%.0f S/s wire, %d per packet, " print(
"stream clock x%.3g, declared %.0f S/s)" "mock udpstreamer: waiting for CONNECT on :%d (%.0f S/s wire, %d per packet, "
% (args.port, args.rate, args.batch, args.stream_scale, declared), flush=True) "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 peer = None
counter = 1 counter = 1
@@ -124,8 +196,11 @@ def main():
t_stream = 0.0 t_stream = 0.0
started = time.monotonic() started = time.monotonic()
next_send = started next_send = started
sock.sendto(build_config(declared), peer) sock.sendto(build_config(declared, time_mode, args.unit), peer)
print("mock udpstreamer: CONNECT from %s:%d, CONFIG sent" % peer, flush=True) print(
"mock udpstreamer: CONNECT from %s:%d, CONFIG sent" % peer,
flush=True,
)
elif magic == MAGIC and ptype == PKT_DISCONNECT: elif magic == MAGIC and ptype == PKT_DISCONNECT:
print("mock udpstreamer: DISCONNECT", flush=True) print("mock udpstreamer: DISCONNECT", flush=True)
peer = None peer = None
@@ -148,11 +223,20 @@ def main():
pdt = dt pdt = dt
if args.clock_jitter > 0.0: if args.clock_jitter > 0.0:
pdt = dt * (1.0 + random.uniform(-args.clock_jitter, args.clock_jitter)) pdt = dt * (1.0 + random.uniform(-args.clock_jitter, args.clock_jitter))
sock.sendto(build_data(counter, t_stream, pdt, args.batch, args.freq), peer) sock.sendto(
build_data(
counter, t_stream, pdt, args.batch, args.freq, time_mode, args.noise
),
peer,
)
counter += 1 counter += 1
t_stream += args.batch * pdt t_stream += args.batch * pdt
next_send += period next_send += period
if args.duration > 0.0 and started is not None and now - started > args.duration: if (
args.duration > 0.0
and started is not None
and now - started > args.duration
):
print("mock udpstreamer: done (%d packets)" % counter, flush=True) print("mock udpstreamer: done (%d packets)" % counter, flush=True)
return return