docs: design for UDPScope, a direct-to-streamer ImGui oscilloscope

A bench scope that attaches straight to one UDPStreamer through the
standalone C client, so it can be dropped on a machine with no StreamHub,
no Go and no browser. Records the decisions that are easy to get wrong:
the time-base rules must follow UDPSourceSession rather than the C
library's arrival-time estimate, decimation must be min/max rather than
LTTB so glitches survive, and the ring must be sized past the trigger
window by an explicit harvest margin.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-08-27 11:28:54 +02:00
co-authored by Claude Opus 4.6
parent 7c5eb31a52
commit 52958cf8bc
@@ -0,0 +1,535 @@
# UDPScope — direct-to-streamer ImGui oscilloscope
Date: 2026-08-27
Status: design approved
## 1. Purpose
A bench oscilloscope that attaches straight to a single `UDPStreamer` and needs
nothing else: no StreamHub, no Go toolchain, no browser, no web server. One
binary plus the fonts it ships with, dropped on a machine that may have none of
the project's other infrastructure installed.
The data path is `UDPStreamer → Common/Client/c → UDPScope`. Everything the Go
hub and the C++ StreamHub do — ring buffering, decimation, the trigger — the
scope does locally, for one source.
Success criterion: deployment simplicity. The feature bar is "what a bench scope
needs", not parity with the StreamHub client.
## 2. Scope
In:
- Single UDPS source, unicast or multicast, with the library's reconnect
behaviour.
- Free-grid multi-plot view built by splitting panes.
- Normal and Single trigger modes, plus an always-on live view.
- Cursors and window measurements.
- Layout and settings persistence.
- CSV export of the displayed capture.
Out:
- Auto and Roll trigger modes.
- X/Y (Lissajous) plotting.
- Multiple simultaneous sources.
- History-on-disk / zooming beyond the buffered window.
## 3. Architecture
Two threads and one shared object.
### 3.1 Receiver thread
Owns the `udps_client_t` and does nothing but
```c
while (!stop) { udps_client_poll(cli, 20); }
```
The library creates no threads and performs all work inside `poll()`, so one
thread owning one client is its intended use. All three callbacks fire on this
thread.
- `on_config` — copies the `udps_signal_t` table into `SignalStore`, resizes
rings, resets the time base.
- `on_data` — reconstructs per-element timestamps (§5), appends samples to the
rings, and runs the trigger detector (§6) over the trigger signal's samples.
- `on_event` — records connection state and the last error for the status bar.
Frame memory belongs to the library and is invalid once the callback returns, so
the receiver copies on the spot. It does that anyway when it appends to a ring.
The alternative — polling inside the render loop, as `udps_dump` does — was
rejected. `SDL_GL_SwapWindow` blocks on vsync, so for up to ~16 ms per frame
nothing drains the socket, and a slow frame (window resize, CSV export) stretches
that to hundreds of milliseconds. At 1 MSps a 200 ms stall is ~3 MB of datagrams
that must survive in `SO_RCVBUF` or be lost, and loss shows up as silent holes in
a scope trace.
A lock-free SPSC staging queue between the two threads was also considered and
rejected as speculative: at 60 Hz the GUI holds the store lock for microseconds
per read, so there is no measured contention to remove. Revisit only if
profiling shows otherwise.
### 3.2 GUI thread
`main.cpp`'s SDL2 + OpenGL3 + ImGui + ImPlot loop, same shape as
`Client/streamhub/main.cpp`: same context setup, same Catppuccin Mocha styling,
same executable-relative font resolution (installed → build tree → source tree).
Each frame it locks the store briefly, reads the windows it needs, unlocks, then
decimates and draws from its own copies. No drawing happens under the lock.
### 3.3 Shared state
`SignalStore` is the entire shared surface: the signal metadata table, one
`SignalBuffer` per signal, the measured per-signal sample rate, the trigger
state, and the connection status. A single `std::mutex` guards all of it.
Keeping the shared surface to one named class is deliberate — there is exactly
one place to look for a race. Note that `SignalBuffer.h`'s doc comment claims
the buffer is thread-safe; it is not, it has no locks. `SignalStore` provides
all the locking.
### 3.4 Module layout
```
Client/udpscope/
CMakeLists.txt SDL2 + OpenGL + FetchContent(ImGui, ImPlot, GoogleTest)
main.cpp SDL/ImGui/ImPlot bootstrap, style, fonts, event loop
App.{h,cpp} owns Receiver/SignalStore/PaneTree/UI state; update()
Receiver.{h,cpp} the thread, the udps_client_t, the three C callbacks
SignalStore.{h,cpp} signal table + rings + trigger state; the one mutex
TimeBase.{h,cpp} producer-clock to wall-clock reconstruction
Trigger.{h,cpp} edge detector and FSM
PaneTree.{h,cpp} BSP split tree: split/close/layout/hit-test
PaneView.cpp ImPlot rendering of one pane
SignalList.cpp side panel, drag source
TriggerBar.cpp trigger controls and status badge
Measure.{h,cpp} cursor deltas and window statistics
Decimate.{h,cpp} min/max envelope decimation
Settings.{h,cpp} layout and config persistence
Export.{h,cpp} CSV writer
tests/ unit tests for the framework-free modules
```
`PaneTree`, `Trigger`, `TimeBase`, `Measure`, `Decimate`, `Settings` and
`Export` are framework-free C++17 with no ImGui and no UDPS dependency. Each is
unit-testable without a window or a socket. That is where the fiddly logic lives
and none of it should need a GUI to exercise.
### 3.5 Reuse
- `../streamhub/SignalBuffer.h` is used verbatim. It is header-only and
framework-free, and the Qt client already reuses it this way.
- `../streamhub/resources/` supplies the fonts and `Icons.h`.
- `../../Common/Client/c/udps_client.c` is compiled directly into a CMake
`udpsclient` static target, so building the scope does not require running the
C library's own Makefile first.
Nothing under `Client/streamhub/` is modified.
### 3.6 Decimation
`SignalBuffer.h` ships `LTTBDecimate` and the scope does not use it. LTTB
selects representative points and will silently drop a one-sample glitch; on a
scope that glitch is usually the thing being looked for. `Decimate.{h,cpp}`
implements a min/max envelope instead — for each screen column, emit the column's
minimum and maximum in time order — which preserves extremes exactly. This is
the same conclusion the Go hub reached when it replaced LTTB with
`minMaxDecimate`.
The emitted pair must stay in time order, not value order, or downstream
binary-searching by time breaks.
## 4. Signal model
### 4.1 Identity
Signals are identified by **name** everywhere in the UI and in persisted
settings, never by index. A reconnect or a streamer reconfiguration may reorder
or renumber signals; pane assignments must survive that.
On a CONFIG update:
- Signals present before and after keep their ring and their pane assignments.
- New signals appear in the signal list, unassigned.
- Signals that vanished keep their pane assignment, drawn greyed and labelled
missing. A transient reconnect must not destroy a layout the user built.
### 4.2 Array handling
A UDPS signal may be a scalar or an `num_rows × num_cols` array. An array is
either a **packed time burst** (N consecutive samples, unrolled onto the time
axis) or a **true vector** (a profile, plotted against element index for the most
recent frame).
`time_mode` decides, and only one case is genuinely ambiguous:
| `time_mode` | elements | treated as |
|---|---|---|
| `FULL_ARRAY`, `FIRST_SAMPLE`, `LAST_SAMPLE` | > 1 | burst, always |
| `PACKET` | > 1 | burst by default, user-switchable to vector |
| any | 1 | scalar |
`PACKET` means the producer stamped the whole datagram with one time, which is
what a genuine vector looks like *and* what a burst carrying no time metadata
looks like. The default is burst, matching `UDPSourceSession`'s handling so the
scope and StreamHub agree on the same stream; the signal list offers a per-signal
"profile" toggle for the case where it really is a vector. `sampling_rate` is not
used as the discriminator — a `PACKET` burst frequently declares no rate, and §5
reconstructs its spacing from inter-packet timing instead.
A vector-mode signal is excluded from trigger capture and from the shared X axis.
A pane holds either time signals or vector signals, not both; dropping across the
two is rejected with a tooltip.
## 5. Time base
The C library's `udps_frame_element_time()` is explicitly an arrival-anchored
*estimate*. It is not sufficient: the kernel frequently delivers several queued
datagrams in one burst, so two packets are processed microseconds apart even
though each represents ~10 ms of signal, and arrival-time interpolation then
crams a packet's samples into that tiny gap. The trace renders as a sawtooth.
`Source/Applications/StreamHub/UDPSourceSession.cpp` documents this failure and
solves it; the scope reproduces the same rules on top of the C API, all of whose
inputs are present in `udps_frame_t`.
Per signal, in priority order:
1. **`FULL_ARRAY` with a declared time signal** — per-element timestamps read
from the referenced time signal's array, offset onto wall clock by the
calibration below.
2. **`FIRST_SAMPLE` / `LAST_SAMPLE` with a declared time signal** — anchor from
element 0 of the time signal, then `± k / sampling_rate` per element,
forwards for `FIRST_SAMPLE` and backwards for `LAST_SAMPLE`.
3. **Accumulated scalar** (`num_samples > 1`, one element per sample) — base
from the packet's embedded producer `hrt`, which is sampled at acquisition
and so immune to the burst-delivery problem, then `base + k × dt`. `dt` is
`1 / sampling_rate` when declared; otherwise the `hrt` gap to the previous
packet divided by *that* packet's sample count, which is exactly one cycle
period because the flushes carry contiguous RT cycles; otherwise 1 ms until
the first gap is known.
4. **`PACKET` burst with no time signal** — elements span
`(lastPacketTime, thisPacketTime]`, i.e. dated backwards from arrival rather
than forwards from it. The samples were acquired *before* the packet arrived,
and spanning backwards keeps ring time strictly monotonic under jitter, where
forward extrapolation would let one packet overlap the next. The first packet
after connect is dropped rather than stored with wrongly spaced timestamps.
5. **Plain scalar, or no usable time reference at all** — packet arrival wall
time (`recv_time`).
Time-signal values convert with the protocol's documented units: `1e-9` s per
count for `UDPS_T_UINT64`, `1e-6` otherwise.
**Calibration.** A time signal or `hrt` gives a producer clock, not wall clock.
`TimeBase` maintains an offset per producer clock, established once from the
first packet (`offset = recv_time producerSeconds`) and thereafter only
corrected if the residual drifts beyond a threshold, so the trace does not jitter
with network delay.
**`hrt` tick rate.** StreamHub converts `hrt` using the local MARTe
`HighResolutionTimer` frequency, which is only valid when the client runs on the
producer's host. The scope cannot assume that, so `TimeBase` estimates
ticks-per-second by least-squares fit of `hrt` against `recv_time` over a rolling
window of packets, yielding both rate and offset. Until the fit has enough
samples (default 32 packets) rule 5 is used.
**Display epoch.** The X axis shows seconds relative to the first sample of the
session, so it reads `0.000 … N s` rather than `1.7e9`. Absolute wall-clock time
is shown in the readout and written to CSV exports.
## 6. Trigger
Runs in the receiver thread, over every sample as it is appended. An edge
detector that only ran at 60 Hz would see the signal through the frame rate,
quantise the trigger time to the frame, and miss edges whenever the GUI hitched.
### 6.1 Configuration
| Field | Meaning |
|---|---|
| `signalName` | which signal is watched |
| `edge` | rising / falling / both |
| `threshold` | crossing level, in signal units |
| `hysteresis` | signal must first leave the band `threshold ± hysteresis`; 0 disables |
| `windowSec` | total capture span |
| `prePercent` | share of the window before the trigger instant |
| `mode` | normal / single |
There is no holdoff setting. The capture window is its own holdoff: the FSM
cannot re-arm until the current capture has been harvested.
### 6.2 State machine
```
Idle ──arm──> Armed ──edge──> Collecting ──post filled──> Held
^ ^ │
│ └────────────── mode == normal ────────────┤
└──────────────────── disarm ─────────────────────────────┘
```
- **Armed** additionally requires the ring to already hold `preSec` of samples —
otherwise the capture would come back short. Until it does, the status badge
shows a fill percentage. This is a real state users see, not an internal
detail.
- **Collecting** ends when the trigger signal's newest sample time reaches
`tTrig + postSec + kHarvestMarginSec`.
- **Held** freezes the display on the capture. `normal` re-arms immediately;
`single` waits for a manual re-arm.
### 6.3 Edge detection
Consecutive samples `(t0,v0) → (t1,v1)` cross rising when `v0 < threshold` and
`v1 >= threshold`. The trigger instant is linearly interpolated:
`tTrig = t0 + (threshold v0) / (v1 v0) × (t1 t0)`, giving sub-sample
resolution so the capture is stable on screen rather than jittering by one
sample period.
With `hysteresis > 0`, a rising cross is only accepted if the signal has been
below `threshold hysteresis` since the previous trigger; symmetrically for
falling. `both` alternates direction.
### 6.4 Harvest
Once `Collecting` completes, the GUI thread extracts
`[tTrig preSec, tTrig + postSec]` from every displayed burst signal's ring into
a **capture snapshot** — a plain, non-circular copy owned by the GUI thread.
Panes render the snapshot, frozen, until the next capture replaces it. The
snapshot is what CSV export writes and what measurements are computed over.
`kHarvestMarginSec` covers late or out-of-order packets only; unlike the Go hub
there is no push-tick latency, because the receiver knows precisely when samples
land. Default `max(0.05 s, 2 × observed frame span)`.
## 7. Buffering
One `SignalBuffer` per signal, sized by
```
capacityPoints = ceil(measuredRate × windowSec × kRingMargin) // kRingMargin = 4
```
clamped to `[4096, maxPointsPerSignal]` (`--max-mpts`, default 8 Mpts ≈ 128 MB).
`measuredRate` is observed (samples appended ÷ elapsed), not the declared
`sampling_rate`, which may be 0.
**Why the margin is explicit.** A capture is not read out when its last sample
arrives but `kHarvestMarginSec` later. A ring holding exactly the window has
already overwritten the front of its own capture by then, and every shot comes
back missing its head. That is precisely the defect just fixed in
`Common/Client/go/wshub` (`captureLagSec`), and the constant carries this comment
so it cannot recur silently. A factor of 4 covers the window itself, the harvest
margin, and rate-estimation error, with headroom for panning the live view.
**Resizing** is triggered by a CONFIG change, a trigger-window change, or the
measured rate drifting more than 2× from the sizing assumption. `SignalBuffer::
setCapacity` clears the buffer, which would blank the display; `SignalStore`
instead reads out the newest `min(count, newCapacity)` points and re-pushes them
after the resize, so the live view survives. Resizing never happens while a
capture is being harvested.
## 8. User interface
### 8.1 Screen layout
```
┌────────────────────────────────────────────────────────────┐
│ menu bar: File View Help [connection badge] │
├────────────────────────────────────────────────────────────┤
│ trigger bar: signal | edge | thr | hyst | win | pre% | │
│ Norm/1x | [ARMED 62%] | Arm Disarm │
├──────────────┬─────────────────────────────────────────────┤
│ signal list │ │
│ (drag │ pane tree │
│ source) │ │
│ │ │
├──────────────┴─────────────────────────────────────────────┤
│ status: packets, MB, frames, gaps, dropped, reconnects │
└────────────────────────────────────────────────────────────┘
```
The status bar reports `udps_stats_t` directly; `counter_gaps` and
`fragments_dropped` are the honest indication that the scope is not seeing
everything, and are highlighted when non-zero.
### 8.2 Pane tree
```cpp
struct PaneNode {
bool leaf;
// leaf:
std::vector<Assignment> signals; // name, colour, line width, VScale
// split:
Orientation orient; // Columns (side by side) | Rows (stacked)
double ratio; // first child's share, 0..1
std::unique_ptr<PaneNode> a, b;
};
```
Operations, all in `PaneTree` and all testable without a GUI:
`splitLeaf(leaf, orient)`, `closeLeaf(leaf)` (the parent is replaced by the
surviving sibling), `layout(rect) → [(leaf*, rect)]`, `hitTestSplitter(pos)`,
`hitTestHandle(pos)`.
**Splitting.** Hovering a pane reveals four inset split handles at the midpoints
of its edges plus an ✕ in the top-right corner. Clicking the left or right handle
splits into columns; top or bottom splits into rows. The new pane is empty and
takes half the space.
Handles are used rather than "click anywhere on the pane border" because a pane
in the middle of a 3×3 layout touches no window edge, and a rule keyed on window
edges would leave such panes unsplittable. Insetting the handles also keeps them
clear of the splitter drag zone.
**Resizing.** The shared border between two panes is a drag target that adjusts
the parent split's `ratio`. Minimum pane size 80 px; a drag that would go below
it clamps.
**Assignment.** The signal list on the left is an ImGui drag source; dropping a
signal on a pane appends it to that pane's list, overlaid on the existing traces.
Right-clicking a trace's legend entry removes it or opens its colour, line width
and vertical scale.
### 8.3 Axes
All time panes share one X axis: pan or zoom in any pane moves all of them, which
is what makes comparing signals across panes meaningful. In live mode the X range
follows the newest sample; any manual pan or zoom detaches it, and a "Live"
button re-attaches.
Vertical scale is per assignment, with three modes:
- **Auto** — fit to the data visible in the current X range.
- **Range** — use the `range_min`/`range_max` the CONFIG packet already carries,
which is free and is the physically meaningful full scale.
- **Manual** — volts-per-division and offset, as on a bench scope.
### 8.4 Cursors and measurements
Two global vertical cursors A and B, toggled from the View menu. Readout shows
`tA`, `tB`, `Δt`, `1/Δt`, and per displayed signal the value at each cursor and
`ΔV`.
Per pane, a collapsible readout gives each signal's min, max, peak-to-peak, mean
and RMS over the current X range. All statistics are computed from the
undecimated ring or capture data, never from the decimated screen points, so a
narrow spike is counted even when it is not individually drawn.
## 9. Persistence
`Settings` writes a line-based recursive format, chosen over JSON because the
pane tree is nested (so INI is awkward) and vendoring a JSON header for one file
works against the single-binary goal. Roughly 100 lines of writer plus recursive
descent parser.
```
udpscope 1
source host=127.0.0.1 port=44500 multicast= iface= dataport=0
trigger signal=Voltage edge=rising thr=0.5 hyst=0.01 win=0.1 pre=20 mode=normal
cursors on 0.0123 0.0456
tree
split cols 0.5
leaf
sig Voltage color=#89b4fa width=1.5 vs=auto
sig Current color=#fab387 width=1.5 vs=manual div=0.2 off=0.0
leaf
sig Temp color=#a6e3a1 width=1.5 vs=range
```
Location: `$XDG_CONFIG_HOME/udpscope/session.conf`, falling back to
`~/.config/udpscope/session.conf`, overridable with `--config PATH`. Saved on
clean exit and from File → Save Layout. A malformed or version-mismatched file
is reported in the status bar and ignored, never partially applied.
## 10. CSV export
File → Export writes the current capture snapshot (or, when not triggered, the
visible live window) in long format:
```
signal,time_s,wallclock_s,value
Voltage,0.000000,1756291200.123456,0.4981
Voltage,0.000001,1756291200.123457,0.4993
```
Long format rather than one column per signal because signals carry independent
timestamps, so a wide format would require resampling and would silently
misrepresent the data. Export covers all signals assigned to any pane, or a
single pane on request.
## 11. Command line
Mirrors `example/udps_dump.c` so the two are interchangeable:
```
udpscope [--host ADDR] [--port N] [--multicast GROUP] [--iface ADDR]
[--data-port N] [--silence SEC] [--config PATH] [--max-mpts N]
```
Long `--` options only. Defaults `127.0.0.1:44500`, matching `udps_dump`.
Precedence: an explicitly given command-line option wins over the same field in
the settings file; anything not given on the command line comes from the settings
file; anything in neither takes the built-in default. Connecting somewhere on the
command line therefore does not silently rewrite the saved session, but saving
the layout afterwards does record the new source.
## 12. Build
`Client/udpscope/CMakeLists.txt`, following `Client/streamhub/CMakeLists.txt`:
- `find_package(OpenGL REQUIRED)`; SDL2 via `CONFIG` then a pkg-config fallback.
- `FetchContent` for ImGui v1.91.8 and ImPlot v0.17, built into an `imgui_lib`
static target compiled with `-w`.
- `add_library(udpsclient STATIC ../../Common/Client/c/udps_client.c)` compiled
as C99 with the library's own warning flags.
- App compiled with `-Wall -Wextra -Wno-unused-parameter`.
- Fonts copied next to the binary; `APP_RESOURCE_DIR` points at the source tree
as the final fallback.
- Install rules for the binary, fonts, `.desktop` entry and icon.
```bash
cd Client/udpscope && cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build
```
## 13. Testing
`UDPSCOPE_BUILD_TESTS` (default ON) adds GoogleTest via `FetchContent` and a
single `udpscope_tests` binary covering the framework-free modules:
- **PaneTree** — split produces the expected geometry; close promotes the
sibling and reclaims its space; ratios survive a round trip through
`layout()`; hit-testing distinguishes handle from splitter; minimum size
clamps.
- **Trigger** — rising, falling and both edges; interpolated trigger time on a
known ramp; hysteresis suppresses a noisy re-cross; the fill gate refuses to
arm without a pre-window; normal re-arms and single does not.
- **TimeBase** — time-signal conversion at both unit scales; offset calibration;
the `hrt` rate fit converges to a known tick rate; the burst-delivery case
produces monotonic evenly spaced timestamps rather than a sawtooth; a `PACKET`
burst stream stays strictly monotonic under jittered arrivals, with no overlap
between consecutive packets.
- **Decimate** — extremes are preserved; output stays in time order; input
shorter than the budget passes through unchanged.
- **Settings** — write/parse round trip reproduces the tree exactly; malformed
input is rejected without partial application.
- **Export** — long-format output for a known snapshot.
`Receiver` and `SignalStore` are exercised indirectly: a test feeds synthetic
`udps_frame_t` structures to the receiver's data-callback logic, which is
factored out of the C callback into a plain method so it can be called without a
socket.
Manual verification: run `Test/Configurations/streamhub_demo.cfg`'s streamer and
point the scope at it, alongside `udps_dump` for cross-checking.
## 14. Documentation
- `Docs/UDPScope.md` — user guide: CLI, pane interaction, trigger, measurements,
export, settings file format.
- A row in the README component table and in the documentation index.
- A line in `CLAUDE.md`'s build section, next to the ImGui and Qt clients.