Initial commit
This commit is contained in:
commit
8059b90de7
29 files changed
+7893
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
build/
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "deps/plotter"]
|
||||||
|
path = deps/plotter
|
||||||
|
url = https://git.ferrarinas.it/martino/plotter.git
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Build and run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git submodule update --init --recursive # deps/plotter is a submodule
|
||||||
|
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo
|
||||||
|
cmake --build build
|
||||||
|
|
||||||
|
./build/udpscope --server 127.0.0.1 --port 8000
|
||||||
|
./build/udpscope --server 10.0.0.5 --port 8000 --multicast 239.1.2.3 --interface 10.0.0.9
|
||||||
|
```
|
||||||
|
|
||||||
|
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per
|
||||||
|
buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (1..4).
|
||||||
|
|
||||||
|
`deps/plotter` is built from source with only the GTK4 adapter enabled
|
||||||
|
(`PLOT_BUILD_GTK4`); its tests and examples are forced off. `deps/udpsclient` is a
|
||||||
|
vendored C99 source pair compiled into a static lib.
|
||||||
|
|
||||||
|
`deps/plotter` is upstream's own repository and this app's figure support was added
|
||||||
|
there rather than worked around here: `Figure::set_min_y_gutter()` (a floor under the
|
||||||
|
common left gutter), and in the GTK4 adapter `PlotArea`'s **figure mode** —
|
||||||
|
`set_figure()`, one `input::Controller` per row, nearest-row event routing, a
|
||||||
|
figure-wide crosshair — plus `PlotArea::row_at()` for naming the row a pointer is over.
|
||||||
|
Changing those means committing in the submodule and moving its pin.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
There is no unit test suite. The only automated check is the app's own headless
|
||||||
|
self-check, driven by env vars and run under a display (`xvfb-run`, llvmpipe) against
|
||||||
|
a live UDPStreamer source:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||||||
|
./build/udpscope --server 127.0.0.1 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
- `UDPSCOPE_EXIT_AFTER_MS` — run the trigger-on → go-live probe, close the window,
|
||||||
|
then dump the whole pipeline state to stdout (frame/sample counts, per-plot painted
|
||||||
|
frames, trace bindings, panel sizes, status line in both states).
|
||||||
|
- `UDPSCOPE_ENABLE_ALL_MS`, `UDPSCOPE_ARM_TRIGGER_MS`, `UDPSCOPE_PROBE_DISARM_MS`,
|
||||||
|
`UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes.
|
||||||
|
- `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are
|
||||||
|
measured on a still picture.
|
||||||
|
|
||||||
|
**Run one self-check at a time.** Two concurrent `xvfb-run -a` instances end up on the
|
||||||
|
same display and the second gets no GL context — it dumps `layout over 0 frames` and
|
||||||
|
`valid=-1`, which looks like a pipeline failure and is not one. `tools/mock_udpstreamer.py`
|
||||||
|
also outlives its `--duration`, so kill it after each run or the leftovers pile up and
|
||||||
|
hold ports.
|
||||||
|
|
||||||
|
Two lines of the self-check output are assertions in disguise:
|
||||||
|
|
||||||
|
- `layout over N frames: axes.x A..B, ...` — **`viewport.w` must collapse to a single
|
||||||
|
value and `axes.x`, `axes.w` and `y-gutter` must *settle*.** `note_y_gutter()` is
|
||||||
|
monotone (`plot_host.cpp`), its one-shot widest-label reservation can land a frame or
|
||||||
|
two after the first measured frame, and the figure serves its floor out of the label
|
||||||
|
strip (`y_width = max(measured, floor - y_title_w)`, `plot.cpp`) so the reservation
|
||||||
|
converges over two layouts rather than one. A `y-gutter 44..74` dragging
|
||||||
|
`axes.x 67..80`/`axes.w 546..559` with it is that settle, not a twitch — confirm it
|
||||||
|
with the `changes` line below. A range that keeps moving is the "twitching axis" bug:
|
||||||
|
the plot being re-measured every frame. **`x-desync` must read 0**: it counts frames
|
||||||
|
in which any row's x range differed from row 0's by more than a pixel of data, i.e.
|
||||||
|
frames the x link group did not reach. `x-ticks` is content, not layout, and
|
||||||
|
legitimately varies by one as a live
|
||||||
|
window slides over a round number. The companion `axes.w changes: N (last at frame F,
|
||||||
|
side panel up=S)` line separates the two readings: `0`, or a handful near the start,
|
||||||
|
is a settled layout; a count near the frame count is the twitch. Runs far longer than
|
||||||
|
the documented probes have shown one ~320 px step — the side panel's width — that
|
||||||
|
reverts; it is intermittent and unexplained, so read `changes` before calling a range
|
||||||
|
the twitch bug.
|
||||||
|
- `[trig-probe] ... trigger=on ... display=...` (stderr, `UDPSCOPE_TRIG_TRACE_MS`) —
|
||||||
|
with `trigger=on`, `display` must read `event(frozen)` or `nothing`, **never**
|
||||||
|
`live`, except for the single interval that contains the toggle itself — recognise
|
||||||
|
it by `latches=0` *and* the next probe reading `nothing` with the same frozen
|
||||||
|
`axis=`. And `snap=[a..b]` must equal `axis=[a..b]`, with `b` positive by roughly
|
||||||
|
the post-trigger share: that is the "post window is empty" defect's observable.
|
||||||
|
**`drawn=[a..b]` must equal `data=[a..b]`** in both modes: `data` is `x_at()` (what
|
||||||
|
the crosshair reads) and `drawn` is `x0() + gather()` (what the renderer maps), so a
|
||||||
|
series that breaks the "gather emits x relative to `x0()`" contract shows up here as
|
||||||
|
the two disagreeing by exactly `x0()` — a trace drawn a whole pre-trigger window left
|
||||||
|
of its axis, invisible once the view is narrower than the shift, while the crosshair
|
||||||
|
keeps reporting correct values.
|
||||||
|
- `[trig-probe/idx] n=.. bounds=[..] dx=.. dx_real=.. dx_ratio=R got=[a..b) want=[c..d)`
|
||||||
|
(stderr, same probe) — **`got` must contain `want`** (`a <= c` and `b >= d`), and
|
||||||
|
`dx_ratio` must stay within about a percent of `1.0`. `got` is the index window
|
||||||
|
`Renderer::draw_traces` gathers, recomputed here from its own `(x - x0()) / dx()`
|
||||||
|
*uniform grid* formula; `want` is the same window found by binary search over the real
|
||||||
|
time ring, which is what `x_at()`/`neighbours()` — and so the crosshair — use. A
|
||||||
|
`dx_ratio` above 1 scales every index up, `a` lands past the first visible sample, and
|
||||||
|
the samples in between sit in the buffer, readable by the crosshair, and are never
|
||||||
|
drawn: the live view's older part goes blank while its newest part looks fine. Also
|
||||||
|
read `axis=` in the companion line against the `window dropdown:` line in the dump —
|
||||||
|
a live axis narrower than the requested window is the same symptom from the other
|
||||||
|
cause (see the `clamp_to_data()` invariant below). Both need a *saturated* ring to
|
||||||
|
show up, so run them under `--buffer-mb 1`, which wraps in a few seconds.
|
||||||
|
- `trigger: edge at X ... want [-a..b] got [-c..d] n=N` (stderr) — `got` must equal
|
||||||
|
`want` and `N` must be exactly the window times the stream's real rate, plus one
|
||||||
|
(2 s at 10 kS/s is `n=20001`, not 19999). `got` shorter than `want` on the *left*
|
||||||
|
means the edge was accepted before the history reached back a full pre window (the
|
||||||
|
gate in `TriggerEngine::process()`); shorter on the *right* means the engine latched
|
||||||
|
early or the cut lost the newest samples. An `N` one or two short of the exact count
|
||||||
|
is the same class of bug, not rounding: `publish_event()` clamps the slice to every
|
||||||
|
value ring's `produced()`, so publishing before each channel of the latching sample
|
||||||
|
has been pushed silently drops it.
|
||||||
|
|
||||||
|
The probes are the regression net — when changing UI or plot-host behaviour, extend
|
||||||
|
the probe/dump in `src/ui.cpp` rather than adding a separate harness.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
`SPECS.md` is the requirement document. `README.md` documents what was actually built,
|
||||||
|
including deliberate deviations from the spec and known limitations — read it before
|
||||||
|
concluding something is a bug.
|
||||||
|
|
||||||
|
### Threads
|
||||||
|
|
||||||
|
Exactly two: a producer thread in `Acquisition` owns `udps_client_t` and calls
|
||||||
|
`udps_client_poll()`; the GTK main thread owns every widget and GL context. They share
|
||||||
|
`ScopeModel` under `ScopeModel::mutex()`, taken once per DATA frame by the producer and
|
||||||
|
once per frame by the UI (`tick()` in `src/ui.cpp`) around `RowSet::bind()`/`acquire()`.
|
||||||
|
Samples themselves cross via `plot::data::RingBuffer` (single producer, single
|
||||||
|
consumer) and never through the lock. Cross-thread scalars are atomics on `ScopeModel`
|
||||||
|
(`trigger_enabled_`, `trigger_armed_`, `trigger_latches_`, `trigger_time_`,
|
||||||
|
`event_`, `generation_`).
|
||||||
|
|
||||||
|
### Data path
|
||||||
|
|
||||||
|
`ScopeModel` holds **one `BufferStore`** (`live()`): one shared time-base ring plus one
|
||||||
|
value ring per channel, sized by `plan_partition()` to cover `--max-window` seconds
|
||||||
|
within the byte budget. The producer always writes it, whatever the trigger is doing —
|
||||||
|
that is what guarantees a full pre-trigger history on the *first* latch after arming.
|
||||||
|
The spec's second buffer (item 1) is the latched `EventSnapshot`, which is already an
|
||||||
|
immutable copy of the captured window; see README for why two live rings were dropped.
|
||||||
|
|
||||||
|
In trigger mode `RowSet::bind()` binds each trace to the snapshot and to nothing else
|
||||||
|
(the last event, or nothing before the first). **Never the live ring**, not even while
|
||||||
|
the post window is filling — SPECS:37 says the view must not refresh until the next
|
||||||
|
event, and `trigger_pending()` is informational only, for the status line's wording.
|
||||||
|
|
||||||
|
Layers to keep straight:
|
||||||
|
|
||||||
|
- `SignalInfo` — one signal as the CONFIG described it. The unit of enable/disable.
|
||||||
|
- `ChannelInfo` / `ChannelBuffer` — the unit of acquisition and plotting. A
|
||||||
|
`ChannelBuffer` is a `plot::data::Series` over a (value, time) ring pair.
|
||||||
|
- `SlotSeries` (`src/channel.hpp`) — a trace's stable series object, rebound each frame
|
||||||
|
to either a `ChannelBuffer` or an `EventSeries`.
|
||||||
|
- `RowSet` (`src/plot_host.hpp`) — **one `plot::Figure`** of up to 4 rows, each a
|
||||||
|
borrowed `plot::Plot*` with `kSlotsPerRow` fixed trace slots. The figure owns
|
||||||
|
everything the rows have to agree on: the x window (one x link group every row joins),
|
||||||
|
the live follow (the *group's* — a member's own axis must be `XFollow::Off`, since
|
||||||
|
`Figure::link_x()` refuses a `Live` member), the left gutter and the crosshair.
|
||||||
|
|
||||||
|
`src/ui.cpp` is the GTK4 window (toolbar, trigger panel, signal panel, plot box) plus
|
||||||
|
the `tick()` frame function that binds, updates the trigger state and rewrites the
|
||||||
|
status line. One `plot::gtk4::PlotArea` in figure mode draws the whole figure. It is by
|
||||||
|
far the largest file.
|
||||||
|
|
||||||
|
### Invariants worth knowing before editing
|
||||||
|
|
||||||
|
- **`plot::Plot` cannot remove or retarget a trace.** Hence fixed trace slots created
|
||||||
|
up front and rebinding via `SlotSeries`; a *name* change (legend text) is the one
|
||||||
|
thing that forces a rebuild. Cursors are parked outside the axis range instead of
|
||||||
|
removed.
|
||||||
|
- **A `Figure`'s row count is fixed at construction**, so `RowSet::rebuild_all()` builds
|
||||||
|
a *new* figure and the area is handed it (`PlotArea::set_figure()`); it costs no GL
|
||||||
|
state. That makes one path for "a row's traces changed" and "the row count changed",
|
||||||
|
and it is why `rebuild_all()` restates a pinned event window (`pinned_pre_`/
|
||||||
|
`pinned_post_`) instead of re-framing on the live edge — a drop during trigger mode
|
||||||
|
would otherwise throw the frozen view onto the stream, where nothing moves it off
|
||||||
|
again until the next latch. `PlotArea::create()` is handed `App::placeholder_plot`,
|
||||||
|
never a row: the area keeps a reference to the plot it was created with, and a rebuild
|
||||||
|
destroys every row.
|
||||||
|
- **Trace slots are identified by signal name, not channel index.** Enabling a signal
|
||||||
|
or a new CONFIG renumbers the channel list, so an index would silently re-point a
|
||||||
|
trace at another signal.
|
||||||
|
- **Any structural change rebuilds the buffer and drops history**: a CONFIG, an
|
||||||
|
enable/disable, or a calibration change. Calibration is applied at ingest, which is
|
||||||
|
why it re-partitions.
|
||||||
|
- **The `Series` grid contract does not hold for `ChannelBuffer`.** x is a streamed
|
||||||
|
timestamp, so `dx()`/`x0()` report a nominal grid (LOD level, coarse snap) while
|
||||||
|
`gather()`, `x_at()`, `neighbours()` and `x_bounds()` use the real time ring.
|
||||||
|
`gather()` emits x relative to `x0()` because absolute epoch seconds do not survive
|
||||||
|
`f32`.
|
||||||
|
- **`gather()` emits x relative to `x0()` in *every* series here**, `EventSeries`
|
||||||
|
included, and the renderer adds `x0()` back. An event's stamps are small (edge
|
||||||
|
relative, `[-pre, +post]`), so emitting them absolute does not overflow anything and
|
||||||
|
does not look like a bug — it just draws the whole capture `x0()` = one pre-trigger
|
||||||
|
window to the left of the axis. That was the "half the plot is empty / the marker sits
|
||||||
|
at the end of the data / nothing is drawn but the crosshair reads values" defect: the
|
||||||
|
crosshair reads `x_at()`, which was right all along. The probe's `data=` vs `drawn=`
|
||||||
|
is the guard.
|
||||||
|
- **A dropped time slot is clamped, never answered with a sentinel.** `acquire()` pins
|
||||||
|
the published window to the value ring's base and both rings keep sliding afterwards —
|
||||||
|
their capacities are equal, so there is no slack — which means the oldest few samples
|
||||||
|
of a saturated window routinely point at time slots the producer has already
|
||||||
|
overwritten. `ChannelBuffer::time_of()` therefore clamps to the oldest *readable*
|
||||||
|
slot: a stamp of `0` would reach `x_bounds()`, and from there `dx()` and `x0()`, which
|
||||||
|
is the renderer's whole index mapping — one unreadable slot inflated `dx()` by the
|
||||||
|
ratio of elapsed stream time to the retained window and clipped the older part of
|
||||||
|
every live view. The library's `StreamSeries` is immune for a reason worth copying:
|
||||||
|
its x is `x0 + i*dx`, a function of the absolute index, so an overwritten slot costs a
|
||||||
|
*y* value, never the geometry. Clamping buys the same bargain here.
|
||||||
|
- **`clamp_to_data()` may not shrink the view below the requested window.** It runs
|
||||||
|
every frame and cannot distinguish "the user zoomed out past the data" from "the ring
|
||||||
|
has only just started filling", so it clamps against `max(retained, window_s_)`: a
|
||||||
|
partly filled window is left empty on its left, which is what a scope that just
|
||||||
|
started should look like. Clamping to `retained` alone turned a requested 2 s window
|
||||||
|
into the ~0.02 s the first frames held, permanently — nothing there grows a span back.
|
||||||
|
Its pan clamping is likewise gated on the data being wider than the view, or the empty
|
||||||
|
left-hand side drags the live edge off the right-hand edge.
|
||||||
|
- **Layout must not depend on content.** The y gutter is reserved at the widest label
|
||||||
|
`TickFormat::Si` can produce and only ever grows; the status label is ellipsized so
|
||||||
|
its text cannot drive the paned divider. Both exist to stop per-frame relayout.
|
||||||
|
The reservation goes on the **figure** (`Figure::set_min_y_gutter()`, added to the
|
||||||
|
plotter for this), not on the rows: `Figure::layout()` clears every row's
|
||||||
|
`set_min_y_gutter()` to measure what its own labels need, so a floor written onto the
|
||||||
|
rows survives exactly until the next layout.
|
||||||
|
- **Signals start disabled** — enabling is what starts acquisition. A trigger on a
|
||||||
|
signal nobody acquires can never fire, so selecting/arming enables it.
|
||||||
|
- **The trigger's event window is separate from the display window**, and
|
||||||
|
`pretrigger_pct` is a share of the *event* span, not of the ring.
|
||||||
|
- **The event window is counted in stream seconds end to end; nothing converts it to
|
||||||
|
a sample count.** `TriggerEngine` latches once the stamps it is fed have advanced
|
||||||
|
past the post window, and `ScopeModel::publish_event()` cuts the snapshot by binary
|
||||||
|
search over the time ring. There used to be a measured samples-per-second estimate
|
||||||
|
in `Acquisition` doing that conversion, and it is precisely why the post window came
|
||||||
|
out empty: any stream whose time signal advances in large steps drove it to its
|
||||||
|
1 S/s floor, the count came out as 1, and the engine latched on the edge sample
|
||||||
|
itself. **Do not reintroduce a rate estimate on this path.**
|
||||||
|
- **The event axis is pinned from the snapshot's own stamps, not from the request.** A
|
||||||
|
capture may legitimately be shorter than asked for — the ring need not reach back a
|
||||||
|
full pre-trigger share on the first latch after arming, so `snap=[-0.339..1.000]`
|
||||||
|
for a ±1 s request is correct there rather than a defect.
|
||||||
|
- **`publish_event()` cuts one absolute index range and uses it for the time ring and
|
||||||
|
every value ring.** That is what makes `stamps` and each `values[c]` equal-length
|
||||||
|
and index-aligned by construction instead of by two truncations that can disagree —
|
||||||
|
`EventSeries::gather()` indexes both at the same `i`.
|
||||||
|
- **The header bar's buttons are icon-only**, so headless probes must reach them
|
||||||
|
through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via
|
||||||
|
`find_labelled_button()`. The Arm/Disarm button in the trigger panel still has text.
|
||||||
|
- Every source file carries an SPDX MIT header; match it in new files.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.24)
|
||||||
|
project(udpscope LANGUAGES C CXX)
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||||
|
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||||
|
|
||||||
|
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
||||||
|
set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
find_package(Threads REQUIRED)
|
||||||
|
|
||||||
|
# --- deps/plotter: built from the submodule, GTK4 adapter only ----------------
|
||||||
|
set(PLOT_BUILD_TESTS OFF CACHE BOOL "" FORCE)
|
||||||
|
set(PLOT_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
|
||||||
|
set(PLOT_BUILD_GLFW OFF CACHE BOOL "" FORCE)
|
||||||
|
set(PLOT_BUILD_IMGUI OFF CACHE BOOL "" FORCE)
|
||||||
|
set(PLOT_BUILD_QT6 OFF CACHE BOOL "" FORCE)
|
||||||
|
set(PLOT_BUILD_GTK4 ON CACHE BOOL "" FORCE)
|
||||||
|
set(PLOT_INSTALL OFF CACHE BOOL "" FORCE)
|
||||||
|
add_subdirectory(deps/plotter EXCLUDE_FROM_ALL)
|
||||||
|
|
||||||
|
# --- deps/udpsclient: vendored C99 receiver ----------------------------------
|
||||||
|
add_library(udpsclient STATIC deps/udpsclient/udps_client.c)
|
||||||
|
target_include_directories(udpsclient PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/deps/udpsclient)
|
||||||
|
set_target_properties(udpsclient PROPERTIES C_STANDARD 99 C_STANDARD_REQUIRED ON)
|
||||||
|
if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
|
||||||
|
target_compile_options(udpsclient PRIVATE -Wall -Wextra)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# --- the application ----------------------------------------------------------
|
||||||
|
add_executable(udpscope
|
||||||
|
src/main.cpp
|
||||||
|
src/config.cpp
|
||||||
|
src/channel.cpp
|
||||||
|
src/model.cpp
|
||||||
|
src/trigger.cpp
|
||||||
|
src/acquisition.cpp
|
||||||
|
src/plot_host.cpp
|
||||||
|
src/ui.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
target_include_directories(udpscope PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
|
||||||
|
target_link_libraries(udpscope PRIVATE plot::gtk4 udpsclient Threads::Threads)
|
||||||
|
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
|
||||||
|
target_compile_options(udpscope PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
# udpscope
|
||||||
|
|
||||||
|
A GTK4 oscilloscope-style viewer for a UDPStreamer source: it receives one stream
|
||||||
|
with [`deps/udpsclient`](deps/udpsclient), buffers it in two identical large
|
||||||
|
circular buffers, and draws it with [`deps/plotter`](deps/plotter).
|
||||||
|
|
||||||
|
`SPECS.md` is the requirement document; this file describes what was built.
|
||||||
|
|
||||||
|
## Build and run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo
|
||||||
|
cmake --build build
|
||||||
|
|
||||||
|
./build/udpscope --server 127.0.0.1 --port 8000 # unicast
|
||||||
|
./build/udpscope --server 10.0.0.5 --port 8000 \
|
||||||
|
--multicast 239.1.2.3 --interface 10.0.0.9 # multicast
|
||||||
|
```
|
||||||
|
|
||||||
|
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb`
|
||||||
|
(size of the sample buffer, default 200), `--max-window` (largest time
|
||||||
|
window the toolbar offers, default 600), `--holdoff` (default trigger hold-off),
|
||||||
|
`--recv-buffer`, `--plots` (initial number of stacked plots, 1..4).
|
||||||
|
|
||||||
|
Every CONFIG is dumped to stderr once -- publish mode and, per signal, its name,
|
||||||
|
element count, rate, time mode and time-signal index -- because a signal that
|
||||||
|
refuses to plot is almost always a signal the stream described in an unexpected
|
||||||
|
way. The status line carries the same story live: `plot 1: 2 trace(s), 1400
|
||||||
|
samples`, and `time x3` when the shared clock is decimated.
|
||||||
|
|
||||||
|
Headless self-check (used by the smoke tests): `UDPSCOPE_EXIT_AFTER_MS=<ms>`
|
||||||
|
switches trigger mode on, waits for an event, then leaves trigger mode the way the
|
||||||
|
"Go live" button does, closes the window, and prints on stdout the status line in
|
||||||
|
both states, the frame and per-channel sample counts, each plot's painted-frame
|
||||||
|
count, which channel each trace slot holds, and the allocated size and reveal
|
||||||
|
state of both side panels.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/config.* command line
|
||||||
|
src/model.* the sample buffer, the partition plan, the signal/channel lists
|
||||||
|
src/channel.* ChannelBuffer (a ring pair as a plot::data::Series) and SlotSeries
|
||||||
|
src/acquisition.* the UDPStreamer client, the producer thread, frame decoding
|
||||||
|
src/trigger.* the trigger configuration and the producer-side state machine
|
||||||
|
src/plot_host.* the one plot::Figure, its rows' traces and cursors, the x link group
|
||||||
|
src/ui.* the GTK4 window: toolbar, trigger panel, signal panel, plot box
|
||||||
|
```
|
||||||
|
|
||||||
|
Threading: one producer thread owns the `udps_client_t` and calls
|
||||||
|
`udps_client_poll()`; the GTK main thread owns every widget and the GL contexts.
|
||||||
|
They share `ScopeModel` under one mutex, which the producer takes once per DATA
|
||||||
|
frame and the GUI thread takes once per frame around binding the traces.
|
||||||
|
Samples cross the boundary through `plot::data::RingBuffer` (single producer,
|
||||||
|
single consumer), never through the lock.
|
||||||
|
|
||||||
|
## How the spec maps onto the code
|
||||||
|
|
||||||
|
**One buffer of `N+1` slices, plus frozen event snapshots.** `ScopeModel`
|
||||||
|
allocates one `BufferStore` per CONFIG: one shared time-base ring plus one value
|
||||||
|
ring per channel (N enabled signals -- see the limitation on multi-element signals
|
||||||
|
below). The spec's second buffer is the latched `EventSnapshot`, which is already
|
||||||
|
an immutable copy of the captured window (see "Trigger" below); a second live ring
|
||||||
|
bought nothing and cost half the memory budget, so `--buffer-mb 200` really is
|
||||||
|
200 MB.
|
||||||
|
`plan_partition()` sizes the rings to cover `--max-window` seconds and then grows
|
||||||
|
them with the rest of the budget. When a channel's declared sample rate is lower
|
||||||
|
than the fastest channel's, it is decimated by the integer ratio
|
||||||
|
(`Partition::stride`); when even the target window does not fit the budget, the
|
||||||
|
shared clock is decimated too (`Partition::time_stride`). Because the split is by
|
||||||
|
*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
|
||||||
|
decimation the two rules coincide and every slice is the same size.
|
||||||
|
|
||||||
|
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
|
||||||
|
trigger state, the first latch after arming opened on however little had
|
||||||
|
accumulated since.
|
||||||
|
|
||||||
|
**Circular, time from the streamed data.** Each ring overwrites its oldest
|
||||||
|
sample. Timestamps come from the stream's time signal when the CONFIG names one
|
||||||
|
(`UDPS_TIME_FULL_ARRAY` / `FIRST_SAMPLE` / `LAST_SAMPLE`), and otherwise from an
|
||||||
|
arrival-anchored estimate that accounts for `udps_frame_t::lost` by dividing the
|
||||||
|
elapsed wall time over the cycles the gap covers. `recv_time` is used only as the
|
||||||
|
fallback when the stream provides no usable time signal (it is then the sample's x,
|
||||||
|
via the arrival estimate); it is never used when the stream's own time signal
|
||||||
|
carries a span.
|
||||||
|
|
||||||
|
**Trigger: what freezes is the view, not acquisition.** `TriggerEngine` runs the
|
||||||
|
edge/hold-off/normal-single state machine on the selected channel, and once the
|
||||||
|
requested post-trigger share of the *event span* (the event-window control, not the
|
||||||
|
whole ring) has been collected, `ScopeModel::publish_event()` copies that window out
|
||||||
|
of the live ring into an immutable `EventSnapshot` and publishes it atomically.
|
||||||
|
Pre-trigger history is what the ring already holds, so the percentage is a share of
|
||||||
|
the event window and not of the buffer.
|
||||||
|
|
||||||
|
In trigger mode a trace is bound to the snapshot and to nothing else: the last
|
||||||
|
event latched, or nothing at all before the first one. Never the live ring, not
|
||||||
|
even while the post window is filling — a picture that changes only on an event is
|
||||||
|
the whole point of trigger mode (SPECS: "data visualisation should not be refreshed
|
||||||
|
till next event"), and binding the live ring mid-capture made the plot scroll for
|
||||||
|
most of every event cycle.
|
||||||
|
|
||||||
|
**The whole trigger path measures the window in stream seconds, and never in
|
||||||
|
samples.** `TriggerEngine` is fed each sample's timestamp and latches once that clock
|
||||||
|
has advanced past the post window; `publish_event()` then finds the slice covering
|
||||||
|
`[edge - pre, edge + post]` by binary search over the time ring. The two are the same
|
||||||
|
clock the x axis reads, so a capture covers the span that was asked for by
|
||||||
|
construction.
|
||||||
|
|
||||||
|
It did not used to. The engine took a post-trigger window in *samples*, converted
|
||||||
|
from seconds by a measured estimate of the trigger channel's cadence, and that
|
||||||
|
estimate is what made captures wrong — most visibly as the reported defect *"only the
|
||||||
|
pre window is filled, the post window is completely empty"*. Any stream whose time
|
||||||
|
signal advances in large steps (one sample per stream second, say) drove the estimate
|
||||||
|
to its 1 S/s floor; the sample count then came out as 1, which the engine read as "no
|
||||||
|
post window requested" and latched on the edge sample itself. The event held a single
|
||||||
|
sample, its span was zero, and the UI fell back to pinning a round `[-1, +1]` axis
|
||||||
|
that nothing filled past x = 0 — an axis that looked right, which is why the defect
|
||||||
|
read as a drawing bug rather than a capture bug. Removing the conversion removes the
|
||||||
|
whole failure class: there is no longer a rate estimate to be wrong.
|
||||||
|
|
||||||
|
An edge is refused until the buffer reaches back a full pre-trigger window behind it,
|
||||||
|
so a capture cannot open on a half-filled pre window. The gate compares the oldest
|
||||||
|
buffered stamp against the edge rather than timing how long the trigger has been
|
||||||
|
armed: the ring fills whether or not the trigger is on, so the history is normally
|
||||||
|
already there and waiting would delay the first event for nothing. It only bites just
|
||||||
|
after a rebuild — a CONFIG, enabling the signal, or a calibration change all drop the
|
||||||
|
history — which is exactly when the first event used to latch on an almost empty pre
|
||||||
|
window and draw half a plot.
|
||||||
|
|
||||||
|
The event is published on the sample that latched it, but only after every channel of
|
||||||
|
that sample has reached its ring: the cut is clamped to the shortest value ring, so
|
||||||
|
publishing from the middle of the channel loop dropped the newest sample — the very one
|
||||||
|
the post window had been waiting for. That cost one sample of twenty thousand on a
|
||||||
|
10 kS/s stream, and the whole post side on a one-sample-per-second one.
|
||||||
|
|
||||||
|
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
|
||||||
|
pinning the request would claim coverage the data does not have. The window's bounds
|
||||||
|
are rounded to the time ring's own `f32` before being compared against it, so a sample
|
||||||
|
sitting exactly on a bound is kept whatever the stream's absolute time happens to be —
|
||||||
|
in f64 that decision fell to how the stamp rounded, which cost a sample at 10 kS/s and
|
||||||
|
a third of the capture at 1 S/s.
|
||||||
|
|
||||||
|
**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*
|
||||||
|
`x0()` and the renderer adds `x0()` back; the event series emitted absolute stamps, so
|
||||||
|
the renderer added `x0()` twice and drew the whole capture one pre-trigger window to the
|
||||||
|
left of the axis it had just been pinned to. Nothing about the numbers looked wrong —
|
||||||
|
the snapshot held the right samples over the right span, and the crosshair (which reads
|
||||||
|
`x_at()`, never `gather()`) reported correct values — but with a 50/50 split the right
|
||||||
|
half of the plot was empty, zooming out showed the trace ending at the x = 0 marker, and
|
||||||
|
zooming in past the shift showed nothing at all. The self-check now prints `data=` and
|
||||||
|
`drawn=` side by side for the bound trace so the contract is checked rather than
|
||||||
|
assumed.
|
||||||
|
|
||||||
|
**The live view had two clipping bugs of its own**, both wearing the same face as that
|
||||||
|
one: the older part of the trace blank, the newest part fine, and the crosshair still
|
||||||
|
reading values in the blank part. Neither is a data bug — the samples were always there.
|
||||||
|
|
||||||
|
The first is the requested time window being quietly redefined. `clamp_to_data()` stops a
|
||||||
|
zoom-out from running off the end of the ring, but it runs every frame and cannot tell
|
||||||
|
that apart from a ring that has only just started filling: the first frames after a
|
||||||
|
signal is enabled hold a couple of hundred samples, a 2 s window was clamped to the
|
||||||
|
~0.02 s they covered, and nothing there ever grows a span back. The view then stayed
|
||||||
|
pinned to the newest 20 ms of a 6.5 s buffer for the rest of the session. It now clamps
|
||||||
|
against the larger of the retained span and the requested window, so a partly filled
|
||||||
|
window simply has an empty left-hand side.
|
||||||
|
|
||||||
|
The second is subtler and needs a saturated ring. A `ChannelBuffer` sample is a (value,
|
||||||
|
stamp) pair split across two rings of equal capacity, and the published window is pinned
|
||||||
|
by `acquire()` while both rings keep sliding under it — so the oldest samples of the
|
||||||
|
window point at time slots the producer has since overwritten. Those used to read back as
|
||||||
|
`0`, a perfectly plausible coordinate, which landed in `x_bounds()` and from there in
|
||||||
|
`dx()` and `x0()`. That is the renderer's entire index mapping: it converts the visible x
|
||||||
|
range into indices with `(x - x0()) / dx()`, so *one* unreadable slot inflated `dx()` by
|
||||||
|
the ratio of elapsed stream time to the retained window, scaled every index up with it,
|
||||||
|
and the gather began well inside the visible range. The stamp is now clamped to the oldest
|
||||||
|
readable slot, which costs at most a short plateau of equal stamps at the very left edge
|
||||||
|
— the same bargain the library's own `StreamSeries` documents, where an overwritten slot
|
||||||
|
costs a y value and never the geometry. The self-check prints `dx_ratio` and the
|
||||||
|
renderer's index window beside the binary-searched one, so both are checked rather than
|
||||||
|
assumed.
|
||||||
|
|
||||||
|
**The panels and the toolbar.** The header bar carries only what you reach for
|
||||||
|
*while watching a signal*: the time window, the plot layout (1..4 rows),
|
||||||
|
pause/resume, the trigger toggle, the side-panel toggle and an overflow menu. Those
|
||||||
|
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
|
||||||
|
data**, **Cursors**, **Reset cursors** and the **Signal list** toggle — the things
|
||||||
|
you set up once. The side column and the two panel toggles stay hidden until a
|
||||||
|
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
|
||||||
|
own toggle).
|
||||||
|
|
||||||
|
The trigger panel selects the **signal** (every signal the CONFIG described, not
|
||||||
|
only the acquired ones, so the list is never empty while a stream is known), the
|
||||||
|
edge, level, pre-trigger percentage, hold-off and mode, and arms the trigger.
|
||||||
|
|
||||||
|
**The event window is its own control**, separate from the display window: it is
|
||||||
|
how much of the stream one captured event covers, and the pre-trigger percentage
|
||||||
|
is a share of *that*. Tying it to the display window was a mistake — with the
|
||||||
|
600 s default a latch took 300 s (half of it) to fill, so the view sat frozen for
|
||||||
|
minutes and the trigger looked dead. With the default 50 ms event window a latch
|
||||||
|
completes 25 ms after the edge. When an event latches and the plot is zoomed out
|
||||||
|
wider than the event window, the display zooms to the event so the captured
|
||||||
|
waveform fills the plot; the window dropdown follows.
|
||||||
|
|
||||||
|
**Hold-off is the quiet time after an event has been captured** — measured from
|
||||||
|
the end of the capture, not from the edge, because the capture itself already
|
||||||
|
blocks re-arming.
|
||||||
|
Choosing a signal, switching trigger mode on, or arming the trigger all enable
|
||||||
|
that signal's acquisition — a trigger on a signal nobody acquires could never
|
||||||
|
fire.
|
||||||
|
|
||||||
|
**Trigger mode is the switch between streaming and triggering**, and the status
|
||||||
|
line always names the state it is in:
|
||||||
|
|
||||||
|
| status line | meaning |
|
||||||
|
|---|---|
|
||||||
|
| `[live]` | trigger mode off: the view follows the stream continuously |
|
||||||
|
| `[trigger: armed, waiting for an event]` | watching for an edge; the view shows the previous state |
|
||||||
|
| `[trigger: EVENT HELD …]` | an event was latched; the view is frozen on it |
|
||||||
|
| `[trigger: disarmed (view frozen)]` | armed and waiting are not the same thing — nothing will arrive until it is armed again |
|
||||||
|
|
||||||
|
While an event is on screen a **red vertical line marks the trigger instant** on
|
||||||
|
every row, at the edge's own stream time — the latch may complete a post-window
|
||||||
|
later, but the line marks the edge, so the pre/post split can be read straight off
|
||||||
|
the plot. It is not one of the user's cursors: it has its own colour, ignores the
|
||||||
|
cursor show/hide toggle, and is parked (invisible) when no event is held.
|
||||||
|
|
||||||
|
Switching the header bar's **trigger** button off returns to `[live]` and resumes
|
||||||
|
drawing the stream; the trigger panel carries the same action as a
|
||||||
|
**Go live (trigger off)** button, so the way out is inside the panel that froze
|
||||||
|
the view. Switching trigger mode on arms it immediately (arming is what "wait for
|
||||||
|
an event" means), and in single-shot mode the trigger disarms itself after a
|
||||||
|
latch, which the status line reports.
|
||||||
|
|
||||||
|
The signal panel is one line per signal: a colour chip naming the trace it is drawn
|
||||||
|
as, a checkbox that acquires it, its name, its unit, and a `⋯` menu holding the
|
||||||
|
information popup and the gain/offset calibration popup. A `GtkSwitch` and two
|
||||||
|
always-visible buttons made a row about twice as tall (46 px against 26 px now),
|
||||||
|
which is what turned a four-signal stream into a panel you have to scroll; Adwaita's
|
||||||
|
button and list-row minimums, not the widgets themselves, set that height, so the
|
||||||
|
row carries a `.udpscope-compact` style class that relaxes them.
|
||||||
|
|
||||||
|
**Signals start disabled**, because enabling one is what starts acquiring it.
|
||||||
|
Enable a signal with its checkbox, or drag it onto a plot: the drop enables the
|
||||||
|
signal if needed, binds it to that row's first free trace slot, and rebuilds the
|
||||||
|
figure so the legend name is right (there is one widget, so the row a drop lands
|
||||||
|
on is `PlotArea::row_at()`). The status line reports the outcome of a drop
|
||||||
|
(`plotting 'sine' on plot 1`, `plot 1 already holds four signals`), so an action
|
||||||
|
that changes nothing still says why. Each plot's y axis has a right-click menu
|
||||||
|
with auto scale, a fixed range, and one **Remove '<signal>' from plot N** entry per
|
||||||
|
occupied trace slot (a plot with nothing on it says so instead).
|
||||||
|
|
||||||
|
**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,
|
||||||
|
which is how the second one was found:
|
||||||
|
|
||||||
|
1. *The gutter followed the y tick labels.* `TickFormat::Si` changes their width
|
||||||
|
as the auto-fitted range moves (`1.5` → `15` → `150` → `1.5k`), and
|
||||||
|
`set_min_y_gutter()` is only a floor — the library still takes the larger of
|
||||||
|
measurement and floor. `RowSet` therefore reserves the width of the widest
|
||||||
|
label the formatter can produce, measured once with the plot's own monospaced
|
||||||
|
font, and hands it to the *figure* (`Figure::set_min_y_gutter()`, added for
|
||||||
|
this): `Figure::layout()` clears every row's own floor each frame to measure
|
||||||
|
what its labels need, so a floor written onto the rows would survive exactly
|
||||||
|
until the next layout. The figure seeds its common gutter — the maximum over
|
||||||
|
the rows, which is also what aligns their axes — with that reservation.
|
||||||
|
2. *The status line drove the pane width.* A `GtkLabel`'s minimum width is its
|
||||||
|
text, and the status line is rewritten every frame; as its counters and
|
||||||
|
messages changed width they moved the paned divider, which resized the plot,
|
||||||
|
which re-laid out the axes and their ticks. The label is now ellipsized, so its
|
||||||
|
minimum is “…”; the plot keeps the width it was given.
|
||||||
|
|
||||||
|
The self-check reports both. `viewport.w` must collapse to a single value, and
|
||||||
|
`axes.x`, `axes.w` and `y-gutter` must settle: the one-shot label reservation
|
||||||
|
lands a few frames after the first measured frame, and because the figure serves
|
||||||
|
its floor out of the label strip the reservation converges over two layouts, so
|
||||||
|
their ranges show that settle and nothing after it. The companion `axes.w
|
||||||
|
changes: N (last at frame F)` line is what separates a settle from the twitch — a
|
||||||
|
handful of changes near the start is settled, a count near the frame count is the
|
||||||
|
bug. `x-ticks` is content, and varies by one as a live window slides over a round
|
||||||
|
number:
|
||||||
|
|
||||||
|
```
|
||||||
|
layout over 952 frames: axes.x 67..80, axes.w 546..559, viewport.w 627..627,
|
||||||
|
y-gutter 44..74, x-ticks 20..21, x-desync 0
|
||||||
|
axes.w changes: 2 (last at frame 35, side panel up=1)
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `UDPSCOPE_PROBE_PAUSE_MS=<ms>` to pause the acquisition part-way through a
|
||||||
|
headless run, so the numbers are measured on a still picture.
|
||||||
|
|
||||||
|
The app also logs, once per second, one line per acquired channel with the number
|
||||||
|
of samples stored and the rate that implies, next to the rate the CONFIG declared:
|
||||||
|
|
||||||
|
```
|
||||||
|
[udpscope] ch 1 'ch2_2kHz' stored=8500 (+4603 kS/s, declared 0 S/s) stride=1 clock=1 y=[-0.98, 0.98]
|
||||||
|
```
|
||||||
|
|
||||||
|
The app no longer decimates (`stride=1 clock=1`), so a stored rate far below the
|
||||||
|
declared one is the datasource's own wire rate, not something the viewer did.
|
||||||
|
|
||||||
|
**Cursors start hidden** and are switched on from the overflow menu; **Reset cursors**
|
||||||
|
switches them on as well, because a reset nobody can see reads as a dead button.
|
||||||
|
The two panels collapse vertically (`SLIDE_DOWN`), so hiding one gives its space
|
||||||
|
back instead of leaving an invisible gap in the column.
|
||||||
|
|
||||||
|
The window dropdown offers the standard ladder up to `--max-window`, capped to
|
||||||
|
what one buffer actually holds (the cap is recomputed when a CONFIG changes the
|
||||||
|
partition, and never on a mere enable or calibration change). A span that is not
|
||||||
|
on the ladder — `Fit` frames whatever the ring holds, which is rarely a round
|
||||||
|
number — gets its own entry rather than being rounded to a neighbour, so the
|
||||||
|
dropdown reads what is on screen instead of approximating it.
|
||||||
|
|
||||||
|
**Fit to data** (overflow menu) does what the plot's own `f` key does: auto-scales every row's y
|
||||||
|
axis and re-frames the x window on the data the ring holds, propagated to every
|
||||||
|
row. The status line acknowledges it (`fitted to the buffered data (1.25 s)`).
|
||||||
|
|
||||||
|
**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.
|
||||||
|
A gesture changes the row it landed on; `Figure::layout()` adopts that change
|
||||||
|
into the group and pushes it to the others, so panning or zooming any row moves
|
||||||
|
them all with nothing to propagate by hand. The live follow is the *group's*
|
||||||
|
(`set_x_link_follow`), not each row's — `Figure::link_x()` refuses a member whose
|
||||||
|
own axis still follows, because a following axis would reinterpret the pushed
|
||||||
|
range as its own span and offset behind its own edge, which is per-row drift by
|
||||||
|
construction. The figure also owns the left gutter and the crosshair, so a
|
||||||
|
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
|
||||||
|
any row's x range differed from row 0's by more than a pixel of data. **It must
|
||||||
|
read 0.**
|
||||||
|
|
||||||
|
`Figure`'s row count is fixed at construction, so there is one structural path:
|
||||||
|
a changed row count, or a changed legend name (the one thing a `Plot` cannot
|
||||||
|
express), builds a new figure and the area is handed it. That costs no GL state —
|
||||||
|
the widget keeps its context. A displayed event survives the rebuild because the
|
||||||
|
pinned `[-pre, +post]` window is restated rather than re-derived from the live
|
||||||
|
edge.
|
||||||
|
|
||||||
|
## Review findings not (yet) implemented
|
||||||
|
|
||||||
|
These are known deviations, found by a spec/code review, that are deliberate for
|
||||||
|
now — they are listed so they are not mistaken for anything else:
|
||||||
|
|
||||||
|
- **There is one live buffer, not two.** The spec's second buffer is served by the
|
||||||
|
latched `EventSnapshot` — an immutable copy of the captured window, which is
|
||||||
|
exactly what "the buffer the view is frozen on" needs to be. Two live rings would
|
||||||
|
also mean acquisition had to follow the trigger state, which is what left the first
|
||||||
|
latch after arming with no pre-trigger history.
|
||||||
|
- **The buffer is allocated at the first CONFIG, not at initialisation.**
|
||||||
|
The spec's "N+1 split" needs N, which only a CONFIG gives; `--buffer-mb` is the
|
||||||
|
*budget*, and the power-of-two ring rounding means the final
|
||||||
|
allocation lands in `(budget/2, budget]` rather than exactly on it. Every CONFIG,
|
||||||
|
enable/disable and calibration re-partitions (and drops the history) because the
|
||||||
|
buffer layout *is* the channel list.
|
||||||
|
- **Channels are not decimated at ingest.** The spec's `window * rate > buffer`
|
||||||
|
rule was implemented as `stride = round(rate_fastest / rate_channel)`, which for
|
||||||
|
a 2 kHz sine on a 1 MHz clock is exactly one sample per period: every kept
|
||||||
|
sample sat at the same phase and the channel plotted as a flat line. Channels
|
||||||
|
are now stored at the shared clock's rate and the LOD min/max pyramid preserves
|
||||||
|
the extremes at draw time. When the requested window does not fit the budget the
|
||||||
|
*shared clock* is decimated instead (the status line's `time xN`).
|
||||||
|
- **Pre-trigger % is a share of the *event* window**, not of the retained history —
|
||||||
|
see the trigger section above for why (the spec's sentence predates the event
|
||||||
|
window being a separate control).
|
||||||
|
- **A plot holds at most four signals** (`RowSet::kSlotsPerRow`), because a `Plot`
|
||||||
|
cannot give up a trace and the slots are therefore created up front.
|
||||||
|
- **One toggle drives both cursor orientations.** The vertical markers and the
|
||||||
|
horizontal ruler exist separately but share one show/hide and one reset.
|
||||||
|
- **The time reference is the first *enabled* channel's signal.** A CONFIG whose
|
||||||
|
time signal belongs to a signal that is disabled contributes no stream time, and
|
||||||
|
the app falls back to arrival-based timing (with a warning on stderr).
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- **Multi-element (vector/matrix) signals are acquired with a zero-order hold.**
|
||||||
|
`udps_client` decodes exactly one *sample* of `num_rows x num_cols` values per
|
||||||
|
DATA packet for such a signal, regardless of the publish mode, so it cannot be
|
||||||
|
decimated on its declared rate. Each element becomes one channel sampled on the
|
||||||
|
shared clock's own cadence with the packet's value held until the next packet —
|
||||||
|
exactly faithful in `strict` mode, and the only reading of the wire that does
|
||||||
|
not invent samples in `accumulate` mode. The info popup and the CONFIG dump on
|
||||||
|
stderr report the element counts.
|
||||||
|
- **The event window can be shorter than the buffer.** The trigger latches a window
|
||||||
|
of the event-span selection; the ring holds far more, so with trigger mode off the
|
||||||
|
rest is scrollable history.
|
||||||
|
- **Calibration is applied at ingest**, so changing gain or offset rebuilds the
|
||||||
|
buffers (and drops the history). Applying it at read time would need the LOD
|
||||||
|
pyramid to be gain-aware.
|
||||||
|
- **Cursors are per row** and not synchronised with each other; show/hide parks
|
||||||
|
them outside the axis range rather than removing them, because `Plot` has no
|
||||||
|
`remove_cursor`. The trigger marker is the one exception: it has its own cursor
|
||||||
|
and is independent of the cursor toggle.
|
||||||
|
- **Verified headless only.** Acquisition, partitioning, the trigger state
|
||||||
|
machine and its integration with the buffers, and rendering under Xvfb
|
||||||
|
(llvmpipe) were exercised; the interactive paths (drag and drop, the dialogs,
|
||||||
|
the right-click menu) were not driven by an automated test.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
- Initialize 2 identical large buffer at initialization (default 200MB each, configurable as cli argument) to use as sample buffer for the rest of the application
|
||||||
|
- Uses `deps/plotter` for plotting
|
||||||
|
- Uses `deps/udpsclient/udps_client.h` to connect to one stream source
|
||||||
|
- the application should be composed of:
|
||||||
|
1. main toolbar that contain:
|
||||||
|
- time window selection
|
||||||
|
- plot layout selection
|
||||||
|
- pause/resume button
|
||||||
|
- enable/disable trigger mode
|
||||||
|
- show/hide and reset cursors [horizontal and or vertical]
|
||||||
|
2. when in trigger mode a collapsable right panel to configure trigger logic:
|
||||||
|
- select signal to use as trigger
|
||||||
|
- select edge detection type (rising, falling, both)
|
||||||
|
- select trigger level
|
||||||
|
- select pre trigger window size (in %)
|
||||||
|
- select trigger hold off time (default 10ms)
|
||||||
|
- select trigger mode [normal], [single]
|
||||||
|
- arm/disarm trigger button
|
||||||
|
3. a collapsable signal panel:
|
||||||
|
- list with all available signals
|
||||||
|
- enable/disable signal to acquire
|
||||||
|
- calibrate signal: popup to select offset and gain for signal
|
||||||
|
- drag and drop signal on plot to start plotting it
|
||||||
|
- button to show signal info in a popup (unit, range, rate, type, quantisation etc)
|
||||||
|
4. main view with plot:
|
||||||
|
- multiples plots depending from selected layout
|
||||||
|
- each plot have it x-axis sync
|
||||||
|
- each plot can have y-axis with [right click menu]:
|
||||||
|
1. auto scale (default)
|
||||||
|
2. fixed range (user definable)
|
||||||
|
- each plot can show one or more signals
|
||||||
|
- signal acqusition:
|
||||||
|
- the memory buffer should be split in N+1 (where N is the number of enabled signals) and +1 is for the shared time base
|
||||||
|
- only if the time window \* signal sample rate is greater than size of signal buffer decimate input signal
|
||||||
|
- the buffer should be used as circular buffer
|
||||||
|
- the time should be computed from the time associated with the signal (not from the client but from the streamed data)
|
||||||
|
- in trigger mode data visualisation should not be refreshed till next event, that's why 2 identical buffer, one is the currently
|
||||||
|
viewed event, the other used to acquire live data for next event
|
||||||
+1
Submodule deps/plotter added at 98cfc5805b.
Vendored
+46
@@ -0,0 +1,46 @@
|
|||||||
|
# UDPS C client library — standalone, no MARTe2, no external dependencies.
|
||||||
|
#
|
||||||
|
# make build libudpsclient.a and the example
|
||||||
|
# make example build only the example
|
||||||
|
# make cxxcheck verify the header is usable from C++
|
||||||
|
# make clean
|
||||||
|
|
||||||
|
CC ?= cc
|
||||||
|
CXX ?= c++
|
||||||
|
AR ?= ar
|
||||||
|
CFLAGS ?= -O2 -g
|
||||||
|
WARN = -Wall -Wextra -Wpedantic
|
||||||
|
STD = -std=c99
|
||||||
|
CPPFLAGS += -I.
|
||||||
|
|
||||||
|
# Old glibc (< 2.17) keeps clock_gettime in librt; harmless to add there.
|
||||||
|
LDLIBS ?=
|
||||||
|
|
||||||
|
LIB = libudpsclient.a
|
||||||
|
OBJ = udps_client.o
|
||||||
|
EXAMPLE = udps_dump
|
||||||
|
|
||||||
|
.PHONY: all example cxxcheck clean
|
||||||
|
|
||||||
|
all: $(LIB) $(EXAMPLE)
|
||||||
|
|
||||||
|
$(LIB): $(OBJ)
|
||||||
|
$(AR) rcs $@ $^
|
||||||
|
|
||||||
|
udps_client.o: udps_client.c udps_client.h
|
||||||
|
$(CC) $(STD) $(WARN) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
|
||||||
|
|
||||||
|
example: $(EXAMPLE)
|
||||||
|
|
||||||
|
$(EXAMPLE): example/udps_dump.c $(LIB)
|
||||||
|
$(CC) $(STD) $(WARN) $(CFLAGS) $(CPPFLAGS) -o $@ $< $(LIB) $(LDLIBS)
|
||||||
|
|
||||||
|
# The header is C++-safe; this target keeps it that way.
|
||||||
|
cxxcheck: udps_client.h
|
||||||
|
echo '#include "udps_client.h"' > .cxxcheck.cpp
|
||||||
|
echo 'int main() { udps_client_config_t c; udps_client_config_init(&c); return 0; }' >> .cxxcheck.cpp
|
||||||
|
$(CXX) -std=c++11 -Wall -Wextra $(CPPFLAGS) -o .cxxcheck .cxxcheck.cpp $(LIB) $(LDLIBS)
|
||||||
|
./.cxxcheck && rm -f .cxxcheck .cxxcheck.cpp
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f $(LIB) $(OBJ) $(EXAMPLE) .cxxcheck .cxxcheck.cpp
|
||||||
Vendored
+1230
File diff suppressed because it is too large.
Load diff
Vendored
+340
@@ -0,0 +1,340 @@
|
|||||||
|
#ifndef UDPS_CLIENT_H
|
||||||
|
#define UDPS_CLIENT_H
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file udps_client.h
|
||||||
|
* @brief Standalone UDPS (UDPStreamer) receiver library — C99, no MARTe2.
|
||||||
|
*
|
||||||
|
* Depends only on libc and BSD sockets, so it can be dropped into any C or C++
|
||||||
|
* program that needs to consume a UDPStreamer / DebugService stream. The wire
|
||||||
|
* format is specified in Docs/Protocol.md; the library reference (and a worked
|
||||||
|
* example) is Docs/UDPS-C-Client.md.
|
||||||
|
*
|
||||||
|
* Usage in one paragraph: fill a udps_client_config_t, create a client, install
|
||||||
|
* callbacks, then call udps_client_poll() in a loop. The client owns the
|
||||||
|
* connection state machine — it sends CONNECT, reassembles fragmented packets,
|
||||||
|
* decodes CONFIG and DATA, sends keepalives, and reconnects when the server
|
||||||
|
* goes silent. Nothing is done behind your back: no threads are created and
|
||||||
|
* every callback runs inside your call to udps_client_poll().
|
||||||
|
*
|
||||||
|
* Threading: a udps_client_t must be used from one thread at a time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/*---------------------------------------------------------------------------*/
|
||||||
|
/* Protocol constants */
|
||||||
|
/*---------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
/** Magic number: ASCII 'UDPS' stored little-endian. */
|
||||||
|
#define UDPS_MAGIC 0x53504455u
|
||||||
|
|
||||||
|
/** Size of the packed packet header on the wire. */
|
||||||
|
#define UDPS_HEADER_SIZE 17u
|
||||||
|
|
||||||
|
/** Size of one serialised signal descriptor in a CONFIG payload. */
|
||||||
|
#define UDPS_SIGNAL_DESC_SIZE 136u
|
||||||
|
|
||||||
|
/** Value of udps_signal_t::time_signal_idx when the signal has no time reference. */
|
||||||
|
#define UDPS_NO_TIME_SIGNAL 0xFFFFFFFFu
|
||||||
|
|
||||||
|
/** Upper bound on elements per signal; larger descriptors are rejected. */
|
||||||
|
#define UDPS_MAX_ELEMENTS (1u << 20)
|
||||||
|
|
||||||
|
/** Packet types (udps_header_t::type). */
|
||||||
|
enum {
|
||||||
|
UDPS_PKT_DATA = 0, /**< Server -> client: signal samples. */
|
||||||
|
UDPS_PKT_CONFIG = 1, /**< Server -> client: signal metadata. */
|
||||||
|
UDPS_PKT_ACK = 2, /**< Client -> server: keepalive. */
|
||||||
|
UDPS_PKT_CONNECT = 3, /**< Client -> server: open a session. */
|
||||||
|
UDPS_PKT_DISCONNECT = 4 /**< Either direction: close a session. */
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Sample type codes (udps_signal_t::type_code). */
|
||||||
|
enum {
|
||||||
|
UDPS_T_UINT8 = 0,
|
||||||
|
UDPS_T_INT8 = 1,
|
||||||
|
UDPS_T_UINT16 = 2,
|
||||||
|
UDPS_T_INT16 = 3,
|
||||||
|
UDPS_T_UINT32 = 4,
|
||||||
|
UDPS_T_INT32 = 5,
|
||||||
|
UDPS_T_UINT64 = 6,
|
||||||
|
UDPS_T_INT64 = 7,
|
||||||
|
UDPS_T_FLOAT32 = 8,
|
||||||
|
UDPS_T_FLOAT64 = 9,
|
||||||
|
UDPS_T_UNKNOWN = 255
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Quantisation codes (udps_signal_t::quant_type). */
|
||||||
|
enum {
|
||||||
|
UDPS_QUANT_NONE = 0, /**< Raw values in the signal's own type. */
|
||||||
|
UDPS_QUANT_UINT8 = 1, /**< [range_min, range_max] mapped onto uint8. */
|
||||||
|
UDPS_QUANT_INT8 = 2,
|
||||||
|
UDPS_QUANT_UINT16 = 3,
|
||||||
|
UDPS_QUANT_INT16 = 4
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Time-reference modes (udps_signal_t::time_mode). */
|
||||||
|
enum {
|
||||||
|
UDPS_TIME_PACKET = 0, /**< No per-element time; use packet arrival. */
|
||||||
|
UDPS_TIME_FULL_ARRAY = 1, /**< The time signal carries one stamp per element. */
|
||||||
|
UDPS_TIME_FIRST_SAMPLE = 2, /**< Time signal (scalar) stamps element 0. */
|
||||||
|
UDPS_TIME_LAST_SAMPLE = 3 /**< Time signal (scalar) stamps element N-1. */
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Publishing modes (udps_frame_t::publish_mode). */
|
||||||
|
enum {
|
||||||
|
UDPS_PUBLISH_STRICT = 0, /**< One packet per RT cycle. */
|
||||||
|
UDPS_PUBLISH_ACCUMULATE = 1, /**< A batch of cycles per packet. */
|
||||||
|
UDPS_PUBLISH_DECIMATE = 2 /**< One packet every N cycles. */
|
||||||
|
};
|
||||||
|
|
||||||
|
/*---------------------------------------------------------------------------*/
|
||||||
|
/* Data model */
|
||||||
|
/*---------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
/** Decoded 17-byte packet header. */
|
||||||
|
typedef struct {
|
||||||
|
uint32_t magic;
|
||||||
|
uint8_t type;
|
||||||
|
uint32_t counter; /**< Same for every fragment of one update. */
|
||||||
|
uint16_t fragment_idx;
|
||||||
|
uint16_t total_fragments; /**< 1 when the update fits in one datagram. */
|
||||||
|
uint32_t payload_bytes;
|
||||||
|
} udps_header_t;
|
||||||
|
|
||||||
|
/** Metadata for one streamed signal, as carried by the CONFIG payload. */
|
||||||
|
typedef struct {
|
||||||
|
char name[65]; /**< NUL-terminated. */
|
||||||
|
uint8_t type_code; /**< UDPS_T_*. */
|
||||||
|
uint8_t quant_type; /**< UDPS_QUANT_*. */
|
||||||
|
uint8_t num_dimensions; /**< 0 scalar, 1 vector, 2 matrix. */
|
||||||
|
uint32_t num_rows;
|
||||||
|
uint32_t num_cols;
|
||||||
|
double range_min; /**< Physical range, used to dequantise. */
|
||||||
|
double range_max;
|
||||||
|
uint8_t time_mode; /**< UDPS_TIME_*. */
|
||||||
|
double sampling_rate; /**< Hz; 0 when unknown. */
|
||||||
|
uint32_t time_signal_idx;/**< Index into the signal list, or UDPS_NO_TIME_SIGNAL. */
|
||||||
|
char unit[33]; /**< NUL-terminated. */
|
||||||
|
} udps_signal_t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Decoded values of one signal within a frame.
|
||||||
|
*
|
||||||
|
* Values are always physical doubles: quantised signals are already expanded
|
||||||
|
* back onto [range_min, range_max]. @c count is @c num_samples for a scalar
|
||||||
|
* signal in Accumulate mode (one value per batched cycle) and the signal's
|
||||||
|
* element count in every other case.
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
const double *values;
|
||||||
|
uint32_t count;
|
||||||
|
} udps_signal_values_t;
|
||||||
|
|
||||||
|
/** One fully decoded DATA packet. */
|
||||||
|
typedef struct {
|
||||||
|
uint32_t counter; /**< Packet counter; gaps mean lost datagrams. */
|
||||||
|
/**
|
||||||
|
* DATA packets missing immediately before this one, from the counter.
|
||||||
|
*
|
||||||
|
* Needed to space samples correctly: the elapsed time since the previous
|
||||||
|
* frame covers the lost packets' cycles too, so dividing it by this
|
||||||
|
* frame's sample count alone gives a period too long by exactly
|
||||||
|
* @c lost + 1, which walks the samples past their own end and into the
|
||||||
|
* range the next frame claims.
|
||||||
|
*/
|
||||||
|
uint32_t lost;
|
||||||
|
uint64_t hrt; /**< Producer's high-resolution timer at send. */
|
||||||
|
double recv_time; /**< Wall-clock seconds (CLOCK_REALTIME) at arrival. */
|
||||||
|
uint8_t publish_mode; /**< UDPS_PUBLISH_*. */
|
||||||
|
uint32_t num_samples; /**< Batched cycles; 1 unless Accumulate. */
|
||||||
|
uint32_t num_signals;
|
||||||
|
const udps_signal_t *signals; /**< num_signals entries, CONFIG order. */
|
||||||
|
const udps_signal_values_t *values; /**< num_signals entries, same order. */
|
||||||
|
} udps_frame_t;
|
||||||
|
|
||||||
|
/** Connection lifecycle events reported through udps_event_cb. */
|
||||||
|
typedef enum {
|
||||||
|
UDPS_EVENT_CONNECTED, /**< Sockets are up and CONNECT was sent. */
|
||||||
|
UDPS_EVENT_DISCONNECTED, /**< Session dropped; the client will retry. */
|
||||||
|
UDPS_EVENT_ERROR /**< Recoverable problem; detail says what. */
|
||||||
|
} udps_event_t;
|
||||||
|
|
||||||
|
/** Cumulative counters, never reset. */
|
||||||
|
typedef struct {
|
||||||
|
uint64_t packets_received; /**< Datagrams (and TCP frames) accepted. */
|
||||||
|
uint64_t bytes_received;
|
||||||
|
uint64_t frames_delivered; /**< DATA packets decoded and handed to you. */
|
||||||
|
uint64_t config_updates;
|
||||||
|
uint64_t fragments_dropped; /**< Duplicate, stale or unplaceable fragments. */
|
||||||
|
uint64_t counter_gaps; /**< DATA packets missing from the sequence. */
|
||||||
|
/**
|
||||||
|
* DATA packets dropped for not advancing the counter: reordered or
|
||||||
|
* duplicated on the wire. Delivering one would stamp its values with a
|
||||||
|
* time base older than data already handed over.
|
||||||
|
*/
|
||||||
|
uint64_t stale_packets;
|
||||||
|
uint64_t reconnects;
|
||||||
|
} udps_stats_t;
|
||||||
|
|
||||||
|
/*---------------------------------------------------------------------------*/
|
||||||
|
/* Client */
|
||||||
|
/*---------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
typedef struct udps_client udps_client_t;
|
||||||
|
|
||||||
|
/** Called whenever a CONFIG packet redefines the signal set. */
|
||||||
|
typedef void (*udps_config_cb)(const udps_signal_t *signals,
|
||||||
|
uint32_t num_signals,
|
||||||
|
uint8_t publish_mode,
|
||||||
|
void *user);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Called for every decoded DATA packet.
|
||||||
|
*
|
||||||
|
* The frame and everything it points at are owned by the client and are only
|
||||||
|
* valid until the callback returns — copy anything you need to keep.
|
||||||
|
*/
|
||||||
|
typedef void (*udps_data_cb)(const udps_frame_t *frame, void *user);
|
||||||
|
|
||||||
|
/** Called on connection state changes and on recoverable errors. */
|
||||||
|
typedef void (*udps_event_cb)(udps_event_t event, const char *detail, void *user);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Transport configuration.
|
||||||
|
*
|
||||||
|
* Zero-initialise with udps_client_config_init(), then override what you need.
|
||||||
|
* Set @c multicast_group to switch from unicast to multicast: in unicast the
|
||||||
|
* client sends CONNECT over UDP and receives everything on its ephemeral port;
|
||||||
|
* in multicast it joins the group for DATA and opens a TCP control connection
|
||||||
|
* to @c server_port for CONNECT and CONFIG.
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
const char *server_addr; /**< IPv4 dotted quad. Required. */
|
||||||
|
uint16_t server_port; /**< UDP port (unicast) or TCP port (multicast). Required. */
|
||||||
|
const char *multicast_group;/**< IPv4 group; NULL selects unicast. */
|
||||||
|
const char *interface_addr; /**< Local IPv4 of the interface to join on. NULL = default route. */
|
||||||
|
uint16_t data_port; /**< Multicast data port; 0 means server_port + 1. */
|
||||||
|
double silence_timeout_s; /**< Reconnect after this long without data. 0 disables. */
|
||||||
|
double reconnect_delay_s; /**< Wait between reconnect attempts. */
|
||||||
|
double keepalive_interval_s;/**< Unicast ACK period. 0 disables. */
|
||||||
|
uint32_t recv_buffer_bytes; /**< SO_RCVBUF; large bursts need a large value. */
|
||||||
|
uint32_t max_packet_bytes; /**< Ceiling on one reassembled payload. */
|
||||||
|
} udps_client_config_t;
|
||||||
|
|
||||||
|
/** Fills @p cfg with the defaults documented in Docs/UDPS-C-Client.md. */
|
||||||
|
void udps_client_config_init(udps_client_config_t *cfg);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Creates a client. No socket is opened until the first poll.
|
||||||
|
* @return NULL if @p cfg is invalid or memory ran out.
|
||||||
|
*/
|
||||||
|
udps_client_t *udps_client_create(const udps_client_config_t *cfg);
|
||||||
|
|
||||||
|
/** Closes the session (sending DISCONNECT if connected) and frees the client. */
|
||||||
|
void udps_client_destroy(udps_client_t *client);
|
||||||
|
|
||||||
|
/** Installs the callbacks. Any of them may be NULL. */
|
||||||
|
void udps_client_set_callbacks(udps_client_t *client,
|
||||||
|
udps_config_cb on_config,
|
||||||
|
udps_data_cb on_data,
|
||||||
|
udps_event_cb on_event,
|
||||||
|
void *user);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Drives the client: connects if needed, then waits for and processes
|
||||||
|
* packets for at most @p timeout_ms milliseconds.
|
||||||
|
*
|
||||||
|
* Callbacks fire from inside this call. A negative @p timeout_ms blocks until
|
||||||
|
* something happens. Call it in a loop; it is the only function that does work.
|
||||||
|
*
|
||||||
|
* @return the number of packets processed (0 on timeout), or -1 if the session
|
||||||
|
* broke. -1 is not fatal: the next call retries after reconnect_delay_s.
|
||||||
|
*/
|
||||||
|
int udps_client_poll(udps_client_t *client, int timeout_ms);
|
||||||
|
|
||||||
|
/** Non-zero once the sockets are up (which does not yet imply CONFIG arrived). */
|
||||||
|
int udps_client_is_connected(const udps_client_t *client);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief The current signal set, or NULL before the first CONFIG.
|
||||||
|
* @param num_signals Out; may be NULL.
|
||||||
|
*/
|
||||||
|
const udps_signal_t *udps_client_signals(const udps_client_t *client,
|
||||||
|
uint32_t *num_signals);
|
||||||
|
|
||||||
|
/** The publishing mode from the last CONFIG (UDPS_PUBLISH_*). */
|
||||||
|
uint8_t udps_client_publish_mode(const udps_client_t *client);
|
||||||
|
|
||||||
|
/** Copies the counters into @p out. */
|
||||||
|
void udps_client_stats(const udps_client_t *client, udps_stats_t *out);
|
||||||
|
|
||||||
|
/** Human-readable description of the last failure. Never NULL. */
|
||||||
|
const char *udps_client_last_error(const udps_client_t *client);
|
||||||
|
|
||||||
|
/*---------------------------------------------------------------------------*/
|
||||||
|
/* Stateless helpers */
|
||||||
|
/*---------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
/** Elements in one sample of @p signal (rows x cols, at least 1). */
|
||||||
|
uint32_t udps_signal_num_elements(const udps_signal_t *signal);
|
||||||
|
|
||||||
|
/** Short name of a type code, e.g. "float32". Never NULL. */
|
||||||
|
const char *udps_type_name(uint8_t type_code);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Decodes a packet header.
|
||||||
|
* @return 0 on success, -1 if @p len is too small or the magic is wrong.
|
||||||
|
*/
|
||||||
|
int udps_parse_header(const void *buf, size_t len, udps_header_t *out);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Decodes a reassembled CONFIG payload.
|
||||||
|
* @param signals Out array of at most @p max_signals entries.
|
||||||
|
* @param num_signals Out; the number actually written.
|
||||||
|
* @param publish_mode Out; may be NULL.
|
||||||
|
* @return 0 on success, -1 if the payload is malformed or does not fit.
|
||||||
|
*/
|
||||||
|
int udps_parse_config(const void *payload,
|
||||||
|
size_t len,
|
||||||
|
udps_signal_t *signals,
|
||||||
|
uint32_t max_signals,
|
||||||
|
uint32_t *num_signals,
|
||||||
|
uint8_t *publish_mode);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief One value out of a frame.
|
||||||
|
* @param sample Accumulate batch slot; ignored for non-scalar signals.
|
||||||
|
* @param elem Element within the sample; ignored for accumulated scalars.
|
||||||
|
* @return the value, or 0.0 if any index is out of range.
|
||||||
|
*/
|
||||||
|
double udps_frame_value(const udps_frame_t *frame,
|
||||||
|
uint32_t signal_idx,
|
||||||
|
uint32_t sample,
|
||||||
|
uint32_t elem);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Arrival-anchored estimate of the wall-clock time of one element.
|
||||||
|
*
|
||||||
|
* Exact only for streams that declare a sampling rate: the packet is assumed to
|
||||||
|
* have arrived as its last element was produced, and earlier elements are dated
|
||||||
|
* backwards by 1/sampling_rate. Signals with UDPS_TIME_PACKET, or without a
|
||||||
|
* sampling rate, all report the arrival time. When the stream carries a time
|
||||||
|
* signal (time_signal_idx != UDPS_NO_TIME_SIGNAL) that signal is the accurate
|
||||||
|
* source — read it like any other signal instead of using this helper.
|
||||||
|
*/
|
||||||
|
double udps_frame_element_time(const udps_frame_t *frame,
|
||||||
|
uint32_t signal_idx,
|
||||||
|
uint32_t elem);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
} /* extern "C" */
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* UDPS_CLIENT_H */
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "acquisition.hpp"
|
||||||
|
|
||||||
|
#include <udps_client.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstring>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
SignalInfo to_info(const udps_signal_t& s) {
|
||||||
|
SignalInfo info;
|
||||||
|
info.name = s.name;
|
||||||
|
info.unit = s.unit;
|
||||||
|
info.type_code = s.type_code;
|
||||||
|
info.quant_type = s.quant_type;
|
||||||
|
info.rows = s.num_rows;
|
||||||
|
info.cols = s.num_cols;
|
||||||
|
info.range_min = s.range_min;
|
||||||
|
info.range_max = s.range_max;
|
||||||
|
info.time_mode = s.time_mode;
|
||||||
|
info.sampling_rate = s.sampling_rate;
|
||||||
|
info.time_signal_idx = s.time_signal_idx;
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Acquisition::Acquisition(ScopeModel& model, const Config& cfg) : model_(model), cfg_(cfg) {}
|
||||||
|
|
||||||
|
Acquisition::~Acquisition() { stop(); }
|
||||||
|
|
||||||
|
void Acquisition::start() {
|
||||||
|
udps_client_config_t c;
|
||||||
|
udps_client_config_init(&c);
|
||||||
|
c.server_addr = cfg_.server.c_str();
|
||||||
|
c.server_port = cfg_.port;
|
||||||
|
c.multicast_group = cfg_.multicast.empty() ? nullptr : cfg_.multicast.c_str();
|
||||||
|
c.interface_addr = cfg_.interface_addr.empty() ? nullptr : cfg_.interface_addr.c_str();
|
||||||
|
c.recv_buffer_bytes = static_cast<std::uint32_t>(cfg_.recv_buffer_bytes);
|
||||||
|
c.silence_timeout_s = 2.0;
|
||||||
|
c.reconnect_delay_s = 0.5;
|
||||||
|
c.keepalive_interval_s = 1.0;
|
||||||
|
|
||||||
|
client_ = udps_client_create(&c);
|
||||||
|
if (client_ == nullptr) {
|
||||||
|
throw std::runtime_error("could not create the UDPS client for " + cfg_.server + ":" +
|
||||||
|
std::to_string(cfg_.port));
|
||||||
|
}
|
||||||
|
udps_client_set_callbacks(client_, &Acquisition::config_trampoline, &Acquisition::data_trampoline,
|
||||||
|
&Acquisition::event_trampoline, this);
|
||||||
|
|
||||||
|
started_ = std::chrono::steady_clock::now();
|
||||||
|
running_.store(true, std::memory_order_release);
|
||||||
|
thread_ = std::thread([this] { run(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
void Acquisition::stop() {
|
||||||
|
if (!running_.exchange(false, std::memory_order_acq_rel)) {
|
||||||
|
if (client_ != nullptr) {
|
||||||
|
udps_client_destroy(client_);
|
||||||
|
client_ = nullptr;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (thread_.joinable()) thread_.join();
|
||||||
|
if (client_ != nullptr) {
|
||||||
|
udps_client_destroy(client_);
|
||||||
|
client_ = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Acquisition::run() {
|
||||||
|
while (running_.load(std::memory_order_acquire)) {
|
||||||
|
udps_client_poll(client_, 50);
|
||||||
|
udps_stats_t s;
|
||||||
|
udps_client_stats(client_, &s);
|
||||||
|
StreamStats& out = model_.stats();
|
||||||
|
out.packets.store(s.packets_received, std::memory_order_relaxed);
|
||||||
|
out.frames.store(s.frames_delivered, std::memory_order_relaxed);
|
||||||
|
out.gaps.store(s.counter_gaps, std::memory_order_relaxed);
|
||||||
|
out.reconnects.store(s.reconnects, std::memory_order_relaxed);
|
||||||
|
out.connected.store(udps_client_is_connected(client_) != 0, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Acquisition::on_config(const udps_signal_t* signals, std::uint32_t count,
|
||||||
|
std::uint8_t publish_mode) {
|
||||||
|
std::vector<SignalInfo> info;
|
||||||
|
info.reserve(count);
|
||||||
|
for (std::uint32_t i = 0; i < count; ++i) info.push_back(to_info(signals[i]));
|
||||||
|
|
||||||
|
// One line per CONFIG on stderr: what the stream says it is. This is the first
|
||||||
|
// thing to look at when a signal refuses to plot.
|
||||||
|
static const char* const kModes[] = {"strict", "accumulate", "decimate"};
|
||||||
|
std::fprintf(stderr, "[udpscope] CONFIG: %u signals, publish=%s\n", count,
|
||||||
|
publish_mode < 3 ? kModes[publish_mode] : "?");
|
||||||
|
for (const SignalInfo& sig : info) {
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[udpscope] '%s' %ux%u (%u elem) rate=%g Hz time=%s idx=%u unit='%s'\n",
|
||||||
|
sig.name.c_str(), sig.rows, sig.cols, sig.elements(), sig.sampling_rate,
|
||||||
|
sig.time_mode == UDPS_TIME_PACKET ? "packet"
|
||||||
|
: sig.time_mode == UDPS_TIME_FULL_ARRAY ? "full-array"
|
||||||
|
: sig.time_mode == UDPS_TIME_FIRST_SAMPLE ? "first-sample"
|
||||||
|
: "last-sample",
|
||||||
|
sig.time_signal_idx, sig.unit.c_str());
|
||||||
|
}
|
||||||
|
std::fflush(stderr);
|
||||||
|
model_.stats().have_config.store(true, std::memory_order_relaxed);
|
||||||
|
model_.set_signals(std::move(info));
|
||||||
|
}
|
||||||
|
|
||||||
|
void Acquisition::on_event(int event, const char* detail) {
|
||||||
|
(void)detail;
|
||||||
|
if (event == UDPS_EVENT_CONNECTED) {
|
||||||
|
model_.stats().connected.store(true, std::memory_order_relaxed);
|
||||||
|
} else if (event == UDPS_EVENT_DISCONNECTED) {
|
||||||
|
model_.stats().connected.store(false, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_signal) {
|
||||||
|
// The frame's span in base ticks: a scalar signal carries `num_samples` values
|
||||||
|
// (one per batched cycle), a block signal (rows*cols > 1) carries its whole
|
||||||
|
// element block. The largest of those is how many base ticks this frame spans --
|
||||||
|
// in `strict` mode `num_samples` is 1 while a 1x1000 block carries 1000 samples.
|
||||||
|
std::uint32_t n = frame->num_samples;
|
||||||
|
for (std::uint32_t i = 0; i < frame->num_signals; ++i) {
|
||||||
|
n = std::max(n, udps_signal_num_elements(&frame->signals[i]));
|
||||||
|
}
|
||||||
|
stamps_.assign(n, 0.0);
|
||||||
|
|
||||||
|
double period = model_.partition().base_dt;
|
||||||
|
if (!(period > 0.0)) period = 1.0e-3;
|
||||||
|
|
||||||
|
bool used_stream_time = false;
|
||||||
|
if (time_signal >= 0 && time_signal < static_cast<int>(frame->num_signals)) {
|
||||||
|
const udps_signal_t& ts = frame->signals[time_signal];
|
||||||
|
// How the client indexes a signal's values depends on its shape
|
||||||
|
// (`udps_frame_value`): a scalar carries one value per batched cycle, so the
|
||||||
|
// stamp of sample k is (sample=k, elem=0); a multi-element signal carries one
|
||||||
|
// *sample* of `elements` values per packet, so the stamp of sample k is
|
||||||
|
// (sample=0, elem=k). Passing the scalar form for both is why FULL_ARRAY time
|
||||||
|
// signals -- the only mode that carries a stamp per element -- read as
|
||||||
|
// element 0 for every sample and were then judged degenerate.
|
||||||
|
const bool scalar_time = udps_signal_num_elements(&ts) == 1;
|
||||||
|
auto value_of = [&](std::uint32_t k) {
|
||||||
|
return scalar_time
|
||||||
|
? udps_frame_value(frame, static_cast<std::uint32_t>(time_signal), k, 0)
|
||||||
|
: udps_frame_value(frame, static_cast<std::uint32_t>(time_signal), 0, k);
|
||||||
|
};
|
||||||
|
switch (ts.time_mode) {
|
||||||
|
case UDPS_TIME_FULL_ARRAY:
|
||||||
|
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = value_of(k);
|
||||||
|
used_stream_time = true;
|
||||||
|
break;
|
||||||
|
case UDPS_TIME_FIRST_SAMPLE: {
|
||||||
|
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) * step;
|
||||||
|
used_stream_time = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case UDPS_TIME_LAST_SAMPLE: {
|
||||||
|
const double anchor = value_of(n > 0 ? n - 1 : 0);
|
||||||
|
for (std::uint32_t k = 0; k < n; ++k)
|
||||||
|
stamps_[k] = anchor - double(n - 1 - k) * period;
|
||||||
|
used_stream_time = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (used_stream_time && n > 0) {
|
||||||
|
// A time signal that does not actually vary -- a vector time signal, whose
|
||||||
|
// values the client reads one element at a time, or a producer that leaves it
|
||||||
|
// zero -- would put every sample at one x and collapse the axis to a point.
|
||||||
|
// The arrival estimate is a worse clock but a usable one; a degenerate one is
|
||||||
|
// not a time base at all.
|
||||||
|
bool usable = std::isfinite(stamps_[0]);
|
||||||
|
double lo = stamps_[0];
|
||||||
|
double hi = stamps_[0];
|
||||||
|
for (std::uint32_t k = 1; usable && k < n; ++k) {
|
||||||
|
if (!std::isfinite(stamps_[k])) {
|
||||||
|
usable = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
lo = std::min(lo, stamps_[k]);
|
||||||
|
hi = std::max(hi, stamps_[k]);
|
||||||
|
}
|
||||||
|
if (usable && hi > lo) {
|
||||||
|
// The stamps must also ADVANCE across packets. A producer that restarts its
|
||||||
|
// stamps every packet (or a signal mislabelled as time) keeps handing back
|
||||||
|
// the same values; the max-clamp would then pin every sample at one x, and
|
||||||
|
// the trigger's hold-off clock with it -- the engine waited forever and no
|
||||||
|
// second event ever fired.
|
||||||
|
if (have_stream_anchor_ && !(stamps_[n - 1] > last_stream_anchor_)) {
|
||||||
|
usable = false;
|
||||||
|
} else {
|
||||||
|
last_stream_anchor_ = stamps_[n - 1];
|
||||||
|
have_stream_anchor_ = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!usable || !(hi > lo)) {
|
||||||
|
used_stream_time = false; // no span to time with; fall back to arrival
|
||||||
|
static bool warned = false;
|
||||||
|
if (!warned) {
|
||||||
|
warned = true;
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[udpscope] warning: the stream's time signal carries no usable span "
|
||||||
|
"(%g..%g) or does not advance across packets; timing samples by "
|
||||||
|
"arrival instead. Check the time signal's mode and index in the "
|
||||||
|
"CONFIG dump above.\n",
|
||||||
|
lo, hi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!used_stream_time) {
|
||||||
|
// Arrival-anchored: the packet is assumed to have arrived as its last element
|
||||||
|
// was produced, and the earlier ones are dated backwards. A gap (`lost`)
|
||||||
|
// shows up as extra elapsed time between arrivals, which is exactly the
|
||||||
|
// spacing the samples had.
|
||||||
|
const double end = frame->recv_time;
|
||||||
|
if (!have_arrival_epoch_) {
|
||||||
|
arrival_epoch_ = end;
|
||||||
|
have_arrival_epoch_ = true;
|
||||||
|
}
|
||||||
|
const double base = end - arrival_epoch_;
|
||||||
|
double step = period;
|
||||||
|
if (have_recv_ && n > 0) {
|
||||||
|
const double elapsed = end - last_recv_;
|
||||||
|
const double cycles = double(n) * double(frame->lost + 1);
|
||||||
|
if (elapsed > 0.0 && cycles > 0.0) step = elapsed / cycles;
|
||||||
|
}
|
||||||
|
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = base - double(n - 1 - k) * step;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stream-time signal may be declared in a time unit rather than seconds
|
||||||
|
// ('ns' is the common one): scale it to seconds so the x axis is in seconds
|
||||||
|
// either way. The scale applies ONLY to stream time: the arrival fallback
|
||||||
|
// already produces seconds off the socket clock -- scaling those by the time
|
||||||
|
// signal's unit shrank the whole time base by 1e9 on 'ns' streams, which
|
||||||
|
// froze the trigger's hold-off clock and pinned the axis at ~0.
|
||||||
|
if (used_stream_time && time_signal >= 0 &&
|
||||||
|
time_signal < static_cast<int>(model_.signals().size())) {
|
||||||
|
double unit_scale = 1.0;
|
||||||
|
const std::string& unit = model_.signals()[static_cast<std::size_t>(time_signal)].unit;
|
||||||
|
if (unit == "ns") unit_scale = 1e-9;
|
||||||
|
else if (unit == "us" || unit == "\u00b5s" || unit == "\xce\xbc" "s") unit_scale = 1e-6;
|
||||||
|
else if (unit == "ms") unit_scale = 1e-3;
|
||||||
|
if (unit_scale != 1.0) {
|
||||||
|
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] *= unit_scale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
last_recv_ = frame->recv_time;
|
||||||
|
have_recv_ = true;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Acquisition::on_data(const udps_frame_t* frame) {
|
||||||
|
if (paused_.load(std::memory_order_relaxed)) return;
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(model_.mutex());
|
||||||
|
if (!running_.load(std::memory_order_relaxed)) return;
|
||||||
|
|
||||||
|
const Partition& part = model_.partition();
|
||||||
|
const std::vector<ChannelInfo>& channels = model_.channels();
|
||||||
|
if (part.time_capacity == 0 || channels.empty()) return;
|
||||||
|
|
||||||
|
// The time reference, in order of preference: the signal the first channel's
|
||||||
|
// CONFIG entry names via `time_signal_idx`, or -- when the stream instead
|
||||||
|
// carries its time base as an explicit full-array signal -- that signal.
|
||||||
|
int time_signal = -1;
|
||||||
|
{
|
||||||
|
const SignalInfo& sig = model_.signals()[channels[0].signal];
|
||||||
|
if (sig.time_signal_idx != 0xFFFFFFFFu && sig.time_signal_idx < model_.signals().size()) {
|
||||||
|
time_signal = static_cast<int>(sig.time_signal_idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (time_signal < 0) {
|
||||||
|
// No channel names a time signal: look for the standalone timestamp signal
|
||||||
|
// (full-array, more than one element, no declared rate).
|
||||||
|
for (std::size_t i = 0; i < model_.signals().size(); ++i) {
|
||||||
|
const SignalInfo& sig = model_.signals()[i];
|
||||||
|
if (sig.time_mode == UDPS_TIME_FULL_ARRAY && sig.elements() > 1 &&
|
||||||
|
sig.sampling_rate == 0.0) {
|
||||||
|
time_signal = static_cast<int>(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const std::uint32_t n = build_stamps(frame, time_signal);
|
||||||
|
if (n == 0) return;
|
||||||
|
|
||||||
|
if (model_.epoch() == 0.0) {
|
||||||
|
model_.set_epoch_if_unset(stamps_[0]);
|
||||||
|
std::fprintf(stderr, "[udpscope] time base: epoch=%.3f, first frame recv=%.3f, n=%u\n",
|
||||||
|
model_.epoch(), frame->recv_time, n);
|
||||||
|
std::fflush(stderr);
|
||||||
|
}
|
||||||
|
const double epoch = model_.epoch();
|
||||||
|
|
||||||
|
TriggerConfig& tc = model_.trigger();
|
||||||
|
// One ring, always filling, whatever the trigger is doing: the pre-trigger
|
||||||
|
// history has to already be there when an edge arrives, and the first latch after
|
||||||
|
// arming would otherwise open on however little had accumulated since. What
|
||||||
|
// freezes in trigger mode is the *view*, not acquisition -- the latch copies the
|
||||||
|
// event window out into an immutable `EventSnapshot`.
|
||||||
|
BufferStore& store = model_.live();
|
||||||
|
const u64 g = part.time_stride;
|
||||||
|
|
||||||
|
// Calibration is read per frame; it only changes under the same lock.
|
||||||
|
TriggerEngine& engine = engine_;
|
||||||
|
const int trig_channel = tc.channel;
|
||||||
|
// The post-trigger window straight through in seconds. There used to be a measured,
|
||||||
|
// smoothed samples-per-second estimate here, converting the window into a sample
|
||||||
|
// count for the engine; the estimate is what sized captures wrong. Its floor
|
||||||
|
// (`inst >= 1.0`) turned any stream whose time signal advances in large steps into
|
||||||
|
// 1 S/s, the count came out as 1, and the engine latched on the edge sample itself
|
||||||
|
// -- an event with no post-trigger data, which is exactly the "post window is
|
||||||
|
// empty" defect. The engine is fed stream time per sample anyway, so it can count
|
||||||
|
// the window itself and no conversion has to exist.
|
||||||
|
//
|
||||||
|
// The oldest buffered stamp goes with it, so the engine can refuse an edge the
|
||||||
|
// history does not reach back far enough behind. Read from the ring rather than
|
||||||
|
// timed from the arm: the ring fills with the trigger off too, so the pre window is
|
||||||
|
// usually already there and a timer would delay the first event for nothing.
|
||||||
|
const double event_span = model_.event_span();
|
||||||
|
const double post_want = tc.post_span_s(event_span);
|
||||||
|
const double oldest_s =
|
||||||
|
store.time().size() > 0 ? double(store.time()[0]) : last_stamp_;
|
||||||
|
engine.configure(tc, post_want, event_span - post_want, oldest_s);
|
||||||
|
// The engine's armed state follows the panel's, both ways: without the disarm
|
||||||
|
// half, Disarm left the engine firing (its own `armed_` was only ever set), and
|
||||||
|
// re-entering trigger mode kept the stale prev/edge/hold-off state.
|
||||||
|
if (tc.enabled && tc.armed) {
|
||||||
|
if (!engine.armed()) {
|
||||||
|
engine.arm(last_stamp_);
|
||||||
|
model_.trigger_armed().store(true, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
} else if (engine.armed()) {
|
||||||
|
engine.disarm();
|
||||||
|
model_.trigger_armed().store(false, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
double last_stamp_used = last_stamp_;
|
||||||
|
|
||||||
|
// The latch is handled on the sample it happens on, and with the edge the engine
|
||||||
|
// latched *on* -- after that sample's channels have all been pushed (see the loop).
|
||||||
|
//
|
||||||
|
// It used to set a flag and publish once after the loop, reading the edge back from
|
||||||
|
// the engine. But the loop keeps feeding the engine after a latch, so any further
|
||||||
|
// edge in the rest of the packet -- the hold-off is 10 ms and a packet can be a long
|
||||||
|
// block of samples -- overwrote that edge before it was read. The capture was then
|
||||||
|
// cut around an edge whose post window had not been collected yet, so the event came
|
||||||
|
// out all pre-trigger with the marker and x = 0 at the right-hand end of the data.
|
||||||
|
// Publishing here also means the ring provably holds the post window: the latch
|
||||||
|
// condition is that stream time has passed `edge + post`, and every sample up to
|
||||||
|
// this one has already been pushed.
|
||||||
|
const double span = std::max(1.0e-9, model_.event_span());
|
||||||
|
const double post_s = tc.post_span_s(span);
|
||||||
|
const auto latch = [&](double edge) {
|
||||||
|
model_.publish_event(span - post_s, post_s, edge);
|
||||||
|
model_.trigger_latches().fetch_add(1, std::memory_order_relaxed);
|
||||||
|
model_.trigger_time().store(edge, std::memory_order_relaxed);
|
||||||
|
model_.trigger_time_valid().store(true, std::memory_order_relaxed);
|
||||||
|
// The captured window is reported from the snapshot itself, not from the request:
|
||||||
|
// `want` versus `got` is what distinguishes "the engine did not wait" from "the
|
||||||
|
// cut lost the data", which is otherwise guesswork from the picture alone.
|
||||||
|
auto snap = model_.event();
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[udpscope] trigger: edge at %.3f s, latched at %.3f s of stream time "
|
||||||
|
"(want [-%.4f..%.4f] got [-%.4f..%.4f] n=%llu, frame recv=%.3f, "
|
||||||
|
"ticks_so_far=%llu, wall=%.3f s)\n",
|
||||||
|
edge, last_stamp_, span - post_s, post_s, snap ? snap->pre_s : 0.0,
|
||||||
|
snap ? snap->post_s : 0.0,
|
||||||
|
static_cast<unsigned long long>(snap ? snap->stamps.size() : 0),
|
||||||
|
frame->recv_time, static_cast<unsigned long long>(tick_),
|
||||||
|
std::chrono::duration<double>(std::chrono::steady_clock::now() - started_)
|
||||||
|
.count());
|
||||||
|
std::fflush(stderr);
|
||||||
|
if (tc.mode == TriggerMode::Single) tc.armed = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (std::uint32_t k = 0; k < n; ++k) {
|
||||||
|
const u64 tick = tick_++;
|
||||||
|
const double rel = std::max(stamps_[k] - epoch, last_stamp_);
|
||||||
|
last_stamp_used = rel;
|
||||||
|
last_stamp_ = rel;
|
||||||
|
|
||||||
|
if (tick % g == g - 1) store.time().push(static_cast<f32>(rel));
|
||||||
|
|
||||||
|
// The latch belongs to this sample, but it cannot be published from inside the
|
||||||
|
// channel loop: `publish_event()` clamps the slice to every value ring's
|
||||||
|
// `produced()`, and the channels *after* the trigger channel have not pushed this
|
||||||
|
// sample yet, so the newest sample -- the one the post window was waiting for --
|
||||||
|
// would be clamped away. One sample is invisible in a 20 000-sample capture (it is
|
||||||
|
// the `n=19999` and the `0.9999` instead of `1.0000` in the probe's output), and at
|
||||||
|
// one sample per second it is the entire post side: `got [-0.9997..0.0000]`, which
|
||||||
|
// reads exactly like the "post window is empty" defect.
|
||||||
|
bool latch_here = false;
|
||||||
|
double latch_edge = 0.0;
|
||||||
|
|
||||||
|
for (std::size_t i = 0; i < channels.size(); ++i) {
|
||||||
|
const u64 period = g * part.stride[i];
|
||||||
|
if (tick % period != period - 1) continue;
|
||||||
|
const ChannelInfo& ch = channels[i];
|
||||||
|
// A signal's columns are consecutive samples of the same channel: a scalar
|
||||||
|
// (one element) carries one value per batched cycle, a block signal carries
|
||||||
|
// the cycle's sample at element `k`. Either way element 0 is the channel.
|
||||||
|
const double raw =
|
||||||
|
ch.elements > 1
|
||||||
|
? udps_frame_value(frame, static_cast<std::uint32_t>(ch.signal), 0,
|
||||||
|
std::min<std::uint32_t>(k, ch.elements - 1))
|
||||||
|
: udps_frame_value(frame, static_cast<std::uint32_t>(ch.signal), k, 0);
|
||||||
|
const f32 value = static_cast<f32>(raw * ch.gain + ch.offset);
|
||||||
|
store.values(i).push(value);
|
||||||
|
if (static_cast<int>(i) == trig_channel) {
|
||||||
|
if (engine.process(value, rel)) {
|
||||||
|
latch_here = true;
|
||||||
|
latch_edge = engine.last_trigger_time();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (latch_here) latch(latch_edge);
|
||||||
|
}
|
||||||
|
last_stamp_ = last_stamp_used;
|
||||||
|
|
||||||
|
model_.trigger_armed().store(engine.armed(), std::memory_order_relaxed);
|
||||||
|
model_.set_trigger_pending(engine.pending());
|
||||||
|
static std::int64_t next_engine_log = 0;
|
||||||
|
const std::int64_t now_e = static_cast<std::int64_t>(frame->recv_time * 1.0e6);
|
||||||
|
if (now_e >= next_engine_log) {
|
||||||
|
next_engine_log = now_e + 1000000;
|
||||||
|
// `elapsed/post_want` is the capture's progress in the only unit that matters now
|
||||||
|
// (stream seconds); `accum` is how many samples that window has collected, which
|
||||||
|
// is what says whether a capture is thin because the window is short or because
|
||||||
|
// the stream is slow.
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[udpscope] trig engine: ch=%d armed=%d pending=%d elapsed=%.4f/%.4f s "
|
||||||
|
"accum=%llu history=%.4f/%.4f s pre_pct=%.0f v=%.4g now_t=%.3f "
|
||||||
|
"holdoff_until=%.3f\n",
|
||||||
|
trig_channel, int(engine.armed()), int(engine.pending()),
|
||||||
|
engine.post_elapsed(), engine.post_seconds(),
|
||||||
|
static_cast<unsigned long long>(engine.post_accum()), engine.history(),
|
||||||
|
engine.pre_seconds(), tc.pretrigger_pct, engine.last_value(),
|
||||||
|
engine.last_t(), engine.holdoff_until());
|
||||||
|
std::fflush(stderr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static trampolines: the client's C ABI cannot call a member function directly.
|
||||||
|
void Acquisition::config_trampoline(const udps_signal_t* signals, std::uint32_t count,
|
||||||
|
std::uint8_t publish_mode, void* user) {
|
||||||
|
static_cast<Acquisition*>(user)->on_config(signals, count, publish_mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Acquisition::data_trampoline(const udps_frame_t* frame, void* user) {
|
||||||
|
static_cast<Acquisition*>(user)->on_data(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Acquisition::event_trampoline(udps_event_t event, const char* detail, void* user) {
|
||||||
|
static_cast<Acquisition*>(user)->on_event(static_cast<int>(event), detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "config.hpp"
|
||||||
|
#include "model.hpp"
|
||||||
|
#include "trigger.hpp"
|
||||||
|
|
||||||
|
#include <udps_client.h>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
/// Owns the UDPStreamer client and the producer thread that feeds the model's
|
||||||
|
/// acquisition buffer. Everything it touches from the thread is serialised on
|
||||||
|
/// `ScopeModel::mutex()`, so a UI-side structural change cannot race a frame.
|
||||||
|
class Acquisition {
|
||||||
|
public:
|
||||||
|
Acquisition(ScopeModel& model, const Config& cfg);
|
||||||
|
~Acquisition();
|
||||||
|
|
||||||
|
Acquisition(const Acquisition&) = delete;
|
||||||
|
Acquisition& operator=(const Acquisition&) = delete;
|
||||||
|
|
||||||
|
/// Opens the client (no socket until the first poll) and starts the thread.
|
||||||
|
/// Throws std::runtime_error when the client cannot be created.
|
||||||
|
void start();
|
||||||
|
/// Stops the thread and destroys the client. Idempotent.
|
||||||
|
void stop();
|
||||||
|
|
||||||
|
void set_paused(bool paused) noexcept { paused_.store(paused, std::memory_order_relaxed); }
|
||||||
|
bool paused() const noexcept { return paused_.load(std::memory_order_relaxed); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
void run();
|
||||||
|
void on_config(const udps_signal_t* signals, std::uint32_t count, std::uint8_t publish_mode);
|
||||||
|
void on_data(const udps_frame_t* frame);
|
||||||
|
void on_event(int event, const char* detail);
|
||||||
|
|
||||||
|
static void config_trampoline(const udps_signal_t* signals, std::uint32_t count,
|
||||||
|
std::uint8_t publish_mode, void* user);
|
||||||
|
static void data_trampoline(const udps_frame_t* frame, void* user);
|
||||||
|
static void event_trampoline(udps_event_t event, const char* detail, void* user);
|
||||||
|
|
||||||
|
/// Fills `stamps_` with this frame's base-tick timestamps, in stream seconds,
|
||||||
|
/// and returns how many were written (`frame->num_samples` unless the frame is
|
||||||
|
/// malformed).
|
||||||
|
std::uint32_t build_stamps(const udps_frame_t* frame, int time_signal);
|
||||||
|
|
||||||
|
ScopeModel& model_;
|
||||||
|
Config cfg_;
|
||||||
|
udps_client* client_ = nullptr;
|
||||||
|
std::thread thread_;
|
||||||
|
std::atomic<bool> running_{false};
|
||||||
|
std::atomic<bool> paused_{false};
|
||||||
|
TriggerEngine engine_;
|
||||||
|
|
||||||
|
std::uint64_t tick_ = 0; ///< base ticks consumed since the epoch
|
||||||
|
std::uint64_t last_tick_ = ~std::uint64_t{0};
|
||||||
|
double last_stamp_ = 0.0; ///< last relative time written (monotonic guard)
|
||||||
|
double last_recv_ = 0.0;
|
||||||
|
bool have_recv_ = false;
|
||||||
|
/// Last accepted stream-time stamp: a "time signal" whose values do not advance
|
||||||
|
/// across packets (a producer that restarts them per packet, or a mislabelled
|
||||||
|
/// signal) would freeze the whole time base -- the trigger's hold-off clock
|
||||||
|
/// included -- so the stream source is dropped in favour of arrival time.
|
||||||
|
double last_stream_anchor_ = 0.0;
|
||||||
|
bool have_stream_anchor_ = false;
|
||||||
|
/// Arrival-clock origin: the first packet's receive time, so arrival stamps are
|
||||||
|
/// small seconds (the time ring stores f32, which cannot hold Unix epochs).
|
||||||
|
double arrival_epoch_ = 0.0;
|
||||||
|
bool have_arrival_epoch_ = false;
|
||||||
|
std::vector<double> stamps_;
|
||||||
|
std::chrono::steady_clock::time_point started_{};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
+317
@@ -0,0 +1,317 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "channel.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// Raw samples read per chunk on the gather path, so it never allocates.
|
||||||
|
constexpr u64 kChunk = 256;
|
||||||
|
|
||||||
|
/// The pyramid handed out for a slot with nothing bound to it. The renderer never
|
||||||
|
/// asks an empty series a bucket question, but the reference must be valid.
|
||||||
|
const plot::data::MinMaxPyramid& null_pyramid() {
|
||||||
|
static const plot::data::MinMaxPyramid empty;
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// --- ChannelBuffer ----------------------------------------------------------
|
||||||
|
|
||||||
|
ChannelBuffer::ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>> values,
|
||||||
|
std::shared_ptr<const plot::data::RingBuffer<f32>> times, u64 stride,
|
||||||
|
f64 epoch, f64 nominal_dt)
|
||||||
|
: values_(std::move(values)), times_(std::move(times)), stride_(stride < 1 ? 1 : stride),
|
||||||
|
epoch_(epoch), dt_(nominal_dt) {}
|
||||||
|
|
||||||
|
void ChannelBuffer::acquire() noexcept {
|
||||||
|
const u64 produced = values_->produced();
|
||||||
|
if (produced <= published_) {
|
||||||
|
// Nothing new; still allow the window to slide if another reader dropped it.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A producer that lapped this ring -- a long trigger wait, or a store that was
|
||||||
|
// not being published for a while -- makes the incremental append meaningless:
|
||||||
|
// the samples between `published_` and `produced_` are no longer in the ring.
|
||||||
|
// Rebuilding from the retained window is O(capacity) once, and it is the only
|
||||||
|
// way the published window stays coherent (an empty or garbled window is what
|
||||||
|
// "the trace disappeared" looks like from outside).
|
||||||
|
if (produced - published_ > values_->capacity()) {
|
||||||
|
published_ = 0;
|
||||||
|
pyramid_.clear();
|
||||||
|
}
|
||||||
|
f32 chunk[kChunk];
|
||||||
|
for (u64 i = published_; i < produced;) {
|
||||||
|
const u64 take = std::min<u64>(kChunk, produced - i);
|
||||||
|
values_->copy_from(i, take, chunk);
|
||||||
|
for (u64 k = 0; k < take; ++k) pyramid_.append(chunk[k]);
|
||||||
|
i += take;
|
||||||
|
}
|
||||||
|
published_ = produced;
|
||||||
|
// The pyramid's window follows the ring's: sample index `base` is the oldest
|
||||||
|
// one still retained. drop_before() is monotonic and cheap.
|
||||||
|
pyramid_.drop_before(values_->base());
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChannelBuffer::invalidate() noexcept {
|
||||||
|
published_ = 0;
|
||||||
|
pyramid_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
f32 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
|
||||||
|
// 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
|
||||||
|
// time ring every `time_stride` ticks and the channel every `stride` of those,
|
||||||
|
// so the last tick of the group is the one that carries the channel's sample.
|
||||||
|
const u64 index = absolute_index * stride_ + (stride_ - 1);
|
||||||
|
// The time ring is circular, so an index stays readable while it is *retained*
|
||||||
|
// and `copy_from()` masks it. Testing it against `capacity()` would reject every
|
||||||
|
// index past the first wrap -- which is every sample once the ring has filled,
|
||||||
|
// and would silently stamp them all with x = 0.
|
||||||
|
//
|
||||||
|
// An index the ring has already dropped is **clamped to the oldest readable slot,
|
||||||
|
// never answered with a sentinel**. The published window is pinned by `acquire()`
|
||||||
|
// to the value ring's base, and both rings keep sliding afterwards (their
|
||||||
|
// capacities are equal, so there is no slack at all): once the ring saturates, the
|
||||||
|
// few oldest samples of the window routinely point at slots the producer has
|
||||||
|
// overwritten since. Returning 0 for those put a stamp of 0 into `x_bounds()`, and
|
||||||
|
// from there into `dx()` and `x0()` -- which is the renderer's entire index
|
||||||
|
// mapping, `(x - x0()) / dx()`. So *one* unreadable slot at the front inflated
|
||||||
|
// `dx()` by the ratio of the whole elapsed stream time to the retained window and
|
||||||
|
// pushed every index up with it: `first` landed well past the first visible
|
||||||
|
// sample, and the samples in between were in the buffer, readable by the crosshair
|
||||||
|
// (`x_at()`/`neighbours()` go to the ring per sample and never saw the sentinel),
|
||||||
|
// and never drawn. That is the "live trace is clipped, only the most recent part
|
||||||
|
// is visible, but the crosshair still reads values there" defect.
|
||||||
|
//
|
||||||
|
// Clamping degrades the way the library's own `StreamSeries` documents for this
|
||||||
|
// contract: a snapshot bounds the sample *count*, not the slots, so the oldest few
|
||||||
|
// samples of a saturated window may read a newer value than the window names. One
|
||||||
|
// frame's worth of samples at the very left edge is then drawn on a short plateau
|
||||||
|
// of equal stamps -- monotone, so the binary searches still hold -- instead of the
|
||||||
|
// whole trace's geometry collapsing.
|
||||||
|
const u64 base = times_->base();
|
||||||
|
const u64 produced = times_->produced();
|
||||||
|
if (produced == 0) return 0.0f;
|
||||||
|
u64 slot = index < base ? base : index;
|
||||||
|
if (slot >= produced) slot = produced - 1;
|
||||||
|
f32 out = 0.0f;
|
||||||
|
times_->copy_from(slot, 1, &out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
f64 ChannelBuffer::dx() const {
|
||||||
|
const u64 n = pyramid_.size();
|
||||||
|
if (n < 2) return dt_ > 0.0 ? dt_ : 1.0;
|
||||||
|
const plot::Range b = x_bounds();
|
||||||
|
const double span = b.max - b.min;
|
||||||
|
if (!(span > 0.0) || !std::isfinite(span)) return dt_ > 0.0 ? dt_ : 1.0;
|
||||||
|
return span / f64(n - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
f64 ChannelBuffer::x0() const {
|
||||||
|
if (pyramid_.size() == 0) return epoch_;
|
||||||
|
return epoch_ + f64(time_of(absolute(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
f64 ChannelBuffer::x_at(u64 i) const {
|
||||||
|
if (i >= pyramid_.size()) return x0();
|
||||||
|
return epoch_ + f64(time_of(absolute(i)));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChannelBuffer::neighbours(f64 x, u64& i0, u64& i1) const {
|
||||||
|
const u64 n = pyramid_.size();
|
||||||
|
if (n == 0) return false;
|
||||||
|
if (x < x_at(0) || x > x_at(n - 1)) return false;
|
||||||
|
// The time ring is monotonic, so the bracketing pair is a binary search rather
|
||||||
|
// than a grid walk: after a lost packet the two samples either side of `x` are
|
||||||
|
// not `x`/`dx` apart, and a grid would name the wrong pair.
|
||||||
|
u64 lo = 0;
|
||||||
|
u64 hi = n - 1;
|
||||||
|
while (lo < hi) {
|
||||||
|
const u64 mid = lo + (hi - lo) / 2;
|
||||||
|
if (x_at(mid) < x) {
|
||||||
|
lo = mid + 1;
|
||||||
|
} else {
|
||||||
|
hi = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i1 = lo;
|
||||||
|
i0 = lo;
|
||||||
|
if (x_at(lo) > x && lo > 0) i0 = lo - 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
u64 ChannelBuffer::snap_index(f64 x) const {
|
||||||
|
u64 i0 = 0;
|
||||||
|
u64 i1 = 0;
|
||||||
|
if (!neighbours(x, i0, i1)) {
|
||||||
|
if (pyramid_.size() == 0) return 0;
|
||||||
|
return x < x_at(0) ? 0 : pyramid_.size() - 1;
|
||||||
|
}
|
||||||
|
if (i0 == i1) return i0;
|
||||||
|
return (x - x_at(i0)) <= (x_at(i1) - x) ? i0 : i1;
|
||||||
|
}
|
||||||
|
|
||||||
|
plot::Range ChannelBuffer::x_bounds() const {
|
||||||
|
const u64 n = pyramid_.size();
|
||||||
|
if (n == 0) return plot::Range{epoch_, epoch_};
|
||||||
|
return plot::Range{epoch_ + f64(time_of(absolute(0))),
|
||||||
|
epoch_ + f64(time_of(absolute(n - 1)))};
|
||||||
|
}
|
||||||
|
|
||||||
|
plot::Range ChannelBuffer::y_bounds() const {
|
||||||
|
const u64 n = pyramid_.size();
|
||||||
|
if (n == 0) return plot::Range{0.0, 0.0};
|
||||||
|
return reduce(0, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChannelBuffer::raw(u64 begin, u64 end, f32* out) const {
|
||||||
|
const u64 n = pyramid_.size();
|
||||||
|
const u64 b = std::min(begin, n);
|
||||||
|
const u64 e = std::min(end, n);
|
||||||
|
const u64 count = e > b ? e - b : 0;
|
||||||
|
if (count > 0) values_->copy_from(absolute(b), count, out);
|
||||||
|
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,
|
||||||
|
plot::data::GatherMode mode) const {
|
||||||
|
const u64 n = pyramid_.size();
|
||||||
|
const u64 b = std::min(begin, n);
|
||||||
|
const u64 e = std::min(std::max(end, b), n);
|
||||||
|
if (b >= e) return;
|
||||||
|
|
||||||
|
const u64 origin = pyramid_.window_base();
|
||||||
|
const f64 base = x0(); // x is emitted relative to this (see the header)
|
||||||
|
|
||||||
|
const auto emit_raw = [&](u64 from, u64 to) {
|
||||||
|
for (u64 i = from; i < to; ++i) {
|
||||||
|
f32 y = 0.0f;
|
||||||
|
raw(i, i + 1, &y);
|
||||||
|
out_xy.push_back(static_cast<f32>(x_at(i) - base));
|
||||||
|
out_xy.push_back(y);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const auto rel = [&](u64 i) { return static_cast<f32>(x_at(i) - base); };
|
||||||
|
|
||||||
|
if (level == 0) {
|
||||||
|
emit_raw(b, e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
level = std::min(level, plot::data::kMaxPyramidLevel);
|
||||||
|
|
||||||
|
// One geometry element per *complete* bucket wholly inside the range. `first` is
|
||||||
|
// a ceiling and `last` a floor, so no aggregate can reach outside [b, e) -- a
|
||||||
|
// floor on `first` or a `last` that includes the bucket holding `e-1` would emit
|
||||||
|
// aggregates covering samples the caller did not ask for (the library's B3 fix,
|
||||||
|
// which this mirrors). The spans left at the two edges are drawn from raw
|
||||||
|
// samples, so the geometry still covers the range exactly and a partial bucket is
|
||||||
|
// drawn once, not twice.
|
||||||
|
const u64 span = u64{plot::data::kBucket} << level;
|
||||||
|
const u64 lbase = pyramid_.level_base(level);
|
||||||
|
const u64 lend = lbase + pyramid_.level_size(level);
|
||||||
|
const u64 ceil_div = (origin + b) / span + ((origin + b) % span == 0 ? u64{0} : u64{1});
|
||||||
|
const u64 first = std::max(ceil_div, lbase);
|
||||||
|
const u64 last = std::min((origin + e) / span, lend);
|
||||||
|
if (first >= last) {
|
||||||
|
emit_raw(b, e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const u64 lead = first * span - origin;
|
||||||
|
if (b < lead) emit_raw(b, std::min(lead, e));
|
||||||
|
|
||||||
|
for (u64 bi = first; bi < last; ++bi) {
|
||||||
|
const u32 count = pyramid_.bucket_count(level, bi);
|
||||||
|
const u64 fs = bi * span - origin; // >= 0: a stored bucket starts inside the window
|
||||||
|
switch (mode) {
|
||||||
|
case plot::data::GatherMode::Nearest: {
|
||||||
|
const u64 centre = bi * span + span / 2 - origin;
|
||||||
|
f32 y = 0.0f;
|
||||||
|
raw(centre, centre + 1, &y);
|
||||||
|
out_xy.push_back(rel(centre));
|
||||||
|
out_xy.push_back(y);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case plot::data::GatherMode::Mean: {
|
||||||
|
if (count == 0) break; // no finite sample: no mean to draw
|
||||||
|
const u64 centre = bi * span + span / 2 - origin;
|
||||||
|
out_xy.push_back(rel(centre));
|
||||||
|
out_xy.push_back(static_cast<f32>(pyramid_.bucket_sum(level, bi) / f64(count)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: { // Envelope
|
||||||
|
if (count == 0) break;
|
||||||
|
const plot::data::MinMax m = pyramid_.bucket(level, bi);
|
||||||
|
const u64 ls = std::min((bi + 1) * span, origin + e) - 1 - origin;
|
||||||
|
out_xy.push_back(rel(fs));
|
||||||
|
out_xy.push_back(m.min);
|
||||||
|
out_xy.push_back(rel(ls));
|
||||||
|
out_xy.push_back(m.max);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const u64 trail = last * span - origin;
|
||||||
|
if (trail < e) emit_raw(trail, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SlotSeries -------------------------------------------------------------
|
||||||
|
|
||||||
|
plot::Range SlotSeries::x_bounds() const {
|
||||||
|
if (event_ != nullptr) return event_->x_bounds();
|
||||||
|
return buffer_ != nullptr ? buffer_->x_bounds() : plot::Range{0.0, 1.0};
|
||||||
|
}
|
||||||
|
|
||||||
|
plot::Range SlotSeries::y_bounds() const {
|
||||||
|
if (event_ != nullptr) return event_->y_bounds();
|
||||||
|
return buffer_ != nullptr ? buffer_->y_bounds() : plot::Range{0.0, 0.0};
|
||||||
|
}
|
||||||
|
|
||||||
|
const plot::data::MinMaxPyramid& SlotSeries::pyramid() const {
|
||||||
|
if (event_ != nullptr) return event_->pyramid();
|
||||||
|
return buffer_ != nullptr ? buffer_->pyramid() : null_pyramid();
|
||||||
|
}
|
||||||
|
|
||||||
|
f64 SlotSeries::x_at(u64 i) const {
|
||||||
|
if (event_ != nullptr) return event_->x_at(i);
|
||||||
|
return buffer_ != nullptr ? buffer_->x_at(i) : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SlotSeries::neighbours(f64 x, u64& i0, u64& i1) const {
|
||||||
|
if (event_ != nullptr) return event_->neighbours(x, i0, i1);
|
||||||
|
return buffer_ != nullptr && buffer_->neighbours(x, i0, i1);
|
||||||
|
}
|
||||||
|
|
||||||
|
u64 SlotSeries::snap_index(f64 x) const {
|
||||||
|
if (event_ != nullptr) return event_->snap_index(x);
|
||||||
|
return buffer_ != nullptr ? buffer_->snap_index(x) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SlotSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||||
|
plot::data::GatherMode mode) const {
|
||||||
|
if (event_ != nullptr) {
|
||||||
|
event_->gather(level, begin, end, out_xy, mode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (buffer_ != nullptr) buffer_->gather(level, begin, end, out_xy, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SlotSeries::copy_raw(u64 begin, u64 end, f32* out) const {
|
||||||
|
if (event_ != nullptr) {
|
||||||
|
event_->raw_values(begin, end, out);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (buffer_ != nullptr) {
|
||||||
|
buffer_->raw(begin, end, out);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (u64 i = 0; i < end - begin; ++i) out[i] = 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <plot/data/minmax_pyramid.hpp>
|
||||||
|
#include <plot/data/ring_buffer.hpp>
|
||||||
|
#include <plot/data/series.hpp>
|
||||||
|
|
||||||
|
#include "event_series.hpp"
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
using plot::f32;
|
||||||
|
using plot::f64;
|
||||||
|
using plot::u32;
|
||||||
|
using plot::u64;
|
||||||
|
|
||||||
|
/// One channel's view of a (value, time) ring pair.
|
||||||
|
///
|
||||||
|
/// The sample grid the library's `Series` contract assumes -- `x = x0 + i*dx` --
|
||||||
|
/// does not hold here, because x is a *streamed timestamp*, not an index: a lost
|
||||||
|
/// datagram leaves a gap and the samples either side of it are not one grid step
|
||||||
|
/// apart. `dx()` and `x0()` therefore report a nominal grid (what the LOD level
|
||||||
|
/// and the snap use), while `gather()` and `x_bounds()` report the real
|
||||||
|
/// timestamps. `reduce()`, `sum()` and `sumsq()` are the inherited ones and read
|
||||||
|
/// only y, so they are exact either way.
|
||||||
|
///
|
||||||
|
/// The time ring is shared by every channel of one buffer (spec: the +1 slice);
|
||||||
|
/// channel sample `j` is stamped by base tick `j * stride`. A stride of 1 is the
|
||||||
|
/// undecimated case and the common one.
|
||||||
|
class ChannelBuffer final : public plot::data::Series {
|
||||||
|
public:
|
||||||
|
ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>> values,
|
||||||
|
std::shared_ptr<const plot::data::RingBuffer<f32>> times, u64 stride, f64 epoch,
|
||||||
|
f64 nominal_dt);
|
||||||
|
|
||||||
|
ChannelBuffer(const ChannelBuffer&) = delete;
|
||||||
|
ChannelBuffer& operator=(const ChannelBuffer&) = delete;
|
||||||
|
|
||||||
|
/// Consumer thread: publishes everything the producer pushed since the previous
|
||||||
|
/// call into the LOD pyramid and slides the window. O(samples produced).
|
||||||
|
void acquire() noexcept;
|
||||||
|
/// Consumer thread: forgets the published window (after a rebind or a reset).
|
||||||
|
void invalidate() noexcept;
|
||||||
|
|
||||||
|
void set_nominal_dt(f64 dt) noexcept { dt_ = dt; }
|
||||||
|
/// Re-bases the x axis: `x0()`/`x_at()` become `epoch + timestamp`. Used to make
|
||||||
|
/// the axis read seconds from the trigger event instead of from the stream.
|
||||||
|
void set_epoch(f64 e) noexcept { epoch_ = e; }
|
||||||
|
u64 stride() const noexcept { return stride_; }
|
||||||
|
|
||||||
|
// --- plot::data::Series ---------------------------------------------------
|
||||||
|
u64 size() const override { return pyramid_.size(); }
|
||||||
|
f64 x0() const override;
|
||||||
|
/// The *mean* spacing of the retained window. The renderer converts a visible x
|
||||||
|
/// range into an index range with `(x - x0()) / dx()`, so this must describe the
|
||||||
|
/// samples that are actually retained: reporting the nominal `stride * dt` made
|
||||||
|
/// that index range exceed the series by the decimation factor, the gather
|
||||||
|
/// clamped to nothing, and the trace drew empty -- which is exactly what a fast
|
||||||
|
/// channel looked like next to a slow one, because only the slow one is
|
||||||
|
/// recognisable from the few samples a wrong range still happens to cover.
|
||||||
|
f64 dx() const override;
|
||||||
|
/// The authoritative x of a sample: the streamed timestamp, not a grid. `dx()`
|
||||||
|
/// is only the mean spacing (the LOD level and a coarse snap use it); a lost
|
||||||
|
/// datagram leaves a gap, so index times must come from the time ring.
|
||||||
|
f64 x_at(u64 i) const override;
|
||||||
|
/// Bracketing samples by binary search over the time ring, which is monotonic,
|
||||||
|
/// so a crosshair value is interpolated between the two samples actually either
|
||||||
|
/// side of the pointer -- a grid walk would land on the wrong pair after a gap.
|
||||||
|
bool neighbours(f64 x, u64& i0, u64& i1) const override;
|
||||||
|
u64 snap_index(f64 x) const override;
|
||||||
|
plot::Range x_bounds() const override;
|
||||||
|
plot::Range y_bounds() const override;
|
||||||
|
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
|
||||||
|
/// x is emitted **relative to `x0()`** (the library's contract): staging an
|
||||||
|
/// absolute epoch time as f32 would quantise it far coarser than a pixel.
|
||||||
|
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||||
|
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override;
|
||||||
|
|
||||||
|
/// `copy_raw`, published so the retargetable trace series can forward the
|
||||||
|
/// protected hook of another object.
|
||||||
|
void raw(u64 begin, u64 end, f32* out) const;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void copy_raw(u64 begin, u64 end, f32* out) const override { raw(begin, end, out); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
/// Absolute (channel) index of retained-window index `i`.
|
||||||
|
u64 absolute(u64 i) const noexcept { return pyramid_.window_base() + i; }
|
||||||
|
/// Timestamp of channel sample `absolute_index`, in seconds since `epoch_`.
|
||||||
|
f32 time_of(u64 absolute_index) const noexcept;
|
||||||
|
|
||||||
|
std::shared_ptr<const plot::data::RingBuffer<f32>> values_;
|
||||||
|
std::shared_ptr<const plot::data::RingBuffer<f32>> times_;
|
||||||
|
u64 stride_ = 1;
|
||||||
|
f64 epoch_ = 0.0;
|
||||||
|
f64 dt_ = 1.0;
|
||||||
|
u64 published_ = 0;
|
||||||
|
plot::data::MinMaxPyramid pyramid_;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A trace's series: one stable object whose channel can be rebound.
|
||||||
|
///
|
||||||
|
/// `Plot` has no `set_trace_series()` and no `remove_trace()`, so a trace's series
|
||||||
|
/// pointer is fixed for the plot's life. Everything that would otherwise need a
|
||||||
|
/// new pointer -- which signal a trace plots, and the switch between the live ring
|
||||||
|
/// and a frozen event -- is therefore expressed here as a rebind.
|
||||||
|
class SlotSeries final : public plot::data::Series {
|
||||||
|
public:
|
||||||
|
/// Takes shared ownership: a structural change in the model may drop its
|
||||||
|
/// `BufferStore` while a trace still points here, and the renderer reads the
|
||||||
|
/// buffer after this frame's bind has returned.
|
||||||
|
void bind(std::shared_ptr<ChannelBuffer> buffer) noexcept {
|
||||||
|
buffer_ = std::move(buffer);
|
||||||
|
event_ = nullptr;
|
||||||
|
}
|
||||||
|
/// Binds the captured event (edge-relative stamps). Binding an event clears
|
||||||
|
/// the ring binding and vice versa: the slot shows exactly one source.
|
||||||
|
void bind_event(std::shared_ptr<const EventSnapshot> snap, std::size_t channel) {
|
||||||
|
if (snap == nullptr || channel >= snap->values.size()) {
|
||||||
|
event_ = nullptr;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event_ == nullptr || !event_->same(snap, channel)) {
|
||||||
|
event_ = std::make_unique<EventSeries>(std::move(snap), channel);
|
||||||
|
}
|
||||||
|
buffer_ = nullptr;
|
||||||
|
}
|
||||||
|
ChannelBuffer* bound() const noexcept { return buffer_.get(); }
|
||||||
|
const EventSeries* bound_event() const noexcept { return event_.get(); }
|
||||||
|
bool empty() const noexcept {
|
||||||
|
if (event_ != nullptr) return event_->size() == 0;
|
||||||
|
return buffer_ == nullptr || buffer_->size() == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
u64 size() const override { return event_ != nullptr ? event_->size() : buffer_ != nullptr ? buffer_->size() : 0; }
|
||||||
|
f64 x0() const override { return event_ != nullptr ? event_->x0() : buffer_ != nullptr ? buffer_->x0() : 0.0; }
|
||||||
|
f64 dx() const override { return event_ != nullptr ? event_->dx() : buffer_ != nullptr ? buffer_->dx() : 1.0; }
|
||||||
|
f64 x_at(u64 i) const override;
|
||||||
|
bool neighbours(f64 x, u64& i0, u64& i1) const override;
|
||||||
|
u64 snap_index(f64 x) const override;
|
||||||
|
plot::Range x_bounds() const override;
|
||||||
|
plot::Range y_bounds() const override;
|
||||||
|
const plot::data::MinMaxPyramid& pyramid() const override;
|
||||||
|
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||||
|
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override;
|
||||||
|
bool streaming() const noexcept override { return event_ == nullptr && buffer_ != nullptr; }
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void copy_raw(u64 begin, u64 end, f32* out) const override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::shared_ptr<ChannelBuffer> buffer_;
|
||||||
|
std::unique_ptr<EventSeries> event_; ///< the captured event, when held
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "config.hpp"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
[[noreturn]] void fail(const std::string& what) { throw std::runtime_error(what); }
|
||||||
|
|
||||||
|
/// `--name=value` / `--name value`; returns the value and advances `i` past both
|
||||||
|
/// spellings. `argv[i]` must start with `--name`.
|
||||||
|
std::string value_of(int argc, char** argv, int& i, const char* name) {
|
||||||
|
const std::string arg = argv[i];
|
||||||
|
const std::string prefix = std::string(name) + "=";
|
||||||
|
if (arg.rfind(prefix, 0) == 0) return arg.substr(prefix.size());
|
||||||
|
if (i + 1 >= argc) fail(std::string("missing value for ") + name);
|
||||||
|
return argv[++i];
|
||||||
|
}
|
||||||
|
|
||||||
|
double parse_double(const char* name, const std::string& v) {
|
||||||
|
try {
|
||||||
|
std::size_t used = 0;
|
||||||
|
const double d = std::stod(v, &used);
|
||||||
|
if (used != v.size()) fail(std::string("not a number: ") + name + " " + v);
|
||||||
|
return d;
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
fail(std::string("not a number: ") + name + " " + v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void print_usage(std::FILE* out, const char* argv0) {
|
||||||
|
std::fprintf(out,
|
||||||
|
"usage: %s --server ADDR --port PORT [options]\n"
|
||||||
|
"\n"
|
||||||
|
" --server ADDR stream source IPv4 address (default 127.0.0.1)\n"
|
||||||
|
" --port PORT UDP port (unicast) or TCP port (multicast) (default 8000)\n"
|
||||||
|
" --multicast GROUP join GROUP for DATA instead of unicast\n"
|
||||||
|
" --interface ADDR local IPv4 of the interface to join on\n"
|
||||||
|
" --buffer-mb MB size of each of the two sample buffers (default 200)\n"
|
||||||
|
" --max-window SEC largest time window the toolbar offers (default 600)\n"
|
||||||
|
" --holdoff SEC default trigger hold-off (default 0.010)\n"
|
||||||
|
" --recv-buffer BYTES SO_RCVBUF for the receiver socket (default 8388608)\n"
|
||||||
|
" --plots N initial number of stacked plots, 1..4 (default 1)\n"
|
||||||
|
" -h, --help this text\n",
|
||||||
|
argv0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Config parse_args(int argc, char** argv) {
|
||||||
|
Config c;
|
||||||
|
for (int i = 1; i < argc; ++i) {
|
||||||
|
const std::string a = argv[i];
|
||||||
|
if (a == "-h" || a == "--help") {
|
||||||
|
print_usage(stdout, argv[0]);
|
||||||
|
std::exit(0);
|
||||||
|
} else if (a == "--server" || a.rfind("--server=", 0) == 0) {
|
||||||
|
c.server = value_of(argc, argv, i, "--server");
|
||||||
|
} else if (a == "--port" || a.rfind("--port=", 0) == 0) {
|
||||||
|
const std::string v = value_of(argc, argv, i, "--port");
|
||||||
|
const long p = std::strtol(v.c_str(), nullptr, 10);
|
||||||
|
if (p <= 0 || p > 65535) fail("port out of range: " + v);
|
||||||
|
c.port = static_cast<std::uint16_t>(p);
|
||||||
|
} else if (a == "--multicast" || a.rfind("--multicast=", 0) == 0) {
|
||||||
|
c.multicast = value_of(argc, argv, i, "--multicast");
|
||||||
|
} else if (a == "--interface" || a.rfind("--interface=", 0) == 0) {
|
||||||
|
c.interface_addr = value_of(argc, argv, i, "--interface");
|
||||||
|
} else if (a == "--buffer-mb" || a.rfind("--buffer-mb=", 0) == 0) {
|
||||||
|
c.buffer_mb = parse_double("--buffer-mb", value_of(argc, argv, i, "--buffer-mb"));
|
||||||
|
if (c.buffer_mb < 1.0) fail("--buffer-mb must be at least 1");
|
||||||
|
} else if (a == "--max-window" || a.rfind("--max-window=", 0) == 0) {
|
||||||
|
c.max_window_s = parse_double("--max-window", value_of(argc, argv, i, "--max-window"));
|
||||||
|
if (c.max_window_s <= 0.0) fail("--max-window must be positive");
|
||||||
|
} else if (a == "--holdoff" || a.rfind("--holdoff=", 0) == 0) {
|
||||||
|
c.trigger_holdoff_s = parse_double("--holdoff", value_of(argc, argv, i, "--holdoff"));
|
||||||
|
if (c.trigger_holdoff_s < 0.0) fail("--holdoff must not be negative");
|
||||||
|
} else if (a == "--recv-buffer" || a.rfind("--recv-buffer=", 0) == 0) {
|
||||||
|
const std::string v = value_of(argc, argv, i, "--recv-buffer");
|
||||||
|
const long n = std::strtol(v.c_str(), nullptr, 10);
|
||||||
|
if (n <= 0) fail("--recv-buffer must be positive: " + v);
|
||||||
|
c.recv_buffer_bytes = static_cast<int>(n);
|
||||||
|
} else if (a == "--plots" || a.rfind("--plots=", 0) == 0) {
|
||||||
|
const std::string v = value_of(argc, argv, i, "--plots");
|
||||||
|
const long n = std::strtol(v.c_str(), nullptr, 10);
|
||||||
|
if (n < 1 || n > 4) fail("--plots must be 1..4: " + v);
|
||||||
|
c.plots = static_cast<int>(n);
|
||||||
|
} else {
|
||||||
|
fail("unknown argument: " + a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
/// Everything the command line can say. Defaults are the ones the spec names
|
||||||
|
/// (200 MB per buffer, 10 ms trigger hold-off) unless the user overrides them.
|
||||||
|
struct Config {
|
||||||
|
std::string server = "127.0.0.1"; ///< IPv4 dotted quad of the stream source.
|
||||||
|
std::uint16_t port = 8000; ///< UDP port (unicast) or TCP port (multicast).
|
||||||
|
std::string multicast; ///< Empty selects unicast.
|
||||||
|
std::string interface_addr; ///< Local IPv4 to join the group on; empty = default route.
|
||||||
|
|
||||||
|
/// Size of *each* of the two sample buffers, in megabytes. The application
|
||||||
|
/// always allocates two identical buffers (spec item 1).
|
||||||
|
double buffer_mb = 200.0;
|
||||||
|
|
||||||
|
/// Largest time window the toolbar offers, in seconds. The buffer partition is
|
||||||
|
/// sized to hold at least this much time (spec: decimation).
|
||||||
|
double max_window_s = 600.0;
|
||||||
|
|
||||||
|
double trigger_holdoff_s = 0.010;
|
||||||
|
int recv_buffer_bytes = 8 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Number of stacked plots (rows) to start with, 1..4.
|
||||||
|
int plots = 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Parses argv. On `--help` prints usage to stdout and exits(0); on a bad
|
||||||
|
/// argument prints a diagnostic to stderr and exits(2).
|
||||||
|
Config parse_args(int argc, char** argv);
|
||||||
|
|
||||||
|
/// Writes the usage text to `out`.
|
||||||
|
void print_usage(std::FILE* out, const char* argv0);
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <plot/data/series.hpp>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <memory>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
using plot::f32;
|
||||||
|
using plot::f64;
|
||||||
|
using plot::u32;
|
||||||
|
using plot::u64;
|
||||||
|
|
||||||
|
/// One captured trigger event, immutable once published: per-channel values over
|
||||||
|
/// a shared, edge-relative time stamp array (the edge sits at exactly x = 0, the
|
||||||
|
/// window spans [-pre, +post]).
|
||||||
|
///
|
||||||
|
/// The plot binds to a snapshot while the trigger holds an event. Because the
|
||||||
|
/// snapshot can never change after publication, nothing can drag, re-base or
|
||||||
|
/// refresh the view while it is held -- the failures the previous design (two
|
||||||
|
/// live ring buffers switched by a pending flag, plus a display-epoch shift)
|
||||||
|
/// could not stop.
|
||||||
|
struct EventSnapshot {
|
||||||
|
std::vector<f32> stamps; ///< edge-relative seconds, monotonic
|
||||||
|
std::vector<std::vector<f32>> values; ///< one array per channel, same length
|
||||||
|
double pre_s = 0.0; ///< seconds of pre-trigger data kept
|
||||||
|
double post_s = 0.0; ///< seconds of post-trigger data kept
|
||||||
|
std::vector<plot::Range> y; ///< per-channel value bounds, precomputed
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A plot::data::Series view of one channel of an EventSnapshot. The stamps are
|
||||||
|
/// near-uniform (the arrival clock), so dx is the mean spacing and x_at returns
|
||||||
|
/// the exact stamp; gather decimates by 2^level like the ring series does.
|
||||||
|
class EventSeries final : public plot::data::Series {
|
||||||
|
public:
|
||||||
|
explicit EventSeries(std::shared_ptr<const EventSnapshot> snap, std::size_t channel)
|
||||||
|
: snap_(std::move(snap)), channel_(channel) {}
|
||||||
|
|
||||||
|
/// Identity for rebind: same snapshot object and channel means the existing
|
||||||
|
/// EventSeries already shows this event.
|
||||||
|
bool same(const std::shared_ptr<const EventSnapshot>& snap, std::size_t channel) const {
|
||||||
|
return snap_ == snap && channel_ == channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shorter of the two arrays this series reads. `gather()` indexes `stamps`
|
||||||
|
/// and `values[channel]` at the same i, so reporting the stamp count alone would
|
||||||
|
/// be an out-of-bounds read the moment they differ.
|
||||||
|
u64 size() const override {
|
||||||
|
if (channel_ >= snap_->values.size()) return 0;
|
||||||
|
return static_cast<u64>(
|
||||||
|
std::min(snap_->stamps.size(), snap_->values[channel_].size()));
|
||||||
|
}
|
||||||
|
f64 x0() const override { return snap_->stamps.empty() ? 0.0 : snap_->stamps.front(); }
|
||||||
|
f64 dx() const override {
|
||||||
|
const auto& s = snap_->stamps;
|
||||||
|
return s.size() > 1 ? (s.back() - s.front()) / static_cast<f64>(s.size() - 1) : 1.0;
|
||||||
|
}
|
||||||
|
f64 x_at(u64 i) const override {
|
||||||
|
const auto& s = snap_->stamps;
|
||||||
|
return i < s.size() ? s[static_cast<std::size_t>(i)] : 0.0;
|
||||||
|
}
|
||||||
|
plot::Range x_bounds() const override {
|
||||||
|
const auto& s = snap_->stamps;
|
||||||
|
return s.empty() ? plot::Range{0.0, 0.0} : plot::Range{s.front(), s.back()};
|
||||||
|
}
|
||||||
|
plot::Range y_bounds() const override {
|
||||||
|
return channel_ < snap_->y.size() ? snap_->y[channel_] : plot::Range{0.0, 0.0};
|
||||||
|
}
|
||||||
|
const plot::data::MinMaxPyramid& pyramid() const override { return empty_pyramid_; }
|
||||||
|
bool streaming() const noexcept override { return false; }
|
||||||
|
|
||||||
|
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||||
|
plot::data::GatherMode /*mode*/ =
|
||||||
|
plot::data::GatherMode::Envelope) const override {
|
||||||
|
if (channel_ >= snap_->values.size()) return;
|
||||||
|
const auto& vals = snap_->values[channel_];
|
||||||
|
const auto& stamps = snap_->stamps;
|
||||||
|
const u64 n = size();
|
||||||
|
const u64 stride = u64{1} << level;
|
||||||
|
if (stride == 0 || begin >= n) return;
|
||||||
|
if (end > n) end = n;
|
||||||
|
// x **relative to x0()**, which is the library's gather contract: the renderer
|
||||||
|
// adds `x0()` back before mapping to the screen. Emitting the absolute stamp made
|
||||||
|
// it add x0 twice, so the whole capture was drawn one `x0()` -- a full
|
||||||
|
// pre-trigger window -- to the left of the axis it had just been measured
|
||||||
|
// against. The event's own stamps are small (edge-relative, so [-pre, +post]),
|
||||||
|
// which is why that shift looked like a plausible picture: with a 50/50 split the
|
||||||
|
// right half of the plot was simply empty, zooming out showed the trace ending at
|
||||||
|
// the x = 0 marker, and zooming in past the shift showed nothing at all while the
|
||||||
|
// crosshair -- which reads `x_at()`, never `gather()` -- went on reporting the
|
||||||
|
// right values.
|
||||||
|
const f64 base = x0();
|
||||||
|
out_xy.reserve(out_xy.size() + static_cast<std::size_t>((end - begin) / stride * 2 + 4));
|
||||||
|
for (u64 i = (begin / stride) * stride; i < end; i += stride) {
|
||||||
|
out_xy.push_back(static_cast<f32>(stamps[static_cast<std::size_t>(i)] - base));
|
||||||
|
out_xy.push_back(vals[static_cast<std::size_t>(i)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool neighbours(f64 x, u64& i0, u64& i1) const override {
|
||||||
|
const u64 n = size();
|
||||||
|
if (n < 2) return false;
|
||||||
|
const f64 step = dx();
|
||||||
|
if (!(step > 0.0)) return false;
|
||||||
|
f64 idx = (x - x0()) / step;
|
||||||
|
if (idx < 0.0) idx = 0.0;
|
||||||
|
if (idx > static_cast<f64>(n - 1)) idx = static_cast<f64>(n - 1);
|
||||||
|
i0 = static_cast<u64>(idx);
|
||||||
|
i1 = i0 + 1 < n ? i0 + 1 : i0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
u64 snap_index(f64 x) const override {
|
||||||
|
u64 i0 = 0, i1 = 0;
|
||||||
|
if (!neighbours(x, i0, i1)) return 0;
|
||||||
|
return i1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SlotSeries::copy_raw forwards here (public: the base's hook is protected,
|
||||||
|
/// and SlotSeries is a different class).
|
||||||
|
void raw_values(u64 begin, u64 end, f32* out) const {
|
||||||
|
const auto& vals = snap_->values[channel_];
|
||||||
|
const u64 n = static_cast<u64>(vals.size());
|
||||||
|
if (end > n) end = n;
|
||||||
|
for (u64 i = begin; i < end; ++i)
|
||||||
|
out[static_cast<std::size_t>(i - begin)] = vals[static_cast<std::size_t>(i)];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void copy_raw(u64 begin, u64 end, f32* out) const override { raw_values(begin, end, out); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::shared_ptr<const EventSnapshot> snap_;
|
||||||
|
std::size_t channel_;
|
||||||
|
plot::data::MinMaxPyramid empty_pyramid_{};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
+273
@@ -0,0 +1,273 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "export.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <bit>
|
||||||
|
#include <cerrno>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// The writers memcpy `f64`/`f32` straight out and label them `<f8`/`<f4`, so a
|
||||||
|
// big-endian host would produce a file whose bytes contradict its own dtype. There is no
|
||||||
|
// such host in this app's reach and a byte-swapping path nobody runs is a path nobody
|
||||||
|
// tests, so the assumption is stated instead of handled.
|
||||||
|
static_assert(std::endian::native == std::endian::little,
|
||||||
|
"the .npy writer labels its fields little-endian and does not swap");
|
||||||
|
|
||||||
|
/// A column label a reader can use: NumPy rejects duplicate field names outright, and a
|
||||||
|
/// quote or a brace in one would break the header dict it is spelled into. Signal names
|
||||||
|
/// come from the stream, so neither is this app's to promise.
|
||||||
|
std::string sanitise(const std::string& name) {
|
||||||
|
std::string out;
|
||||||
|
out.reserve(name.size());
|
||||||
|
for (const char ch : name) {
|
||||||
|
const bool ok = (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') ||
|
||||||
|
(ch >= '0' && ch <= '9') || ch == '_';
|
||||||
|
out.push_back(ok ? ch : '_');
|
||||||
|
}
|
||||||
|
if (out.empty()) out = "col";
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The final labels: sanitised, and de-duplicated against each other *and* against the
|
||||||
|
/// two time columns, with a `_2`, `_3` suffix. Two elements of one vector signal can
|
||||||
|
/// easily sanitise to the same string.
|
||||||
|
std::vector<std::string> column_labels(const ExportTable& table) {
|
||||||
|
std::vector<std::string> taken{"time", "stream_time"};
|
||||||
|
std::vector<std::string> out;
|
||||||
|
out.reserve(table.columns.size());
|
||||||
|
for (const ExportTable::Column& col : table.columns) {
|
||||||
|
const std::string base = sanitise(col.name);
|
||||||
|
std::string label = base;
|
||||||
|
for (int n = 2; std::find(taken.begin(), taken.end(), label) != taken.end(); ++n) {
|
||||||
|
label = base + "_" + std::to_string(n);
|
||||||
|
}
|
||||||
|
taken.push_back(label);
|
||||||
|
out.push_back(std::move(label));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct File {
|
||||||
|
explicit File(const std::string& path) : f(std::fopen(path.c_str(), "wb")) {}
|
||||||
|
~File() {
|
||||||
|
if (f != nullptr) std::fclose(f);
|
||||||
|
}
|
||||||
|
File(const File&) = delete;
|
||||||
|
File& operator=(const File&) = delete;
|
||||||
|
std::FILE* f = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
bool opened(const File& out, const std::string& path, std::string& error) {
|
||||||
|
if (out.f != nullptr) return true;
|
||||||
|
error = "cannot write '" + path + "': " + std::strerror(errno);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
||||||
|
ExportTable table;
|
||||||
|
const RowSet::Row& r0 = rows.row(0);
|
||||||
|
if (r0.plot == nullptr) return table;
|
||||||
|
const plot::Range view = r0.plot->x_range(r0.axis_x);
|
||||||
|
|
||||||
|
// Which channels: every bound slot on a visible row, de-duplicated, then sorted. A
|
||||||
|
// signal dropped on two rows is one column, and channel order is the signal list's
|
||||||
|
// order, so the columns read in the same order as the panel.
|
||||||
|
std::vector<int> chans;
|
||||||
|
for (int i = 0; i < rows.row_count(); ++i) {
|
||||||
|
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||||||
|
const int c = rows.channel_of(i, s);
|
||||||
|
if (c >= 0 && std::find(chans.begin(), chans.end(), c) == chans.end()) {
|
||||||
|
chans.push_back(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::sort(chans.begin(), chans.end());
|
||||||
|
if (chans.empty()) return table;
|
||||||
|
|
||||||
|
const std::vector<ChannelInfo>& infos = model.channels();
|
||||||
|
const auto name_of = [&](int c) {
|
||||||
|
return static_cast<std::size_t>(c) < infos.size() ? infos[static_cast<std::size_t>(c)].name
|
||||||
|
: std::string("ch") + std::to_string(c);
|
||||||
|
};
|
||||||
|
|
||||||
|
// What the rows bind to is what gets exported, which is the same branch `RowSet::bind()`
|
||||||
|
// takes: the frozen event in trigger mode, the live ring otherwise. Reading the ring
|
||||||
|
// while an event is on screen would export data the user cannot see.
|
||||||
|
std::shared_ptr<const EventSnapshot> snap = model.event();
|
||||||
|
if (model.trigger_enabled() && snap != nullptr) {
|
||||||
|
// The snapshot is immutable and already index-aligned, so the visible window is two
|
||||||
|
// binary searches over its stamps and the same index range for every channel.
|
||||||
|
const std::vector<f32>& st = snap->stamps;
|
||||||
|
const auto lo = std::lower_bound(st.begin(), st.end(), static_cast<f32>(view.min));
|
||||||
|
const auto hi = std::upper_bound(st.begin(), st.end(), static_cast<f32>(view.max));
|
||||||
|
if (hi <= lo) return table;
|
||||||
|
const std::size_t first = static_cast<std::size_t>(lo - st.begin());
|
||||||
|
const std::size_t n = static_cast<std::size_t>(hi - lo);
|
||||||
|
// The event's x = 0 is the edge, so the stream clock has to be added back for the
|
||||||
|
// second column -- the one case where the visible x is not the stream's own.
|
||||||
|
const f64 edge = model.trigger_time().load(std::memory_order_relaxed);
|
||||||
|
|
||||||
|
table.time.resize(n);
|
||||||
|
table.stream_time.resize(n);
|
||||||
|
for (std::size_t i = 0; i < n; ++i) {
|
||||||
|
table.time[i] = static_cast<f64>(st[first + i]);
|
||||||
|
table.stream_time[i] = edge + table.time[i];
|
||||||
|
}
|
||||||
|
for (const int c : chans) {
|
||||||
|
const std::size_t ch = static_cast<std::size_t>(c);
|
||||||
|
if (ch >= snap->values.size()) continue;
|
||||||
|
const std::vector<f32>& src = snap->values[ch];
|
||||||
|
if (src.size() < first + n) continue;
|
||||||
|
table.columns.push_back({name_of(c), std::vector<f32>(src.begin() + static_cast<std::ptrdiff_t>(first),
|
||||||
|
src.begin() + static_cast<std::ptrdiff_t>(first + n))});
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live: one absolute index range over the shared time ring, used for every channel --
|
||||||
|
// `live_window_locked()` is the same cut a latch makes, so an export and a capture of
|
||||||
|
// the same window hold the same samples. The lock is against a structural change
|
||||||
|
// dropping the rings under the copy, which is what the UI thread has to fear here.
|
||||||
|
std::lock_guard<std::mutex> lock(model.mutex());
|
||||||
|
// The axis reads `display_epoch + stamp`, so the window has to be put back on the
|
||||||
|
// stream's clock before the ring is searched. It is 0 in live mode, but reading it is
|
||||||
|
// what keeps this correct rather than merely true today.
|
||||||
|
const f64 epoch = model.display_epoch();
|
||||||
|
u64 first = 0, last = 0;
|
||||||
|
model.live_window_locked(view.min - epoch, view.max - epoch, first, last);
|
||||||
|
const u64 n = last - first;
|
||||||
|
if (n == 0) return table;
|
||||||
|
|
||||||
|
BufferStore& store = model.live();
|
||||||
|
std::vector<f32> stamps(static_cast<std::size_t>(n));
|
||||||
|
store.time().copy_from(first, n, stamps.data());
|
||||||
|
table.time.resize(static_cast<std::size_t>(n));
|
||||||
|
table.stream_time.resize(static_cast<std::size_t>(n));
|
||||||
|
for (std::size_t i = 0; i < stamps.size(); ++i) {
|
||||||
|
table.stream_time[i] = static_cast<f64>(stamps[i]);
|
||||||
|
table.time[i] = epoch + table.stream_time[i];
|
||||||
|
}
|
||||||
|
for (const int c : chans) {
|
||||||
|
const std::size_t ch = static_cast<std::size_t>(c);
|
||||||
|
if (ch >= model.channel_count()) continue;
|
||||||
|
std::vector<f32> values(static_cast<std::size_t>(n));
|
||||||
|
store.values(ch).copy_from(first, n, values.data());
|
||||||
|
table.columns.push_back({name_of(c), std::move(values)});
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool write_npy(const std::string& path, const ExportTable& table, std::string& error) {
|
||||||
|
if (table.empty()) {
|
||||||
|
error = "nothing to export: no signal is plotted in the visible range";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const std::vector<std::string> labels = column_labels(table);
|
||||||
|
|
||||||
|
// `descr` as a list of (name, format) pairs is a *packed* structured dtype -- NumPy
|
||||||
|
// only inserts alignment padding when asked to (`align=True`) -- so the records below
|
||||||
|
// are field widths back to back with no gaps.
|
||||||
|
std::string descr = "[('time', '<f8'), ('stream_time', '<f8')";
|
||||||
|
for (const std::string& label : labels) descr += ", ('" + label + "', '<f4')";
|
||||||
|
descr += "]";
|
||||||
|
std::string header = "{'descr': " + descr + ", 'fortran_order': False, 'shape': (" +
|
||||||
|
std::to_string(table.rows()) + ",), }";
|
||||||
|
// v1.0: a 6-byte magic, 2 version bytes and a 2-byte little-endian header length, and
|
||||||
|
// the whole preamble padded so the data starts on a 64-byte boundary.
|
||||||
|
constexpr std::size_t kPreamble = 10;
|
||||||
|
const std::size_t unpadded = kPreamble + header.size() + 1;
|
||||||
|
const std::size_t padded = (unpadded + 63) / 64 * 64;
|
||||||
|
header.append(padded - unpadded, ' ');
|
||||||
|
header.push_back('\n');
|
||||||
|
if (header.size() > 0xFFFFu) {
|
||||||
|
error = "too many columns for a v1.0 .npy header";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
File out(path);
|
||||||
|
if (!opened(out, path, error)) return false;
|
||||||
|
const unsigned char len[2] = {static_cast<unsigned char>(header.size() & 0xFFu),
|
||||||
|
static_cast<unsigned char>((header.size() >> 8) & 0xFFu)};
|
||||||
|
std::fwrite("\x93NUMPY\x01\x00", 1, 8, out.f);
|
||||||
|
std::fwrite(len, 1, 2, out.f);
|
||||||
|
std::fwrite(header.data(), 1, header.size(), out.f);
|
||||||
|
|
||||||
|
// One record at a time, into a reused buffer: a column-major staging array would be a
|
||||||
|
// second copy of the whole export, which at a full ring is tens of megabytes.
|
||||||
|
std::vector<unsigned char> record(2 * sizeof(f64) + table.columns.size() * sizeof(f32));
|
||||||
|
for (std::size_t i = 0; i < table.rows(); ++i) {
|
||||||
|
std::size_t at = 0;
|
||||||
|
std::memcpy(record.data() + at, &table.time[i], sizeof(f64));
|
||||||
|
at += sizeof(f64);
|
||||||
|
std::memcpy(record.data() + at, &table.stream_time[i], sizeof(f64));
|
||||||
|
at += sizeof(f64);
|
||||||
|
for (const ExportTable::Column& col : table.columns) {
|
||||||
|
const f32 v = i < col.values.size() ? col.values[i] : 0.0f;
|
||||||
|
std::memcpy(record.data() + at, &v, sizeof(f32));
|
||||||
|
at += sizeof(f32);
|
||||||
|
}
|
||||||
|
if (std::fwrite(record.data(), 1, record.size(), out.f) != record.size()) {
|
||||||
|
error = "short write to '" + path + "': " + std::strerror(errno);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (std::fflush(out.f) != 0) {
|
||||||
|
error = "cannot flush '" + path + "': " + std::strerror(errno);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool write_csv(const std::string& path, const ExportTable& table, std::string& error) {
|
||||||
|
if (table.empty()) {
|
||||||
|
error = "nothing to export: no signal is plotted in the visible range";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
File out(path);
|
||||||
|
if (!opened(out, path, error)) return false;
|
||||||
|
|
||||||
|
const std::vector<std::string> labels = column_labels(table);
|
||||||
|
std::string line = "time,stream_time";
|
||||||
|
for (const std::string& label : labels) line += "," + label;
|
||||||
|
line += "\n";
|
||||||
|
std::fwrite(line.data(), 1, line.size(), out.f);
|
||||||
|
|
||||||
|
// `%.17g` for the f64 times and `%.9g` for the f32 values: the shortest widths that
|
||||||
|
// round-trip each type exactly. Anything less silently rewrites the samples, which is
|
||||||
|
// the one thing an export may not do.
|
||||||
|
char buf[64];
|
||||||
|
for (std::size_t i = 0; i < table.rows(); ++i) {
|
||||||
|
line.clear();
|
||||||
|
std::snprintf(buf, sizeof(buf), "%.17g", table.time[i]);
|
||||||
|
line += buf;
|
||||||
|
std::snprintf(buf, sizeof(buf), "%.17g", table.stream_time[i]);
|
||||||
|
line += ",";
|
||||||
|
line += buf;
|
||||||
|
for (const ExportTable::Column& col : table.columns) {
|
||||||
|
std::snprintf(buf, sizeof(buf), "%.9g",
|
||||||
|
double(i < col.values.size() ? col.values[i] : 0.0f));
|
||||||
|
line += ",";
|
||||||
|
line += buf;
|
||||||
|
}
|
||||||
|
line += "\n";
|
||||||
|
if (std::fwrite(line.data(), 1, line.size(), out.f) != line.size()) {
|
||||||
|
error = "short write to '" + path + "': " + std::strerror(errno);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (std::fflush(out.f) != 0) {
|
||||||
|
error = "cannot flush '" + path + "': " + std::strerror(errno);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "model.hpp"
|
||||||
|
#include "plot_host.hpp"
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
/// What the plots are showing, as a table: one row per sample of the visible x window,
|
||||||
|
/// one column per plotted signal.
|
||||||
|
///
|
||||||
|
/// The samples are the *stored* ones, never the LOD pyramid's buckets: the pyramid is
|
||||||
|
/// what makes a million samples drawable in a few hundred pixels, and exporting it would
|
||||||
|
/// hand back the envelope the renderer drew rather than the acquisition. So an export is
|
||||||
|
/// at the full rate the ring holds, however far the view is zoomed out.
|
||||||
|
struct ExportTable {
|
||||||
|
struct Column {
|
||||||
|
std::string name; ///< the legend name, sanitised for a column label
|
||||||
|
std::vector<f32> values; ///< `rows()` long, the ring's own `f32`
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The x the plot shows and the crosshair reads: stream seconds in live mode,
|
||||||
|
/// edge-relative seconds in `[-pre, +post]` while an event is held.
|
||||||
|
std::vector<f64> time;
|
||||||
|
/// The stream's own clock, so two exports taken in different modes line up. Equal to
|
||||||
|
/// `time` in live mode; `time + trigger_time` while an event is held, which is the one
|
||||||
|
/// case where the visible x is not the stream's.
|
||||||
|
std::vector<f64> stream_time;
|
||||||
|
std::vector<Column> columns;
|
||||||
|
|
||||||
|
std::size_t rows() const noexcept { return time.size(); }
|
||||||
|
bool empty() const noexcept { return columns.empty() || time.empty(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Collects every signal bound to a visible row, over the shared x window, at the stored
|
||||||
|
/// rate. A signal on several rows is one column; the columns come out in channel order,
|
||||||
|
/// which is the signal list's order.
|
||||||
|
///
|
||||||
|
/// Takes `model.mutex()` for the live cut, so call it from the GTK thread like any other
|
||||||
|
/// model access. In trigger mode nothing is locked: the `EventSnapshot` is immutable.
|
||||||
|
ExportTable collect_export(ScopeModel& model, RowSet& rows);
|
||||||
|
|
||||||
|
/// Writes a NumPy `.npy` file holding one structured array: `time` and `stream_time` as
|
||||||
|
/// `<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.
|
||||||
|
///
|
||||||
|
/// `pd.DataFrame(np.load(path))` is the whole reader.
|
||||||
|
bool write_npy(const std::string& path, const ExportTable& table, std::string& error);
|
||||||
|
|
||||||
|
/// Writes the same table as CSV: a header row of column names, then `%.*g` fields --
|
||||||
|
/// enough digits to round-trip each column's own type and no more.
|
||||||
|
bool write_csv(const std::string& path, const ExportTable& table, std::string& error);
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "acquisition.hpp"
|
||||||
|
#include "config.hpp"
|
||||||
|
#include "model.hpp"
|
||||||
|
#include "plot_host.hpp"
|
||||||
|
#include "ui.hpp"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <exception>
|
||||||
|
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
udpscope::Config cfg;
|
||||||
|
try {
|
||||||
|
cfg = udpscope::parse_args(argc, argv);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
std::fprintf(stderr, "udpscope: %s\n", e.what());
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
udpscope::ScopeModel model(cfg);
|
||||||
|
udpscope::RowSet rows;
|
||||||
|
rows.set_row_count(cfg.plots);
|
||||||
|
udpscope::Acquisition acquisition(model, cfg);
|
||||||
|
try {
|
||||||
|
acquisition.start();
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
std::fprintf(stderr, "udpscope: %s\n", e.what());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int result = udpscope::run_ui(argc, argv, model, acquisition, rows);
|
||||||
|
acquisition.stop();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
+378
@@ -0,0 +1,378 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "model.hpp"
|
||||||
|
|
||||||
|
#include <udps_client.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
u64 pow2_floor(u64 v) {
|
||||||
|
if (v == 0) return 0;
|
||||||
|
u64 p = 1;
|
||||||
|
while (p <= v / 2) p <<= 1;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // 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 p;
|
||||||
|
const std::size_t n = channel_dt.size();
|
||||||
|
p.stride.assign(n, 1);
|
||||||
|
p.value_capacity.assign(n, 0);
|
||||||
|
if (n == 0) return p;
|
||||||
|
|
||||||
|
// The fastest channel drives the shared time base: every other channel is
|
||||||
|
// decimated by the integer ratio of its own interval to that one.
|
||||||
|
double base_dt = 0.0;
|
||||||
|
for (double dt : channel_dt) {
|
||||||
|
if (dt > 0.0) base_dt = base_dt > 0.0 ? std::min(base_dt, dt) : dt;
|
||||||
|
}
|
||||||
|
// No declared rate: assume 1 kHz rather than one sample per second, so a
|
||||||
|
// stream that only says "here are samples" still gets a sane time scale and a
|
||||||
|
// sane first frame in `Acquisition::build_stamps()`.
|
||||||
|
if (!(base_dt > 0.0)) base_dt = 1.0e-3;
|
||||||
|
p.base_dt = base_dt;
|
||||||
|
|
||||||
|
// No per-channel decimation, and none on the shared clock either. A stride of
|
||||||
|
// `round(dt_i / dt_fastest)` throws away all but one sample per stride, and when
|
||||||
|
// that stride equals the signal's own period -- a 2 kHz sine on a 1 MHz clock,
|
||||||
|
// stride 500 -- every kept sample sits at the same phase: the channel plots as a
|
||||||
|
// flat line. Decimating the shared clock to make a 600 s window fit did the same
|
||||||
|
// to *every* channel. The LOD min/max pyramid is what preserves the extremes at
|
||||||
|
// draw time, and it needs the real samples; the buffer simply holds as many as
|
||||||
|
// it holds, and the window dropdown is capped to that span.
|
||||||
|
for (std::size_t i = 0; i < n; ++i) p.stride[i] = 1;
|
||||||
|
p.time_stride = 1;
|
||||||
|
|
||||||
|
const u64 budget_bytes = static_cast<u64>(std::max(1.0, buffer_bytes));
|
||||||
|
const u64 budget = budget_bytes / sizeof(f32);
|
||||||
|
double weight = 1.0; // the time slice itself
|
||||||
|
for (u64 s : p.stride) weight += 1.0 / static_cast<double>(s);
|
||||||
|
|
||||||
|
const u64 slots = budget / static_cast<u64>(std::llround(weight));
|
||||||
|
u64 time_capacity = pow2_floor(std::max<u64>(slots, 128));
|
||||||
|
if (time_capacity == 0) {
|
||||||
|
throw std::runtime_error("buffer too small for one time-base sample per channel");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The target span sets a floor, not a ceiling: the rest of the budget buys a
|
||||||
|
// longer ring, which is strictly more history to scroll back through. Growth
|
||||||
|
// stops before the total would exceed the budget. A channel's slots are rounded
|
||||||
|
// *up* on the power of two `RingBuffer` will round them to anyway, so every
|
||||||
|
// channel covers at least the time ring's span.
|
||||||
|
// Floor, not ceiling: a channel's window must not be *longer* than the time ring
|
||||||
|
// it reads its stamps from. Rounding up (with a stride that is not a power of two)
|
||||||
|
// gave a channel more samples than the shared timeline covers, and the oldest of
|
||||||
|
// them referenced time slots the ring had already overwritten -- they were then
|
||||||
|
// stamped with the epoch, a plausible-looking coordinate that collapsed the trace
|
||||||
|
// against the axis. Flooring can leave a channel holding as little as ~62% of the
|
||||||
|
// time ring's span, which is a shorter history, not a wrong one.
|
||||||
|
const auto capacity_for = [&](u64 ticks, u64 stride) {
|
||||||
|
return std::max<u64>(pow2_floor(ticks / stride), 1);
|
||||||
|
};
|
||||||
|
const auto bytes_for = [&](u64 ticks) {
|
||||||
|
u64 total = ticks;
|
||||||
|
for (u64 s : p.stride) total += capacity_for(ticks, s);
|
||||||
|
return total * sizeof(f32);
|
||||||
|
};
|
||||||
|
while (time_capacity * 2 <= budget && bytes_for(time_capacity * 2) <= budget_bytes) {
|
||||||
|
time_capacity *= 2;
|
||||||
|
}
|
||||||
|
p.time_capacity = time_capacity;
|
||||||
|
for (std::size_t i = 0; i < n; ++i) {
|
||||||
|
p.value_capacity[i] = capacity_for(p.time_capacity, p.stride[i]);
|
||||||
|
}
|
||||||
|
p.span_s = static_cast<double>(p.time_capacity) * static_cast<double>(p.time_stride) * base_dt;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- BufferStore ------------------------------------------------------------
|
||||||
|
|
||||||
|
BufferStore::BufferStore(const Partition& part, f64 span_s)
|
||||||
|
: part_(part),
|
||||||
|
time_(std::make_shared<plot::data::RingBuffer<f32>>(part.time_capacity)) {
|
||||||
|
(void)span_s;
|
||||||
|
const std::size_t n = part.value_capacity.size();
|
||||||
|
values_.reserve(n);
|
||||||
|
channels_.reserve(n);
|
||||||
|
for (std::size_t i = 0; i < n; ++i) {
|
||||||
|
values_.push_back(std::make_shared<plot::data::RingBuffer<f32>>(part.value_capacity[i]));
|
||||||
|
// `ChannelBuffer::dx()` multiplies this by the channel's stride, so the
|
||||||
|
// value here is the *slot* interval: stride * slot_dt is the real sample
|
||||||
|
// interval at the decimation the partition chose.
|
||||||
|
const double dt = part.slot_dt();
|
||||||
|
channels_.push_back(std::make_shared<ChannelBuffer>(values_[i], time_, part.stride[i], 0.0,
|
||||||
|
dt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void BufferStore::acquire_all() {
|
||||||
|
for (auto& c : channels_) c->acquire();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ScopeModel -------------------------------------------------------------
|
||||||
|
|
||||||
|
ScopeModel::ScopeModel(const Config& cfg) : cfg_(cfg) {
|
||||||
|
trigger_.holdoff_s = cfg.trigger_holdoff_s;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ScopeModel::set_signals(std::vector<SignalInfo> signals) {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
// Keep the enable state across CONFIG updates for signals that kept their name.
|
||||||
|
std::unordered_map<std::string, bool> was_enabled;
|
||||||
|
for (std::size_t i = 0; i < signals_.size(); ++i) {
|
||||||
|
was_enabled.emplace(signals_[i].name, i < enabled_.size() ? enabled_[i] : false);
|
||||||
|
}
|
||||||
|
signals_ = std::move(signals);
|
||||||
|
enabled_.assign(signals_.size(), false);
|
||||||
|
for (std::size_t i = 0; i < signals_.size(); ++i) {
|
||||||
|
auto it = was_enabled.find(signals_[i].name);
|
||||||
|
// Off until the user enables it (spec: the signal panel's switch is what
|
||||||
|
// starts acquisition of a signal). A later CONFIG keeps whatever the user
|
||||||
|
// chose for a signal that kept its name.
|
||||||
|
enabled_[i] = it != was_enabled.end() ? it->second : false;
|
||||||
|
}
|
||||||
|
rebuild_channels();
|
||||||
|
rebuild_locked();
|
||||||
|
generation_.fetch_add(1, std::memory_order_release);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ScopeModel::rebuild_channels() {
|
||||||
|
channels_.clear();
|
||||||
|
for (std::size_t s = 0; s < signals_.size(); ++s) {
|
||||||
|
const SignalInfo& sig = signals_[s];
|
||||||
|
if (s >= enabled_.size() || !enabled_[s]) continue;
|
||||||
|
if (is_time_signal(signals_, s)) continue;
|
||||||
|
ChannelInfo ch;
|
||||||
|
ch.signal = s;
|
||||||
|
ch.enabled = true;
|
||||||
|
// A UDPStreamer signal is one channel: its columns are consecutive samples of
|
||||||
|
// that channel within a DATA packet (1x1000 is 1000 samples of the same
|
||||||
|
// signal, not 1000 signals). The worker client hands a multi-element signal
|
||||||
|
// exactly `elements` values per packet, which is the block of samples.
|
||||||
|
ch.name = sig.name;
|
||||||
|
ch.unit = sig.unit;
|
||||||
|
ch.elements = sig.elements();
|
||||||
|
ch.dt = sig.sampling_rate > 0.0 ? 1.0 / sig.sampling_rate : 0.0;
|
||||||
|
channels_.push_back(std::move(ch));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ScopeModel::rebuild_locked() {
|
||||||
|
std::vector<double> dts;
|
||||||
|
dts.reserve(channels_.size());
|
||||||
|
for (const ChannelInfo& c : channels_) dts.push_back(c.dt);
|
||||||
|
|
||||||
|
if (dts.empty()) {
|
||||||
|
part_ = Partition{};
|
||||||
|
store_.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
part_ = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0);
|
||||||
|
store_ = std::make_unique<BufferStore>(part_, part_.span_s);
|
||||||
|
|
||||||
|
if (trigger_.channel >= static_cast<int>(channels_.size())) trigger_.channel = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScopeModel::is_time_signal(const std::vector<SignalInfo>& signals,
|
||||||
|
std::size_t signal) {
|
||||||
|
if (signal >= signals.size()) return false;
|
||||||
|
const SignalInfo& s = signals[signal];
|
||||||
|
// Referenced by name: another signal declares this one as its time base.
|
||||||
|
for (const SignalInfo& other : signals) {
|
||||||
|
if (other.time_signal_idx == signal) return true;
|
||||||
|
}
|
||||||
|
// A full-array signal that declares **no sample rate** is the stream's timestamp
|
||||||
|
// signal: it has stamps but no cadence of its own, because it is not data.
|
||||||
|
// Data channels can also be declared full-array (per-element stamps), and they
|
||||||
|
// keep a declared rate -- treating them as time signals hid every real channel.
|
||||||
|
if (s.time_mode == UDPS_TIME_FULL_ARRAY && s.elements() > 1 && s.sampling_rate == 0.0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScopeModel::signal_enabled(std::size_t signal) const {
|
||||||
|
return signal < enabled_.size() && enabled_[signal];
|
||||||
|
}
|
||||||
|
|
||||||
|
void ScopeModel::set_signal_enabled(std::size_t signal, bool on) {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
if (signal >= enabled_.size() || enabled_[signal] == on) return;
|
||||||
|
enabled_[signal] = on;
|
||||||
|
// The trigger names a channel by its display name, which is stable across
|
||||||
|
// renumbering: when its own signal is disabled the channel vanishes (the trigger
|
||||||
|
// then waits, and comes back when the signal is re-enabled).
|
||||||
|
const int old_trigger = trigger_.channel;
|
||||||
|
const std::string trigger_name =
|
||||||
|
(old_trigger >= 0 && old_trigger < static_cast<int>(channels_.size()))
|
||||||
|
? channels_[static_cast<std::size_t>(old_trigger)].name
|
||||||
|
: std::string();
|
||||||
|
rebuild_channels();
|
||||||
|
trigger_.channel = -1;
|
||||||
|
if (!trigger_name.empty()) {
|
||||||
|
for (std::size_t i = 0; i < channels_.size(); ++i) {
|
||||||
|
if (channels_[i].name == trigger_name) trigger_.channel = static_cast<int>(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rebuild_locked();
|
||||||
|
generation_.fetch_add(1, std::memory_order_release);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ScopeModel::set_calibration(std::size_t signal, double gain, double offset) {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
bool any = false;
|
||||||
|
for (ChannelInfo& c : channels_) {
|
||||||
|
if (c.signal != signal) continue;
|
||||||
|
c.gain = gain;
|
||||||
|
c.offset = offset;
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
// Calibration is applied at ingest, so the buffers are rebuilt to avoid mixing
|
||||||
|
// calibrated and uncalibrated samples (and stale LOD aggregates).
|
||||||
|
if (any) {
|
||||||
|
rebuild_locked();
|
||||||
|
generation_.fetch_add(1, std::memory_order_release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double ScopeModel::channel_dt(std::size_t channel) const {
|
||||||
|
return channel < channels_.size() ? channels_[channel].dt : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
BufferStore& ScopeModel::live() { return *store_; }
|
||||||
|
|
||||||
|
void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& last) {
|
||||||
|
BufferStore& src = live();
|
||||||
|
plot::data::RingBuffer<f32>& tsrc = src.time();
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// the same length and index-aligned *by construction*, instead of by two independent
|
||||||
|
// truncations that could disagree.
|
||||||
|
const u64 tbase = tsrc.produced() - tsrc.size();
|
||||||
|
const u64 tend = tsrc.produced();
|
||||||
|
// The bounds are rounded to the ring's own `f32` before they are compared against
|
||||||
|
// it. The window arrives in f64 and the stamps are stored as f32, so an f64
|
||||||
|
// comparison decides a sample that sits exactly on a bound by which way its stamp
|
||||||
|
// happened to round -- the same nominal window then keeps or drops its first sample
|
||||||
|
// depending on how large the stream time is (`f32(38.001) < 38.001` but
|
||||||
|
// `f32(18.001) > 18.001`). One sample is invisible at 10 kS/s and a third of the
|
||||||
|
// capture at 1 S/s. Comparing in the stored type makes a bound that lands on a
|
||||||
|
// sample include it, always.
|
||||||
|
const f32 t_lo = static_cast<f32>(lo_s);
|
||||||
|
const f32 t_hi = static_cast<f32>(hi_s);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// thing that knows how many samples a second of this stream holds.
|
||||||
|
u64 a = tbase, b = tend;
|
||||||
|
while (a < b) {
|
||||||
|
const u64 m = a + (b - a) / 2;
|
||||||
|
if (tsrc[m - tbase] < t_lo) {
|
||||||
|
a = m + 1;
|
||||||
|
} else {
|
||||||
|
b = m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
first = a;
|
||||||
|
b = tend;
|
||||||
|
while (a < b) {
|
||||||
|
const u64 m = a + (b - a) / 2;
|
||||||
|
if (tsrc[m - tbase] <= t_hi) {
|
||||||
|
a = m + 1;
|
||||||
|
} else {
|
||||||
|
b = m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
last = a;
|
||||||
|
|
||||||
|
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
||||||
|
plot::data::RingBuffer<f32>& from = src.values(c);
|
||||||
|
first = std::max(first, from.produced() - from.size());
|
||||||
|
last = std::min(last, from.produced());
|
||||||
|
}
|
||||||
|
if (last < first) last = first;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
|
||||||
|
auto snap = std::make_shared<EventSnapshot>();
|
||||||
|
BufferStore& src = live();
|
||||||
|
plot::data::RingBuffer<f32>& tsrc = src.time();
|
||||||
|
|
||||||
|
u64 first = 0, last = 0;
|
||||||
|
live_window_locked(edge - std::max(0.0, pre_s), edge + std::max(0.0, post_s), first, last);
|
||||||
|
const u64 n = last - first;
|
||||||
|
|
||||||
|
snap->stamps.resize(static_cast<std::size_t>(n));
|
||||||
|
if (n > 0) {
|
||||||
|
tsrc.copy_from(first, n, snap->stamps.data());
|
||||||
|
// 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
|
||||||
|
// disagree with.
|
||||||
|
for (auto& t : snap->stamps) t -= static_cast<f32>(edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
snap->values.resize(channels_.size());
|
||||||
|
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0});
|
||||||
|
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
||||||
|
auto& vals = snap->values[c];
|
||||||
|
vals.resize(static_cast<std::size_t>(n));
|
||||||
|
if (n > 0) src.values(c).copy_from(first, n, vals.data());
|
||||||
|
f32 lo = vals.empty() ? 0.0f : vals.front();
|
||||||
|
f32 hi = lo;
|
||||||
|
for (f32 v : vals) {
|
||||||
|
lo = std::min(lo, v);
|
||||||
|
hi = std::max(hi, v);
|
||||||
|
}
|
||||||
|
snap->y[c] = plot::Range{lo, hi};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The span is read off the stamps that were actually captured, never off the
|
||||||
|
// request: the ring may not reach back a full `pre_s`, and the producer stops the
|
||||||
|
// capture at the first sample past `post_s` rather than exactly on it.
|
||||||
|
snap->pre_s = snap->stamps.empty() ? 0.0 : -static_cast<double>(snap->stamps.front());
|
||||||
|
snap->post_s = snap->stamps.empty() ? 0.0 : static_cast<double>(snap->stamps.back());
|
||||||
|
event_.store(std::move(snap), std::memory_order_release);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BufferStore::set_epoch(f64 epoch) noexcept {
|
||||||
|
for (auto& c : channels_) c->set_epoch(epoch);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ScopeModel::set_display_epoch(f64 e) noexcept {
|
||||||
|
display_epoch_.store(e, std::memory_order_relaxed);
|
||||||
|
if (store_) store_->set_epoch(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ScopeModel::acquire_all() {
|
||||||
|
if (store_) store_->acquire_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ScopeModel::set_trigger_enabled(bool on) {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
trigger_.enabled = on;
|
||||||
|
trigger_enabled_.store(on, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ScopeModel::set_epoch_if_unset(f64 e) noexcept {
|
||||||
|
if (epoch_ == 0.0) epoch_ = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
+297
@@ -0,0 +1,297 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "channel.hpp"
|
||||||
|
#include "config.hpp"
|
||||||
|
#include "event_series.hpp"
|
||||||
|
#include "trigger.hpp"
|
||||||
|
|
||||||
|
#include <plot/data/ring_buffer.hpp>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
/// One streamed signal, as the CONFIG packet described it.
|
||||||
|
struct SignalInfo {
|
||||||
|
std::string name;
|
||||||
|
std::string unit;
|
||||||
|
std::uint8_t type_code = 0;
|
||||||
|
std::uint8_t quant_type = 0;
|
||||||
|
std::uint32_t rows = 1;
|
||||||
|
std::uint32_t cols = 1;
|
||||||
|
double range_min = 0.0;
|
||||||
|
double range_max = 1.0;
|
||||||
|
std::uint8_t time_mode = 0;
|
||||||
|
double sampling_rate = 0.0;
|
||||||
|
std::uint32_t time_signal_idx = 0xFFFFFFFFu;
|
||||||
|
|
||||||
|
std::uint32_t elements() const noexcept {
|
||||||
|
const std::uint32_t n = rows * cols;
|
||||||
|
return n == 0 ? 1u : n;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// One plottable signal element: a signal with a single element, or one element
|
||||||
|
/// of a vector/matrix signal. The unit of enable/disable is the *signal*; the
|
||||||
|
/// unit of acquisition and plotting is the channel.
|
||||||
|
struct ChannelInfo {
|
||||||
|
std::size_t signal = 0;
|
||||||
|
bool enabled = false; ///< whether its signal is enabled
|
||||||
|
double gain = 1.0; ///< calibration, applied as sample * gain + offset
|
||||||
|
double offset = 0.0;
|
||||||
|
|
||||||
|
std::string name; ///< the signal's name
|
||||||
|
std::string unit;
|
||||||
|
|
||||||
|
/// Samples of this channel per DATA packet (`elements()` of the signal). A
|
||||||
|
/// UDPStreamer signal is one channel: its columns are consecutive samples of the
|
||||||
|
/// same signal, not separate channels.
|
||||||
|
std::uint32_t elements = 1;
|
||||||
|
|
||||||
|
/// Sample interval of this channel's own cadence, in seconds. 0 when unknown.
|
||||||
|
double dt = 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// How one buffer's storage is divided between the shared time base and the
|
||||||
|
/// channels (spec item 2: "split in N+1", the +1 being the time base).
|
||||||
|
///
|
||||||
|
/// The split is by *time span*, not by bytes: every channel's ring and the time
|
||||||
|
/// ring cover the same amount of stream time, which is what makes a shared time
|
||||||
|
/// base meaningful. When no channel is decimated (the common case) the two rules
|
||||||
|
/// coincide and every slice is the same size.
|
||||||
|
struct Partition {
|
||||||
|
u64 time_capacity = 0; ///< time-base slots retained
|
||||||
|
u64 time_stride = 1; ///< base ticks per time-base slot (reserved; 1)
|
||||||
|
std::vector<u64> stride; ///< time slots per channel sample (>= 1)
|
||||||
|
std::vector<u64> value_capacity; ///< samples retained per channel
|
||||||
|
double base_dt = 1.0; ///< seconds per base tick
|
||||||
|
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).
|
||||||
|
double slot_dt() const noexcept { return base_dt * static_cast<double>(time_stride); }
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Plans a partition for `channel_dt` cadences within `buffer_bytes`, targeting a
|
||||||
|
/// span of `target_span_s` seconds. Throws std::runtime_error when the budget
|
||||||
|
/// cannot hold even one sample per channel.
|
||||||
|
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes);
|
||||||
|
|
||||||
|
/// One of the two identical sample buffers: a shared time-base ring plus one
|
||||||
|
/// value ring and one `ChannelBuffer` per channel.
|
||||||
|
class BufferStore {
|
||||||
|
public:
|
||||||
|
BufferStore(const Partition& part, f64 span_s);
|
||||||
|
|
||||||
|
/// Re-bases every channel's x (see `ChannelBuffer::set_epoch`). Used to make the
|
||||||
|
/// axis read seconds from the trigger event.
|
||||||
|
void set_epoch(f64 epoch) noexcept;
|
||||||
|
|
||||||
|
plot::data::RingBuffer<f32>& time() noexcept { return *time_; }
|
||||||
|
const plot::data::RingBuffer<f32>& time() const noexcept { return *time_; }
|
||||||
|
plot::data::RingBuffer<f32>& values(std::size_t channel) { return *values_[channel]; }
|
||||||
|
ChannelBuffer& channel(std::size_t channel) { return *channels_[channel]; }
|
||||||
|
/// Shared handle to a channel view: the caller keeps the storage alive even if
|
||||||
|
/// the model rebuilds.
|
||||||
|
std::shared_ptr<ChannelBuffer> shared_channel(std::size_t channel) {
|
||||||
|
return channels_[channel];
|
||||||
|
}
|
||||||
|
const Partition& partition() const noexcept { return part_; }
|
||||||
|
|
||||||
|
/// Consumer: publishes every channel's new samples.
|
||||||
|
void acquire_all();
|
||||||
|
|
||||||
|
private:
|
||||||
|
Partition part_;
|
||||||
|
std::shared_ptr<plot::data::RingBuffer<f32>> time_;
|
||||||
|
std::vector<std::shared_ptr<plot::data::RingBuffer<f32>>> values_;
|
||||||
|
std::vector<std::shared_ptr<ChannelBuffer>> channels_;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Live connection and counter state, readable from the UI thread.
|
||||||
|
struct StreamStats {
|
||||||
|
std::atomic<std::uint64_t> packets{0};
|
||||||
|
std::atomic<std::uint64_t> frames{0};
|
||||||
|
std::atomic<std::uint64_t> gaps{0};
|
||||||
|
std::atomic<std::uint64_t> reconnects{0};
|
||||||
|
std::atomic<bool> connected{false};
|
||||||
|
std::atomic<bool> have_config{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The application's data model: the stream description, the two sample buffers,
|
||||||
|
/// the trigger configuration and the counters. Structural changes (a CONFIG
|
||||||
|
/// update, an enable/disable, a calibration) rebuild the buffers, so all mutating
|
||||||
|
/// access is serialised on one mutex; the acquisition thread takes it once per
|
||||||
|
/// DATA frame.
|
||||||
|
class ScopeModel {
|
||||||
|
public:
|
||||||
|
explicit ScopeModel(const Config& cfg);
|
||||||
|
|
||||||
|
const Config& config() const noexcept { return cfg_; }
|
||||||
|
|
||||||
|
// --- stream description ---------------------------------------------------
|
||||||
|
/// Takes a new CONFIG: rebuilds the signal and channel lists and the two
|
||||||
|
/// buffers. Clears the trigger's channel selection if the stream changed.
|
||||||
|
void set_signals(std::vector<SignalInfo> signals);
|
||||||
|
const std::vector<SignalInfo>& signals() const noexcept { return signals_; }
|
||||||
|
const std::vector<ChannelInfo>& channels() const noexcept { return channels_; }
|
||||||
|
|
||||||
|
bool signal_enabled(std::size_t signal) const;
|
||||||
|
/// True when the signal is the stream's time base, i.e. when some other signal
|
||||||
|
/// declares it via `time_signal_idx`. A signal used as a time base builds the x
|
||||||
|
/// axis and is not a plottable channel.
|
||||||
|
static bool is_time_signal(const std::vector<SignalInfo>& signals,
|
||||||
|
std::size_t signal);
|
||||||
|
void set_signal_enabled(std::size_t signal, bool on);
|
||||||
|
void set_calibration(std::size_t signal, double gain, double offset);
|
||||||
|
|
||||||
|
std::size_t channel_count() const noexcept { return channels_.size(); }
|
||||||
|
const Partition& partition() const noexcept { return part_; }
|
||||||
|
|
||||||
|
/// The channel's sample interval (0 when the stream did not say).
|
||||||
|
double channel_dt(std::size_t channel) const;
|
||||||
|
|
||||||
|
// --- buffers --------------------------------------------------------------
|
||||||
|
/// The one sample store: the producer always writes it, and the plot draws from
|
||||||
|
/// it whenever it is not showing a frozen event. Always-filling is what
|
||||||
|
/// guarantees a full pre-trigger history on the *first* latch after the trigger
|
||||||
|
/// is armed.
|
||||||
|
BufferStore& live();
|
||||||
|
|
||||||
|
/// Builds the captured event from the slice of the live buffer covering
|
||||||
|
/// `[edge - pre_s, edge + post_s]` in stream seconds, and publishes it atomically.
|
||||||
|
/// The stamps are re-based so the edge sits at x = 0. The plot binds to this
|
||||||
|
/// immutable snapshot -- it is the "frozen buffer" -- while the live ring keeps
|
||||||
|
/// filling behind it.
|
||||||
|
///
|
||||||
|
/// The cut is by time, not by a sample count: the window the trigger panel asks
|
||||||
|
/// for is in seconds and the time ring stores seconds, so any count in between
|
||||||
|
/// needs a cadence estimate -- which is exactly what used to size the capture
|
||||||
|
/// wrong.
|
||||||
|
void publish_event(double pre_s, double post_s, double edge);
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// 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
|
||||||
|
/// lands on a sample includes it whatever the magnitude of the stream time.
|
||||||
|
///
|
||||||
|
/// 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);
|
||||||
|
/// The event on screen, or null before the first latch.
|
||||||
|
std::shared_ptr<const EventSnapshot> event() const noexcept {
|
||||||
|
return event_.load(std::memory_order_acquire);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publishes the live buffer's new samples to its `ChannelBuffer`s.
|
||||||
|
void acquire_all();
|
||||||
|
|
||||||
|
// --- trigger --------------------------------------------------------------
|
||||||
|
/// The trigger configuration. Mutating it means holding `mutex()`: the producer
|
||||||
|
/// reads it once per frame (and writes `armed` itself when a single-shot latch
|
||||||
|
/// disarms), so an unguarded field write can be lost or read half-updated. The
|
||||||
|
/// panel's callbacks take the lock for the fields they touch.
|
||||||
|
TriggerConfig& trigger() noexcept { return trigger_; }
|
||||||
|
const TriggerConfig& trigger() const noexcept { return trigger_; }
|
||||||
|
|
||||||
|
/// Trigger mode on/off, as both the frame tick and the producer read it: the one
|
||||||
|
/// trigger field the tick needs and the only one written from the UI, so it has
|
||||||
|
/// an atomic mirror and a setter that keeps the copy under the lock in step.
|
||||||
|
bool trigger_enabled() const noexcept {
|
||||||
|
return trigger_enabled_.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
void set_trigger_enabled(bool on);
|
||||||
|
|
||||||
|
/// Whether the engine is mid-capture (an edge was seen and the post-trigger
|
||||||
|
/// window is still filling). Purely informational: it drives the status line's
|
||||||
|
/// wording. The plot deliberately does *not* react to it -- in trigger mode the
|
||||||
|
/// picture changes only on a latch (SPECS: "data visualisation should not be
|
||||||
|
/// refreshed till next event").
|
||||||
|
bool trigger_pending() const noexcept {
|
||||||
|
return trigger_pending_.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
void set_trigger_pending(bool on) noexcept {
|
||||||
|
trigger_pending_.store(on, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The x origin the plot uses: 0 for "seconds since the stream started", or minus
|
||||||
|
/// the trigger instant while in trigger mode, so the axis reads seconds from the
|
||||||
|
/// event. Applied to the live store's channels.
|
||||||
|
f64 display_epoch() const noexcept { return display_epoch_.load(std::memory_order_relaxed); }
|
||||||
|
void set_display_epoch(f64 e) noexcept;
|
||||||
|
|
||||||
|
// --- stream state ---------------------------------------------------------
|
||||||
|
StreamStats& stats() noexcept { return stats_; }
|
||||||
|
const StreamStats& stats() const noexcept { return stats_; }
|
||||||
|
/// Seconds since the epoch of the streamed time, or 0 before the first frame.
|
||||||
|
f64 epoch() const noexcept { return epoch_; }
|
||||||
|
void set_epoch_if_unset(f64 e) noexcept;
|
||||||
|
|
||||||
|
/// Bumped whenever the signal or channel lists change, so the UI knows to
|
||||||
|
/// rebuild the panels it derives from them.
|
||||||
|
unsigned generation() const noexcept { return generation_.load(std::memory_order_acquire); }
|
||||||
|
|
||||||
|
/// The length of the event the trigger latches, in seconds: the time window the
|
||||||
|
/// toolbar selects. The pre-trigger percentage is a share of *this*, not of the
|
||||||
|
/// whole ring, which is what makes the control mean something on a long buffer.
|
||||||
|
void set_event_span(double seconds) noexcept {
|
||||||
|
event_span_.store(seconds > 0.0 ? seconds : 1.0, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
double event_span() const noexcept { return event_span_.load(std::memory_order_relaxed); }
|
||||||
|
|
||||||
|
/// Trigger state for the UI, published by the acquisition thread. Atomics
|
||||||
|
/// rather than fields of `TriggerConfig` because the producer writes the arm
|
||||||
|
/// state when a single-shot trigger disarms itself, and the frame tick reads it
|
||||||
|
/// without holding the model mutex.
|
||||||
|
std::atomic<bool>& trigger_armed() noexcept { return trigger_armed_; }
|
||||||
|
/// Incremented every time an event is latched, so the UI can tell "waiting for
|
||||||
|
/// an event" from "an event is on screen".
|
||||||
|
std::atomic<std::uint64_t>& trigger_latches() noexcept { return trigger_latches_; }
|
||||||
|
/// The stream time of the edge behind the event on screen, and whether there is
|
||||||
|
/// one. Written by the acquisition thread on a latch and cleared by the UI when
|
||||||
|
/// the event stops meaning anything (arming again, or leaving trigger mode).
|
||||||
|
std::atomic<double>& trigger_time() noexcept { return trigger_time_; }
|
||||||
|
std::atomic<bool>& trigger_time_valid() noexcept { return trigger_time_valid_; }
|
||||||
|
|
||||||
|
/// Serialises a structural change against the acquisition thread.
|
||||||
|
std::mutex& mutex() noexcept { return mutex_; }
|
||||||
|
|
||||||
|
/// Rebuilds buffers from the current channel list. Caller holds `mutex()`.
|
||||||
|
void rebuild_locked();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void rebuild_channels();
|
||||||
|
|
||||||
|
Config cfg_;
|
||||||
|
std::vector<SignalInfo> signals_;
|
||||||
|
std::vector<ChannelInfo> channels_;
|
||||||
|
std::vector<bool> enabled_;
|
||||||
|
|
||||||
|
Partition part_;
|
||||||
|
std::unique_ptr<BufferStore> store_;
|
||||||
|
TriggerConfig trigger_{};
|
||||||
|
StreamStats stats_{};
|
||||||
|
f64 epoch_ = 0.0;
|
||||||
|
std::atomic<double> event_span_{1.0};
|
||||||
|
std::atomic<bool> trigger_pending_{false};
|
||||||
|
std::atomic<std::shared_ptr<const EventSnapshot>> event_; ///< the event on screen
|
||||||
|
std::atomic<double> display_epoch_{0.0};
|
||||||
|
std::atomic<bool> trigger_armed_{false};
|
||||||
|
std::atomic<bool> trigger_enabled_{false};
|
||||||
|
std::atomic<std::uint64_t> trigger_latches_{0};
|
||||||
|
std::atomic<double> trigger_time_{0.0};
|
||||||
|
std::atomic<bool> trigger_time_valid_{false};
|
||||||
|
std::atomic<unsigned> generation_{0};
|
||||||
|
std::mutex mutex_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "plot_host.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// A small qualitative palette, one colour per *signal*. It is indexed by the signal
|
||||||
|
/// rather than by the trace slot so that a signal plotted on several rows is the same
|
||||||
|
/// colour in all of them: the signal list's chip is what ties a row of that list to a
|
||||||
|
/// trace on screen, and a colour that depended on where the signal landed would name a
|
||||||
|
/// different trace in every row.
|
||||||
|
constexpr plot::u32 kPalette[] = {
|
||||||
|
0x4FA3FFu, 0xFF8A4Fu, 0x5FD08Au, 0xE86A9Cu, 0xC9A227u, 0x8F7BFFu, 0x4FD0D0u, 0xFF6B6Bu,
|
||||||
|
};
|
||||||
|
constexpr std::size_t kPaletteSize = sizeof(kPalette) / sizeof(kPalette[0]);
|
||||||
|
|
||||||
|
/// The chip of a signal that is acquired but on no plot, and of one that is not even
|
||||||
|
/// acquired. Mid grey: it has to read as "no trace" next to any palette entry.
|
||||||
|
constexpr plot::u32 kUnplottedColor = 0x6E6E6Eu;
|
||||||
|
|
||||||
|
constexpr plot::u32 kCursorAColor = 0xFFD24Fu;
|
||||||
|
constexpr plot::u32 kCursorBColor = 0x7FE0FFu;
|
||||||
|
constexpr plot::u32 kRulerColor = 0xB0B0B0u;
|
||||||
|
constexpr plot::u32 kTriggerColor = 0xFF5B5Bu;
|
||||||
|
|
||||||
|
constexpr double kParked = -1.0e12; ///< hides an instrument without removing it
|
||||||
|
|
||||||
|
/// The gap between two rows, in device pixels. The rows used to be separate widgets in
|
||||||
|
/// a box, so there was a widget gap; a figure tiles its viewport, and without a gap two
|
||||||
|
/// stacked frames read as one tall plot with a line through it.
|
||||||
|
constexpr plot::f32 kRowGap = 6.0f;
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
plot::Color RowSet::palette_color(int index) {
|
||||||
|
const std::size_t i = static_cast<std::size_t>(index < 0 ? 0 : index) % kPaletteSize;
|
||||||
|
return plot::Color::hex(kPalette[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
plot::Color RowSet::unplotted_color() { return plot::Color::hex(kUnplottedColor); }
|
||||||
|
|
||||||
|
RowSet::RowSet() { rebuild_all(); }
|
||||||
|
|
||||||
|
void RowSet::set_row_count(int rows) {
|
||||||
|
const int want = std::clamp(rows, 1, kMaxRows);
|
||||||
|
if (want == rows_) return;
|
||||||
|
rows_ = want;
|
||||||
|
// A `Figure`'s row count is fixed at construction, so a new count is a new figure.
|
||||||
|
rebuild_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
|
||||||
|
r.plot = &p;
|
||||||
|
r.axis_x = r.plot->add_x_axis(
|
||||||
|
{"time", {-1.0, 1.0}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
|
||||||
|
r.axis_y = r.plot->add_y_axis(
|
||||||
|
{"value", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
|
||||||
|
for (int s = 0; s < kSlotsPerRow; ++s) {
|
||||||
|
const std::size_t i = static_cast<std::size_t>(s);
|
||||||
|
r.plot->add_trace(
|
||||||
|
{&r.slots[i], palette_color(r.color_index[i]), 1.2f, r.axis_x, r.axis_y, r.name[i]});
|
||||||
|
}
|
||||||
|
r.cursor_a = r.plot->add_cursor(0.0, plot::Color::hex(kCursorAColor));
|
||||||
|
r.cursor_b = r.plot->add_cursor(0.0, plot::Color::hex(kCursorBColor));
|
||||||
|
r.ruler = r.plot->add_h_ruler(0.0, plot::Color::hex(kRulerColor));
|
||||||
|
r.trigger_marker = r.plot->add_cursor(kParked, plot::Color::hex(kTriggerColor));
|
||||||
|
// A marker, not an instrument: it must sit exactly on the edge's time, so its
|
||||||
|
// snap is Free and the controller never rounds it onto a sample.
|
||||||
|
r.plot->set_cursor_snap(r.trigger_marker, plot::SnapMode::Free);
|
||||||
|
// No `set_x_follow(Live)`: the link group owns the window *and* the follow state, and
|
||||||
|
// `Figure::link_x()` refuses a member whose x axis still follows on its own.
|
||||||
|
}
|
||||||
|
|
||||||
|
void RowSet::rebuild_all() {
|
||||||
|
figure_ = std::make_unique<plot::Figure>(static_cast<plot::u32>(rows_));
|
||||||
|
figure_->set_row_gap(kRowGap);
|
||||||
|
x_group_ = figure_->add_x_link();
|
||||||
|
for (int i = 0; i < kMaxRows; ++i) {
|
||||||
|
Row& r = row(i);
|
||||||
|
row_dirty_[static_cast<std::size_t>(i)] = false;
|
||||||
|
if (i >= rows_) {
|
||||||
|
r.plot = nullptr; // not a row of this figure: nothing to plot into
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
install(r, i, figure_->plot(static_cast<plot::u32>(i)));
|
||||||
|
place_cursors(r);
|
||||||
|
if (trigger_marker_on_) {
|
||||||
|
r.plot->set_cursor_x(r.trigger_marker, trigger_marker_x_, plot::CursorPlacement::Free);
|
||||||
|
}
|
||||||
|
figure_->link_x(static_cast<plot::u32>(i), x_group_);
|
||||||
|
}
|
||||||
|
if (y_gutter_px_ > 0.0) apply_y_gutter();
|
||||||
|
// A new figure starts with no window at all, so the one the rows were showing has to
|
||||||
|
// be restated -- as the pin it was, if an event is on screen: assigning a trace or
|
||||||
|
// changing the row count while a capture is displayed must not throw the view back
|
||||||
|
// onto the live edge, which in trigger mode nothing would move it off again until the
|
||||||
|
// next latch.
|
||||||
|
if (pinned_) {
|
||||||
|
pin_event_window(pinned_pre_, pinned_post_);
|
||||||
|
} else {
|
||||||
|
set_time_window(window_s_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RowSet::rebuild_if_dirty() {
|
||||||
|
bool any = false;
|
||||||
|
for (int i = 0; i < kMaxRows; ++i) {
|
||||||
|
if (row_dirty_[static_cast<std::size_t>(i)]) any = true;
|
||||||
|
}
|
||||||
|
if (!any) return false;
|
||||||
|
// One row's legend text changed, which `Plot` cannot express, so its traces have to
|
||||||
|
// be created again -- and a row belongs to a figure, so the figure is rebuilt. That
|
||||||
|
// costs the plots' CPU state and nothing else: the host keeps its GL context and is
|
||||||
|
// handed the new figure.
|
||||||
|
rebuild_all();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void RowSet::assign(int row_index, int slot, std::string signal_name,
|
||||||
|
std::string display_name, int color_index) {
|
||||||
|
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return;
|
||||||
|
Row& r = row(row_index);
|
||||||
|
const std::size_t s = static_cast<std::size_t>(slot);
|
||||||
|
r.signal_name[s] = std::move(signal_name);
|
||||||
|
r.channel[s] = -1; // re-resolved by the next bind()
|
||||||
|
r.name[s] = std::move(display_name);
|
||||||
|
r.color_index[s] = color_index;
|
||||||
|
row_dirty_[static_cast<std::size_t>(row_index)] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RowSet::slot_bound(int row_index, int slot) const {
|
||||||
|
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return false;
|
||||||
|
return !row(row_index).signal_name[static_cast<std::size_t>(slot)].empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
int RowSet::channel_of(int row_index, int slot) const {
|
||||||
|
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return -1;
|
||||||
|
return row(row_index).channel[static_cast<std::size_t>(slot)];
|
||||||
|
}
|
||||||
|
|
||||||
|
void RowSet::bind(ScopeModel& model) {
|
||||||
|
const std::vector<SignalInfo>& signals = model.signals();
|
||||||
|
const std::vector<ChannelInfo>& channels = model.channels();
|
||||||
|
for (int i = 0; i < kMaxRows; ++i) {
|
||||||
|
Row& r = row(i);
|
||||||
|
for (int s = 0; s < kSlotsPerRow; ++s) {
|
||||||
|
const std::size_t si = static_cast<std::size_t>(s);
|
||||||
|
if (r.signal_name[si].empty()) {
|
||||||
|
r.channel[si] = -1;
|
||||||
|
r.slots[si].bind(nullptr);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Resolve the stable identity every frame: the channel list is rebuilt (and
|
||||||
|
// renumbered) whenever a signal is enabled, disabled or re-described, and a
|
||||||
|
// stored index would then point at a different signal's channel.
|
||||||
|
int signal_index = -1;
|
||||||
|
for (std::size_t k = 0; k < signals.size(); ++k) {
|
||||||
|
if (signals[k].name == r.signal_name[si]) {
|
||||||
|
signal_index = static_cast<int>(k);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int channel = -1;
|
||||||
|
if (signal_index >= 0) {
|
||||||
|
for (std::size_t c = 0; c < channels.size(); ++c) {
|
||||||
|
if (static_cast<int>(channels[c].signal) == signal_index) {
|
||||||
|
channel = static_cast<int>(c);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.channel[si] = channel;
|
||||||
|
if (channel < 0) {
|
||||||
|
// Its signal is present but not acquired: keep the identity (so enabling it
|
||||||
|
// brings the trace back) and plot nothing meanwhile.
|
||||||
|
r.slots[si].bind(nullptr);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// In trigger mode the trace shows the captured event and nothing else: the
|
||||||
|
// last one latched, or nothing at all before the first. Never the live ring,
|
||||||
|
// not even while the post window is filling -- a frozen picture that only
|
||||||
|
// changes on an event is the whole point of trigger mode, and binding the
|
||||||
|
// live ring mid-capture made the plot scroll for most of every event cycle.
|
||||||
|
const std::size_t ci = static_cast<std::size_t>(channel);
|
||||||
|
if (model.trigger_enabled()) {
|
||||||
|
if (auto snap = model.event()) {
|
||||||
|
r.slots[si].bind_event(std::move(snap), ci);
|
||||||
|
} else {
|
||||||
|
r.slots[si].bind(nullptr); // armed, waiting for the first event: blank
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
r.slots[si].bind(model.live().shared_channel(ci));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RowSet::live_edge(double& newest) const {
|
||||||
|
bool any = false;
|
||||||
|
for (int i = 0; i < rows_; ++i) {
|
||||||
|
const Row& r = row(i);
|
||||||
|
if (!r.plot) continue;
|
||||||
|
double edge = 0.0;
|
||||||
|
// Follow-independent: it reads the row's streaming traces, so a row whose own axis
|
||||||
|
// is `Off` (which every member of a link group is) still reports its edge.
|
||||||
|
if (!r.plot->x_live_edge(r.axis_x, edge)) continue;
|
||||||
|
newest = any ? std::max(newest, edge) : edge;
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
return any;
|
||||||
|
}
|
||||||
|
|
||||||
|
void RowSet::set_time_window(double seconds) {
|
||||||
|
window_s_ = std::max(0.05, seconds);
|
||||||
|
pinned_ = false;
|
||||||
|
if (!figure_) return;
|
||||||
|
figure_->set_x_link_follow(x_group_, plot::XFollow::Live);
|
||||||
|
double newest = 0.0;
|
||||||
|
if (live_edge(newest)) {
|
||||||
|
// Under a following group the range states the span and -- through its maximum --
|
||||||
|
// where the window sits behind the live edge, so a maximum *at* the edge is what
|
||||||
|
// makes the offset zero and parks the window on the newest sample. Asking for
|
||||||
|
// `{0, window}` here instead would state an offset of a whole epoch.
|
||||||
|
figure_->set_x_link_range(x_group_, {newest - window_s_, newest});
|
||||||
|
} else {
|
||||||
|
figure_->set_x_link_range(x_group_, {0.0, window_s_});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RowSet::set_y_auto(int row_index, bool on) {
|
||||||
|
Row& r = row(row_index);
|
||||||
|
if (!r.plot) return;
|
||||||
|
r.plot->set_y_auto_scale(r.axis_y, on);
|
||||||
|
if (on) r.plot->auto_fit_y(r.axis_y);
|
||||||
|
}
|
||||||
|
|
||||||
|
void RowSet::set_y_range(int row_index, double lo, double hi) {
|
||||||
|
Row& r = row(row_index);
|
||||||
|
if (!r.plot) return;
|
||||||
|
const double a = std::min(lo, hi);
|
||||||
|
const double b = std::max(lo, hi);
|
||||||
|
if (!std::isfinite(a) || !std::isfinite(b) || !(b > a)) return;
|
||||||
|
r.plot->set_y_range(r.axis_y, {a, b});
|
||||||
|
}
|
||||||
|
|
||||||
|
void RowSet::place_cursors(Row& r) {
|
||||||
|
if (!r.plot) return;
|
||||||
|
const bool show = r.cursors_visible;
|
||||||
|
const double a = show ? r.cursor_a_x : kParked;
|
||||||
|
const double b = show ? r.cursor_b_x : kParked;
|
||||||
|
const double h = show ? r.ruler_y : kParked;
|
||||||
|
r.plot->set_cursor_x(r.cursor_a, a);
|
||||||
|
r.plot->set_cursor_x(r.cursor_b, b);
|
||||||
|
r.plot->set_cursor_y(r.ruler, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
void RowSet::clamp_to_data() {
|
||||||
|
if (!figure_) return;
|
||||||
|
// The rows share one window, so they share one bound: the union of what they hold.
|
||||||
|
// Taking each row's own would let a row whose signal was enabled a moment ago clamp
|
||||||
|
// the window every row is looking at.
|
||||||
|
plot::Range data{};
|
||||||
|
bool have = false;
|
||||||
|
for (int i = 0; i < rows_; ++i) {
|
||||||
|
Row& r = row(i);
|
||||||
|
if (!r.plot) continue;
|
||||||
|
const plot::data::Series* series = nullptr;
|
||||||
|
for (int s = 0; s < kSlotsPerRow && series == nullptr; ++s) {
|
||||||
|
if (!r.signal_name[static_cast<std::size_t>(s)].empty()) {
|
||||||
|
series = r.slots[static_cast<std::size_t>(s)].bound();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (series == nullptr || series->size() < 2) continue;
|
||||||
|
const plot::Range b = series->x_bounds();
|
||||||
|
if (!std::isfinite(b.min) || !std::isfinite(b.max) || !(b.max > b.min)) continue;
|
||||||
|
data = have ? plot::Range{std::min(data.min, b.min), std::max(data.max, b.max)} : b;
|
||||||
|
have = true;
|
||||||
|
}
|
||||||
|
if (!have) return;
|
||||||
|
const double retained = data.max - data.min;
|
||||||
|
const double span = figure_->x_link_range(x_group_).span();
|
||||||
|
if (!(span > 0.0) || !std::isfinite(span)) return;
|
||||||
|
// The *requested* window is intent, and a buffer that has not filled it yet must not
|
||||||
|
// be allowed to redefine it. This runs every frame and cannot tell "the user zoomed
|
||||||
|
// out past the data" from "the ring has only just started filling", and the first
|
||||||
|
// frames after a signal is enabled hold a couple of hundred samples: clamping to
|
||||||
|
// those shrank a requested 2 s window to ~0.02 s, and nothing here ever grows a span
|
||||||
|
// back, so the view stayed pinned to the newest 20 ms of a 6.5 s buffer for the rest
|
||||||
|
// of the run. That is the loudest form of the "live trace is clipped, only the most
|
||||||
|
// recent part is visible, data is there because the crosshair reads it" defect -- the
|
||||||
|
// data was never clipped at all, the window was. Clamping against `max(retained,
|
||||||
|
// window_s_)` leaves a partly filled window empty on its left, which is what a scope
|
||||||
|
// that just started should look like.
|
||||||
|
//
|
||||||
|
// The span only. A pan is the group's `offset` behind its live edge and the group
|
||||||
|
// clamps it against what its members retain on every layout, so there is nothing
|
||||||
|
// here to do about `view.min < data.min` -- and doing it anyway would state a maximum
|
||||||
|
// away from the live edge, i.e. ask the group to scroll back.
|
||||||
|
const double limit = std::max(retained, window_s_);
|
||||||
|
if (span > limit) figure_->set_x_link_range(x_group_, {data.max - limit, data.max});
|
||||||
|
}
|
||||||
|
|
||||||
|
void RowSet::pin_event_window(double pre_s, double post_s) {
|
||||||
|
window_s_ = std::max(1.0e-3, pre_s + post_s);
|
||||||
|
pinned_ = true;
|
||||||
|
pinned_pre_ = pre_s;
|
||||||
|
pinned_post_ = post_s;
|
||||||
|
if (!figure_) return;
|
||||||
|
// The captured event sits at [-pre, +post] with the edge at 0, and the bound series is
|
||||||
|
// the immutable snapshot. The group must stop following first: Live re-derives the
|
||||||
|
// window from a live edge, and with nothing streaming it would keep placing the window
|
||||||
|
// from the last stale edge instead of the event.
|
||||||
|
figure_->set_x_link_follow(x_group_, plot::XFollow::Off);
|
||||||
|
figure_->set_x_link_range(x_group_, {-pre_s, post_s});
|
||||||
|
}
|
||||||
|
|
||||||
|
void RowSet::fit_all() {
|
||||||
|
for (int i = 0; i < rows_; ++i) {
|
||||||
|
Row& r = row(i);
|
||||||
|
if (r.plot) r.plot->auto_fit_y(r.axis_y);
|
||||||
|
}
|
||||||
|
Row& first = row(0);
|
||||||
|
if (!first.plot || !figure_) return;
|
||||||
|
// Row 0 fits its own x to the data it holds; that span becomes the shared window, and
|
||||||
|
// stating it through the group is what puts every row on it.
|
||||||
|
first.plot->auto_fit_x();
|
||||||
|
const plot::Range fitted = first.plot->x_range(first.axis_x);
|
||||||
|
const double span = fitted.span();
|
||||||
|
if (!std::isfinite(span) || !(span > 0.0)) return;
|
||||||
|
if (pinned_) {
|
||||||
|
// Fitting a captured event: the window is the snapshot's own `[-pre, +post]` span,
|
||||||
|
// and `set_time_window()` would re-arm the follow and re-frame on a live edge that
|
||||||
|
// trigger mode has nothing bound to.
|
||||||
|
window_s_ = span;
|
||||||
|
figure_->set_x_link_range(x_group_, fitted);
|
||||||
|
} else {
|
||||||
|
set_time_window(span);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RowSet::note_y_gutter(double need_px) {
|
||||||
|
double want = std::min(need_px + 6.0, 200.0);
|
||||||
|
// Reserve room for the widest label the formatter can produce, once, with the
|
||||||
|
// font the labels are actually drawn in (the embedded font is monospaced, so a
|
||||||
|
// character count is an exact width).
|
||||||
|
if (!y_gutter_reserved_) {
|
||||||
|
for (int i = 0; i < rows_; ++i) {
|
||||||
|
Row& r = row(i);
|
||||||
|
if (!r.plot) continue;
|
||||||
|
const plot::render::TextBackend* text = r.plot->text_backend();
|
||||||
|
if (text == nullptr) continue;
|
||||||
|
const plot::render::Style& style = r.plot->style();
|
||||||
|
const double advance = double(text->measure("0123456789")) / 10.0;
|
||||||
|
const double pad = double(style.device(style.grid_label_pad));
|
||||||
|
// "-1.23e-45" is the longest label `format_si()`'s fallback can print.
|
||||||
|
want = std::max(want, 9.0 * advance + 2.0 * pad + 2.0);
|
||||||
|
y_gutter_reserved_ = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (want > y_gutter_px_) y_gutter_px_ = want;
|
||||||
|
}
|
||||||
|
|
||||||
|
void RowSet::apply_y_gutter() {
|
||||||
|
if (!figure_ || !(y_gutter_px_ > 0.0)) return;
|
||||||
|
figure_->set_min_y_gutter(static_cast<f32>(y_gutter_px_));
|
||||||
|
}
|
||||||
|
|
||||||
|
void RowSet::set_trigger_marker(double x, bool visible) {
|
||||||
|
trigger_marker_on_ = visible;
|
||||||
|
trigger_marker_x_ = x;
|
||||||
|
const double want = visible ? x : kParked;
|
||||||
|
for (int i = 0; i < rows_; ++i) {
|
||||||
|
Row& r = row(i);
|
||||||
|
if (!r.plot) continue;
|
||||||
|
// Re-assert against what the plot *holds*, not against a cached value: the
|
||||||
|
// marker is a cursor, so the input layer will happily drag it, and a marker the
|
||||||
|
// user can move off the trigger instant is not marking anything. Comparing
|
||||||
|
// with the plot also keeps the steady state write-free (a write invalidates the
|
||||||
|
// plot's cached measurements).
|
||||||
|
if (r.plot->cursor(r.trigger_marker).x == want) continue;
|
||||||
|
r.plot->set_cursor_x(r.trigger_marker, want, plot::CursorPlacement::Free);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RowSet::set_cursors_visible(bool on) {
|
||||||
|
for (int i = 0; i < kMaxRows; ++i) {
|
||||||
|
Row& r = row(i);
|
||||||
|
r.cursors_visible = on;
|
||||||
|
place_cursors(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RowSet::reset_cursors() {
|
||||||
|
for (int i = 0; i < kMaxRows; ++i) {
|
||||||
|
Row& r = row(i);
|
||||||
|
if (!r.plot) continue;
|
||||||
|
const plot::Range xr = r.plot->x_range(r.axis_x);
|
||||||
|
const double span = xr.span() > 0.0 ? xr.span() : window_s_;
|
||||||
|
r.cursor_a_x = xr.min + 0.25 * span;
|
||||||
|
r.cursor_b_x = xr.min + 0.75 * span;
|
||||||
|
r.ruler_y = r.plot->y_range(r.axis_y).min +
|
||||||
|
0.5 * (r.plot->y_range(r.axis_y).span() > 0.0 ? r.plot->y_range(r.axis_y).span()
|
||||||
|
: 1.0);
|
||||||
|
place_cursors(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "model.hpp"
|
||||||
|
|
||||||
|
#include <plot/plot/figure.hpp>
|
||||||
|
#include <plot/plot/plot.hpp>
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
/// The stacked plots (spec item 4).
|
||||||
|
///
|
||||||
|
/// The rows are the rows of **one `plot::Figure`**, rendered by one
|
||||||
|
/// `plot::gtk4::PlotArea` in figure mode. The figure owns everything the rows have
|
||||||
|
/// to agree on, which is why it is one figure and not N plots:
|
||||||
|
///
|
||||||
|
/// - the x window, through a single x link group every row joins. A gesture on any
|
||||||
|
/// row changes that row's range, the group adopts it and pushes it to the others
|
||||||
|
/// (`Figure::sync_x_links()`, run from `layout()`), so there is no "which row
|
||||||
|
/// acted" to work out and nothing to propagate by hand;
|
||||||
|
/// - the live follow, which is the *group's* (`set_x_link_follow`). A member's own
|
||||||
|
/// axis must be `XFollow::Off` -- `Figure::link_x()` refuses a `Live` member --
|
||||||
|
/// because a following axis would reinterpret the pushed range as its own span and
|
||||||
|
/// offset behind its own edge, which is per-row drift by construction;
|
||||||
|
/// - the left gutter, so a vertical feature lands in the same pixel column in every
|
||||||
|
/// row (`Figure::layout()` lays each row out twice and imposes the maximum);
|
||||||
|
/// - the crosshair, placed on every row at one x by `PlotArea`'s figure-mode sync.
|
||||||
|
///
|
||||||
|
/// A row's `Plot` cannot drop a trace, so every row carries a fixed set of trace
|
||||||
|
/// slots created up front; assigning a signal to a slot is a rebind of that slot's
|
||||||
|
/// `SlotSeries`, and only a *name* change -- which `Plot` cannot express -- forces a
|
||||||
|
/// rebuild (see `row_dirty()`). A rebuild is of the whole figure: `Figure`'s row
|
||||||
|
/// count is fixed at construction, so there is one path for "a row's traces changed"
|
||||||
|
/// and "the row count changed", and it costs no GL state -- the area keeps its
|
||||||
|
/// context and is simply handed the new figure.
|
||||||
|
class RowSet {
|
||||||
|
public:
|
||||||
|
static constexpr int kMaxRows = 4;
|
||||||
|
static constexpr int kSlotsPerRow = 4;
|
||||||
|
|
||||||
|
RowSet();
|
||||||
|
|
||||||
|
struct Row {
|
||||||
|
/// The figure's row, borrowed. Null for a row beyond `row_count()`, which is how
|
||||||
|
/// every loop here and in the UI stays inside the rows that exist. Invalidated by
|
||||||
|
/// `rebuild_all()`, which builds a new figure.
|
||||||
|
plot::Plot* plot = nullptr;
|
||||||
|
std::array<SlotSeries, kSlotsPerRow> slots;
|
||||||
|
/// Model channel per trace slot. Must start at -1: `std::array<Row, N>{}`
|
||||||
|
/// zero-initialises a `Row`, and a zero here reads as "slot 0 already holds
|
||||||
|
/// channel 0", which makes every drop look like the row is full.
|
||||||
|
/// What the slot plots, as a *stable identity*: a channel index is positional,
|
||||||
|
/// and enabling/disabling a signal or a new CONFIG renumbers the channel list,
|
||||||
|
/// so an index would silently start plotting a different signal. Empty name =
|
||||||
|
/// free slot.
|
||||||
|
std::array<std::string, kSlotsPerRow> signal_name;
|
||||||
|
/// The channel index `signal_name`/`element` resolved to, filled by `bind()`.
|
||||||
|
std::array<int, kSlotsPerRow> channel = {-1, -1, -1, -1};
|
||||||
|
/// The trace's legend name (the channel's display name), which is what the
|
||||||
|
/// plot was built with.
|
||||||
|
std::array<std::string, kSlotsPerRow> name;
|
||||||
|
/// The palette entry the slot's trace is drawn in. A *signal's* index, not the
|
||||||
|
/// slot's: a signal dropped on two rows has to be the same colour in both, or the
|
||||||
|
/// signal list's colour chip cannot name a trace. Supplied by the caller, because
|
||||||
|
/// only it knows the signal list this indexes into. A trace's colour is fixed at
|
||||||
|
/// `add_trace()`, which is why this is stored rather than resolved per frame --
|
||||||
|
/// changing it needs a rebuild, and `assign()` already forces one.
|
||||||
|
std::array<int, kSlotsPerRow> color_index = {0, 1, 2, 3};
|
||||||
|
plot::u32 axis_x = 0;
|
||||||
|
plot::u32 axis_y = 0;
|
||||||
|
plot::u32 cursor_a = 0;
|
||||||
|
plot::u32 cursor_b = 0;
|
||||||
|
plot::u32 ruler = 0;
|
||||||
|
/// The vertical line marking the trigger instant, drawn whenever an event is
|
||||||
|
/// on screen (and parked outside the axis otherwise). Not the user's cursor:
|
||||||
|
/// it keeps its position whatever the cursor toggle says.
|
||||||
|
plot::u32 trigger_marker = 0;
|
||||||
|
/// Off by default: the cursors are switched on from the toolbar, and a rebuild
|
||||||
|
/// (any drop, any row-count change) must not switch them back off.
|
||||||
|
bool cursors_visible = false;
|
||||||
|
double cursor_a_x = 0.0;
|
||||||
|
double cursor_b_x = 0.0;
|
||||||
|
double ruler_y = 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
int row_count() const noexcept { return rows_; }
|
||||||
|
/// Shows `rows` rows. A changed count rebuilds the figure, since a `Figure`'s row
|
||||||
|
/// count is fixed at construction.
|
||||||
|
void set_row_count(int rows);
|
||||||
|
Row& row(int i) { return storage_[static_cast<std::size_t>(i)]; }
|
||||||
|
const Row& row(int i) const { return storage_[static_cast<std::size_t>(i)]; }
|
||||||
|
|
||||||
|
/// The figure the rows live in. The host must be handed this again after every
|
||||||
|
/// `rebuild_all()`.
|
||||||
|
plot::Figure& figure() noexcept { return *figure_; }
|
||||||
|
|
||||||
|
/// Builds a new figure: one `Plot` per visible row with its axes, cursors and fixed
|
||||||
|
/// trace slots, all joined by one x link group.
|
||||||
|
void rebuild_all();
|
||||||
|
/// Rebuilds if any row's traces changed, and reports whether it did -- in which case
|
||||||
|
/// the caller re-points its host at `figure()`.
|
||||||
|
bool rebuild_if_dirty();
|
||||||
|
bool row_dirty(int i) const { return row_dirty_[static_cast<std::size_t>(i)]; }
|
||||||
|
|
||||||
|
/// Binds a row's trace slot to a signal element (`signal_name` empty frees it).
|
||||||
|
/// `color_index` is the signal's palette entry (see `Row::color_index`).
|
||||||
|
void assign(int row, int slot, std::string signal_name, std::string display_name,
|
||||||
|
int color_index);
|
||||||
|
/// Whether a slot plots anything.
|
||||||
|
bool slot_bound(int row, int slot) const;
|
||||||
|
/// The channel index a row/slot holds, or -1.
|
||||||
|
int channel_of(int row, int slot) const;
|
||||||
|
|
||||||
|
/// Rebinds every slot to what it should show this frame: the model's live buffer,
|
||||||
|
/// or -- in trigger mode -- the captured event. Called every frame, because the
|
||||||
|
/// channel list is renumbered by any structural change.
|
||||||
|
void bind(ScopeModel& model);
|
||||||
|
/// Publishes the live buffer's new samples.
|
||||||
|
void acquire(ScopeModel& model) { model.acquire_all(); }
|
||||||
|
|
||||||
|
/// Frames the newest `seconds` of the stream: the link group follows the live edge,
|
||||||
|
/// with its window ending on that edge (which is what resets any pan the user had).
|
||||||
|
void set_time_window(double seconds);
|
||||||
|
double time_window() const noexcept { return window_s_; }
|
||||||
|
|
||||||
|
void set_y_auto(int row, bool on);
|
||||||
|
void set_y_range(int row, double lo, double hi);
|
||||||
|
|
||||||
|
void set_cursors_visible(bool on);
|
||||||
|
void reset_cursors();
|
||||||
|
|
||||||
|
/// Places the trigger marker at stream time `x` (seconds since the epoch), or
|
||||||
|
/// parks it when there is no event on screen.
|
||||||
|
void set_trigger_marker(double x, bool visible);
|
||||||
|
|
||||||
|
/// Keeps the shared x window inside the data the rings actually hold: zooming out
|
||||||
|
/// stops at the whole retained span. A time window is a view of the acquisition, so
|
||||||
|
/// there is nothing to see outside it -- zooming into empty time only loses the data
|
||||||
|
/// off the edge of the plot. Only the *span*: a following group clamps its own pan
|
||||||
|
/// against what its members retain (`Figure::place_live_window()`), while a wheel
|
||||||
|
/// notch multiplies the span and nothing in the library bounds that.
|
||||||
|
void clamp_to_data();
|
||||||
|
|
||||||
|
/// Pins the shared x window to the captured event: `[-pre, +post]` with the edge at
|
||||||
|
/// 0, so nothing outside the pre+post window is visible.
|
||||||
|
void pin_event_window(double pre_s, double post_s);
|
||||||
|
|
||||||
|
/// Fits every row's y axis to the data it holds, and re-frames the shared x window
|
||||||
|
/// on what row 0 retains (the same thing the plot's own `f` key does). The window
|
||||||
|
/// the toolbar reports becomes the fitted span, so the dropdown can say what is
|
||||||
|
/// actually on screen.
|
||||||
|
void fit_all();
|
||||||
|
|
||||||
|
/// Reports the left gutter the rows currently need, and reserves at least the width
|
||||||
|
/// of the widest *plausible* y label.
|
||||||
|
///
|
||||||
|
/// The gutter is measured from the y tick labels, and `TickFormat::Si` makes their
|
||||||
|
/// width change as the auto-fitted range moves (`1.5` becomes `15`, `150`, `1.5k`),
|
||||||
|
/// so a gutter that follows them drags the whole axes -- and with it the x axis --
|
||||||
|
/// left and right by a few pixels every frame. Since `set_min_y_gutter()` is a
|
||||||
|
/// floor and the library still takes the larger of the two, a floor smaller than
|
||||||
|
/// the labels cannot stop that; the reservation therefore covers the widest label
|
||||||
|
/// `format_si()` can produce (three significant digits, a sign and a prefix, or the
|
||||||
|
/// `%.3g` fallback) plus the label padding, measured once with the plot's own font.
|
||||||
|
/// Both reservations only ever grow, so the gutter is constant in practice.
|
||||||
|
void note_y_gutter(double need_px);
|
||||||
|
|
||||||
|
/// Applies the stable gutter as the figure's floor. Call once per frame, before the
|
||||||
|
/// frame is drawn.
|
||||||
|
///
|
||||||
|
/// It has to be the *figure's* floor and not each row's: `Figure::layout()` clears
|
||||||
|
/// every row's `set_min_y_gutter()` to measure what its own labels need, so a floor
|
||||||
|
/// written onto the rows survives exactly until the next layout.
|
||||||
|
void apply_y_gutter();
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// signal appears in.
|
||||||
|
static plot::Color palette_color(int index);
|
||||||
|
/// The colour of a signal that is on no plot, which the signal list shows instead of
|
||||||
|
/// leaving its chip blank -- a blank chip is a narrower cell than a filled one, so the
|
||||||
|
/// names beside it shifted sideways as signals were placed.
|
||||||
|
static plot::Color unplotted_color();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void install(Row& r, int row_index, plot::Plot& p);
|
||||||
|
void place_cursors(Row& r);
|
||||||
|
/// The newest x any row's live traces hold, which is what the group follows.
|
||||||
|
bool live_edge(double& newest) const;
|
||||||
|
|
||||||
|
std::unique_ptr<plot::Figure> figure_;
|
||||||
|
/// The one x link group every visible row belongs to. Groups are numbered from 1.
|
||||||
|
plot::u32 x_group_ = 0;
|
||||||
|
int rows_ = 1;
|
||||||
|
double window_s_ = 2.0;
|
||||||
|
double trigger_marker_x_ = 0.0;
|
||||||
|
bool trigger_marker_on_ = false;
|
||||||
|
/// The event window currently pinned, so a rebuild can restate it instead of
|
||||||
|
/// re-framing on the live edge.
|
||||||
|
double pinned_pre_ = 0.0;
|
||||||
|
double pinned_post_ = 0.0;
|
||||||
|
bool pinned_ = false;
|
||||||
|
double y_gutter_px_ = 0.0;
|
||||||
|
bool y_gutter_reserved_ = false;
|
||||||
|
std::array<Row, static_cast<std::size_t>(kMaxRows)> storage_{};
|
||||||
|
std::array<bool, static_cast<std::size_t>(kMaxRows)> row_dirty_{};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "trigger.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
double TriggerConfig::post_span_s(double buffer_span_s) const {
|
||||||
|
const double pre = std::clamp(pretrigger_pct, 0.0, 100.0) / 100.0;
|
||||||
|
return buffer_span_s * (1.0 - pre);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TriggerEngine::configure(const TriggerConfig& cfg, double post_seconds,
|
||||||
|
double pre_seconds, double oldest_s) {
|
||||||
|
const bool channel_changed = cfg.channel != cfg_.channel;
|
||||||
|
cfg_ = cfg;
|
||||||
|
post_seconds_ = post_seconds > 0.0 ? post_seconds : 0.0;
|
||||||
|
pre_seconds_ = pre_seconds > 0.0 ? pre_seconds : 0.0;
|
||||||
|
oldest_s_ = oldest_s;
|
||||||
|
if (channel_changed) {
|
||||||
|
have_prev_ = false;
|
||||||
|
pending_ = false;
|
||||||
|
post_accum_count_ = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TriggerEngine::arm(double now_s) noexcept {
|
||||||
|
armed_ = true;
|
||||||
|
pending_ = false;
|
||||||
|
post_accum_count_ = 0.0;
|
||||||
|
holdoff_until_s_ = now_s;
|
||||||
|
have_prev_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TriggerEngine::process(double value, double now_s) noexcept {
|
||||||
|
if (!cfg_.enabled || !armed_ || cfg_.channel < 0) return false;
|
||||||
|
|
||||||
|
// Hold-off: settled, waiting for the next edge.
|
||||||
|
if (!pending_ && now_s < holdoff_until_s_) {
|
||||||
|
prev_ = value;
|
||||||
|
have_prev_ = true;
|
||||||
|
last_t_s_ = now_s;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!have_prev_) {
|
||||||
|
prev_ = value;
|
||||||
|
have_prev_ = true;
|
||||||
|
last_t_s_ = now_s;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
last_t_s_ = now_s;
|
||||||
|
|
||||||
|
if (pending_) {
|
||||||
|
// Accumulate the post-trigger window; latch once the stream clock has advanced
|
||||||
|
// past it. Time, not a sample count: `now_s` is the same clock the x axis reads,
|
||||||
|
// so the capture covers exactly the requested span whatever the cadence is.
|
||||||
|
++post_accum_count_;
|
||||||
|
prev_ = value;
|
||||||
|
if (now_s - pending_start_s_ >= post_seconds_) {
|
||||||
|
pending_ = false;
|
||||||
|
post_accum_count_ = 0;
|
||||||
|
if (cfg_.mode == TriggerMode::Single) {
|
||||||
|
armed_ = false;
|
||||||
|
} else {
|
||||||
|
holdoff_until_s_ = now_s + std::max(0.0, cfg_.holdoff_s);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edge detection against the level.
|
||||||
|
const bool crossed_up = prev_ < cfg_.level && value >= cfg_.level;
|
||||||
|
const bool crossed_down = prev_ > cfg_.level && value <= cfg_.level;
|
||||||
|
const bool fired = (cfg_.edge == TriggerEdge::Rising && crossed_up) ||
|
||||||
|
(cfg_.edge == TriggerEdge::Falling && crossed_down) ||
|
||||||
|
(cfg_.edge == TriggerEdge::Both && (crossed_up || crossed_down));
|
||||||
|
prev_ = value;
|
||||||
|
if (!fired) return false;
|
||||||
|
|
||||||
|
// The pre-trigger window has to already be behind this edge, or the capture opens
|
||||||
|
// on a half-filled pre window and the event is short on its left side -- the plot
|
||||||
|
// then shows a window that is correctly sized but only partly drawn. The buffer
|
||||||
|
// fills whether or not the trigger is armed, so this normally passes on the first
|
||||||
|
// edge; it only bites just after a rebuild (a CONFIG, or enabling the signal),
|
||||||
|
// when the history genuinely is not there yet.
|
||||||
|
if (now_s - oldest_s_ < pre_seconds_) return false;
|
||||||
|
|
||||||
|
// The edge's own time, which is what a trigger marker marks: the latch itself
|
||||||
|
// may happen a post-window later.
|
||||||
|
last_trigger_s_ = now_s;
|
||||||
|
|
||||||
|
// The edge always opens a capture; there is no "latch on the edge itself" case.
|
||||||
|
// A zero-length post window latches on the very next sample, which is the least a
|
||||||
|
// capture can hold, and every longer window is just more samples -- no conversion
|
||||||
|
// from seconds to a sample count, so no conversion to get wrong.
|
||||||
|
pending_ = true;
|
||||||
|
pending_start_s_ = now_s;
|
||||||
|
post_accum_count_ = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
/// Which way the trigger signal crosses the level.
|
||||||
|
enum class TriggerEdge { Rising, Falling, Both };
|
||||||
|
|
||||||
|
/// How an armed trigger re-arms after it fired.
|
||||||
|
enum class TriggerMode { Normal, Single };
|
||||||
|
|
||||||
|
/// The trigger panel's configuration (spec item 2.2).
|
||||||
|
///
|
||||||
|
/// The trigger names a *signal* (an index into `ScopeModel::signals()`), not a
|
||||||
|
/// channel: the panel lists every signal the stream describes, whether or not it
|
||||||
|
/// is being acquired, and picking one is what arms the trigger on it. The
|
||||||
|
/// acquisition resolves the signal to the channel carrying it.
|
||||||
|
struct TriggerConfig {
|
||||||
|
bool enabled = false; ///< trigger mode on/off (toolbar).
|
||||||
|
bool armed = false; ///< arm/disarm button.
|
||||||
|
int channel = -1; ///< index into ScopeModel::channels(), -1 = none.
|
||||||
|
double level = 0.0;
|
||||||
|
TriggerEdge edge = TriggerEdge::Rising;
|
||||||
|
double pretrigger_pct = 50.0; ///< share of the buffer shown before the event.
|
||||||
|
double holdoff_s = 0.010; ///< default from the spec.
|
||||||
|
TriggerMode mode = TriggerMode::Normal;
|
||||||
|
|
||||||
|
/// Seconds of post-trigger data to collect before latching the event, given
|
||||||
|
/// the buffer's span.
|
||||||
|
double post_span_s(double buffer_span_s) const;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The producer-side trigger state machine. Fed one sample at a time in stream
|
||||||
|
/// order; it says when the event buffer should be latched.
|
||||||
|
class TriggerEngine {
|
||||||
|
public:
|
||||||
|
/// Configures the channel and the post-trigger window. Resets the state when the
|
||||||
|
/// channel changes.
|
||||||
|
///
|
||||||
|
/// `post_seconds` is the post-trigger window in **stream seconds**, the same clock
|
||||||
|
/// `process()` is fed and the same clock the x axis reads, so the capture covers
|
||||||
|
/// exactly that much axis by construction.
|
||||||
|
///
|
||||||
|
/// It used to be a count of samples, converted from seconds by a measured estimate
|
||||||
|
/// of the channel's cadence. That estimate is what made the window wrong: when it
|
||||||
|
/// collapsed (any stream whose time signal advances in large steps drove it to the
|
||||||
|
/// 1 S/s floor) the count came out as 1, the engine latched on the edge sample
|
||||||
|
/// itself, and the event had no post-trigger data at all. Seconds need no estimate.
|
||||||
|
///
|
||||||
|
/// `pre_seconds` is the pre-trigger window, and `oldest_s` the stream time of the
|
||||||
|
/// oldest sample the buffer still holds. Together they gate the edge: a capture is
|
||||||
|
/// only allowed once the history reaches back a full `pre_seconds` before it, so an
|
||||||
|
/// event cannot latch onto a half-filled pre window. `oldest_s` has to be the real
|
||||||
|
/// buffer's, not a timer since arming -- the ring keeps filling with the trigger
|
||||||
|
/// off, so the history is usually already there and waiting would be wrong.
|
||||||
|
void configure(const TriggerConfig& cfg, double post_seconds, double pre_seconds,
|
||||||
|
double oldest_s);
|
||||||
|
/// Disarms without forgetting the configuration.
|
||||||
|
void disarm() noexcept { armed_ = false; pending_ = false; }
|
||||||
|
void arm(double now_s) noexcept;
|
||||||
|
|
||||||
|
/// Feeds one sample of the trigger channel. `now_s` is the sample's timestamp
|
||||||
|
/// in stream seconds. Returns true when the event has just been latched.
|
||||||
|
bool process(double value, double now_s) noexcept;
|
||||||
|
|
||||||
|
bool pending() const noexcept { return pending_; }
|
||||||
|
bool armed() const noexcept { return armed_; }
|
||||||
|
|
||||||
|
/// Diagnostics for the capture state machine: how many samples have landed in
|
||||||
|
/// the post-trigger window, the window it is filling (in stream seconds), how
|
||||||
|
/// much of that has elapsed, and the last value fed.
|
||||||
|
std::uint64_t post_accum() const noexcept { return post_accum_count_; }
|
||||||
|
double post_seconds() const noexcept { return post_seconds_; }
|
||||||
|
double post_elapsed() const noexcept {
|
||||||
|
return pending_ ? last_t_s_ - pending_start_s_ : 0.0;
|
||||||
|
}
|
||||||
|
/// How much history the buffer holds behind the newest sample, against the
|
||||||
|
/// pre-trigger window an edge needs before it may open a capture.
|
||||||
|
double history() const noexcept { return last_t_s_ - oldest_s_; }
|
||||||
|
double pre_seconds() const noexcept { return pre_seconds_; }
|
||||||
|
double last_value() const noexcept { return prev_; }
|
||||||
|
bool have_last() const noexcept { return have_prev_; }
|
||||||
|
double last_t() const noexcept { return last_t_s_; }
|
||||||
|
double holdoff_until() const noexcept { return holdoff_until_s_; }
|
||||||
|
|
||||||
|
/// Stream time of the edge that armed the event now on screen. Valid once
|
||||||
|
/// `process()` has fired; the view is centred on this instant (pre-trigger share
|
||||||
|
/// before it, post-trigger share after), so it is the x a trigger marker belongs
|
||||||
|
/// at. Unchanged while a later event has not fired yet.
|
||||||
|
double last_trigger_time() const noexcept { return last_trigger_s_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
TriggerConfig cfg_{};
|
||||||
|
bool armed_ = false;
|
||||||
|
bool have_prev_ = false;
|
||||||
|
double prev_ = 0.0;
|
||||||
|
bool pending_ = false; ///< an edge was seen; collecting the post window
|
||||||
|
double pending_start_s_ = 0.0;
|
||||||
|
double holdoff_until_s_ = 0.0;
|
||||||
|
std::uint64_t post_accum_count_ = 0;
|
||||||
|
double post_seconds_ = 0.0; ///< post-trigger window, in stream seconds.
|
||||||
|
double pre_seconds_ = 0.0; ///< pre-trigger window an edge needs behind it.
|
||||||
|
double oldest_s_ = 0.0; ///< stream time of the oldest sample still buffered.
|
||||||
|
double last_t_s_ = 0.0;
|
||||||
|
double last_trigger_s_ = 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
+2164
File diff suppressed because it is too large.
Load diff
+17
@@ -0,0 +1,17 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
struct Config;
|
||||||
|
class ScopeModel;
|
||||||
|
class Acquisition;
|
||||||
|
class RowSet;
|
||||||
|
|
||||||
|
/// Runs the GTK application. Never returns while the window is open; returns the
|
||||||
|
/// `g_application_run()` result once it closes.
|
||||||
|
int run_ui(int argc, char** argv, ScopeModel& model, Acquisition& acq, RowSet& rows);
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
"""A minimal UDPStreamer (UDPS) producer, for exercising udpscope without MARTe2.
|
||||||
|
|
||||||
|
Unicast only: binds `--port`, waits for a CONNECT datagram, then streams CONFIG
|
||||||
|
once and DATA packets to whoever connected. Publish mode is Accumulate, so each
|
||||||
|
packet carries `--batch` consecutive samples per (scalar) signal -- the shape the
|
||||||
|
viewer's per-sample path expects.
|
||||||
|
|
||||||
|
Signals: `time` (the stream's own clock, seconds), `sine`, `square`, `ramp`.
|
||||||
|
Everything is float64 and unquantised.
|
||||||
|
|
||||||
|
./tools/mock_udpstreamer.py --port 8000 --rate 10000 --batch 100
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import math
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
|
||||||
|
MAGIC = 0x53504455
|
||||||
|
PKT_DATA, PKT_CONFIG, PKT_ACK, PKT_CONNECT, PKT_DISCONNECT = 0, 1, 2, 3, 4
|
||||||
|
T_FLOAT64 = 9
|
||||||
|
QUANT_NONE = 0
|
||||||
|
TIME_PACKET, TIME_FULL_ARRAY = 0, 1
|
||||||
|
PUBLISH_ACCUMULATE = 1
|
||||||
|
NO_TIME_SIGNAL = 0xFFFFFFFF
|
||||||
|
|
||||||
|
|
||||||
|
def header(pkt_type, counter, payload_bytes):
|
||||||
|
return struct.pack("<IBIHHI", MAGIC, pkt_type, counter, 0, 1, payload_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
def signal_desc(name, *, rate, time_mode, time_idx, unit, rows=1, cols=1, dims=0):
|
||||||
|
"""One 136-byte signal descriptor."""
|
||||||
|
d = bytearray(136)
|
||||||
|
d[0:64] = name.encode()[:64].ljust(64, b"\0")
|
||||||
|
d[64] = T_FLOAT64
|
||||||
|
d[65] = QUANT_NONE
|
||||||
|
d[66] = dims
|
||||||
|
struct.pack_into("<II", d, 67, rows, cols)
|
||||||
|
struct.pack_into("<dd", d, 75, -10.0, 10.0)
|
||||||
|
d[91] = time_mode
|
||||||
|
struct.pack_into("<d", d, 92, rate)
|
||||||
|
struct.pack_into("<I", d, 100, time_idx)
|
||||||
|
d[104:136] = unit.encode()[:32].ljust(32, b"\0")
|
||||||
|
return bytes(d)
|
||||||
|
|
||||||
|
|
||||||
|
def build_config(rate):
|
||||||
|
sigs = [
|
||||||
|
# The stream's clock: referenced by every data signal, so udpscope treats
|
||||||
|
# it as the time base rather than a plottable channel.
|
||||||
|
signal_desc("time", rate=0.0, time_mode=TIME_FULL_ARRAY,
|
||||||
|
time_idx=NO_TIME_SIGNAL, unit="s"),
|
||||||
|
signal_desc("sine", rate=rate, time_mode=TIME_FULL_ARRAY, time_idx=0, unit="V"),
|
||||||
|
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"),
|
||||||
|
]
|
||||||
|
payload = struct.pack("<I", len(sigs)) + b"".join(sigs)
|
||||||
|
payload += bytes([PUBLISH_ACCUMULATE])
|
||||||
|
return header(PKT_CONFIG, 0, len(payload)) + payload
|
||||||
|
|
||||||
|
|
||||||
|
def build_data(counter, t0, dt, batch, freq):
|
||||||
|
stamps = [t0 + i * dt for i in range(batch)]
|
||||||
|
sine = [math.sin(2.0 * math.pi * freq * t) for t in stamps]
|
||||||
|
square = [1.0 if math.sin(2.0 * math.pi * freq * 0.5 * t) >= 0.0 else -1.0 for t in stamps]
|
||||||
|
ramp = [(freq * t) % 1.0 * 2.0 - 1.0 for t in stamps]
|
||||||
|
payload = struct.pack("<QI", int(t0 * 1e9), batch)
|
||||||
|
for arr in (stamps, sine, square, ramp):
|
||||||
|
payload += struct.pack("<%dd" % batch, *arr)
|
||||||
|
return header(PKT_DATA, counter, len(payload)) + payload
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--port", type=int, default=8000)
|
||||||
|
ap.add_argument("--rate", type=float, default=10000.0, help="samples per second")
|
||||||
|
ap.add_argument("--batch", type=int, default=100, help="samples per packet")
|
||||||
|
ap.add_argument("--freq", type=float, default=5.0, help="waveform frequency, Hz")
|
||||||
|
ap.add_argument("--duration", type=float, default=0.0, help="0 = forever")
|
||||||
|
ap.add_argument("--stream-scale", type=float, default=1.0,
|
||||||
|
help="stream seconds per wall second: 0.1 mimics a producer whose own "
|
||||||
|
"clock advances ten times slower than the wire delivers samples")
|
||||||
|
ap.add_argument("--declared-rate", type=float, default=-1.0,
|
||||||
|
help="sampling_rate to put in CONFIG; -1 = the real rate, 0 = 'unknown'")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
dt = args.stream_scale / args.rate # stream seconds per sample
|
||||||
|
period = args.batch / args.rate # wall seconds per packet
|
||||||
|
declared = args.rate if args.declared_rate < 0.0 else args.declared_rate
|
||||||
|
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
sock.bind(("0.0.0.0", args.port))
|
||||||
|
sock.settimeout(0.001)
|
||||||
|
print("mock udpstreamer: waiting for CONNECT on :%d (%.0f S/s wire, %d per packet, "
|
||||||
|
"stream clock x%.3g, declared %.0f S/s)"
|
||||||
|
% (args.port, args.rate, args.batch, args.stream_scale, declared), flush=True)
|
||||||
|
|
||||||
|
peer = None
|
||||||
|
counter = 1
|
||||||
|
t_stream = 0.0
|
||||||
|
started = None
|
||||||
|
next_send = time.monotonic()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
buf, addr = sock.recvfrom(2048)
|
||||||
|
if len(buf) >= 17:
|
||||||
|
magic, ptype = struct.unpack_from("<IB", buf, 0)
|
||||||
|
if magic == MAGIC and ptype == PKT_CONNECT:
|
||||||
|
peer = addr
|
||||||
|
counter = 1
|
||||||
|
t_stream = 0.0
|
||||||
|
started = time.monotonic()
|
||||||
|
next_send = started
|
||||||
|
sock.sendto(build_config(declared), peer)
|
||||||
|
print("mock udpstreamer: CONNECT from %s:%d, CONFIG sent" % peer, flush=True)
|
||||||
|
elif magic == MAGIC and ptype == PKT_DISCONNECT:
|
||||||
|
print("mock udpstreamer: DISCONNECT", flush=True)
|
||||||
|
peer = None
|
||||||
|
except socket.timeout:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if peer is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
if now < next_send:
|
||||||
|
time.sleep(min(next_send - now, 0.002))
|
||||||
|
continue
|
||||||
|
|
||||||
|
sock.sendto(build_data(counter, t_stream, dt, args.batch, args.freq), peer)
|
||||||
|
counter += 1
|
||||||
|
t_stream += args.batch * dt
|
||||||
|
next_send += period
|
||||||
|
if args.duration > 0.0 and started is not None and now - started > args.duration:
|
||||||
|
print("mock udpstreamer: done (%d packets)" % counter, flush=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in new issue
Block a user