Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27c9b34277 | ||
|
|
22e7a8efcc | ||
|
|
e83ad86b8f | ||
|
|
afd2a6cdc1 | ||
|
|
41cd8e5f38 | ||
|
|
6138e6bdce | ||
|
|
fe499bfe41 | ||
|
|
d549223889 | ||
|
|
829176248a | ||
|
|
395c9caec3 | ||
|
|
669db6df9a | ||
|
|
bb62b8178c | ||
|
|
d8a79b96ba | ||
|
|
e140642ed0 | ||
|
|
6fe1b22524 | ||
|
|
1baa58b8fd | ||
|
|
c1460a6977 | ||
|
|
620473a5be | ||
|
|
908286ee89 | ||
|
|
761615c2fe | ||
|
|
62b5f14504 | ||
|
|
4e29b958a2 | ||
|
|
8b2dae7655 | ||
|
|
a43d3830f0 | ||
|
|
d8705a369f | ||
|
|
796dda4ad5 | ||
|
|
4f4851f4e3 | ||
|
|
121abbefae | ||
|
|
cc8335c514 | ||
|
|
d9287ea826 | ||
|
|
8c9c6ff7fd | ||
|
|
574f47f769 | ||
|
|
75aa62cc3c | ||
|
|
dbe6377415 | ||
|
|
e615465366 | ||
|
|
0b146f1d53 | ||
|
|
f9cea4885b | ||
|
|
6492bb910f | ||
|
|
cb866774c6 | ||
|
|
a493737ca1 | ||
|
|
132041f79e | ||
|
|
d2ec89d866 | ||
|
|
619b5e356c | ||
|
|
f224f56c4c | ||
|
|
44d2fd35dc | ||
|
|
e10a92fc52 | ||
|
|
a519e13b67 | ||
|
|
4235ee6225 | ||
|
|
345145c52b | ||
|
|
50a1ddca62 | ||
|
|
881b52748e | ||
|
|
bb99be5889 | ||
|
|
667ed0c100 | ||
|
|
749b34a2cb | ||
|
|
c38630643d | ||
|
|
5c2c221777 | ||
|
|
1bde8809dd | ||
|
|
2c0e21fd20 | ||
|
|
84aebf4116 | ||
|
|
380ddb9a77 | ||
|
|
2730707372 | ||
|
|
fec0a1032e | ||
|
|
1583741e03 | ||
|
|
d34bff3f03 | ||
|
|
d5a9b3c5d5 | ||
|
|
be71a1d4bb | ||
|
|
84c158ff21 | ||
|
|
ee660c14fd | ||
|
|
a989935bbd | ||
|
|
0106aca69b | ||
|
|
b02419dee3 | ||
|
|
5eb20f3cdb | ||
|
|
8b3240cacb | ||
|
|
aaf544be1e | ||
|
|
d34b97158e | ||
|
|
47d49e75ff | ||
|
|
b090cc2a21 | ||
|
|
c3d588f03d | ||
|
|
0e891ada04 | ||
|
|
28464601a9 | ||
|
|
afb9680fd7 | ||
|
|
28d80f2e90 | ||
|
|
c94bc4316c | ||
|
|
148d1168d6 | ||
|
|
c86d2117fb | ||
|
|
1a02946656 | ||
|
|
90a17eb186 | ||
|
|
c37fbdde21 | ||
|
|
1ccc70c5ac | ||
|
|
07fcf61a70 | ||
|
|
d344361165 | ||
|
|
0a64e9e854 | ||
|
|
723f5c3b4c | ||
|
|
f00d161900 | ||
|
|
2d9f327da7 | ||
|
|
bb01278d88 | ||
|
|
a8c25f0cb3 | ||
|
|
ed82abd405 | ||
|
|
7a982da6c4 | ||
|
|
8073e7b3fd | ||
|
|
68743a7d7f | ||
|
|
4c56172005 | ||
|
|
0e256764d5 | ||
|
|
8f54841c2c | ||
|
|
210d124814 | ||
|
|
d2d4b7f204 | ||
|
|
51d13a09cb | ||
|
|
8320988d96 | ||
|
|
176ef1df9f | ||
|
|
cb1bbd2b7b | ||
|
|
d90903d229 | ||
|
|
eacb3f5efc | ||
|
|
4549c622fc | ||
|
|
3859d2ca79 | ||
|
|
4666b807fc | ||
|
|
ee55ac84b7 | ||
|
|
b32987d7e0 | ||
|
|
d6afdfef9f | ||
|
|
7c60d79b65 | ||
|
|
fc81e661d0 | ||
|
|
d38ab3f9c9 | ||
|
|
e9d259ac7f | ||
|
|
6830b97c02 | ||
|
|
5915fc8421 | ||
|
|
85ba835d61 | ||
|
|
45958a441a | ||
|
|
6e79a67252 | ||
|
|
57e2f6aa8b | ||
|
|
d11e7d48c4 | ||
|
|
b645859f64 | ||
|
|
2b89f72960 | ||
|
|
ec8eae0317 | ||
|
|
82180f45f7 | ||
|
|
cd9657663a | ||
|
|
219ae91fd8 | ||
|
|
723d1db3d2 | ||
|
|
d5d1295b26 | ||
|
|
aa8ec1794a | ||
|
|
c8f10f59dc | ||
|
|
90d5d0e2d2 | ||
|
|
728e7fbda5 | ||
|
|
090b6bb57d | ||
|
|
7aa9cf4664 | ||
|
|
806e168ec4 | ||
|
|
ffc3ec1b4d | ||
|
|
aefac2d796 | ||
|
|
2ed42fe632 | ||
|
|
4a58887cb1 | ||
|
|
5bb4bd207d | ||
|
|
db81ab2700 | ||
|
|
8ee4effb70 | ||
|
|
415fceca87 | ||
|
|
2cee85c52b | ||
|
|
4f496b14b7 | ||
|
|
1a8218887c | ||
|
|
d79810c7ba | ||
|
|
601923f1d1 | ||
|
|
a00663510f | ||
|
|
a26770ec32 | ||
|
|
0876ecfe22 | ||
|
|
d147678b1c | ||
|
|
b3fd9ae00f | ||
|
|
178cf54ef6 | ||
|
|
9aa1d80158 | ||
|
|
a5ca841603 | ||
|
|
19aad492eb | ||
|
|
6770c6a648 | ||
|
|
98be03e00d | ||
|
|
f179fbbb44 |
No files matched your search
@@ -1 +1,2 @@
|
||||
build/
|
||||
__pycache__/
|
||||
@@ -1,100 +0,0 @@
|
||||
# Final Fixes Report
|
||||
|
||||
## Fix 1 — `known_sources()` round-trip check
|
||||
**File:** `src/layout_store.cpp`
|
||||
|
||||
Changed the port parse to capture the end pointer and check `*end != '\0'`, then added a round-trip guard: `sanitize(host) + "_" + std::to_string(port) != stem` rejects any filename that `layout_path()` could not have written. This prevents `foo_8000x.toml` from being listed as `foo:8000` (whose `×` would unlink `foo_8000.toml`) and `_8000.toml` from yielding an empty host (whose `sanitize("")` round-trips to `__8000`). Also fixed include order (`<cstdio>` before `<cstdlib>` — Fix 6) and updated the `multicast`/`interface` comment to state the TOML rule directly rather than pointing at an adjacent comment (Fix 5).
|
||||
|
||||
## Fix 2 — use-after-free: form outliving chooser, unguarded timeout
|
||||
**File:** `src/source_dialog.cpp`
|
||||
|
||||
(a) Added form destruction at the top of `chooser_finish()`: null the pointer first (so `close-request` cannot re-enter), then `gtk_window_destroy()` the form. Any path that finishes the chooser while the form is up — a row click, Escape — previously left a window with four handlers bound to a dead stack frame.
|
||||
|
||||
(b) Stored the id from `g_timeout_add()` for `pick_probe` in `pick_probe_id` and called `g_source_remove(pick_probe_id)` after `g_main_loop_run()` returns (guarded on non-zero). A row picked before the timeout fires would otherwise leave an armed source on the default main context pointing at the gone frame.
|
||||
|
||||
## Fix 3 — false comment on empty-list placeholder
|
||||
**File:** `src/source_dialog.cpp`
|
||||
|
||||
Replaced the false "Not a GtkListBoxRow, so it cannot be activated..." comment with the truth: GTK4 auto-wraps any non-row widget, but the wrapped row carries no `"source-index"` data, so `g_object_get_data()` returns null and `on_row_activated`'s bounds check refuses it.
|
||||
|
||||
## Fix 4 — unreachable duplicate branch in `pick_probe()`
|
||||
**File:** `src/source_dialog.cpp`
|
||||
|
||||
Removed the second `row == nullptr` guard; the `c.known.empty()` guard above it was the only case that produced a null row from the real list. Two identical print statements for one cause made the probe output ambiguous.
|
||||
|
||||
## Fix 5 — cross-reference comment in `save_layout()`
|
||||
**File:** `src/layout_store.cpp`
|
||||
|
||||
Replaced "for the same reason as `enabled` below" with the rule itself: a bare TOML key written after a `[[table]]` header belongs to that table, with `enabled` named as the historical example of where this lost a key.
|
||||
|
||||
## Fix 6 — include ordering
|
||||
**File:** `src/layout_store.cpp`
|
||||
|
||||
Swapped `<cstdlib>` and `<cstdio>` back into alphabetical order.
|
||||
|
||||
## Fix 7 — CLAUDE.md probe text
|
||||
**File:** `CLAUDE.md`
|
||||
|
||||
Fixed `picked nothing` → `picked nothing: the list is empty` to match the exact stderr output. Fix 4 did not change the emitted text, so no further adjustment needed.
|
||||
|
||||
## Fix 8 — dead-port run must not overwrite a configured session
|
||||
**Files:** `src/ui.cpp`, `CLAUDE.md`, `README.md`
|
||||
|
||||
Added `if (app.model->signals().empty()) return;` in `save_layout_now()`, immediately after the `source_port == 0` guard. A run that receives no CONFIG has an empty signal list and must not overwrite a file that holds real signal names — doing so produces `signals = ["", ...]`, which the chooser's filter refuses, silently removing the source from the list. Also deleted all 116 flattened `~/.config/udpscope/*.toml` files (confirmed all had no named signal). Updated CLAUDE.md (chooser invariant section) and README.md (source-chooser paragraph) to document the rule.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
### 1. Build
|
||||
```
|
||||
cmake --build build
|
||||
```
|
||||
Output: clean, only the three pre-existing warnings (unused `output_built`, two missing-field-initializer on `MathNode`).
|
||||
|
||||
### 2. `/tmp/check_sources` (two new assertions added)
|
||||
```
|
||||
g++ -std=c++20 -Isrc -Ideps/plotter/include /tmp/check_sources.cpp src/layout_store.cpp src/math_channel.cpp src/measure.cpp -o /tmp/check_sources
|
||||
/tmp/check_sources
|
||||
```
|
||||
Output:
|
||||
```
|
||||
a session with no named signal is not listed ok
|
||||
newest first ok
|
||||
oldest last ok
|
||||
the stored group comes back ok
|
||||
the row and signal counts are the file's ok
|
||||
the filename splits at the last underscore ok
|
||||
forget_source reports success ok
|
||||
forget_source removes exactly one ok
|
||||
foo_8000x.toml is not listed (trailing 'x' in port) ok
|
||||
_8000.toml is not listed (empty host does not round-trip) ok
|
||||
all ok
|
||||
```
|
||||
|
||||
### 3. `/tmp/check_layout_rt`
|
||||
```
|
||||
/tmp/check_layout_rt
|
||||
```
|
||||
Output: all 11 assertions `ok`.
|
||||
|
||||
### 4. End-to-end chooser + scope run
|
||||
```
|
||||
UDPSCOPE_PROBE_PICK_MS=800 UDPSCOPE_EXIT_AFTER_MS=6000 ./build/udpscope
|
||||
```
|
||||
Relevant output:
|
||||
```
|
||||
[sources] picked 127.0.0.1:8000 multicast= interface=
|
||||
[udpscope] layout loaded from /tmp/fix-e2e/udpscope/127.0.0.1_8000.toml
|
||||
[udpscope] frames=817 packets=818 gaps=0 channels=1 span=1677.722s stride=1
|
||||
[udpscope] layout over 476 frames: axes.x 135..147, axes.w 722..734, viewport.w 870..870, y-gutter 88..100, x-ticks 20..21, x-desync 0
|
||||
[udpscope] trace ink: 406 paints checked, 0 collapsed, 0 staircase, 0 short, worst cover 2.5%
|
||||
```
|
||||
|
||||
### 5. Fix 8 dead-port assertion
|
||||
```
|
||||
timeout 15 xvfb-run -a env XDG_CONFIG_HOME=/tmp/fix-e2e UDPSCOPE_EXIT_AFTER_MS=4000 ./build/udpscope --server 127.0.0.1 --port 8000
|
||||
cat /tmp/fix-e2e/udpscope/127.0.0.1_8000.toml
|
||||
```
|
||||
File mtime stayed at 10:01 (written by the live mock run); the dead-port run at 10:02 did not touch it.
|
||||
File contents: `signals = ["sine", "", "", ""]` — the configured signal name was preserved.
|
||||
@@ -38,7 +38,7 @@ The detail lives in `docs/claude/`. Read the files that touch the area you are e
|
||||
| File | What it covers |
|
||||
| --- | --- |
|
||||
| [build-and-run.md](docs/claude/build-and-run.md) | Configuring and building, the desktop-entry target, the command-line options, and the two vendored dependencies (`deps/plotter`, `deps/udpsclient`) including what was changed upstream and why. |
|
||||
| [verification.md](docs/claude/verification.md) | How to run the headless self-check, the **two baselines every run is read against** (exactly four build warnings; no `Gtk-CRITICAL`), and the environment variable that schedules each probe. |
|
||||
| [verification.md](docs/claude/verification.md) | How to run the headless self-check, the **two baselines every run is read against** (exactly three build warnings; no `Gtk-CRITICAL`), and the environment variable that schedules each probe. |
|
||||
| [probe-readings.md](docs/claude/probe-readings.md) | The per-line reference for the self-check's output: what every probe and dump line means, the value it must read, and the defect a wrong reading names. |
|
||||
|
||||
### How the application is put together
|
||||
@@ -57,7 +57,7 @@ The detail lives in `docs/claude/`. Read the files that touch the area you are e
|
||||
| [invariants-rendering.md](docs/claude/invariants-rendering.md) | How the renderer maps a visible range onto samples (never the `dx()` grid), what `gather()` emits and relative to which origin, why every series needs a real `MinMaxPyramid`, and why a dense trace is drawn one pixel column at a time. |
|
||||
| [invariants-trigger-and-pinning.md](docs/claude/invariants-trigger-and-pinning.md) | The event window counted in stream seconds end to end, what pins an axis in each mode, and what each pin is measured *from* (the snapshot's own stamps, not the request). |
|
||||
| [invariants-time-axis-and-window.md](docs/claude/invariants-time-axis-and-window.md) | **No plot interaction changes the time scale**; `apply_time_window()` is the only path that does; how the x labels pick their form; what a fit restores. |
|
||||
| [invariants-measurements.md](docs/claude/invariants-measurements.md) | Why the pane is refreshed on a cadence, why neither pass may read its window raw, and how a crossing is counted with hysteresis. |
|
||||
| [invariants-measurements.md](docs/claude/invariants-measurements.md) | Why the pane is refreshed on a cadence, why neither pass may read its window raw, how a crossing is counted with hysteresis, and which mean over a split is which. |
|
||||
| [invariants-math-channels.md](docs/claude/invariants-math-channels.md) | A math channel *is* a channel; the engine runs on the producer thread, so a regression there costs packets and not frames; the node editor's own rules. |
|
||||
| [invariants-session-and-export.md](docs/claude/invariants-session-and-export.md) | What a session file persists and in what order, when a save is refused, and when an export is collected. |
|
||||
| [invariants-gtk-and-chrome.md](docs/claude/invariants-gtk-and-chrome.md) | The GTK signal-signature and dialog-lifetime traps this app has already fallen into, the status bar's two tiers, the six bundled icons and their authoring rules, and the help pages. |
|
||||
| [invariants-gtk-and-chrome.md](docs/claude/invariants-gtk-and-chrome.md) | The GTK signal-signature and dialog-lifetime traps this app has already fallen into, the status bar's two tiers, the six bundled icons and their authoring rules, and how the Mallard manual is opened. |
|
||||
@@ -11,6 +11,8 @@ if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
||||
endif()
|
||||
|
||||
find_package(Threads REQUIRED)
|
||||
# `CMAKE_INSTALL_DATADIR` (the `share` under the prefix) for the help set's install path.
|
||||
include(GNUInstallDirs)
|
||||
|
||||
# --- libadwaita: the shortcut overview ---------------------------------------
|
||||
# GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and libadwaita's
|
||||
@@ -61,23 +63,146 @@ add_custom_command(
|
||||
COMMENT "Compiling udpscope icon resources"
|
||||
VERBATIM)
|
||||
|
||||
# --- help pages: Pango markup in a GResource ----------------------------------
|
||||
# A second bundle beside the icons'. --c-name is given explicitly so the two generated
|
||||
# sources cannot collide on symbol names.
|
||||
set(UDPSCOPE_HELP_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/help)
|
||||
file(GLOB UDPSCOPE_HELP_FILES CONFIGURE_DEPENDS ${UDPSCOPE_HELP_DIR}/*.txt)
|
||||
add_custom_command(
|
||||
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c
|
||||
COMMAND ${GLIB_COMPILE_RESOURCES}
|
||||
--target=${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c
|
||||
--sourcedir=${UDPSCOPE_HELP_DIR}
|
||||
--c-name=udpscope_help
|
||||
--generate-source
|
||||
${UDPSCOPE_HELP_DIR}/udpscope-help.gresource.xml
|
||||
DEPENDS ${UDPSCOPE_HELP_DIR}/udpscope-help.gresource.xml ${UDPSCOPE_HELP_FILES}
|
||||
COMMENT "Compiling udpscope help resources"
|
||||
# --- the manual: a Mallard help set, installed for the `help:` URI ------------
|
||||
# The manual is no longer a GResource compiled into the binary. It is a Mallard set that the
|
||||
# desktop help viewer (Yelp on GNOME) reads off disk, because that is the only way `F1` can
|
||||
# open a real, searchable, translatable document and the only way the freedesktop Help System
|
||||
# Specification's `help:` URI resolves it. The user documentation is then *files*, checked and
|
||||
# installed rather than compiled in.
|
||||
#
|
||||
# The document identifier -- the `<name>` in `help:<name>` -- is derived from the desktop
|
||||
# file's own basename (its desktop-file id), not written out twice: the same string is the
|
||||
# `.desktop` file, the GApplication id and the installed directory. The specification installs
|
||||
# a document at `<datadir>/help/<lang>/<document>/` and resolves `help:<document>` to the first
|
||||
# match under $XDG_DATA_HOME/$XDG_DATA_DIRS, so the directory name *is* the identifier.
|
||||
# NAME_WE is not used: it strips the *longest* extension, so it would cut `org.udpscope.app`
|
||||
# down to `org`. Strip the two suffixes this one file actually carries, in order.
|
||||
get_filename_component(_udpscope_desktop
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/data/org.udpscope.app.desktop.in NAME)
|
||||
string(REGEX REPLACE "\\.desktop\\.in$" "" UDPSCOPE_HELP_NAME "${_udpscope_desktop}")
|
||||
unset(_udpscope_desktop)
|
||||
set(UDPSCOPE_HELP_SRC ${CMAKE_CURRENT_SOURCE_DIR}/help/C/${UDPSCOPE_HELP_NAME})
|
||||
file(GLOB UDPSCOPE_HELP_FILES CONFIGURE_DEPENDS
|
||||
${UDPSCOPE_HELP_SRC}/*.page ${UDPSCOPE_HELP_SRC}/figures/*.png)
|
||||
|
||||
# The install rule the specification asks for. `CMAKE_INSTALL_DATADIR` is `share` by default,
|
||||
# so the set lands at `<prefix>/share/help/C/<document>/`, which is exactly where a viewer
|
||||
# looks. There is no install step for the binary itself (see the desktop-entry target below),
|
||||
# but the manual is *read from a datadir* by another process, so it has to be installable.
|
||||
#
|
||||
# `COMPONENT Help` is what lets the opt-in targets below install *only* the manual: it names this
|
||||
# rule, and `cmake --install --component Help` runs it and nothing else. The ordinary
|
||||
# `cmake --install` installs every component, so its behaviour is unchanged -- the component is a
|
||||
# filter a caller may apply, not a change to the default.
|
||||
install(DIRECTORY ${UDPSCOPE_HELP_SRC}/
|
||||
DESTINATION ${CMAKE_INSTALL_DATADIR}/help/C/${UDPSCOPE_HELP_NAME}
|
||||
COMPONENT Help
|
||||
FILES_MATCHING PATTERN "*.page" PATTERN "*.png" PATTERN "*.jpg")
|
||||
|
||||
# --- installing the manual alone: prefix-wide and user-local, both opt-in -----
|
||||
# A user who runs the binary out of a build tree wants *just* the manual where `help:` resolves,
|
||||
# and not the `cmake --install` of a whole project. These two targets are that, one per
|
||||
# destination; neither is built by default and neither is touched by `cmake --build build`.
|
||||
#
|
||||
# `install-help` honours `CMAKE_INSTALL_PREFIX`, so a packager or a system-wide user points it at
|
||||
# `/usr` with -DCMAKE_INSTALL_PREFIX at configure time. It goes through the install machinery
|
||||
# (`cmake --install`) rather than a hand-rolled copy, which is what makes DESTDIR and `--prefix`
|
||||
# behave exactly as they do for the ordinary install. It cannot install system-wide on its own:
|
||||
# `/usr` is root-owned, so the target writes to the configured prefix and a system-wide caller
|
||||
# wraps it in `sudo` -- a build target has no more privilege than the shell that runs it.
|
||||
add_custom_target(install-help
|
||||
COMMAND ${CMAKE_COMMAND} --install ${CMAKE_BINARY_DIR} --component Help
|
||||
COMMENT "Installing the manual into ${CMAKE_INSTALL_PREFIX}"
|
||||
VERBATIM)
|
||||
|
||||
# `install-help-user` is the no-root case the launch-failure alert names. It resolves the user
|
||||
# data directory the way `help_installed_path()` does -- `$XDG_DATA_HOME` when it is set,
|
||||
# `$HOME/.local/share` otherwise, the XDG Base Directory Specification's own default -- and copies
|
||||
# the set there. The copy is done by a script rather than by `cmake --install --prefix` because
|
||||
# the destination is the *data directory*, not a prefix: `cmake --install`'s datadir is always
|
||||
# `<prefix>/share`, so an arbitrary `$XDG_DATA_HOME` cannot be expressed as a prefix. The script
|
||||
# prints every path it wrote; the copy uses the install rule's file filter, so both destinations
|
||||
# carry the same tree.
|
||||
add_custom_target(install-help-user
|
||||
COMMAND ${CMAKE_COMMAND}
|
||||
-DHELP_SRC=${UDPSCOPE_HELP_SRC}
|
||||
-DHELP_NAME=${UDPSCOPE_HELP_NAME}
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/install_help_user.cmake
|
||||
COMMENT "Installing the manual into the user's XDG data directory"
|
||||
VERBATIM)
|
||||
|
||||
# --- UI files: GtkBuilder XML in a GResource ----------------------------------
|
||||
# The third bundle, for the same reason as the other two: `.ui` files are data, so they are compiled
|
||||
# in rather than looked for at runtime -- and `preprocess="xml-stripblanks"` makes a malformed one a
|
||||
# build failure with a file and line number, instead of a window that silently refuses to open.
|
||||
set(UDPSCOPE_UI_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/ui)
|
||||
file(GLOB UDPSCOPE_UI_FILES CONFIGURE_DEPENDS ${UDPSCOPE_UI_DIR}/*.ui)
|
||||
add_custom_command(
|
||||
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
|
||||
COMMAND ${GLIB_COMPILE_RESOURCES}
|
||||
--target=${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
|
||||
--sourcedir=${UDPSCOPE_UI_DIR}
|
||||
--c-name=udpscope_ui
|
||||
--generate-source
|
||||
${UDPSCOPE_UI_DIR}/udpscope-ui.gresource.xml
|
||||
DEPENDS ${UDPSCOPE_UI_DIR}/udpscope-ui.gresource.xml ${UDPSCOPE_UI_FILES}
|
||||
COMMENT "Compiling udpscope UI resources"
|
||||
VERBATIM)
|
||||
# The generated source carries each file as one C string literal, and a `.ui` is big enough to pass the
|
||||
# 4095-character minimum the standard requires a compiler to support: `-Woverlength-strings` fires on
|
||||
# generated *data*, not on anything anyone wrote. Silenced for this one generated file (the icon bundle
|
||||
# is small enough per file never to need it), so the build stays at its three warnings. The manual is
|
||||
# no longer compiled in, so the help bundle's own warning is gone with it.
|
||||
set_source_files_properties(${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
|
||||
PROPERTIES COMPILE_OPTIONS "-Wno-overlength-strings")
|
||||
|
||||
# --- the UI files: an opt-in check, never part of `cmake --build` ---------------
|
||||
# The interface is hand-written GtkBuilder XML, and nothing in the build sees a mistake in it: a
|
||||
# property a class does not have is a `Gtk-ERROR` when the window is built, and `Gtk-ERROR` *aborts
|
||||
# the process* -- so a typo takes the application down rather than failing to open a dialog.
|
||||
# `xml-stripblanks` catches malformed XML and nothing else, and the probes catch the rest only for the
|
||||
# windows they open.
|
||||
#
|
||||
# blueprint-compiler type-checks every property and child type against the GIR, and it reads XML as
|
||||
# well as blueprint (`decompile` turns one into the other), so `tools/check_ui.py` round-trips each
|
||||
# file through it. The XML stays the source of truth and stays hand-written; nothing here is needed to
|
||||
# build the application, which is the whole point of not converting the files.
|
||||
#
|
||||
# No ALL: this runs only when it is named, so `cmake --build build` is unaffected and
|
||||
# blueprint-compiler is not a build dependency.
|
||||
find_program(UDPSCOPE_PYTHON NAMES python3)
|
||||
if(UDPSCOPE_PYTHON)
|
||||
add_custom_target(test-ui
|
||||
COMMAND ${UDPSCOPE_PYTHON} ${CMAKE_CURRENT_SOURCE_DIR}/tools/check_ui.py ${UDPSCOPE_UI_DIR}
|
||||
DEPENDS ${UDPSCOPE_UI_FILES} ${CMAKE_CURRENT_SOURCE_DIR}/tools/check_ui.py
|
||||
COMMENT "Checking the UI files with blueprint-compiler"
|
||||
VERBATIM)
|
||||
else()
|
||||
message(WARNING "python3 was not found: the test-ui target is not available")
|
||||
endif()
|
||||
|
||||
# --- the manual: an opt-in check, never part of `cmake --build` -----------------
|
||||
# The manual is hand-written Mallard, and `cmake --build` never reads it -- a broken xref or a
|
||||
# missing figure is a page in the viewer with no content or no image, not a build failure.
|
||||
# `tools/check_help.sh` runs yelp-check's four offline subcommands over the set (links, media,
|
||||
# orphans, ids) and renders the whole set to HTML with yelp-build, so the manual is checked the
|
||||
# way the `.ui` files are. `yelp-check validate` is *not* used: it fetches the Mallard RelaxNG
|
||||
# schema over the network, which is not available here and would make the check depend on it.
|
||||
#
|
||||
# No ALL: this runs only when it is named, so `cmake --build build` is unaffected and
|
||||
# yelp-tools is not a build dependency.
|
||||
find_program(UDPSCOPE_YELP_CHECK NAMES yelp-check)
|
||||
find_program(UDPSCOPE_YELP_BUILD NAMES yelp-build)
|
||||
if(UDPSCOPE_YELP_CHECK AND UDPSCOPE_YELP_BUILD)
|
||||
add_custom_target(test-help
|
||||
COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/tools/check_help.sh ${UDPSCOPE_HELP_SRC}
|
||||
DEPENDS ${UDPSCOPE_HELP_FILES} ${CMAKE_CURRENT_SOURCE_DIR}/tools/check_help.sh
|
||||
COMMENT "Checking the Mallard help set with yelp-check and yelp-build"
|
||||
VERBATIM)
|
||||
else()
|
||||
message(WARNING "yelp-check/yelp-build were not found: the test-help target is not available")
|
||||
endif()
|
||||
|
||||
# --- build info: version, commit and timestamp compiled into the binary -------
|
||||
# Written at configure time so add_executable can name the file, and again on every
|
||||
# build so the commit and the timestamp belong to the binary that is running rather
|
||||
@@ -99,16 +224,21 @@ set_source_files_properties(${UDPSCOPE_BUILD_INFO} PROPERTIES GENERATED TRUE)
|
||||
# --- the application ----------------------------------------------------------
|
||||
add_executable(udpscope
|
||||
${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
|
||||
${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c
|
||||
${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
|
||||
${UDPSCOPE_BUILD_INFO}
|
||||
src/main.cpp
|
||||
src/config.cpp
|
||||
src/channel.cpp
|
||||
src/model.cpp
|
||||
src/condition.cpp
|
||||
src/trigger.cpp
|
||||
src/capture.cpp
|
||||
src/waveform.cpp
|
||||
src/event_store.cpp
|
||||
src/acquisition.cpp
|
||||
src/plot_host.cpp
|
||||
src/export.cpp
|
||||
src/search.cpp
|
||||
src/layout_store.cpp
|
||||
src/source_dialog.cpp
|
||||
src/measure.cpp
|
||||
@@ -116,10 +246,30 @@ add_executable(udpscope
|
||||
src/status_text.cpp
|
||||
src/shortcuts.cpp
|
||||
src/help_window.cpp
|
||||
src/settings.cpp
|
||||
src/ui_prefs.cpp
|
||||
src/ui.cpp
|
||||
src/ui_probe.cpp
|
||||
src/ui_widgets.cpp
|
||||
src/ui_events.cpp
|
||||
src/ui_status.cpp
|
||||
src/ui_measure.cpp
|
||||
src/ui_cursors.cpp
|
||||
src/ui_math.cpp
|
||||
src/ui_layout.cpp
|
||||
src/ui_trigger.cpp
|
||||
src/ui_signals.cpp
|
||||
src/ui_context_help.cpp
|
||||
src/ui_tour.cpp
|
||||
)
|
||||
|
||||
target_include_directories(udpscope PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
|
||||
# The manual's identifier (`show_help()` builds `help:<id>` from it) and its directory in the
|
||||
# source tree (what `help_self_check()` scans). Both come from the block near the top, so the
|
||||
# identifier is the desktop file's basename and nothing is written out twice.
|
||||
target_compile_definitions(udpscope PRIVATE
|
||||
UDPSCOPE_HELP_NAME="${UDPSCOPE_HELP_NAME}"
|
||||
UDPSCOPE_HELP_DIR="${UDPSCOPE_HELP_SRC}")
|
||||
target_link_libraries(udpscope PRIVATE plot::gtk4 udpsclient Threads::Threads
|
||||
PkgConfig::ADWAITA)
|
||||
add_dependencies(udpscope udpscope_build_info)
|
||||
|
||||
@@ -36,3 +36,18 @@
|
||||
- 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
|
||||
|
||||
## Deviations from the spec
|
||||
|
||||
The following were built differently from what was asked for, each for a reason `README.md` states
|
||||
in full:
|
||||
|
||||
- **`todo.md` item 2 — relative timing.** Implemented as `--relative-time`. The retained history
|
||||
in this mode is **41.943 s** against the default **83.886 s** (measured at 100 kS/s, 4 channels,
|
||||
`--buffer-mb 200`): exactly half, because a second f64 ring for absolute time raises a tick's
|
||||
cost from 24 to 32 bytes — a third more — and `Partition::time_capacity` is floored to a power
|
||||
of two, so a third more weight costs a full halving. The design document and the implementation
|
||||
plan both put the cost at ~22%, which is wrong; the per-byte figure of 75% is wrong for the same
|
||||
reason.
|
||||
Absolute stream time is preserved in a separate ring and used for the export's `abs_seconds`
|
||||
column, exactly as `todo.md` requires.
|
||||
@@ -0,0 +1,44 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
#
|
||||
# The `install-help-user` target's body. Copies the Mallard help set into the user's XDG data
|
||||
# directory -- where `help:<id>` resolves without root -- and prints every path it wrote.
|
||||
#
|
||||
# `$XDG_DATA_HOME` is honoured when it is set, and `$HOME/.local/share` is the fallback when it
|
||||
# is not: that is the XDG Base Directory Specification's own stated default for the variable, and
|
||||
# it is the default `help_installed_path()` falls back to as well, so the manual lands where the
|
||||
# application and the desktop viewer look for it. The variable names a *data directory*, not a
|
||||
# prefix -- its `share/` is not appended -- so the set goes to `<data_home>/help/C/<id>/`. An
|
||||
# arbitrary `$XDG_DATA_HOME` need not end in `share`, which is exactly why this copies the tree
|
||||
# rather than deferring to `cmake --install --prefix`, whose datadir is always `<prefix>/share`.
|
||||
#
|
||||
# Like the desktop-entry target, this runs only when it is named: `cmake --build build` never
|
||||
# touches the user's data directory.
|
||||
#
|
||||
# Expects -DHELP_SRC= and -DHELP_NAME= on the command line.
|
||||
|
||||
if(DEFINED ENV{XDG_DATA_HOME} AND NOT "$ENV{XDG_DATA_HOME}" STREQUAL "")
|
||||
set(data_home "$ENV{XDG_DATA_HOME}")
|
||||
elseif(DEFINED ENV{HOME} AND NOT "$ENV{HOME}" STREQUAL "")
|
||||
set(data_home "$ENV{HOME}/.local/share")
|
||||
else()
|
||||
message(FATAL_ERROR
|
||||
"XDG_DATA_HOME and HOME are both unset or empty; cannot determine the user data directory")
|
||||
endif()
|
||||
|
||||
if(NOT DEFINED HELP_SRC OR NOT DEFINED HELP_NAME)
|
||||
message(FATAL_ERROR "HELP_SRC and HELP_NAME are both required")
|
||||
endif()
|
||||
|
||||
set(dest "${data_home}/help/C/${HELP_NAME}")
|
||||
|
||||
# The same filter as the install() rule, so both destinations carry the same tree: the pages and
|
||||
# the figures, and nothing an editor left behind.
|
||||
file(COPY "${HELP_SRC}/" DESTINATION "${dest}"
|
||||
FILES_MATCHING PATTERN "*.page" PATTERN "*.png" PATTERN "*.jpg")
|
||||
|
||||
# Printed because there is no uninstall target: undoing this is these paths.
|
||||
file(GLOB_RECURSE installed LIST_DIRECTORIES FALSE RELATIVE "${dest}" "${dest}/*")
|
||||
list(SORT installed)
|
||||
foreach(path IN LISTS installed)
|
||||
message(STATUS "wrote ${dest}/${path}")
|
||||
endforeach()
|
||||
@@ -33,6 +33,13 @@
|
||||
/* Tunable limits */
|
||||
/*---------------------------------------------------------------------------*/
|
||||
|
||||
/** Datagrams held waiting for an earlier counter. */
|
||||
#define UDPS_REORDER_SLOTS 64
|
||||
/** Total bytes the reorder ring may hold, so a fast stream cannot grow it without bound. */
|
||||
#define UDPS_REORDER_BYTES (4u * 1024u * 1024u)
|
||||
/** How long a gap is given to resolve before it is committed, by default. */
|
||||
#define UDPS_REORDER_WINDOW_S_DEFAULT 0.020
|
||||
|
||||
/** Concurrent in-flight reassemblies. The server interleaves at most a couple. */
|
||||
#define UDPS_MAX_SLOTS 4
|
||||
|
||||
@@ -41,7 +48,7 @@
|
||||
#define UDPS_FRAG_MASK_BYTES (UDPS_MAX_FRAGMENTS / 8)
|
||||
|
||||
/** Largest datagram we can receive, header included. */
|
||||
#define UDPS_RX_BUF_BYTES (65535 + 17)
|
||||
#define UDPS_RX_BUF_BYTES UDPS_MAX_DATAGRAM_BYTES
|
||||
|
||||
/** An incomplete reassembly older than this is abandoned. */
|
||||
#define UDPS_SLOT_STALE_S 2.0
|
||||
@@ -151,6 +158,16 @@ typedef struct {
|
||||
double first_seen;
|
||||
} udps_slot_t;
|
||||
|
||||
/** One slot in the reorder ring: a raw payload held until its counter is next. */
|
||||
typedef struct {
|
||||
uint8_t *buf; /**< owned; grown on demand to the payload's length */
|
||||
size_t cap;
|
||||
size_t len;
|
||||
uint32_t counter;
|
||||
double recv_time;
|
||||
int used;
|
||||
} udps_held_t;
|
||||
|
||||
struct udps_client {
|
||||
udps_client_config_t cfg;
|
||||
/* Owned copies: the caller's strings need not outlive the create call. */
|
||||
@@ -181,12 +198,20 @@ struct udps_client {
|
||||
|
||||
uint8_t *rxbuf;
|
||||
|
||||
uint32_t last_counter;
|
||||
udps_held_t held[UDPS_REORDER_SLOTS];
|
||||
size_t held_bytes; /**< payload bytes currently held */
|
||||
int held_count;
|
||||
uint32_t next_counter; /**< the counter the sequence is waiting for */
|
||||
double gap_deadline; /**< wall time the current gap gives up at; 0 = no gap */
|
||||
|
||||
int have_counter;
|
||||
|
||||
udps_stats_t stats;
|
||||
char err[256];
|
||||
|
||||
udps_datagram_cb datagram_cb; /**< NULL until set; fires inside poll(). */
|
||||
void *datagram_user;
|
||||
|
||||
udps_config_cb on_config;
|
||||
udps_data_cb on_data;
|
||||
udps_event_cb on_event;
|
||||
@@ -475,43 +500,23 @@ static int decode_config(udps_client_t *c, const uint8_t *pl, size_t len) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int decode_data(udps_client_t *c, const uint8_t *pl, size_t len,
|
||||
uint32_t counter, double recv_time) {
|
||||
/* Decodes and delivers one DATA payload whose ordering has already been resolved
|
||||
* by the caller. `lost` counts how many counters the sequence skipped over
|
||||
* before this packet, and `late` marks it as having lost the race past the
|
||||
* reorder window (so the consumer already has a hole where it belongs). */
|
||||
static int deliver_data(udps_client_t *c, const uint8_t *pl, size_t len,
|
||||
uint32_t counter, double recv_time, uint32_t lost, uint8_t late) {
|
||||
uint32_t nsamples = 1u;
|
||||
size_t off = 8u;
|
||||
size_t total = 0u;
|
||||
size_t written = 0u;
|
||||
uint32_t i;
|
||||
uint32_t lost = 0u;
|
||||
udps_frame_t frame;
|
||||
|
||||
if (c->num_sigs == 0u) {
|
||||
return 0; /* DATA before CONFIG: nothing to decode against. */
|
||||
}
|
||||
|
||||
/* Order the sequence before spending anything on the payload.
|
||||
*
|
||||
* Reassembly completes in arrival order, not counter order, so a packet
|
||||
* delayed or duplicated on the wire surfaces after a newer one has already
|
||||
* been delivered. Its samples carry an older time base: they land on top
|
||||
* of data the consumer already has and leave the span they should have
|
||||
* filled empty. Nothing in the payload distinguishes such a packet from a
|
||||
* good one, only the counter does.
|
||||
*
|
||||
* The counter is a wrapping uint32, so it is ordered by the signed
|
||||
* difference; comparing the values directly would call the first packet
|
||||
* after the wrap stale and reject the stream from then on. */
|
||||
if (c->have_counter) {
|
||||
int32_t delta = (int32_t)(counter - c->last_counter);
|
||||
if (delta <= 0) {
|
||||
c->stats.stale_packets++;
|
||||
return 0;
|
||||
}
|
||||
lost = (uint32_t)delta - 1u;
|
||||
c->stats.counter_gaps += lost;
|
||||
}
|
||||
c->last_counter = counter;
|
||||
c->have_counter = 1;
|
||||
if (len < 8u) {
|
||||
return fail(c, "DATA payload too short (%lu bytes)", (unsigned long)len);
|
||||
}
|
||||
@@ -554,10 +559,12 @@ static int decode_data(udps_client_t *c, const uint8_t *pl, size_t len,
|
||||
}
|
||||
|
||||
c->stats.frames_delivered++;
|
||||
c->stats.counter_gaps += lost;
|
||||
|
||||
if (c->on_data != NULL) {
|
||||
frame.counter = counter;
|
||||
frame.lost = lost;
|
||||
frame.lost = late ? 0u : lost;
|
||||
frame.late = late;
|
||||
frame.hrt = rd_u64(pl);
|
||||
frame.recv_time = recv_time;
|
||||
frame.publish_mode = c->publish_mode;
|
||||
@@ -570,6 +577,245 @@ static int decode_data(udps_client_t *c, const uint8_t *pl, size_t len,
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Finds a free slot in the reorder ring and stores the payload there.
|
||||
*
|
||||
* A duplicate counter (same datagram arriving twice on the wire) replaces the
|
||||
* existing slot so one counter never consumes two slots and both get delivered.
|
||||
* The search for that slot therefore runs *before* the capacity checks, and the
|
||||
* checks are applied only on the path that needs a new slot: a duplicate at a
|
||||
* full ring consumes nothing and must still be accepted.
|
||||
* Returns non-zero and holds nothing when there is no room (ring full or byte
|
||||
* budget exhausted); `order_data` below says what the caller then does. */
|
||||
static int hold_packet(udps_client_t *c, const uint8_t *pl, size_t len,
|
||||
uint32_t counter, double recv_time) {
|
||||
int i;
|
||||
int slot = -1;
|
||||
|
||||
/* Duplicate counter: reuse the existing slot to avoid double delivery. */
|
||||
for (i = 0; i < UDPS_REORDER_SLOTS; i++) {
|
||||
if (c->held[i].used && c->held[i].counter == counter) {
|
||||
slot = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (slot >= 0) {
|
||||
/* The replacement releases the length already held, so the budget is
|
||||
* weighed against what the slot will cost rather than what it adds. */
|
||||
if (c->held_bytes - c->held[slot].len + len > UDPS_REORDER_BYTES) {
|
||||
return -1;
|
||||
}
|
||||
} else {
|
||||
if (c->held_count >= UDPS_REORDER_SLOTS) {
|
||||
return -1;
|
||||
}
|
||||
if (c->held_bytes + len > UDPS_REORDER_BYTES) {
|
||||
return -1;
|
||||
}
|
||||
for (i = 0; i < UDPS_REORDER_SLOTS; i++) {
|
||||
if (!c->held[i].used) {
|
||||
slot = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (slot < 0) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (ensure_cap((void **)&c->held[slot].buf, &c->held[slot].cap, len) != 0) {
|
||||
return -1;
|
||||
}
|
||||
/* If this is a duplicate replacement, subtract the old length first. */
|
||||
if (c->held[slot].used) {
|
||||
c->held_bytes -= c->held[slot].len;
|
||||
c->held_count--;
|
||||
}
|
||||
memcpy(c->held[slot].buf, pl, len);
|
||||
c->held[slot].len = len;
|
||||
c->held[slot].counter = counter;
|
||||
c->held[slot].recv_time = recv_time;
|
||||
c->held[slot].used = 1;
|
||||
c->held_bytes += len;
|
||||
c->held_count++;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Walks the held ring and delivers every entry whose counter matches the
|
||||
* expected sequence, in order. The first delivery carries `first_lost` (so
|
||||
* a caller that committed a gap can report the correct count); every
|
||||
* subsequent delivery in the same drain pass carries lost = 0.
|
||||
*
|
||||
* Clears the gap deadline once the ring empties. */
|
||||
static int drain_held(udps_client_t *c, uint32_t first_lost) {
|
||||
int found;
|
||||
int i;
|
||||
int rc;
|
||||
uint32_t lost;
|
||||
|
||||
lost = first_lost;
|
||||
do {
|
||||
found = 0;
|
||||
for (i = 0; i < UDPS_REORDER_SLOTS; i++) {
|
||||
if (c->held[i].used &&
|
||||
(int32_t)(c->held[i].counter - c->next_counter) == 0) {
|
||||
uint32_t cnt = c->held[i].counter;
|
||||
double rt = c->held[i].recv_time;
|
||||
const uint8_t *pl = c->held[i].buf;
|
||||
size_t len = c->held[i].len;
|
||||
/* Release the slot before delivering so deliver_data can call
|
||||
* back into the client without hitting a stale used entry. */
|
||||
c->held_bytes -= len;
|
||||
c->held_count--;
|
||||
c->held[i].used = 0;
|
||||
c->next_counter = cnt + 1u;
|
||||
c->stats.late_resequenced++;
|
||||
rc = deliver_data(c, pl, len, cnt, rt, lost, 0);
|
||||
lost = 0u;
|
||||
if (rc != 0) {
|
||||
return rc;
|
||||
}
|
||||
found = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} while (found);
|
||||
|
||||
if (c->held_count == 0) {
|
||||
c->gap_deadline = 0.0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Gives up on the current gap: finds the lowest held counter (by signed delta,
|
||||
* not raw value, so a wrap mid-gap is handled correctly), advances
|
||||
* next_counter to it, then drains. The first delivery out of that drain
|
||||
* carries the gap's lost count; the rest carry 0.
|
||||
*
|
||||
* With nothing held it just clears the deadline and returns. */
|
||||
static int commit_gap(udps_client_t *c) {
|
||||
int i;
|
||||
int best = -1;
|
||||
uint32_t lost;
|
||||
|
||||
if (c->held_count == 0) {
|
||||
c->gap_deadline = 0.0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (i = 0; i < UDPS_REORDER_SLOTS; i++) {
|
||||
if (!c->held[i].used) {
|
||||
continue;
|
||||
}
|
||||
if (best < 0 ||
|
||||
(int32_t)(c->held[i].counter - c->held[best].counter) < 0) {
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
|
||||
lost = (uint32_t)(int32_t)(c->held[best].counter - c->next_counter);
|
||||
c->next_counter = c->held[best].counter;
|
||||
return drain_held(c, lost);
|
||||
}
|
||||
|
||||
/* Orders the sequence before spending anything on the payload.
|
||||
*
|
||||
* The counter is a wrapping uint32, so it is ordered by the signed difference;
|
||||
* comparing the values directly would call the first packet after the wrap stale
|
||||
* and reject the stream from then on.
|
||||
*
|
||||
* Three cases. A packet that *continues* the sequence is decoded now and costs no
|
||||
* latency at all -- that is the common case and it must stay free. A packet that
|
||||
* runs ahead of the sequence means an earlier one is missing, so it is held and the
|
||||
* gap is given `reorder_window_s` to resolve; a reorder on the wire resolves in
|
||||
* milliseconds, and holding is what turns it back into order. A packet behind the
|
||||
* sequence lost that race: the consumer already has a hole where it belongs, so it
|
||||
* is delivered flagged rather than discarded, which is the whole point -- a dropped
|
||||
* packet cannot fill the hole it left.
|
||||
*
|
||||
* The full ring is specified here, in one place. A packet that needs a slot and
|
||||
* finds none gives up on the current gap -- `commit_gap()` delivers the lowest held
|
||||
* run and so frees at least one slot -- counts the event in
|
||||
* `stats.reorder_overflows`, and the decision is then taken again, once, against
|
||||
* the sequence that commit left behind. That re-decision is why this is a bounded
|
||||
* loop and not a straight line: the commit advances `next_counter` to the lowest
|
||||
* held counter, which may put it past the arriving packet, and the packet is then
|
||||
* behind the sequence rather than ahead of it. Whatever happens, the arriving
|
||||
* packet is held or delivered; it is never dropped, because nothing else records it
|
||||
* and the consumer would reserve a hole for it that nothing could ever backfill.
|
||||
* Past the cap it is delivered *in order* and not flagged late: a packet ahead of
|
||||
* the sequence has no reserved hole, the consumer looks a late packet's hole up by
|
||||
* counter and ignores one it cannot place, so advancing `next_counter` to it and
|
||||
* reporting the counters it skipped as `lost` is what reserves their hole. */
|
||||
static int order_data(udps_client_t *c, const uint8_t *pl, size_t len,
|
||||
uint32_t counter, double recv_time) {
|
||||
int32_t delta;
|
||||
int attempt;
|
||||
int rc;
|
||||
|
||||
if (c->cfg.reorder_window_s <= 0.0) {
|
||||
/* Resequencing off: byte-for-byte the behaviour before this task. `delta`
|
||||
* is measured against the *next* counter rather than the last, which is the
|
||||
* same test shifted by one -- `delta < 0` here is `delta <= 0` there. */
|
||||
uint32_t lost = 0u;
|
||||
if (c->have_counter) {
|
||||
delta = (int32_t)(counter - c->next_counter);
|
||||
if (delta < 0) {
|
||||
c->stats.stale_packets++;
|
||||
return 0;
|
||||
}
|
||||
lost = (uint32_t)delta;
|
||||
}
|
||||
c->have_counter = 1;
|
||||
c->next_counter = counter + 1u;
|
||||
return deliver_data(c, pl, len, counter, recv_time, lost, 0);
|
||||
}
|
||||
if (!c->have_counter) {
|
||||
c->have_counter = 1;
|
||||
c->next_counter = counter + 1u;
|
||||
return deliver_data(c, pl, len, counter, recv_time, 0u, 0);
|
||||
}
|
||||
for (attempt = 0; attempt < 2; attempt++) {
|
||||
delta = (int32_t)(counter - c->next_counter);
|
||||
if (delta == 0) {
|
||||
rc = deliver_data(c, pl, len, counter, recv_time, 0u, 0);
|
||||
c->next_counter = counter + 1u;
|
||||
if (rc == 0) { rc = drain_held(c, 0u); }
|
||||
return rc;
|
||||
}
|
||||
if (delta < 0) {
|
||||
/* Behind the sequence. */
|
||||
c->stats.late_delivered++;
|
||||
return deliver_data(c, pl, len, counter, recv_time, 0u, 1);
|
||||
}
|
||||
if (hold_packet(c, pl, len, counter, recv_time) == 0) {
|
||||
if (c->gap_deadline == 0.0) {
|
||||
c->gap_deadline = recv_time + c->cfg.reorder_window_s;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
/* No room: give up on the gap rather than growing the ring, and say so
|
||||
* in a counter, so an undersized window is a reading and not a mystery. */
|
||||
c->stats.reorder_overflows++;
|
||||
rc = commit_gap(c);
|
||||
if (rc != 0) {
|
||||
/* The consumer refused a delivery; nothing is freed and nothing more
|
||||
* may be delivered, so the arriving packet is its caller's problem. */
|
||||
return rc;
|
||||
}
|
||||
}
|
||||
/* The attempt cap is spent and the ring still has no room (the byte budget
|
||||
* cannot fit this one even with a slot free). Deliver it rather than drop it. */
|
||||
delta = (int32_t)(counter - c->next_counter);
|
||||
if (delta < 0) {
|
||||
c->stats.late_delivered++;
|
||||
return deliver_data(c, pl, len, counter, recv_time, 0u, 1);
|
||||
}
|
||||
rc = deliver_data(c, pl, len, counter, recv_time, (uint32_t)delta, 0);
|
||||
c->next_counter = counter + 1u;
|
||||
if (rc == 0) { rc = drain_held(c, 0u); }
|
||||
return rc;
|
||||
}
|
||||
|
||||
/*---------------------------------------------------------------------------*/
|
||||
/* Fragment reassembly */
|
||||
/*---------------------------------------------------------------------------*/
|
||||
@@ -579,6 +825,14 @@ static void slot_reset_all(udps_client_t *c) {
|
||||
for (i = 0; i < UDPS_MAX_SLOTS; i++) {
|
||||
c->slots[i].active = 0;
|
||||
}
|
||||
/* A reconnect starts a new sequence; payloads held from the previous one
|
||||
* must not be delivered into it. */
|
||||
for (i = 0; i < UDPS_REORDER_SLOTS; i++) {
|
||||
c->held[i].used = 0;
|
||||
}
|
||||
c->held_count = 0;
|
||||
c->held_bytes = 0;
|
||||
c->gap_deadline = 0.0;
|
||||
}
|
||||
|
||||
static void gc_slots(udps_client_t *c, double now) {
|
||||
@@ -596,7 +850,7 @@ static void deliver_payload(udps_client_t *c, uint8_t type, const uint8_t *pl,
|
||||
if (type == UDPS_PKT_CONFIG) {
|
||||
(void)decode_config(c, pl, len);
|
||||
} else {
|
||||
(void)decode_data(c, pl, len, counter, recv_time);
|
||||
(void)order_data(c, pl, len, counter, recv_time);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -906,6 +1160,7 @@ static int do_connect(udps_client_t *c) {
|
||||
c->last_data = now_mono();
|
||||
c->last_keepalive = c->last_data;
|
||||
c->have_counter = 0;
|
||||
c->next_counter = 0u;
|
||||
slot_reset_all(c);
|
||||
if (c->ever_connected) {
|
||||
c->stats.reconnects++;
|
||||
@@ -981,6 +1236,7 @@ void udps_client_config_init(udps_client_config_t *cfg) {
|
||||
cfg->keepalive_interval_s = 15.0; /* server evicts silent clients at 30 s */
|
||||
cfg->recv_buffer_bytes = 4u * 1024u * 1024u;
|
||||
cfg->max_packet_bytes = 1u * 1024u * 1024u;
|
||||
cfg->reorder_window_s = UDPS_REORDER_WINDOW_S_DEFAULT;
|
||||
}
|
||||
|
||||
static void copy_str(char *dst, size_t cap, const char *src) {
|
||||
@@ -992,6 +1248,15 @@ static void copy_str(char *dst, size_t cap, const char *src) {
|
||||
dst[cap - 1u] = '\0';
|
||||
}
|
||||
|
||||
void udps_client_set_datagram_cb(udps_client_t *client,
|
||||
udps_datagram_cb cb, void *user) {
|
||||
if (client == NULL) {
|
||||
return;
|
||||
}
|
||||
client->datagram_cb = cb;
|
||||
client->datagram_user = user;
|
||||
}
|
||||
|
||||
udps_client_t *udps_client_create(const udps_client_config_t *cfg) {
|
||||
udps_client_t *c;
|
||||
|
||||
@@ -1048,6 +1313,9 @@ void udps_client_destroy(udps_client_t *c) {
|
||||
for (i = 0; i < UDPS_MAX_SLOTS; i++) {
|
||||
free(c->slots[i].payload);
|
||||
}
|
||||
for (i = 0; i < UDPS_REORDER_SLOTS; i++) {
|
||||
free(c->held[i].buf);
|
||||
}
|
||||
free(c->sigs);
|
||||
free(c->valbuf);
|
||||
free(c->vals);
|
||||
@@ -1151,6 +1419,14 @@ int udps_client_poll(udps_client_t *c, int timeout_ms) {
|
||||
}
|
||||
budget = clamp_deadline(budget, c->cfg.silence_timeout_s, now - c->last_data);
|
||||
|
||||
/* A held packet must flush on time even when nothing further arrives: at 10 pkt/s
|
||||
* the next arrival is 100 ms away, which is five times the whole budget. */
|
||||
if (c->gap_deadline > 0.0) {
|
||||
double left = c->gap_deadline - now_wall();
|
||||
if (left < 0.0) { left = 0.0; }
|
||||
if (left < budget) { budget = left; }
|
||||
}
|
||||
|
||||
FD_ZERO(&rset);
|
||||
maxfd = -1;
|
||||
if (c->udp_fd >= 0 && c->udp_fd < FD_SETSIZE) {
|
||||
@@ -1204,6 +1480,11 @@ int udps_client_poll(udps_client_t *c, int timeout_ms) {
|
||||
}
|
||||
drained++;
|
||||
c->last_data = now_mono();
|
||||
/* The raw datagram, before anything reads out of rxbuf: this is the
|
||||
* only place a malformed or duplicate datagram is still visible. */
|
||||
if (c->datagram_cb != NULL) {
|
||||
c->datagram_cb(c->rxbuf, (size_t)n, now_wall(), c->datagram_user);
|
||||
}
|
||||
if ((size_t)n >= UDPS_HEADER_SIZE) {
|
||||
handle_packet(c, c->rxbuf, (size_t)n);
|
||||
}
|
||||
@@ -1221,6 +1502,10 @@ int udps_client_poll(udps_client_t *c, int timeout_ms) {
|
||||
}
|
||||
gc_slots(c, now_wall());
|
||||
|
||||
if (c->gap_deadline > 0.0 && now_wall() >= c->gap_deadline) {
|
||||
(void)commit_gap(c);
|
||||
}
|
||||
|
||||
if (c->cfg.silence_timeout_s > 0.0 &&
|
||||
(now - c->last_data) >= c->cfg.silence_timeout_s) {
|
||||
do_disconnect(c, "server went silent");
|
||||
|
||||
@@ -150,6 +150,13 @@ typedef struct {
|
||||
* range the next frame claims.
|
||||
*/
|
||||
uint32_t lost;
|
||||
/**
|
||||
* 1 when this frame's counter is behind the sequence already delivered: it lost
|
||||
* the race past the reorder window and the consumer has a hole where it belongs.
|
||||
* @c lost is 0 for such a frame -- it is not missing anything, it *is* what was
|
||||
* missing -- so a consumer sizes its backfill from @c counter, not from @c lost.
|
||||
*/
|
||||
uint8_t late;
|
||||
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_*. */
|
||||
@@ -180,6 +187,30 @@ typedef struct {
|
||||
* time base older than data already handed over.
|
||||
*/
|
||||
uint64_t stale_packets;
|
||||
/**
|
||||
* DATA packets that were held waiting for an earlier counter and subsequently
|
||||
* delivered via @c drain_held(). This includes a packet released by the
|
||||
* reorder window expiring: that packet arrives alongside a non-zero @c lost
|
||||
* and therefore alongside a gap, so @c late_resequenced is not by itself a
|
||||
* count of gaps avoided. Compare @c late_delivered, which counts packets
|
||||
* that lost the race past an already-expired window entirely.
|
||||
*/
|
||||
uint64_t late_resequenced;
|
||||
/**
|
||||
* DATA packets delivered behind the sequence because the reorder window had
|
||||
* already closed on them. The consumer has a hole for these and may fill it;
|
||||
* before the window existed they were counted in @c stale_packets and dropped.
|
||||
*/
|
||||
uint64_t late_delivered;
|
||||
/**
|
||||
* Times a DATA packet needed a slot in the reorder ring and found none, so the
|
||||
* current gap was committed early to make room. Every such packet is still held
|
||||
* or delivered -- none is dropped -- but the window it was given was shorter
|
||||
* than @c reorder_window_s asked for. A non-zero reading says the ring is
|
||||
* undersized for the stream's reordering depth, which is a reading and not a
|
||||
* mystery; it is the only place that shows up.
|
||||
*/
|
||||
uint64_t reorder_overflows;
|
||||
uint64_t reconnects;
|
||||
} udps_stats_t;
|
||||
|
||||
@@ -206,6 +237,20 @@ 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);
|
||||
|
||||
/**
|
||||
* Largest datagram recv() can deliver on the client's socket, and therefore the
|
||||
* most a raw-datagram observer ever sees in one call.
|
||||
*/
|
||||
#define UDPS_MAX_DATAGRAM_BYTES (65535 + 17)
|
||||
|
||||
/** Called for every datagram as recv() returned it, before the magic check,
|
||||
* reassembly or resequencing: the bytes a capture has to store, and the only
|
||||
* place malformed or duplicate datagrams are still visible. Runs on the polling
|
||||
* thread, inside udps_client_poll(), like every other callback. `bytes` is
|
||||
* valid until the callback returns. */
|
||||
typedef void (*udps_datagram_cb)(const uint8_t *bytes, size_t len,
|
||||
double recv_time, void *user);
|
||||
|
||||
/**
|
||||
* @brief Transport configuration.
|
||||
*
|
||||
@@ -226,6 +271,14 @@ typedef struct {
|
||||
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. */
|
||||
/**
|
||||
* How long a packet may be held waiting for an earlier counter, in seconds.
|
||||
*
|
||||
* A *contiguous* arrival is never held, so this is the latency a gap costs and
|
||||
* not a latency the stream pays. 0 disables resequencing entirely and restores
|
||||
* the drop-on-late behaviour. Default 0.020.
|
||||
*/
|
||||
double reorder_window_s;
|
||||
} udps_client_config_t;
|
||||
|
||||
/** Fills @p cfg with the defaults documented in Docs/UDPS-C-Client.md. */
|
||||
@@ -247,6 +300,12 @@ void udps_client_set_callbacks(udps_client_t *client,
|
||||
udps_event_cb on_event,
|
||||
void *user);
|
||||
|
||||
/** Installs the raw-datagram observer. Separate from
|
||||
* udps_client_set_callbacks(), whose signature has call sites and which a
|
||||
* capture hook is no reason to change. May be NULL. */
|
||||
void udps_client_set_datagram_cb(udps_client_t *client,
|
||||
udps_datagram_cb cb, void *user);
|
||||
|
||||
/**
|
||||
* @brief Drives the client: connects if needed, then waits for and processes
|
||||
* packets for at most @p timeout_ms milliseconds.
|
||||
|
||||
@@ -65,6 +65,12 @@ window change moves the trigger with it.
|
||||
- **Ctrl+drag** frames x to a box, **Alt+drag** y.
|
||||
- **Fit to data** (menu) auto-scales both axes and frames the whole buffered range.
|
||||
|
||||
Each plot has its own **right-click menu**, in the same compact row style as the app's menu:
|
||||
**Y axis: auto scale**, **Y axis: fixed range...**, **Fit X axis**, **Fit all** (`f`), and
|
||||
**Remove signal** — which swaps the menu to one entry per trace on that plot plus *All traces on
|
||||
this plot*, with a **Back** row to return. That is how a trace comes off a row without going through
|
||||
the layout editor.
|
||||
|
||||
The view is bounded by the **time window**, not by the buffer: zooming out stops at the span you set,
|
||||
and panning stops where the data starts. When the buffer holds *less* than the window — right after a
|
||||
signal is enabled — the window is left empty on its left, which is what a scope that has just started
|
||||
|
||||
@@ -34,16 +34,120 @@ deliberate: the entry then always launches exactly the binary that was last buil
|
||||
`StartupWMClass` is needed — GTK takes the WM class from the application ID, and the desktop
|
||||
file's basename is that same string, which is what associates a running window with the entry.
|
||||
|
||||
The interface files can be checked without building anything or opening a window:
|
||||
|
||||
```sh
|
||||
cmake --build build --target test-ui # or: make -C build test-ui
|
||||
```
|
||||
|
||||
That round-trips every `.ui` through `blueprint-compiler` — `decompile` turns it into blueprint, and
|
||||
compiling *that* type-checks every property and child type against the GIR — printing one line per file.
|
||||
It exists because the alternative is what happens otherwise: a property a class does not have is a
|
||||
`Gtk-ERROR` when the window is built, and `Gtk-ERROR` **aborts the process**, so a typo in a `.ui` takes
|
||||
the application down rather than failing to open a dialog. `preprocess="xml-stripblanks"` catches
|
||||
malformed XML and nothing else, and the probes catch the rest only for the windows they open.
|
||||
|
||||
The XML stays the source of truth and stays hand-written: this is a check, not a compiler, and nothing
|
||||
here is needed to *build* the application. `blueprint-compiler` and `python3` are found at configure
|
||||
time; without them the target is absent rather than silently passing. `tools/check_ui.py --lint` also
|
||||
prints the linter's advice, which is advice and deliberately not a gate.
|
||||
|
||||
The manual has its own check, the same shape:
|
||||
|
||||
```sh
|
||||
cmake --build build --target test-help
|
||||
```
|
||||
|
||||
That runs `yelp-check`'s four offline subcommands over `help/C/<id>/` (links, media, orphans, ids)
|
||||
and renders the whole set with `yelp-build html` into a temp directory, so the manual is checked the
|
||||
way the `.ui` files are. **`yelp-check validate` is deliberately not used**: it fetches the Mallard
|
||||
RelaxNG schema over the network, which is absent on a build box, so it would fail for that reason and
|
||||
not for a fault in the pages. `yelp-check`/`yelp-build` are found at configure time; without them the
|
||||
target is absent rather than silently passing.
|
||||
|
||||
The manual is a Mallard set under `help/C/<id>/`, installed by an `install()` rule to
|
||||
`<datadir>/help/C/<id>/` — where the freedesktop Help System Specification's `help:` URI resolves
|
||||
`help:<id>` — so it is *read from a datadir by another process* even though the binary itself has no
|
||||
install step. `F1` opens it in Yelp through that URI. The document identifier is the desktop file's
|
||||
basename (`data/org.udpscope.app.desktop.in` → `org.udpscope.app`), so it is the `.desktop` name, the
|
||||
GApplication id and the install directory at once. The figures are regenerated by
|
||||
`tools/make-help-shots.sh`, which drives the app headlessly against the mock and draws the annotation
|
||||
boxes with ImageMagick; Mallard is translatable with `itstool`, but only the `C` set is authored.
|
||||
|
||||
Because the manual is the only thing udpscope installs, it can be installed *alone* — where `help:`
|
||||
finds it, and without the `cmake --install` of a whole project — by either of two opt-in targets.
|
||||
Neither is built by default and `cmake --build build` never touches either:
|
||||
|
||||
```sh
|
||||
cmake --build build --target install-help-user # <data-home>/help/C/<id>/, no root
|
||||
cmake --build build --target install-help # <CMAKE_INSTALL_PREFIX>/share/help/C/<id>/
|
||||
```
|
||||
|
||||
`install-help-user` writes to `$XDG_DATA_HOME` when it is set and to `$HOME/.local/share` when it is
|
||||
not — the XDG Base Directory Specification's own default for the variable, and the fallback
|
||||
`help_installed_path()` uses — so it needs no root and lands the set exactly
|
||||
where `F1` and the alert look for it. `install-help` goes through the install machinery into the
|
||||
configured `CMAKE_INSTALL_PREFIX`, so a packager or a system-wide user points it at `/usr` with
|
||||
`-DCMAKE_INSTALL_PREFIX` at configure time. Neither target can install system-wide by itself: a build
|
||||
target has no more privilege than the shell that ran it, so `install-help` writes to the configured
|
||||
prefix and a system-wide caller wraps it in `sudo`. Both print where they wrote. Both land the same
|
||||
tree the `install()` rule does — the user-local copy applies that rule's file filter, and the
|
||||
prefix-wide target *is* that rule, reached through the `Help` component
|
||||
(`cmake --install --component Help`) — and the ordinary `cmake --install` still installs every
|
||||
component, so its behaviour is unchanged.
|
||||
|
||||
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per
|
||||
buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row
|
||||
count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards).
|
||||
count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards),
|
||||
`--capture-packets N` (the raw-datagram ring of README's "Packet capture"; 1..100000,
|
||||
the value is required, and the popover button uses 1000 when the flag was absent).
|
||||
The flag is **not** persisted in the session file: persistence of the enable state
|
||||
belongs with `todo.md` item 3, alongside the relative-time toggle.
|
||||
|
||||
`tools/mock_udpstreamer.py` has waveform-injection options used by the probes:
|
||||
|
||||
- `--square-harmonics N` shapes `square`: `N` odd harmonics of the ideal step's Fourier
|
||||
series, so each edge overshoots by the Gibbs constant (about 9 % of the 2 V swing at the
|
||||
default 30, i.e. `y = ±1.179`) and then rings — a band-limited edge, which is what a
|
||||
crossing count or a pulse-width test has to survive. The series is deterministic and the
|
||||
odd harmonics keep the ideal wave's edge instants and its polarity, so the level-0 trigger,
|
||||
the search and the glitch's inverted pulse still read where they did on the step; the
|
||||
*extremes* are what move. `0` (or negative) is the ideal ±1 step, which is what a probe
|
||||
asserting an exact extreme or an exact width asks for — the noise probe's group-delay leg is
|
||||
the one that does, because its `want` is the analytic `tau*ln2` of a step
|
||||
(see [verification.md](verification.md)). It costs the producer about 0.5 ms per
|
||||
2000-sample packet on top of the ideal branch, so at the fast mock's 1.3 MS/s the mock
|
||||
delivers at roughly nine tenths of its declared pace — a reading that scales with the
|
||||
retained span reads that much lower, and nothing else does.
|
||||
- `--glitch-at S` / `--glitch-width W` invert the `square` signal for `W` seconds at the
|
||||
start of every square period from stream second `S` onwards — a runt pulse of a known
|
||||
width, which is what a `<` pulse-width trigger and a glitch search are checked against.
|
||||
- `--quiet-at S` freezes `sine` at its held value from stream second `S` onwards, so it
|
||||
stops crossing zero: the dropout trigger's input.
|
||||
- `--spike-every S` / `--spike-amp A` / `--spike-width N` inject an alternating-sign
|
||||
impulse train onto `sine` only — the other four signals (`time`, `square`, `ramp`,
|
||||
`noise`) are untouched, which is what lets a probe compare a spiked channel against a
|
||||
clean one in the same run. Every `S` seconds a spike of `N` samples at amplitude `A` is
|
||||
added to `sine`; the sign alternates per period because a one-signed train is a DC offset
|
||||
a median would partly track rather than reject. `N` is in **samples**, not seconds, so its
|
||||
duration follows `--rate`. `0` (the default for both `S` and `A`) injects nothing, so
|
||||
every existing invocation is unchanged. **`--batch` must stay at or below 1000** with or
|
||||
without these options: 2000 samples × 5 signals × `f64` is 80 kB and exceeds UDP's
|
||||
~65507-byte payload limit, and the mock dies on its first `sendto` with
|
||||
`OSError: [Errno 90] Message too long` while the run still exits 0.
|
||||
|
||||
Run with no `--server` and udpscope asks which source to open: the sessions it has stored, plus a
|
||||
form for a new host. Every probe below passes `--server`, so none of them sees it.
|
||||
|
||||
`deps/plotter` is built from source with only the GTK4 adapter enabled
|
||||
(`PLOT_BUILD_GTK4`); its tests and examples are forced off. `deps/udpsclient` is a
|
||||
vendored C99 source pair compiled into a static lib.
|
||||
vendored C99 source pair compiled into a static lib. `deps/udpsclient` gained one
|
||||
additive entry point for the packet capture — `udps_client_set_datagram_cb()`, firing
|
||||
inside `udps_client_poll()` immediately after the UDP `recv()` returns, before the
|
||||
magic check or reassembly — plus the public `UDPS_MAX_DATAGRAM_BYTES` the capture
|
||||
sizes its slots from. A separate setter, not a fourth parameter on
|
||||
`udps_client_set_callbacks()`: that signature has call sites, and a capture hook is no
|
||||
reason to change them.
|
||||
|
||||
**libadwaita ≥ 1.8 is a system dependency** (`pkg_check_modules(ADWAITA ... libadwaita-1>=1.8)`),
|
||||
for two widgets: the shortcut overview (`AdwShortcutsDialog`) and the About dialog
|
||||
@@ -62,6 +166,39 @@ handler keyed by a pointer-to-pointer (`gpointer slot` cast to `AdwDialog**`), s
|
||||
handler clears either pointer without knowing which dialog it is. Presenting either on this
|
||||
app's plain `GtkApplicationWindow` is supported and warning-free; it was checked.
|
||||
|
||||
`deps/udpsclient` is a vendored C99 source pair and its diff from the upstream source
|
||||
is intentional. Five things were changed:
|
||||
|
||||
1. **A reorder ring of reassembled payloads.** When a packet arrives with a higher counter
|
||||
than expected, the raw datagram is held in a fixed-size ring rather than decoded immediately.
|
||||
A deadline (`now + reorder_window_s`, default 0.020 s) is set for the gap; the deadline is
|
||||
served by a third `clamp_deadline()` call on the `select()` budget
|
||||
(`udps_client.c:1145-1152`) so a quiet stream does not wait for the next arrival.
|
||||
2. **Late delivery replacing the outright discard.** A packet that arrives past the window — or
|
||||
when the ring is drained by the deadline — is delivered to the callback flagged `late` rather
|
||||
than discarded with `stats.stale_packets++; return 0`. That flag is what makes backfill
|
||||
possible at all; without it the samples are gone before udpscope can act.
|
||||
3. **The third `clamp_deadline()`.** The existing pair handles the keepalive and the silence
|
||||
timeout; the reorder deadline is a third call to the same helper, which already understood
|
||||
the pattern.
|
||||
4. **`reorder_window_s = 0` as the switch.** `udps_client_config_t::reorder_window_s`,
|
||||
defaulted to `UDPS_REORDER_WINDOW_S_DEFAULT` (0.020) in `udps_client_config_init()`,
|
||||
is the only knob. Setting it to zero disables resequencing and restores the previous
|
||||
discard-on-late behaviour exactly — so a reported regression is bisectable without reverting
|
||||
the source. **There is no command-line flag for the reorder window.** It is not surfaced on
|
||||
udpscope's command line; do not document a `--reorder-window-ms`.
|
||||
5. **The full ring never drops the arriving packet, and says so in a counter.** A packet that
|
||||
needs a slot and finds none commits the current gap (which frees at least one, since
|
||||
`commit_gap()` always delivers) and the ordering decision is taken once more against the
|
||||
sequence that commit left behind — a bounded retry, because the commit can advance
|
||||
`next_counter` past the arriving counter and turn an ahead-of-sequence packet into a behind-
|
||||
of-sequence one. Past the retry it is delivered *in order*, with the counters it skipped as
|
||||
its `lost`, and never flagged `late`: a packet ahead of the sequence has no reserved hole and
|
||||
`Acquisition::backfill_from()` silently ignores a late packet it cannot place, so flagging it
|
||||
would be the same silent loss by another route. `stats.reorder_overflows` counts the event,
|
||||
so an undersized ring is a reading rather than a mystery; it is surfaced in the exit dump and
|
||||
the `[reseq-probe]` line as `overflows=`.
|
||||
|
||||
`deps/plotter` is upstream's own repository and this app's figure support was added
|
||||
there rather than worked around here: `Figure::set_min_y_gutter()` (a floor under the
|
||||
common left gutter), `Figure::set_right_gutter()` / `Plot::set_right_gutter()` (an empty
|
||||
@@ -85,7 +222,16 @@ because a tick a hair under a rung's top rounds to 1000 at three significant fig
|
||||
`%.3g` writes that `1e+03`, which is how a one-second tick read `1e+3ms`. All four carry
|
||||
upstream tests — `the_atlas_is_filtered_not_point_sampled`,
|
||||
`grid_hairlines_land_on_whole_pixels`, `the_tick_at_the_origin_reads_zero` and
|
||||
`format_time_never_lets_a_field_reach_its_radix`. The minor
|
||||
`format_time_never_lets_a_field_reach_its_radix`. The box-zoom selection was the one gesture with no
|
||||
feedback at all: the controller tracked the rect and nothing drew it. The drawing went upstream with
|
||||
the rest — `Plot::set_selection_rect()` (the plot carries a rect the host hands it and learns nothing
|
||||
about pointers), `Controller::selection_preview()` (the mask decides which axis is *not* zoomed, and
|
||||
the preview is widened along it, because the swept sliver of a horizontal Ctrl drag is the gesture and
|
||||
the band across the plot is what the release will apply) and `Renderer::draw_selection_frame()` — and
|
||||
it carries three tests: `a_ctrl_box_zoom_previews_the_band_it_will_zoom`,
|
||||
`the_selection_frame_is_carried_and_taken_off` and `the_box_zoom_frame_lands_exactly_where_it_says`,
|
||||
the last of which reads the headless framebuffer back and compares the same pixels with and without
|
||||
the frame. The minor
|
||||
grid's look is set there too, not here: `grid_minor` carries 50% alpha, `grid_minor_width` is
|
||||
0.75 (sub-pixel, expressed as coverage at 1x) and `grid_minor_dash_on`/`_off` are 3/3 — a
|
||||
fragment discard, no extra geometry — so the subdivisions read as texture under the labelled
|
||||
|
||||
@@ -12,6 +12,33 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
`plan_partition()` rather than recomputing the factor, because two expressions for that
|
||||
decision is how the buffer and the view end up disagreeing about the stored rate. It
|
||||
takes `mutex()` itself — do not call it holding the lock.
|
||||
- **A rebuild that would plan the *same* storage clears the store instead of allocating one.**
|
||||
`rebuild_locked()` compares the new `Partition` against the current one with
|
||||
`Partition::same_storage_as()` — every field that decides an allocation: `time_capacity`,
|
||||
`time_stride`, `abs_time`, `base_dt` and the per-channel `stride`/`value_capacity` vectors — and
|
||||
on a match calls `BufferStore::reset()` rather than constructing a new one. `base_dt` is in the
|
||||
comparison on purpose: it is not a size, but it is what the slots *mean*, and keeping samples
|
||||
under a changed `base_dt` would re-date the whole ring.
|
||||
**Construction is the cost.** A `RingBuffer<T>` holds a `std::vector<std::atomic<T>>`, which is
|
||||
value-initialised, so making a store touches every byte of `--buffer-mb` — about a second per
|
||||
gigabyte, mostly first-touch page faults — while `reset()` is one relaxed store per ring plus an
|
||||
`invalidate()` and an epoch back to zero. It must leave nothing of the old history behind, which
|
||||
is why it clears the time ring, the absolute-time ring, every value ring, and both the published
|
||||
window and the pyramid of every channel: a reused store that kept a stale published window would
|
||||
hand the renderer samples the new plan does not describe.
|
||||
`reset()` runs on the consumer thread under `ScopeModel::mutex()`, which is what makes a
|
||||
`RingBuffer::clear()` safe here at all — the producer holds that same mutex for the whole of
|
||||
`on_data()`.
|
||||
Every allocation that *is* made says so: `[udpscope] buffer allocated: N channels, S s span,
|
||||
T ms` on stderr. A start used to emit fifteen of those; two is a real start, and a count that
|
||||
climbs again is this rule having stopped applying.
|
||||
- **Enables are applied in one batch, because each one re-plans.** `set_signals_enabled()` takes
|
||||
the mutex once, moves every signal in the list, and re-plans and bumps the generation **once** —
|
||||
and not at all when nothing actually moved, which is the common case for a restored session
|
||||
re-enabling what is already on. Calling `set_signal_enabled()` in a loop is a buffer rebuild per
|
||||
signal; `apply_layout()` and the waveform loader collect their wanted signals into one vector for
|
||||
that reason, and `apply_layout()` records the session's span **first**, with no channels bound, so
|
||||
the `apply_time_window()` that follows re-plans nothing.
|
||||
- **The shared clock is decimated to fit the requested window; channels never are.** A
|
||||
window too long to store at the wire rate used to be refused (the control was capped to
|
||||
`Partition::span_s`), which made a 600 s view of a 1 MS/s stream unreachable.
|
||||
@@ -36,6 +63,59 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
length. The LOD min/max pyramid still preserves the extremes *of what is stored*; a spike
|
||||
narrower than a group is now filtered away rather than aliased, which is the correct
|
||||
trade and is why the status line's `rate` field is worth reading.
|
||||
- **A file is ingested too, so the loader decimates by the same factor.** `load_waveform_into_model()`
|
||||
sets the target span to the recording's own length and enables every column, and the plan that
|
||||
comes back may well decimate: 20 s of 10 channels at 500 kS/s is 480 MB of rings, and the budget is
|
||||
`--buffer-mb`. The loader therefore reads `partition().time_stride` **after** the enables and pushes
|
||||
one slot per that many rows, carrying the group's mean at the group's mean stamp — the packet path's
|
||||
filter, written a second time because a file does not arrive in packets. Pushing every row instead
|
||||
overruns the ring and leaves the file as **its own tail**, with nothing to say so: a 20 s event
|
||||
opened as its last 4 s, `stored=10000000` against `published=2097152` in the channel dump, and a
|
||||
trace that looks like a short recording rather than a truncated one. The `waveform loaded:` line
|
||||
carries `rows=` and `decim=` beside `samples=` for exactly this reason — `samples * decim == rows`
|
||||
is the arithmetic that says the whole file is in the buffer.
|
||||
- **One bad packet is bridged; only a run of them changes the time source.** The stream-time
|
||||
checks in `build_stamps()` reject three things: stamps that are not finite, a packet with no
|
||||
span at all, and a packet whose stamps do not advance past the previous packet's last. A
|
||||
fourth was added because a real device's clock glitches: a packet whose stamps jump **more
|
||||
than 256 packet spans** ahead is a fault, not a gap, and must be rejected — letting it
|
||||
through poisons `last_stamp_`, and the monotonic clamp (`max(stamps[k] - epoch,
|
||||
last_stamp_)`) then pins every later packet flat at the glitched value, so the whole stream
|
||||
freezes at one x and never recovers. A rejected packet is **bridged**: its stamps are
|
||||
replaced by a continuation of the accepted timeline at the declared cadence, so neither the
|
||||
monotonic mark nor `last_stream_anchor_` is poisoned and the next good packet is accepted
|
||||
normally. Believing the glitch instead is what condemned the *next*, perfectly good packet
|
||||
(it now sits below the poisoned anchor, so it fails the advance test) and threw the whole
|
||||
stream onto the arrival clock. Only **four consecutive** rejected packets abandon the
|
||||
declared time signal, because that is what a signal which is not a time base at all looks
|
||||
like; a first packet with nothing to bridge from still falls back immediately. The unit
|
||||
scale ('ns', 'us', 'ms') is therefore resolved **before** these checks rather than after:
|
||||
they weigh the stamps against a cadence in seconds, and on a 'ns' stream an unscaled
|
||||
comparison is off by 1e9, so every packet would read as an implausible jump. The guard is
|
||||
`tools/mock_udpstreamer.py --time-glitch-at`/`--time-glitch-by` (see
|
||||
[verification.md](verification.md)).
|
||||
- **A *restarted* client is the other way into the same poisoning, and it is not a glitch.** Going live
|
||||
from a file view starts a new `udps_client`, and the new stream's stamps can sit **behind** the ones the
|
||||
old stream left in `last_stamp_` — the file view's samples are still in the store. The advance test then
|
||||
rejects every packet of a perfectly good stream, four rejections abandon the declared time signal, and
|
||||
the arrival clock derives its epoch from the stale mark: every sample is stamped **flat at the old
|
||||
value**. `trig engine: … ring=[2.5200..2.5200] n=567000 → n=1131000 → n=1689000` is what that looks
|
||||
like — the count climbing at the stream's rate while the retained span is one value — until some later
|
||||
rebuild resets the store, which is where the "few seconds of nothing" came from.
|
||||
`Acquisition::start()` therefore calls `ScopeModel::reset_stream()` and re-bases `last_stamp_` to
|
||||
`-infinity`: a started or restarted client is a *new stream*, so the previous one's history goes (which
|
||||
is what `reset()` is for) and the monotonic guard re-bases on the new clock instead of pinning it.
|
||||
`ring` moving within a stream round-trip of *Go live* is the assertion.
|
||||
- **The arrival-clock fallback does not drop history.** `arrival_epoch_` is derived from
|
||||
`last_stamp_`, so the arrival clock *continues* from the monotonic mark and the two
|
||||
timelines share a zero by construction — the recorded stamps are therefore still on one
|
||||
timeline and are kept. The fallback used to call `reset_live_history_locked()` on the
|
||||
reasoning that the old stamps belonged to a dead clock; they do not, and that wipe is what
|
||||
made a single glitching packet look like "the live stream restarted from 0 with no action
|
||||
from the user". The continuity is also what makes a rebase of `TriggerEngine` unnecessary:
|
||||
the engine holds four absolute stamps (`holdoff_until_s_`, `pending_start_s_`,
|
||||
`last_trigger_s_`, `last_t_s_`) and they stay valid across the change. Anything that makes
|
||||
the clock *discontinuous* breaks that and has to reset them.
|
||||
- **The trigger sees the stored (filtered) signal, not the wire signal.** `engine.process()`
|
||||
is fed the averaged value, inside the `if` that pushes it. An edge found on a sample that
|
||||
is not in the buffer would be cut from data that does not contain it.
|
||||
@@ -66,6 +146,69 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
the export guard in [probe-readings.md](probe-readings.md) for why it is no longer exactly one
|
||||
value). Three steps at 2, 2.5 and
|
||||
3 ULP of *the stream's uptime* — microseconds, not femtoseconds — is this defect.
|
||||
- **Append is not the only write.** A backfill amends slots that a reservation wrote as NaN, and
|
||||
the amend is split across threads for a reason:
|
||||
- **Ring amend — producer thread.** `RingBuffer<T>::amend(absolute, v)` is a single relaxed
|
||||
store, bounds-checked against `[produced_ - capacity, produced_)`. The same race class as a
|
||||
push, and an `f32` cannot tear. Measurements and export read the ring directly, so they see
|
||||
a repair immediately and no second notification is needed.
|
||||
- **Pyramid amend — consumer (GTK main thread), inside `ChannelBuffer::acquire()`.** The
|
||||
producer collects amended values in a plain `std::vector<Amend>` member and the consumer
|
||||
drains it after its own append pass. `acquire()` is called under `model.mutex()`, and the
|
||||
producer holds that same mutex for all of `on_data`, so the vector is correctly serialised
|
||||
with no lock-free queue. The reason the pyramid amend cannot also be on the producer: a
|
||||
bucket is a 32-byte non-atomic aggregate that the renderer reads **unlocked at paint time**,
|
||||
so a producer writing it could be caught mid-bucket — a `count` that does not match its
|
||||
`sum`, producing a wrong mean from a structure whose readers trust `count == 0` to mean
|
||||
*absent*. On the consumer that cannot happen.
|
||||
- **A hole is stamped at reservation; backfill changes values only.** The f64 time ring is
|
||||
never amended. `neighbours()' binary search requires a monotonic array, and `window_x0_`
|
||||
must remain valid; pre-stamping the hole at the declared cadence (exact in relative mode,
|
||||
the bridge mechanism in stream mode) is what keeps both invariants, and it is why a late
|
||||
packet's counter, not its arrival stamp, is what addresses the hole. The reservation cap
|
||||
(`kGlitchPackets`, 256) is counted in **packets, not samples**, so one hole's sample cost
|
||||
is `256 × batch_` and scales with the stream's batch — 25 600 samples at a batch of 100,
|
||||
2.56 M at a batch of 10 000 — which is the number to weigh against the ring, not the 256.
|
||||
- **A reservation is weighed against the wire, and never booked on the counter's word alone.**
|
||||
The counter is a *claim* about the stream; the arrival clock is a *measurement* of it, and only
|
||||
the two together justify writing NaN into the ring (`Acquisition::gap_plausible()`, called
|
||||
before anything is booked *and* before relative mode's `slot_base_` advance, because the advance
|
||||
and the reservation are the same claim). Two tests, either one refusing is enough:
|
||||
- **The arrival clock.** `missing` packets would have taken `missing` frame intervals to arrive,
|
||||
so a gap whose elapsed arrival time is under half of that is refused. Half and not one because
|
||||
the socket clock jitters and a drain can compress two frames' arrival times -- and a refused
|
||||
gap costs a *shading*, never data, so the test is deliberately permissive.
|
||||
- **The ring.** `missing × batch_` samples become `missing × batch_ / time_stride` slots, and the
|
||||
hole may not lap `time_capacity`. This is the *sample* form of the rule the 256-packet cap
|
||||
states in packets -- the cap's own comment says 256 packet spans in one go is an outage, and
|
||||
the invariant above says the number to weigh against the ring is `256 × batch_` and not the
|
||||
256 -- and it has to exist in samples because a hole that laps the ring erases the history it
|
||||
is meant to annotate, can never be backfilled (its slots are already overwritten) and is never
|
||||
forgotten (`forget_holes_before()` waits for slots the ring has passed), so it leaves a
|
||||
*permanent* band over a buffer that is all NaN.
|
||||
What its absence looks like, which is why it is written down: a device whose counter counts
|
||||
*samples*, or frames of an inner layer, reports a forward jump on every datagram while the wire
|
||||
delivers on time. Every frame then reserves, and with `relative_time` every frame books NaN
|
||||
slots by construction -- one band over the whole window with the samples washed out,
|
||||
`backfilled=0` (no packet was ever missing, so nothing can repair it), and the status bar reading
|
||||
`live` at the right rate the whole time. The refusal is counted in `StreamStats::gaps_refused`
|
||||
and said once per connection with the numbers, because the status bar's `gaps` climbs either way
|
||||
and the state is otherwise invisible; `tools/mock_udpstreamer.py --counter-step N` is the mock
|
||||
shape (its counter is otherwise always +1, so this is the one wire shape it could not produce),
|
||||
and `gaps_refused` rising while `nan_slots` stays 0 is the reading. A refused gap is not a silent
|
||||
one: the timeline is not stretched over loss that did not happen, so it shows as the x
|
||||
discontinuity it is.
|
||||
- **A re-plan drops every hole and restarts the producer's tick.** `rebuild_locked()` replaces
|
||||
the store, so `produced()` restarts at 0 and a hole's `slot_begin`/`tick_begin`/`x_begin`
|
||||
describe a buffer that is gone; it therefore clears `holes_` and bumps
|
||||
`ScopeModel::plan_generation()`, which the producer compares against the value it last saw
|
||||
and resets `tick_` on. That is what keeps `tick_ / period == produced()`, the relation
|
||||
`backfill_from()` derives a slot index from — without it the amend falls outside the ring
|
||||
and repairs nothing while the hole is still marked repaired, a band saying the opposite of
|
||||
the truth. The generation is the signal and the stride/channel-count comparison is not: a
|
||||
reconnect with an identical CONFIG rebuilds the store leaving both unchanged. `slot_base_`
|
||||
is **not** reset — it is relative mode's clock origin, samples since the stream began, and
|
||||
relative time must not jump backwards when the user retypes the window.
|
||||
- **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
|
||||
@@ -110,3 +253,93 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
— the oldest frame or so of samples — but there its observable is a *prefix plateau*, which is
|
||||
exactly what the renderer's increasing-suffix filter was written to absorb, so it is a different
|
||||
symptom and deliberately not fixed here.
|
||||
|
||||
## The packet capture sits at `recv()`, and its file is reserved, not sparse
|
||||
|
||||
Every wire behaviour a forensic capture exists to answer — a counter that skips, a datagram that
|
||||
arrives twice, a fragment that never completes, a CONFIG whose header does not parse — is
|
||||
invisible by the time a `udps_frame_t` reaches `Acquisition::on_data`: the client has already
|
||||
validated, reassembled, resequenced and dropped. The capture therefore hooks
|
||||
`udps_client_set_datagram_cb()`, which the client fires immediately after the UDP `recv()`
|
||||
returns and before anything reads out of `rxbuf`, and it stores the bytes without interpreting
|
||||
them. The capture is of the *UDP socket*: in multicast mode CONFIG arrives over TCP and is out
|
||||
of reach.
|
||||
|
||||
The ring file is reserved with `posix_fallocate` rather than left sparse because the stores are
|
||||
`memcpy`s into an `mmap`: a store into a page the filesystem cannot back raises **SIGBUS**, which
|
||||
kills udpscope outright. An out-of-space condition has to be an error at the moment the user
|
||||
toggles — reported in the popover row, the button back off — not a crash ten minutes into the
|
||||
run the capture was switched on for. Within the reserved file, a slot's trailing `seq` word is
|
||||
published with a release store *after* its bytes are down, so a slot carrying a non-zero `seq`
|
||||
is complete and no `fsync` is needed for the file to be readable after a crash: the kernel's
|
||||
dirty pages are the file, and a half-copied slot still reads `seq == 0`.
|
||||
|
||||
## A reconnect with an unchanged CONFIG keeps the buffer
|
||||
|
||||
A disconnect -- EMI makes it a silence timeout, not a polite DISCONNECT -- must not cost the
|
||||
acquisition. What historically did was the reconnect's CONFIG: `ScopeModel::set_signals()` rebuilt
|
||||
the store on *every* CONFIG, and a rebuild is what drops history, resets `tick_`, retires the
|
||||
holes and invalidates a held event. Now the incoming CONFIG is compared against the stream the
|
||||
model already has -- names, shapes, rates, ranges, time modes, time-signal indices -- and an
|
||||
**identical CONFIG returns early**: the ring keeps its history, the trigger keeps its state and
|
||||
its held event, and the outage shows as the gap in the data that it was. Only a CONFIG that
|
||||
*changes* the stream re-plans.
|
||||
|
||||
The producer's own state is per *connection*, and carrying it across a drop makes recovery read
|
||||
as failure: `Acquisition::reset_session_state()` (on DISCONNECTED, and again on the next CONFIG)
|
||||
clears the counter mark, the stream-time anchor and the smoothed arrival interval, so the first
|
||||
frame of the new session re-anchors instead of reading as late, as a glitch or as an outage. The
|
||||
arrival clock's *anchor* deliberately stays -- stamps are small seconds the ring's timeline is
|
||||
already scaled against, and re-anchoring mid-stream would move x. The clock *choice* is re-derived
|
||||
per connection (`time_source_ = 0` on CONFIG) for the same reason: a session that dropped may
|
||||
come back with a different answer.
|
||||
|
||||
Relative time's `slot_base_` also survives: it is samples-since-the-stream-began, the user's
|
||||
chosen origin, and must not jump backwards because the link blipped.
|
||||
|
||||
## There may be no store at all
|
||||
|
||||
`rebuild_locked()` calls `store_.reset()` for a stream with **no channels** — the model's state until the
|
||||
first CONFIG arrives — so `store_` is legitimately null, and `ScopeModel::live()` dereferences it. Every
|
||||
path reachable without a stream has to ask first: **pausing a scope that is not connected** is one (the
|
||||
button works, and freezing a view means taking a window from the rings), and it crashed inside
|
||||
`live_window_locked()` on a null store. The guards belong at the entry points the UI can reach
|
||||
(`live_window_locked()`, `publish_pause_view()`), not in `live()`, which cannot express "nothing there"
|
||||
through a reference. The paths that only run for a *bound* trace — the series' gather, the SlotSeries'
|
||||
`shared_channel()` — are safe by construction: a slot with no channel binds no series at all.
|
||||
`UDPSCOPE_PROBE_PAUSE_MS` against a port nothing is listening on is the check.
|
||||
|
||||
## A waveform is a source that never arrives
|
||||
|
||||
`--open FILE` (and the chooser's Waveforms page) puts an exported `.npy` into the model instead of a
|
||||
stream: one signal and channel per column, the samples pushed through `push_loaded_slot()` — the
|
||||
producer's own order (values, the shared clock, the absolute clock, then the derived channels), under
|
||||
the model's lock — and the display epoch pinned to the file's last stamp so the axis reads
|
||||
`[-window, 0]` exactly as the export did. Everything downstream is unchanged, which is the point:
|
||||
cursors, measurements, math channels and another export work on a file because they work on the
|
||||
rings, not on the wire.
|
||||
|
||||
Three things follow, and each is a place the live assumptions would otherwise leak:
|
||||
|
||||
- **Nothing is ever starved.** There is no socket and no source, so the starvation notice is skipped
|
||||
while a waveform is open, and the popover's Stream row names the file rather than reporting a
|
||||
connection that has not answered.
|
||||
- **The loader must not hold the model's mutex.** Every setter it needs (`set_signals`,
|
||||
`set_signal_enabled`, `set_target_span`) takes that same non-recursive lock; the first version
|
||||
wrapped the whole load in it and deadlocked on the first call. The per-slot push is a model method
|
||||
for the same reason.
|
||||
- **The signal names come from the file.** A `.npy` written by `write_npy()` is a structured array
|
||||
whose dtype names its fields, so an export is self-describing and there is no sidecar to lose.
|
||||
`.csv` is not read: it carries the names too, but as text, and the item asked for the binary one.
|
||||
- **A waveform has no source, and the app must not invent one.** The address the command line
|
||||
*defaults* to (`127.0.0.1:8000`) is not one: leaving it in place made a file-mode run load — and
|
||||
then *save* — that source's session, which overwrote the arrangement the file was opened with
|
||||
(every drop then refused, the row full) and could clobber a real session. `App::source_port` is 0
|
||||
in file mode, which is what makes `save_layout_now()` refuse and the first-CONFIG layout load skip
|
||||
— the same two things that make a source-less run safe.
|
||||
- **One row per column.** Four columns to one row fills the default single row and then refuses
|
||||
every drop ("plot 1 already holds four signals"), which is a loaded file the user cannot plot
|
||||
anything *else* on or rearrange. One per row (sized in `main.cpp`, before the UI exists, as
|
||||
`--plots` does) leaves three free slots each, shows a four-signal file as four stacked traces, and
|
||||
keeps the rest of the columns draggable. Sizing the rows from inside the activation instead leaves
|
||||
the panels built for the old count and the app hangs on the first relayout.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Invariants: GTK traps, the status bar, the icons and the help
|
||||
# Invariants: GTK traps, the status bar, the icons and the manual
|
||||
|
||||
The GTK signal-signature and dialog-lifetime traps this app has already fallen into, plus the status bar's two tiers, the bundled icons and the help pages.
|
||||
The GTK signal-signature and dialog-lifetime traps this app has already fallen into, plus the status bar's two tiers, the bundled icons and how the Mallard manual is installed and opened.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
@@ -18,6 +18,14 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
points (`on_trigger_field_changed`, `on_trigger_drop_changed`) onto one
|
||||
`publish_trigger_fields()`: changing the edge or Normal/Single dropdown used to segfault
|
||||
outright. `UDPSCOPE_PROBE_FIELDS_MS` is the guard.
|
||||
- **`MeasureDialog` and `MathDialog` are brace-initialised *positionally* in `src/ui.cpp`**, so a field
|
||||
added in the middle of either silently shifts every argument after it: add at the end of the widget group
|
||||
and read the initialiser in the same commit. A row array widened by a new control is the same trap from
|
||||
the other side -- a `rows[N]` that was not grown shows a control the sync never touches, and the compiler
|
||||
says so as an out-of-bounds warning on `rows`.
|
||||
- **A control that cannot affect the current type is *hidden*, not disabled**: the trigger's band row is
|
||||
hidden for `dropout`, the measurement's for every non-crossing type, and the node editor's median row for
|
||||
every op but `de-noise`. A greyed row is a question the user can see and cannot answer.
|
||||
- **`GtkDropDown`'s `notify::selected` arrives after the `set_selected()` that caused it
|
||||
returns**, so the `updating_panels` flag — set around a programmatic write and cleared when
|
||||
it returns — does *not* suppress it. Where the write is part of a readout the handler reads
|
||||
@@ -41,58 +49,40 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
one of them with reality): it is computed on the first *tick*, because the dump
|
||||
runs after the window is destroyed and asking GTK about a widget then is four criticals, which is
|
||||
how the first version of that line earned them. What stays in the menu is what acts on the *data*
|
||||
(Fit, Export) and the two editors that are not panes (Layout, Signal list). The Arm/Disarm button in the trigger panel still has text.
|
||||
- **Six of this app's icons are its own, and they are a `GResource` compiled into the
|
||||
(Fit, Export, Configurations) and the one editor that is not a pane (the layout editor): the signal list
|
||||
is a *tab* of the sidebar, so there is nothing left to reveal or hide about it. The Arm/Disarm button in the trigger panel still has text.
|
||||
- **Seven of this app's icons are its own, and they are a `GResource` compiled into the
|
||||
binary.** No icon theme has a glyph for an oscilloscope trigger, a pair of cursors or a
|
||||
measurement ruler, and the stock names those buttons used to carry said something else
|
||||
entirely: a *record dot* for the trigger and three different **magnifiers** for cursors,
|
||||
measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,math}-symbolic.svg`
|
||||
are the four action icons; beside them in `src/icons/scalable/apps/` are the two
|
||||
**application** icons, `org.udpscope.app.svg` (128px colour artwork) and
|
||||
`org.udpscope.app-symbolic.svg` (16px). The `apps` context is where an icon theme keeps an
|
||||
application's own icon and is where the **in-process** window icon resolves from; it is also
|
||||
the layout that `cmake/install_desktop_entry.cmake` mirrors when it copies the same two files
|
||||
into `$XDG_DATA_HOME/icons/hicolor/scalable/apps` and `.../symbolic/apps` — those installed
|
||||
copies are what makes `Icon=org.udpscope.app` in the desktop entry resolve (a desktop shell
|
||||
cannot read a GResource compiled into the binary).
|
||||
All six are bundled by `src/icons/udpscope.gresource.xml` (CMake runs
|
||||
`glib-compile-resources`) and reached through **one**
|
||||
`gtk_icon_theme_add_resource_path()` in `run_ui()`, right after `adw_init()` — not at the top
|
||||
of `build_toolbar()`, where it used to be: there are two consumers now (the window's own icon,
|
||||
set before the toolbar is built, and the toolbar's buttons) and `run_ui()` is the one place
|
||||
ahead of both. `adw_init()` calls `gtk_init()`, which is what makes
|
||||
`gdk_display_get_default()` valid there.
|
||||
A resource rather than files beside the binary because udpscope is run straight out of the
|
||||
build tree and has no install step. Five authoring rules, each of which fails quietly:
|
||||
* **GTK's symbolic recolour only reaches `fill` on `path`, `rect` and `circle`.** It wraps the
|
||||
SVG in a stylesheet for those selectors, so a `stroke`, a `line` or a `polyline` keeps the
|
||||
colour written in the file and stays dark on a dark theme. **In a symbolic icon everything is
|
||||
therefore a filled `path`** — including anything that wants to look stroked, which is drawn as
|
||||
a closed band (two offset curves joined) instead. The rule is scoped to symbolic icons on
|
||||
purpose: `org.udpscope.app.svg` is 128px colour artwork that is never recoloured, so it strokes
|
||||
its sine freely, which is the sane way to draw a smooth one. Do not "fix" one to match the
|
||||
other.
|
||||
* **Whole-pixel coordinates**, because these are drawn at 16 px far more often than not: the
|
||||
first draft placed features on half-pixels and the whole icon was a grey smear at 1x.
|
||||
* `fill-rule="evenodd"` is how the ruler's body is a hole *and* its ticks still fill -- a tick
|
||||
inside the hole is three crossings deep, which is odd. Nonzero would fill the hole back in.
|
||||
* Overlapping subpaths under the default nonzero rule must be **wound the same way** or they
|
||||
cancel instead of uniting. All of them run clockwise.
|
||||
* **A malformed SVG is a build error, and that is the only place it can be caught.** Every
|
||||
`<file>` in `src/icons/udpscope.gresource.xml` carries `preprocess="xml-stripblanks"`, which
|
||||
makes `glib-compile-resources` pipe it through `xmllint` — so `--` inside an XML comment, which
|
||||
is illegal and which librsvg refuses the whole file for, fails the build with a file, a line
|
||||
and a caret under the offending character (`…app-symbolic.svg:5: parser error : Double hyphen
|
||||
within comment`). It has to be here because **no runtime check can see it**: librsvg refuses
|
||||
the file but the icon *name* still resolves, so `gtk_icon_theme_has_icon()` returns true and
|
||||
the icon draws nothing. `gtk_icon_theme_lookup_icon()` cannot tell either (see the `app icons:` line under
|
||||
Verification), `GMarkup` accepts the double hyphen because it is a lenient subset parser, and
|
||||
`gdk_pixbuf` goes through glycin here, which sandboxes a subprocess per load and refuses this
|
||||
project's own icons outright. The cost is that the `xmllint` binary is a build dependency; it
|
||||
ships with libxml2, which GTK already requires.
|
||||
`gtk_icon_theme_has_icon()` is checked for every toolbar button and reported by the dump (see
|
||||
`toolbar icons:` in [probe-readings.md](probe-readings.md)): a resource path that was never added does not raise an
|
||||
error, it draws `image-missing`.
|
||||
measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,events,resize-rows,tab-measure,tab-signals}-symbolic.svg`
|
||||
are the seven action icons. `udpscope-math-symbolic` left with the toolbar toggle that was its last
|
||||
user; `udpscope-measure-symbolic` **stayed**, because the Preferences window's Plot page carries it --
|
||||
an icon with a live user is not an orphan, whatever the toggle that introduced it did -- and it is
|
||||
the Preferences window's icon, not the tab's.
|
||||
|
||||
The two **tabs** got glyphs of their own, and each is a *pair* rather than one of the icons above.
|
||||
`udpscope-tab-measure-symbolic` is two cursors beside the ruler they read against — each a cap over two
|
||||
bold dashes, beside a ruler of two bands with three marks between them and **no end caps** (a closed
|
||||
frame reads as a rectangle, and costs a fifth of the ink on two lines that say nothing) — and
|
||||
`udpscope-tab-signals-symbolic` is a sine crossing its dashed level, beside two bold channel rows. They
|
||||
are not reuses, because a glyph the toolbar already carries means two things the moment a tab shows it
|
||||
too; instead the *trigger's* own icon (`udpscope-trigger-symbolic`) was redrawn to the Signals pill's
|
||||
picture, so the pill and the button that turns the trigger on are recognisably about the same thing.
|
||||
The wave is a **full period and not a hump**: a crest and a trough are what make it read as a wave
|
||||
rather than as a hill, and the level's dashes survive between the crossings. (A hump was tried and
|
||||
reverted — it named the "above the level" half of a trigger and lost the crossings, which is the half
|
||||
that matters.)
|
||||
Both are **32x16** composites drawn **bold** — one thick band, two thick dashes, few features — because
|
||||
at this size a thin wave or a 1px tick reads as *texture* rather than as a shape, which is what "not
|
||||
clear" looks like. Wider than tall on purpose: a 16px square cell carries one motif, not a pair. And
|
||||
**GDK paints an icon into a square box whatever the document's aspect** (measured:
|
||||
`gtk_icon_theme_lookup_icon()` at 32 reports `32x32`, and librsvg fits it to `32x16`), so the pair is
|
||||
letterboxed rather than stretched — and the pill's image is given `pixel_size` 32 in `name_side_tabs()`,
|
||||
which draws the artwork at the size it is written, so its bars stay crisp. Every glyph here is
|
||||
**inset two pixels**, because art flush with the cell edge is what a button's padding or a crop takes a
|
||||
pixel off, and a fractional scale is what would blur it, which is why nothing is drawn at 24 or 48. The
|
||||
two tab glyphs measure 29% and 30% ink, inside the range the icons above them set.
|
||||
- **A shortcut table row's `accel` is GTK accelerator syntax, and a pointer gesture has
|
||||
none.** `src/shortcuts.cpp`'s strings are handed to `AdwShortcutsItem`, which parses them
|
||||
with `gtk_accelerator_parse()` — so they are `"space"`, `"<Control>e"`, `"<Control>slash"`,
|
||||
@@ -121,19 +111,26 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
tick updates it, so a rebuild is a per-frame relayout of a widget on screen.
|
||||
`update_status_popover()` only ever calls `gtk_label_set_text()`, and `popover_rebuilds` in
|
||||
the dump is the assertion.
|
||||
- **The About dialog, the help window and the shortcut overview are opened by `show_about()`,
|
||||
`show_help()` and `show_shortcuts()`, and each closes with the matching call.** There is
|
||||
exactly **one `GtkWindow`** among the three — the paged help window. It closes with
|
||||
- **The About dialog and the shortcut overview are opened by `show_about()` and `show_shortcuts()`,
|
||||
and each closes with the matching call; the manual is *not* one of these.** `show_help()` hands
|
||||
the `help:<document>` URI to the desktop help viewer (`GtkUriLauncher`, i.e. Yelp) — a process of
|
||||
its own that cannot be closed from here — because the manual is a Mallard set installed for the
|
||||
freedesktop `help:` URI, not a GResource in this binary (see the help bullet below). So there is
|
||||
exactly **one `GtkWindow`** among what the app opens: the shortcut overview. It closes with
|
||||
`gtk_window_close()`, which emits `close-request` so the handler that nulls the file-scope
|
||||
pointer runs. `gtk_window_destroy()` does **not** emit that signal and must not be used — the
|
||||
math dialog fell into exactly this trap, and the pointer then points at a dead widget until the
|
||||
next attempt to open the same window. The other two — the About dialog (an `AdwAboutDialog`)
|
||||
and the shortcut overview (an `AdwShortcutsDialog`) — are **`AdwDialog`s and not `GtkWindow`s**:
|
||||
they close with `adw_dialog_close()` and clear on `::closed`, there being no `::close-request`
|
||||
on an `AdwDialog`. Both share one `on_dialog_closed()` handler keyed by a **pointer to the
|
||||
pointer** it must null (`&g_about`, `&g_shortcuts`), which is what keeps one handler honest for
|
||||
two dialogs. One `GtkWidget*` (or `AdwDialog*`) per window, nulled in its own close handler, is
|
||||
the shape that prevents the double-open defect across all three.
|
||||
next attempt to open the same window. The About dialog (an `AdwAboutDialog`) is an **`AdwDialog`
|
||||
and not a `GtkWindow`**: it closes with `adw_dialog_close()` and clears on `::closed`, there being
|
||||
no `::close-request` on an `AdwDialog`. It shares one `on_dialog_closed()` handler with the About
|
||||
pointer, keyed by a **pointer to the pointer** it must null (`&g_about`), which keeps the handler
|
||||
honest without knowing which dialog it is. One `GtkWidget*` (or `AdwDialog*`) per window, nulled
|
||||
in its own close handler, is the shape that prevents the double-open defect. `close_help_windows()`
|
||||
(misnamed now) closes just the two dialogs; a failed *launch* is reported by the launcher's own
|
||||
callback with a `GtkAlertDialog`, so a click that cannot open the manual says so. That callback is
|
||||
also `show_help()`'s **optional reporter** (`std::function<void(bool ok, const std::string& error)>`)
|
||||
— the one hook that runs on the success path, where no window of ours is left to observe; the probe
|
||||
uses it and nothing else does.
|
||||
- **`build_info.cpp` is generated, never committed.** `cmake/write_build_info.cmake` runs at
|
||||
configure time (so `add_executable` can name the file) and again on every build through
|
||||
`udpscope_build_info` (so the commit and the timestamp belong to the binary that is running).
|
||||
@@ -147,15 +144,534 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
arrives with `Mod::Shift` set. Ctrl+Alt together zooms neither axis, which needs no special
|
||||
case. The guards are `deps/plotter`'s own `ctrl_plus_zooms_only_x` and `alt_plus_zooms_only_y`
|
||||
tests, plus `UDPSCOPE_PROBE_ZOOMKEYS_MS`.
|
||||
- **A help page is one `.txt`, one `<file>` line in the GResource manifest, and one `kPages`
|
||||
row.** The bodies are Pango markup in a second GResource (`--c-name=udpscope_help`, so its
|
||||
generated symbols cannot collide with the icons'), validated with `pango_parse_markup()` at
|
||||
load; a page that fails either lookup or parse renders a one-line stand-in and is excluded from
|
||||
`parsed`. `UDPSCOPE_PROBE_HELP_MS`'s `parsed` must equal `pages`.
|
||||
A page may also carry screenshots (`HelpPage::images`, a null-terminated list of resources).
|
||||
They are `GtkPicture`s **with a height request**: one created `can_shrink` asks for *no* height
|
||||
at all inside a box, so without the request it is allocated nothing and the page renders its text
|
||||
with a silent gap where its screenshots should be — which is what the first version did.
|
||||
- **A help page is a Mallard file, and the manual is *not* in the binary.** `help/C/<id>/` holds
|
||||
`index.page`, one `<topic>.page` per subject, and the `figures/*.png` the pages link, and the
|
||||
whole set is installed (the `install()` rule in CMakeLists) to `<datadir>/help/C/<id>/` — the
|
||||
path the freedesktop Help System Specification's `help:` URI resolves `help:<id>` to. The
|
||||
document identifier is *derived from the desktop file's basename* (`org.udpscope.app.desktop.in`
|
||||
→ `org.udpscope.app`), so it is the `.desktop` name, the GApplication id and the install
|
||||
directory at once; `UDPSCOPE_HELP_NAME` and `UDPSCOPE_HELP_DIR` are compiled in from that one
|
||||
place. `show_help()` opens `help:<id>` with `GtkUriLauncher` (Yelp runs in its own process, so
|
||||
nothing here holds a pointer to it), and its failure callback shows a `GtkAlertDialog`. That
|
||||
failure is **careful about which thing is missing**: `gtk_uri_launcher_launch_finish()` reports a
|
||||
launch that could not be handed over — the *handler's* absence — and cannot tell that from a
|
||||
document that was never installed, so `help_installed_path()` walks the specification's own
|
||||
`$XDG_DATA_HOME/help/<lang>/<id>/` then `$XDG_DATA_DIRS/help/<lang>/<id>/` for an `index.page` and
|
||||
the dialog says which one it is: with the manual there, install a viewer (for example `yelp`); with
|
||||
it absent, the identifier and the install command, the **system-wide** form first
|
||||
(`cmake -DCMAKE_INSTALL_PREFIX=/usr .. && sudo cmake --build build --target install-help`), the
|
||||
no-root `install-help-user` second with its caveat (see the location rule below) — and a
|
||||
viewer as well, the second possibility the error alone cannot rule out. Reporting a missing
|
||||
*document* as a missing *handler* is the defect that shape avoids.
|
||||
|
||||
`UDPSCOPE_PROBE_HELP_MS` launches it too, and its `[help] launch attempted= done= ok= error=''
|
||||
installed=` line is the reading — `ok=1` is the URI handed to a viewer and not a window on screen,
|
||||
since Yelp is another process and cannot map one headlessly; `installed=` sits beside it because a
|
||||
failing `ok=0` has to be read against whether the document was there at all.
|
||||
|
||||
`UDPSCOPE_PROBE_HELP_MS` no longer parses pages — they are files, not markup in a resource — so
|
||||
its `[help]` line asserts the set is *there*: `topics` (the `.page` files other than
|
||||
`index.page`), `index`, and `figures`, scanned from `UDPSCOPE_HELP_DIR`. `topics=0 index=0` means
|
||||
the manual is missing from the tree, which is the finding the old `parsed != pages` used to name.
|
||||
`cmake --build build --target test-help` checks the set the way `test-ui` checks the `.ui` files:
|
||||
`yelp-check`'s offline subcommands (links, media, orphans, ids) and a `yelp-build html` render.
|
||||
`yelp-check validate` is deliberately absent — it fetches the Mallard RelaxNG schema over the
|
||||
network and fails offline for that reason, not for a fault in the pages. The figures come from
|
||||
`tools/make-help-shots.sh`, which drives the app headlessly against the mock and annotates a grab
|
||||
(GTK4 must be forced to the Cairo renderer, `GSK_RENDERER=cairo`, or the X11 surface reads back
|
||||
blank). Mallard is translatable with `itstool`, but there is no i18n set-up here: only the `C`
|
||||
set is authored.
|
||||
|
||||
**A user-local help set is one Yelp cannot draw the figures of, so the system-wide install is
|
||||
the one to recommend — and this is a *location* rule, not a markup or a file rule.** Yelp
|
||||
sandboxes its WebKit subprocess, and that sandbox exposes the system help directories
|
||||
(`/usr/share/help`, `/usr/local/share/help`) but not `$XDG_DATA_HOME/help`: the pages load,
|
||||
their `<media>` figures do not, because each figure is a *separate file* the page references
|
||||
rather than something embedded in it. The symptoms read as anything but a location problem —
|
||||
opening a page *as a file* renders it with no images, and opening it through the app's `help:`
|
||||
URI leaves the viewer spinning **for ever**, the failed image fetch meaning the page's load
|
||||
event never fires while the sidebar title still updates, which looks like a navigation failure
|
||||
rather than a missing resource. A shipped GNOME page that carries a figure
|
||||
(`/usr/share/help/C/accerciser/api_browser_plugin.page`) draws either way, which is what
|
||||
isolates the cause to where the set *lives*. `make install-help` (into `/usr/local`) followed
|
||||
by `yelp /usr/local/share/help/C/<id>/<topic>.page` works; the same set under
|
||||
`~/.local/share/help` does not. Two genuine defects were chased and fixed before the location
|
||||
was found — a `<figure>` with no `<desc>` and a `bKGD` chunk the PNG spec forbids on an
|
||||
alpha image — so do not read a figure that will not draw as a repeat of either, and do not
|
||||
revert those fixes: this is why both the failure alert in `src/help_window.cpp` and the
|
||||
manual's own `overview.page` name the system-wide install first and state the user-local one's
|
||||
caveat beside it.
|
||||
- **The chooser carries the same help affordances as the window, and the tour request crosses as a
|
||||
value — not a global.** The source chooser runs *before* `App` exists, so a user who has not started
|
||||
yet is the one least able to find the help; its header bar therefore has a help `GtkMenuButton`
|
||||
(the `.ui` holds the button, code holds the rows) whose three entries are the window's Help view's
|
||||
the same way — `ui_impl::menu_button()`/`menu_toggle()`, the app's own row shape — with the
|
||||
`.udpscope-menu` class on the popover. That class's rules only exist once `install_css()` has run, so
|
||||
`choose_source()` calls it before building the rows (`install_css()` is idempotent precisely because
|
||||
it now has two callers; the app's `on_activate()` still calls it and is a no-op by then). The rows
|
||||
show **no accelerators**: the window's Help view prints `F1`/`Ctrl+T` because the window binds them,
|
||||
and this dialog binds none, so a key printed here would be documentation that does nothing.
|
||||
*Documentation* calls `show_help()` straight from the chooser (the manual is another process and
|
||||
needs nothing of the app); *Enable contextual help* writes the settings file, which is what the app
|
||||
reads when it starts a moment later, and **re-syncs this dialog's own "(i)" rows** through
|
||||
`chooser_help_sync()`; *Guided tour* starts a tour **of this window** -- the chooser's own walk --
|
||||
and that walk's last stop sets **`Config::tour_pending`**, the one value `main()` owns and hands to
|
||||
both `choose_source()` and `run_ui()` (`open_waveform` and the preferences' relative time cross the
|
||||
same way). `on_activate()` schedules `tour_start_requested()` 1.5 s later, the same delay the
|
||||
first-run auto-start uses, and **that callback is separate from `tour_autostart()` on purpose**: it
|
||||
sets `tour_requested`, not `tour_auto_started`, so `help layer:` says which of the two started the
|
||||
window's half instead of conflating a user's request with a first run. The chooser's half has its own
|
||||
entry points in `source_dialog.cpp` (`chooser_tour_begin`, `chooser_tour_autostart`), and *that* walk
|
||||
is what a first run meets first -- see the tour bullets in the help-affordance section below.
|
||||
- **A `GtkPaned` hands its start child at least its minimum unless the position says otherwise.**
|
||||
Putting the events pane in a paned is what makes its width the user's to drag -- but with the
|
||||
position left alone, `shrink-start-child` or not, the pane's minimum is reserved as an empty column
|
||||
while it is *closed*, and the plot pays for a pane nobody opened. The position is the only knob that
|
||||
says "no room": store the width when it is closed (reading `gtk_paned_get_position()` *before*
|
||||
zeroing it) and set 0, restoring it when the pane opens. `gtk_paned_set_shrink_start_child(TRUE)` is
|
||||
still needed, or the minimum would clamp even a zero position.
|
||||
- **An unrevealed `GtkRevealer` measures 0x0 and is still given the leftover width.** `reveal_child
|
||||
= FALSE` makes both the revealer's minimum and its natural size zero -- and its box then hands it
|
||||
everything the expanding sibling beside it did not claim, so a *collapsed* pane reserved a
|
||||
310-pixel empty column between the window edge and a plot drawn 630 pixels wide in a 1270-pixel
|
||||
window. Every vertical revealer in the side panel hides this, because there the leftover goes down
|
||||
instead of across. `gtk_widget_set_visible()` is what a layout honours, so anything that must not
|
||||
take room has to be hidden and not merely unrevealed; the revealer keeps the slide while the pane
|
||||
*is* open. The allocated width is how this was found -- the revealer's own measure said 0.
|
||||
**The sidebar is a tab stack now, and has no `GtkRevealer` left in it**: an `AdwViewSwitcher`
|
||||
above an `AdwViewStack`, each page a `GtkScrolledWindow` of its own (the two pages are different
|
||||
heights, so one shared scroller would leave a switch scrolled where the new page never asked to
|
||||
be). The reason is the one this trap teaches: five sections competing for one 320 px column left
|
||||
every one of them short of room. A *feature's* on/off is no longer a visibility at all -- the
|
||||
**cursors'** toggle sets its pane's **body** insensitive -- the readout has nothing to show with the
|
||||
instruments off -- and the *body* and not the pane, because the pane's heading carries its own switch
|
||||
(see the next bullet). The **trigger's body is deliberately never disabled**: a trigger is configured
|
||||
*before* it is switched on, so switching it off to change it, or to read what it is set to, is the
|
||||
ordinary way to work. It used to go insensitive with the mode, which left the settings unreachable
|
||||
exactly when a user wanted to change them; `trigger panel sensitive …=1` is the regression guard, and a
|
||||
`0` there means the configuration has been locked out again. The
|
||||
trap itself does not go away with the widget: a stack's hidden page measures 0x0 too and is still
|
||||
given the leftover width, so **ask about state, not about size** carries over unchanged. Every
|
||||
allocation read in the sidebar is a debug capture, and each reads 0 on the page that is *not*
|
||||
current -- a change in what the reading means, not a defect, and no functional code may depend on
|
||||
those numbers.
|
||||
- **`AdwViewSwitcherPolicy.narrow` still draws a label; an *empty title* is what makes a pill an icon.**
|
||||
Measured on libadwaita 1.10: with `narrow` and a title, the pill holds a `GtkImage` **and** a
|
||||
`GtkLabel` — 48px of "Measure" inside a 54px pill — so the policy alone does not give an icon-only tab
|
||||
bar. The sidebar's pages therefore have **no title** (their `name` is what identifies them to
|
||||
`select_side_tab()` and the session file) and the pills are *named in code* instead: the first tick
|
||||
calls `name_side_tabs()`, which walks the switcher's children — the pills are there in page order, and
|
||||
`AdwViewSwitcherButton` is not a public type, so there is no type check to make — and sets each pill's
|
||||
**tooltip and accessible name** from the page's name, then hides the empty label that would otherwise
|
||||
leave the icon off-centre. An icon-only pill is still a named control for a screen reader, and
|
||||
`switcher: … pills=2 labels=0 names=2` is the reading for all three facts at once.
|
||||
- **An *overlaid* sidebar covers the plot, so a drag out of it has nowhere to land.** `collapsed` in an
|
||||
`AdwOverlaySplitView` means the sidebar sits *on* the plot rather than beside it: the pointer is over the
|
||||
list, never over a drop target, and a signal can be dragged but not dropped. `row_drag_sidebar()` hides
|
||||
the sidebar on `drag-begin` and restores it on `drag-end` — only when collapsed, because side by side the
|
||||
plot is already visible and hiding the list the drag started in would be startling. `pane drag: sidebar
|
||||
hidden for the drag=1 restored after it=1` is the reading; the state is forced collapsed the way the
|
||||
sidebar probe forces it.
|
||||
- **A stock icon name has to be one *Adwaita* has, and a blank button is the failure mode.** The two
|
||||
panes' `[edit]` buttons are `document-edit-symbolic` (the pencil: edit) and `object-select-symbolic`
|
||||
(the check: done). The done one was `emblem-ok-symbolic`, which breeze ships and **Adwaita does not** —
|
||||
so the button that leaves configuration mode was blank, which no other reading could see: the button
|
||||
worked, the layout was unchanged, and only `pane edits:` (the two names and
|
||||
`gtk_icon_theme_has_icon()`'s verdict, clicked into each mode) would say so. Check a name against
|
||||
`/usr/share/icons/Adwaita` before using it; a name found under `breeze` is not an answer.
|
||||
- **A pane's heading switch and the header bar's button are one control in two places.** Each pane's
|
||||
heading (`build_trigger_panel()` builds the trigger's, `cursors-panel.ui` has the cursors' in its
|
||||
`head`) carries a `GtkSwitch` that runs the *same action* the toolbar button runs, and
|
||||
`sync_panel_switches()` re-reads each pair from the toolbar's state after any change — so clicking
|
||||
either end moves both, and `panel switches: trigger=1/1 cursors=1/1` is the reading. Two traps:
|
||||
a `GtkSwitch` emits `notify::active` for a *programmatic* write as well, so the sync holds a guard
|
||||
(`App::panel_switch_sync`) or the program's own write comes back as a click; and the switcher must
|
||||
stay *out* of what the pane disables, because a control inside an insensitive parent cannot be
|
||||
clicked — a switch that greyed out with its own pane could never turn it back on. That is why the
|
||||
sensitive target is the pane's **body** (`trigger_body`, `cursors_body`) and not the pane.
|
||||
- **A list box outlives the children of the pane it sits in, and the teardown order is not yours to
|
||||
choose.** Tearing the window down finalizes the events pane's buttons, then the list box, and the
|
||||
list's own `selected-rows-changed` then reaches a handler holding the freed pointers: four
|
||||
`Gtk-CRITICAL`s from `gtk_widget_set_sensitive` and a read of memory nobody owns. `close-request` is
|
||||
*not* the hook -- `UDPSCOPE_EXIT_AFTER_MS` ends the self-check with `gtk_window_destroy()`, which does
|
||||
not emit it. The hook that does work is `App::window`: every teardown path clears it *before* the
|
||||
widgets go (the exit probe says so in a comment: "stop the tick before the widgets go"), so a handler
|
||||
that can fire during teardown checks that first, rather than asking GTK whether a pointer is still a
|
||||
widget.
|
||||
- **A `GtkPicture`'s natural size *is* its content, so a size request on it is a floor, not a cap.**
|
||||
A 480x160 texture in a row of a list claims the row's whole width and squeezes the text beside it
|
||||
out of the row -- the box gives a non-expanding child its natural size and grows it further when
|
||||
the expanding child has nothing left to take. Give the picture a **frame** whose size request equals
|
||||
the **scaled texture's** size (natural == minimum, which is what stops the box growing it), and
|
||||
scale the texture once at decode: it is also what keeps a per-row cache at 12 KB per picture rather
|
||||
than 300.
|
||||
- **A pointer to a widget inside a list survives the list being rebuilt.** `events_thumb` named a row's
|
||||
picture across `refresh_events_pane()`, which removes every row first: the next reader of that
|
||||
pointer got a `Gtk-CRITICAL` from `gtk_picture_get_paintable` on freed memory (the four criticals
|
||||
read as "the pane is empty"). A rebuild has to null what it is about to destroy, not only rebuild it.
|
||||
- **`gtk_picture_set_filename()` can render nothing, silently.** It hands the file to the image
|
||||
loader's sandboxed, asynchronous decoder, which on this machine fails without a message: the
|
||||
picture keeps a *paint-able* and takes an allocation, so a probe that checks
|
||||
`gtk_picture_get_paintable()` reads success while the pane shows an empty rectangle (the event
|
||||
pane's preview did exactly that). `gdk_texture_new_from_filename()` decodes the same file on this
|
||||
thread and `gtk_picture_set_paintable()` hands over the result -- and a NULL texture at least says
|
||||
so. A picture that must be on screen is also a picture with a **height request**: `can_shrink`
|
||||
alone asks for no height at all in a box.
|
||||
- **A cache that is the *only* source of a widget's content must be created by the thing that reads
|
||||
it.** The event pane's thumbnails come from an `EventThumbCache` hung off the list box with
|
||||
`g_object_set_data_full`, and `event_preview_texture()` answers out of that cache or not at all.
|
||||
Nothing ever created it — so for as long as the pane existed, **every** row came up without a
|
||||
picture, which is what "event screenshot are unusable and sometime missing" was. A missing cache
|
||||
there is not a slow path, it is a blank one; `event_thumb_cache()` therefore creates one on first
|
||||
use. Owned by the **list**, because nothing else has the pane's lifetime: textures of a list that
|
||||
no longer exists are so much held memory, and the destroy notify is where they are unref'd.
|
||||
- **Hover magnification is a tooltip, not a popover of ours.** A row's thumbnail is 138 px wide and
|
||||
the stored preview is 480: the small one says *which* event this is, and the zoom says what is in
|
||||
it. GTK's own tooltip (`gtk_widget_set_has_tooltip` + `query-tooltip` + `gtk_tooltip_set_custom`)
|
||||
is shown, placed, dismissed **and destroyed** by GTK, and takes no pointer grab. A popover per row
|
||||
would be a parented widget to unparent on every rebuild of the pane — one defect below — and a
|
||||
grab to lose, for a thing with no interaction in it. Two details: `can_shrink` must be FALSE on the
|
||||
tooltip's picture or the tooltip is given the smallest size its content allows (the opposite of the
|
||||
point), and the path handed to the handler has to be **owned by the closure**
|
||||
(`g_signal_connect_data` with a destroy func), since the record it came from is a loop variable.
|
||||
- **Unparenting a popover inside its own `closed` emission is a use-after-free.** Dismissals go
|
||||
through `gtk_popover_popdown()`, which emits `closed`; a handler that unparents the popover there
|
||||
drops the reference the parent held, the widget dies, and GTK's own popdown path then runs on freed
|
||||
memory — `gtk_popover_get_autohide: assertion 'GTK_IS_POPOVER (popover)' failed`, on every menu
|
||||
entry that dismisses its popover, which is every entry there is. `on_popover_closed()` therefore
|
||||
defers the unparent by one idle and holds a reference across it
|
||||
(`g_idle_add_full(..., g_object_ref(popover), g_object_unref)`), so the popover outlives the
|
||||
emission that dismissed it.
|
||||
- **`find_accel()` masks the modifiers before comparing.** Lock, Num-Lock and the pointer buttons
|
||||
all arrive in `GdkModifierType`, so an unmasked compare matches no table row at all once
|
||||
Caps Lock is on.
|
||||
- **A UI file is data, and a mistake in it is a runtime abort, not a build failure.** `.ui` files live
|
||||
in `src/ui/` and are compiled into the binary by the third GResource (`udpscope-ui.gresource.xml`,
|
||||
`prefix=/org/udpscope/ui`), loaded with `gtk_builder_new_from_resource()`.
|
||||
|
||||
`preprocess="xml-stripblanks"` catches a *malformed* file at build time -- which is why it is on
|
||||
every file in every bundle -- and that includes an XML rule easy to break by hand: **a comment may
|
||||
not contain a double hyphen**, so prose written the way every C++ comment in this project is written
|
||||
(` -- like this -- `) fails the build with a caret under the offending character. Use an em dash in a
|
||||
UI file.
|
||||
|
||||
What it cannot catch is a file that is valid XML but wrong for GTK, and that is what
|
||||
`cmake --build build --target test-ui` is for: it round-trips every file through
|
||||
`blueprint-compiler`, which type-checks properties and child types against the GIR -- for *every*
|
||||
file, rather than for the windows a probe happens to open (see
|
||||
[build-and-run.md](build-and-run.md)). Without that check, `GtkBuilder` reports such a file with a
|
||||
**`Gtk-ERROR`, which aborts the process** by default, so a window built from bad markup takes the
|
||||
application down rather than failing to appear.
|
||||
|
||||
Two more paid for: a `GIcon` property takes an **object**, not a name
|
||||
(`<property name="icon">foo</property>` is read as a reference to another object and fails with
|
||||
"Object with ID foo not found" -- the icon is a nested `GThemedIcon`); and a `.ui` big enough to
|
||||
exceed 4095 characters needs `-Wno-overlength-strings` on its generated source, because
|
||||
glib-compile-resources writes each file as one C string literal and the warning fires on generated
|
||||
*data*.
|
||||
- **Deprecated widgets may be the right widget, as long as no deprecated *function* is called.** The
|
||||
shortcut overview is a `GtkShortcutsWindow` (`gtk/deprecated/`, deprecated in 4.20) because libadwaita's
|
||||
successor, `AdwShortcutsDialog`, cannot draw a mouse gesture: `AdwShortcutsItem` has no icon property at
|
||||
all, so its shortcut cell can only be an accelerator, and its cell reads "No Shortcut" when there is
|
||||
none. `GtkShortcutsShortcut` has one generic gesture type whose `icon` *is* drawn. Loading the window
|
||||
from a `.ui` and presenting it as a `GtkWindow` names no deprecated function, so the build stays at its
|
||||
four warnings. When such a window closes, its builder is **replaced on the next opening**, not kept: the
|
||||
window belongs to the builder's object list and a destroyed `GtkWindow` cannot be presented again.
|
||||
- **An `AdwDialog` is not a `GtkWindow`, and not every toplevel is a widget either.** The preferences
|
||||
are an `AdwDialog`, which is a `GObject` that a *window* presents, so it cannot parent a
|
||||
`GtkFileDialog`: the form keeps the window it was opened with (`PrefsForm::parent`, the caller's) and
|
||||
hands that to the picker. Two things follow, and each cost a run. In its floating presentation mode an
|
||||
`AdwDialog` adds a **native surface** to `gtk_window_get_toplevels()`, so that list holds things that
|
||||
are neither `GtkWindow`s nor widgets -- `GTK_WINDOW()` on one is a segfault inside `gtk_window_close()`,
|
||||
and `g_object_unref()` on one is a `GLib-GObject-CRITICAL`. Anything walking it must test
|
||||
`GTK_IS_WIDGET()` *and* `GTK_IS_WINDOW()` first, and a probe should not walk it at all where a
|
||||
cancellable will do.
|
||||
- **A folder chooser outlives the dialog that opened it unless something cancels it.** The picker's
|
||||
answer writes a row, and the row is gone once the dialog closes. `PrefsForm::browse_cancel` is
|
||||
cancelled by the `closed` handler and replaced by the next chooser: one open picker at a time, and none
|
||||
that can call back into a cleared form. It is also how a probe takes the cancel path without a mouse.
|
||||
- **An `AdwApplicationWindow` refuses a titlebar, and refuses it by *aborting*.** `gtk_window_set_titlebar()`
|
||||
on one is a fatal `Adwaita-ERROR` (`is not supported for AdwApplicationWindow`), not a warning that
|
||||
leaves the window merely ugly. The main window is therefore a `GtkApplicationWindow` -- an Adw window
|
||||
would want its header as an `AdwToolbarView`'s top bar instead -- and `build_toolbar()`'s `GtkHeaderBar`
|
||||
stays the window's own titlebar, which is also what gives the window its controls: a `GtkHeaderBar`
|
||||
draws them only as a titlebar.
|
||||
- **libadwaita's rows do not share one property set, and a `.ui` is where that shows.** An
|
||||
`AdwEntryRow` is an `AdwPreferencesRow`: it has a `title` and **no `subtitle`**. `AdwSpinRow` and
|
||||
`AdwSwitchRow` are `AdwActionRow`s and have both. A hint about an entry -- "empty = unicast" --
|
||||
therefore belongs in the *group's* `description`, which is the better place for it anyway: a
|
||||
placeholder vanishes as soon as there is a value, and a description does not. And an `AdwDialog` has
|
||||
**no `content` property**: its child is a plain `<child>`, where an `AdwToolbarView` does take
|
||||
`content`. Both mistakes are a `Gtk-ERROR` at load, so both abort the process, and both are caught by
|
||||
the probe that opens every window rather than by anything at build time.
|
||||
- **A dialog reachable from two windows cannot own its state in either.** The preferences open from
|
||||
the main window's menu *and* from the source chooser, which exists before `App` does (`main()`
|
||||
chooses a source, then builds the model and the UI). The widgets therefore live in a `PrefsForm` the
|
||||
caller owns — `App::prefs`, `Chooser::prefs` — and what a change *means* is a callback, because it
|
||||
means different things on the two sides. Both are plain members of their owners, on the same stack
|
||||
frame, so nothing can outlive the frame that built it.
|
||||
- **A transient window is destroyed before its parent.** `chooser_finish()` destroys the preferences
|
||||
window before the chooser, and nulls the pointer *before* the destroy so `close-request` cannot
|
||||
re-enter a dead window. GTK does not promise to take a transient down with its parent, and a window
|
||||
whose handlers point into a returned stack frame is how this project already earned one
|
||||
use-after-free.
|
||||
|
||||
- **A `_` in a label is a mnemonic only on a widget that was asked for one.** GTK reads the underscore as
|
||||
"the next letter is this widget's accelerator, and draw it underlined", so wherever the widget has not
|
||||
been asked, GTK draws the underscore *literally* -- which is what `_Directory`, `_Server`, `Keep _events`
|
||||
and eleven more titles did in the two preference forms, for as long as they existed. Two different
|
||||
switches, FALSE by default in both cases: `GtkLabel`/`GtkButton` take `use-underline`, and the rows take
|
||||
**`AdwPreferencesRow:use-underline`** ("whether an embedded underline in the title indicates a mnemonic").
|
||||
The row classes that carry it are not all related -- `AdwEntryRow` has its own, while
|
||||
`AdwSpinRow`/`AdwComboRow`/`AdwSwitchRow` get it through `AdwActionRow` -- so `row_underlines()` asks by
|
||||
name on a `GObject` (`g_object_class_find_property`, then `g_object_get`) instead of casting to one row
|
||||
type: casting a row whose type is only forward-declared in that translation unit does not even compile.
|
||||
`test-ui` is what catches the property being set on a class that does not have it -- it validates every
|
||||
property against the GIR, inherited ones included.
|
||||
- **A mnemonic is unique per window, and the first match wins.** `_Cancel` and `_Connect` both claimed C in
|
||||
one form, so `Alt+C` could only ever reach one of them and the other's marker was unreachable -- worse
|
||||
than no mnemonic, because the underscore is there in the source for a reader to trust. `Conn_ect` freed
|
||||
it. Rows on different pages of a preferences dialog may share a letter: an unshown page cannot be
|
||||
activated.
|
||||
|
||||
- **A `GtkExpander`'s child is not in the widget-parent chain.** `gtk_widget_get_parent()` on the widget an
|
||||
expander holds answers NULL, before *and* after the window is presented, while `gtk_expander_get_child()`
|
||||
returns it -- measured all three ways (the `.ui`'s `child` property, a code `gtk_expander_set_child()`, a
|
||||
code-made expander). So a probe that walks parents to prove a widget is inside a panel reads 0 for
|
||||
everything behind an expander, on a panel that is entirely correct; the walk has to start at
|
||||
`gtk_expander_get_child()`. That mistake cost a false regression, a "the property does not attach it"
|
||||
conclusion drawn from it, and a pointless second root in the file before the throwaway program settled it.
|
||||
Related: `gtk_expander_set_child()` asserts that its child has no parent yet, so a block the `.ui` has
|
||||
already put in the panel cannot then be handed to an expander -- the file's `child` property is the right
|
||||
way, and it works.
|
||||
|
||||
- **`--` is not allowed inside an XML comment, and a `.ui` comment is XML.** `<!-- ... -- ... -->` is a parse
|
||||
error: `xml-stripblanks` fails the build, and `make test-ui` prints `FAIL <file>` with **no** message under
|
||||
it, because blueprint-compiler's decompiler aborts on the malformed XML before it can report anything --
|
||||
the bare `FAIL` line is that mistake and nothing else. The house style for a dash in prose is the em dash,
|
||||
which is why no other `.ui` in `src/ui/` contains a double hyphen. It has cost two slices a debugging
|
||||
round for one character.
|
||||
|
||||
- **A `.ui` file instantiates its objects once, so anything whose *count* is data cannot live in one.** The
|
||||
layout editor's overlay buttons are one per row, plus one per boundary between them: a `GtkBuilder` would
|
||||
hand back the first and nothing for the rest, and there is no "prototype the code copies" in the format.
|
||||
They are therefore code, and what they share -- the icon, the two CSS classes that make a small round
|
||||
on-screen-display control, and the size -- is one function (`layout_overlay_button()`) rather than three
|
||||
copies. The same reason keeps the math editor's palette and parameter rows in code: their count follows the
|
||||
model, not the file. `.ui` files are for the frame, and the frame is what has no count.
|
||||
|
||||
- **A code-added child of a header bar lands *after* the file's, so going first means using a slot.**
|
||||
`gtk_header_bar_pack_start()` appends to the start group and there is no prepend. The time-window control is
|
||||
code -- its menu's rows come from the buffer's limits -- and belongs at the bar's head, so `header-bar.ui`
|
||||
declares an empty `window_control_slot` box as its first start child and the code appends the control into
|
||||
it. Packing the control straight onto the bar would have put it to the right of everything the file packed,
|
||||
and `[udpscope] header:` (`window_button_w=130 bar_w=756`) is the reading that shows such a move.
|
||||
- **`<child type="end">` is added leftwards-from-the-right, in document order.** A header bar's end group
|
||||
reads the *reverse* of the file: `header-bar.ui` declares menu, sidebar, math, measures, cursors and the
|
||||
screen shows cursors, measures, math, sidebar, menu. The three comments this replaced each had it wrong
|
||||
once, which is why the order is the file's now and a byte-for-byte diff of the probe is the check.
|
||||
|
||||
- **A widget can end up in the wrong container, and only a *structural* read notices.** `math_body` was
|
||||
written as a `<child>` of the *window* instead of the box above it when the math editor's frame moved into a
|
||||
`.ui`. A `GtkWindow` takes exactly one default `<child>`, so nothing failed: the body -- with the canvas and
|
||||
the parameter form inside it -- was left parentless, `visible` but never `mapped`, size `0x0`, drawing
|
||||
nothing. The symptom was "the editor opens and the canvas is blank", with the window sized around its
|
||||
remaining children. `check_ui` passes such a file; `xml-stripblanks` passes it; so does every probe that
|
||||
reads *values* off the widgets inside. What catches it is a reading that walks *up* from the widget
|
||||
(`in_dialog=1`) or asks whether it was ever given an allocation (`mapped=1`, `canvas=420x836`). When a frame
|
||||
moves into a file, the probe that proves it has to read the frame's *structure*, not only the numbers it
|
||||
holds.
|
||||
|
||||
- **A popover's autohide is only as good as its grab.** `GtkPopover` dismisses itself when it loses the
|
||||
pointer grab -- and the grab is the thing that can be missing: a window manager moving it, a nested popover
|
||||
(a `GtkDropDown`'s menu, which the time-window row has) taking it, and *every* headless run, where there is
|
||||
no manager to hand one out and the probes have to turn autohide off before a popover will stay up to be
|
||||
measured. The symptom is "sometimes it is awkward to close": a click anywhere else leaves the bubble up.
|
||||
`on_window_popover_press()` states the rule outright instead -- any press that reaches the window's own
|
||||
surface closes the window popover. That needs no hit test beyond the button, because a popover *is* its own
|
||||
surface and a click inside it never arrives; the button is left to its own click, whose toggle is what
|
||||
closes it. `[windowpop]` reads both halves, with autohide off so it is testing the rule and not the grab.
|
||||
|
||||
- **A notice shares the status line with the glance, so it has to *look* like a message.** The status bar
|
||||
renders `app.notice + " | " + glance` in one label, which is exactly where a reader skimming the
|
||||
routine text ("live · 9.97 kS/s · 2 s window") will not see it: a refused drop that said "plot 1 holds four
|
||||
signals already" was read as no feedback at all. While a notice is fresh the label carries the stock
|
||||
`.warning` class, so a message looks like a message and the class goes when the notice does. Two corollaries
|
||||
worth knowing: the line composes on its own cadence, so a notice can appear a beat after it was set (do not
|
||||
read the label in the same tick a handler ran), and a refusal should say what to *do* about it -- "drop it on
|
||||
another plot, or add a row with the layout editor (L)" rather than the bare statement of the limit.
|
||||
|
||||
- **The status line cannot cover the gap before the window exists, so a window does.** Between the chooser
|
||||
closing and `gtk_window_present()` there is no main loop at all: the chooser destroyed its own window,
|
||||
then `main()` allocates the sample buffer, waits for the first CONFIG or reads a recording, and
|
||||
`on_activate()` builds every panel -- at a few gigabytes of buffer that is the ten seconds a user reported
|
||||
as "the dialog disappeared and nothing happened". `loading_show()`/`loading_hide()` (in
|
||||
`source_dialog.cpp`, beside the chooser they continue) keep an `AdwWindow` on screen across it, raised in
|
||||
`main()` **before** the model is constructed and taken down in `on_activate()` **after** the present --
|
||||
either side is a frame of empty screen. Three rules it exists under:
|
||||
- **It must be painted before the caller blocks.** `gtk_window_present()` only queues the frame, and
|
||||
nothing after it runs a loop, so `loading_show()` ends in a nested `GMainLoop` quit by a timeout
|
||||
(the chooser's own idiom). Iterating pending events is *not* enough: a window needs a frame-clock
|
||||
cycle, and the frame clock only ticks while a loop runs.
|
||||
- **It says what the wait is, in words.** The spinner is frozen for the whole of it -- no loop -- and a
|
||||
progress bar would be a lie for the same reason, so the text carries the information: the address
|
||||
being connected to or the file being opened, and under it the buffer size, which is the number that
|
||||
explains the duration. It is `deletable=0` with no title buttons: a close button that cannot be
|
||||
serviced until the work is over is worse than none.
|
||||
- **Only the path that *asked* gets one.** `main()` raises it only when the chooser actually ran, which
|
||||
is why every documented probe (all of them pass `--server`) is on exactly the path it was before --
|
||||
and a `--server` run never had a window to lose, so the post-present notice is the right place for it.
|
||||
`loading_hide()` is a no-op when none is up, so `run_ui()` needs no notion of which path it is on.
|
||||
|
||||
## The help affordance layer
|
||||
|
||||
The `Help` submenu, the contextual help's "?" and the guided tour, and the traps each of them met.
|
||||
|
||||
- **`gtk_widget_measure()` includes the widget's own margins.** Measured: a box whose natural width
|
||||
is 280 and whose `margin-start` is 453 reports `natural = 733`. That matters wherever a measure is
|
||||
used to *place* the widget and the margin *is* the placement: the width the widget was placed at
|
||||
comes back as its width, and the card of the guided tour walked between the bottom-right corner and
|
||||
the left of the target from frame to frame because of it (a `place_natural()` that set no size
|
||||
request did not help -- the margin alone was the loop). Take the margins off a measure that feeds a
|
||||
position back. `hdl`/`bd`-style CSS padding is *not* part of it, and neither is the other axis's
|
||||
margin, so it is `margin-start + margin-end` on the horizontal and top/bottom on the vertical.
|
||||
- **A widget's allocation is not `gtk_widget_get_width()`, and a measure is never a position.** GTK's
|
||||
`gtk_widget_get_width()`/`get_height()` return the widget's **content** box: `size_allocate`
|
||||
subtracts the CSS border and padding before storing them (measured on the tour card, which carries
|
||||
`border 1px` + `padding 12px`: `get_width()` reads 254 where the rectangle on screen is 280), so a
|
||||
card *placed* from it overhangs its own size and covers the target it is meant to sit beside.
|
||||
`gtk_widget_compute_bounds()` is that rectangle -- border and padding in, margins out -- and is what
|
||||
the probe's geometry and the help's figures already read. And `gtk_widget_measure()` cannot stand in
|
||||
for it: its size includes the widget's margins (the bullet above), and asking for a **height with a
|
||||
width** (`for_size`) on a widget whose margins were then subtracted from that width makes GTK warn
|
||||
`Trying to measure GtkBox ... for width of N, but it needs at least M` **once per frame**, because
|
||||
the for_size lands one margin short of the minimum the widget answers with. The guided tour's card
|
||||
therefore takes its size from `compute_bounds()` once a frame has run and falls back to a measure
|
||||
**with no for_size at all** (`-1`, the form GTK measures without that check) only on a frame with
|
||||
nothing allocated yet. The warning reaches no probe -- GTK is built with **structured logging**, so
|
||||
it goes through the log *writer* and never `g_log_set_handler()`/`g_log_set_default_handler()` --
|
||||
which is why the exit dump counts GTK's own warnings through a writer function (`gtk warnings:`,
|
||||
which must read 0) as the one reading that can assert the class away.
|
||||
- **The overflow menu's `Help` submenu is a two-page `GtkStack`, and neither of the obvious
|
||||
alternatives works.** Rebuilding the rows -- the plot menu's own shape -- destroys
|
||||
`app.layout_toggle`, which points into the main view and which `sync_layout_editor()` and the `L`
|
||||
accel read; swapping the *popover's child* instead leaves whichever view is not showing with the
|
||||
popover as its only owner, so it is finalized the moment it is swapped out. The popover's child is a
|
||||
stack, the visible child is the view, and `gtk_popover_present()` re-measures a popover that is
|
||||
already up. `UDPSCOPE_PROBE_MENUS_MS` walks the *visible page*, which is the one line the stack cost
|
||||
the probe.
|
||||
- **A "(i)" joins its surface's row as a sibling, not as an overlay child.** An overlay child
|
||||
sits *on* whatever is under it -- and every panel heading already has a switch or a pencil in a
|
||||
corner. `src/ui_context_help.cpp` is a table of (id, title, body, shortcut, host) where the host is
|
||||
the *row* -- a panel's heading usually, and the row of a specific control (the "record events"
|
||||
switch, the pre-trigger share, the search block's expander *header*) where that is the thing being
|
||||
explained. The one exception is the
|
||||
plot, which has no heading: its "(i)" is an overlay child of the overlay the layout editor already
|
||||
uses, and its corner is free. The icon is **`dialog-information-symbolic`** -- `help-about-symbolic`
|
||||
is *already* the status bar's information button, and two "help" glyphs meaning different things on
|
||||
the same line is the trap the seven app icons exist to avoid; the dump reads the name back through
|
||||
the theme for the same reason the pane [edit] buttons do. `math_head` exists only while the math
|
||||
editor is open, so that row is skipped when it is null.
|
||||
- **A `GtkMenuButton` ignores both `min-width` and a size request, and the icon is a node *below*
|
||||
its `button` subnode.** The "(i)" is meant to be a *mark*, not a button, and the default is 34x34 (a
|
||||
24 px floor plus 5 px of padding from Adwaita's `button` rule). Measured on libadwaita 1.10: setting
|
||||
`min-width`/`min-height` on the widget changed nothing, and neither did `gtk_widget_set_size_request()`
|
||||
-- the menu button's own measure wins. The node tree is `menubutton > button > box > image` (read off
|
||||
the CSS node dump), so `install_css()` reaches the glyph as
|
||||
`menubutton.udpscope-hint > button > box > image`. The direct-grandchild form `> button > image`
|
||||
matches **nothing** and is silent about it -- the glyph keeps its natural 16 px, which is why an
|
||||
earlier version shipped a 16x16 mark and took that for the floor it was not. Zeroing the outer node
|
||||
and the `button` subnode and asking the glyph for **12 px** (`min-width`/`min-height` *and*
|
||||
`-gtk-icon-size`, the latter what actually *scales* the drawing rather than clamping its box) gives
|
||||
**12x12**. (A class on the outer node alone leaves the subnode at 34; a class on the subnode alone
|
||||
shrank the width to 16 and left the height at 26.) There is no lower floor: the symbolic glyph renders
|
||||
at any request (measured down to 4 px), so 12 px is a legibility choice and not a limit. The dump's
|
||||
`hint=` reading is that number, measured with `gtk_widget_compute_bounds()` on the first *visible*
|
||||
hint -- a hint inside a collapsed `GtkExpander`'s *child* is not in the widget-parent chain at all and
|
||||
reads 0x0, which would look like a mark shrunk to nothing. That is why the search block's hint rides
|
||||
the expander's **header** (a `label-widget` box, `search_head` in `src/ui/trigger-panel.ui`, which
|
||||
*is* in the tree) rather than its child, and why the `[helpui] collapsed search:` reading asserts the
|
||||
header hint is mapped and 12x12 while the block is collapsed.
|
||||
- **Off is gone, not greyed, and the buttons are found by a data key rather than kept in a list.** The
|
||||
list is what the math editor cannot survive: its heading lives in a builder that is dropped and
|
||||
rebuilt on every opening, so a stored pointer into it would go stale one dialog later. A key on the
|
||||
button (`udpscope-help-hint`, carrying the surface's id) is both the mark that says "this is a hint"
|
||||
and the name the probe and the dump report it by, and removing a hint is `gtk_widget_unparent()`,
|
||||
which means the same thing for a box and for the plot's overlay. The button itself is
|
||||
`ui_impl::hint_button()` reached either by the table's `context_help_sync()` or by
|
||||
`context_help_button()` for a window with no `App` (the source chooser) -- one button, two callers,
|
||||
so the two windows cannot drift into two affordances.
|
||||
- **The guided tour takes no grab, and that is the whole reason it is an overlay.** Four dim
|
||||
rectangles around the hole are ordinary widgets that swallow the clicks that land on them and
|
||||
nothing else; `Skip`, `Finish` and `Escape` hide them, and there is no popover, no gesture and no
|
||||
grab to leak. The probe asserts it as a reading -- `controllers=` against the count taken before the
|
||||
tour opened -- rather than as a claim, because "no lingering grab" is exactly the sort of thing a
|
||||
later change can add back without noticing.
|
||||
- **The tour is one implementation over two windows.** `ui_impl::TourView` holds the overlay children
|
||||
and the walk's place in it (`tour_build`/`tour_begin`/`tour_advance`/`tour_end`/`tour_update`), and
|
||||
what differs between the windows is only the *stops*: a `TourStop` names a widget the owner resolves,
|
||||
so each window keeps its own table -- the main window's against `App` fields,
|
||||
`source_dialog.cpp`'s `kChooserStops` against the `Chooser`'s. A walk that passes its last stop calls
|
||||
the view's `on_done`; the window's passes null, the chooser's sets `Config::tour_pending`, which is
|
||||
how the two halves are one tour. `App` carries its own `TourView` (`app.tour`), so the machinery is
|
||||
compiled once and the probe reads either window's state by the same fields.
|
||||
- **A stop declares the page it stands on, and the tour selects that page as the stop is entered.** A
|
||||
stop whose target lives on another page of a stack -- the window's `signals` (and `trigger`) stops
|
||||
on the sidebar's `signals` page, the chooser's "Two kinds of input" stop on its `waveforms` page --
|
||||
names that page in `TourStop::page`, and `show_step()` selects it from `TourView::page_stack`
|
||||
before anything is measured. The page switch is **not** followed by a one-shot measure: `show_step()`
|
||||
selects the page and `tour_update()`, which already runs every frame, fills the spotlight in on the
|
||||
frame *after*, when the new page has been laid out. A stack allocates the new page's widgets on the
|
||||
frame after the switch, so a measurement taken in the switch's own tick reads zero (the allocation
|
||||
bullet in [invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)) -- and reading the
|
||||
page *name* back is fine immediately, because that is state and not geometry.
|
||||
- **The stop filter runs on `get_mapped()`, not `is_visible()`, and that is what separates a wrong
|
||||
page from a collapsed pane.** GTK 4.24's `gtk_widget_is_visible()` walks only the widgets' own
|
||||
`visible` flags up the parents; it does **not** consult `child_visible`. Both a `GtkStack`'s
|
||||
non-current pages and `AdwOverlaySplitView`'s hidden sidebar are hidden with
|
||||
`gtk_widget_set_child_visible()`, so a widget on another page, *and* one in a collapsed pane, both
|
||||
read `is_visible() == TRUE` (verified against 4.24.1) -- the old filter kept a stop it meant to
|
||||
drop and left the spotlight on a target with no allocation. `stop_is_walkable()` keeps a stop when
|
||||
its target is **mapped**, or when it names a page and the stack that page belongs to is mapped: a
|
||||
wrong page leaves the stack mapped, so the stop is kept and the page switch reveals it; a collapsed
|
||||
pane leaves the stack unmapped too, so no page can reveal it and the stop is dropped. The filter
|
||||
lives in `tour_begin()`, not in each window's `start_tour()`, both so the two windows cannot drift
|
||||
into two rules and because the rule needs the view's own `page_stack`.
|
||||
- **The chooser's tour needs an overlay over its *whole* window, and `AdwWindow`'s content is
|
||||
replaced, not wrapped.** Two of the chooser's stops name header-bar buttons, and an overlay inside
|
||||
the `AdwToolbarView`'s `content` would give them negative coordinates and clamp the hole to the top
|
||||
edge. So the overlay *becomes* the window's content and adopts the toolbar view -- and the order
|
||||
matters: `gtk_overlay_set_child()` refuses a child that still has a parent, so
|
||||
`adw_window_set_content(win, overlay)` goes **first** (that call is what unparents the toolbar view)
|
||||
and `gtk_overlay_set_child(overlay, content)` second. The builder's object list still owns the view,
|
||||
so nothing is finalized in between. Getting the order wrong is a `Gtk-CRITICAL` and an empty
|
||||
chooser, which is exactly what the first version of this did.
|
||||
- **The chooser's hints are its own table because it runs before `App`.** An `AdwEntryRow` is not a
|
||||
box and its editor is internal, so a hint cannot join the host/port fields as a sibling -- appending
|
||||
it to the `AdwPreferencesGroup` would make it a row under the field rather than a mark beside it, so
|
||||
no hint is invented for them and the group's own `description` carries that text. The two rows the
|
||||
dialog *does* have -- the heading above the saved-source list and the waveforms page's action row --
|
||||
carry `chooser_sources` and `chooser_waveform`, added with the same `context_help_button()` the
|
||||
window uses and re-synced by its Help menu's toggle.
|
||||
- **The hole is kept from the last frame that could measure the target.** `gtk_widget_compute_bounds()`
|
||||
answers nothing on a frame that lands between two layout passes, and letting the hole empty there
|
||||
made the spotlight and the card jump to the corner and back; a *target that is not mapped* is the
|
||||
different case (a genuinely absent surface) and is what empties it. The placement is also
|
||||
re-measured on **every frame**, never once at the start: a widget's allocation is 0 on the frame it
|
||||
is first shown, and a window resize moves it under a one-shot measurement.
|
||||
- **The first-run tour is a `settings.toml` key (`tour_seen`), written when the tour *starts*.** Not
|
||||
"the settings file did not exist": a user who has already changed the theme has a file and has still
|
||||
never seen the tour. Written on the way *in*, because a user who skips it in the first second has
|
||||
still seen it. It starts **in the source chooser** when one is shown (the chooser is the surface a
|
||||
first run meets first, and the window's own auto-start is unreachable until the chooser has returned
|
||||
with a source) and in the window otherwise; either way it is a 1.5 s timer, so the window is up and
|
||||
laid out before anything points at it. The chooser's walk writes the key itself and hands the second
|
||||
half over by `Config::tour_pending`, so a first run is *one* tour that crosses the two windows.
|
||||
- **The `help figures:` line is captured on the tick, like every other size the dump prints.** It names
|
||||
ten surfaces' window-coordinate rectangles for the help's annotated figures, and the dump runs after
|
||||
the window is destroyed -- a `gtk_widget_compute_bounds()` there is the "no `gtk_widget_*` call in the
|
||||
dump" rule again. `is_visible()`, not `get_visible()`: a widget under a hidden parent -- the trigger
|
||||
summary while the pane is in configuration mode -- keeps its own flag true and reads 0x0, and a crop
|
||||
box drawn from that is a box over the wrong thing. Its numbers depend on the *window's* size, which
|
||||
under the documented `xvfb-run` is the window's 756x894 minimum (a 640x480 screen), so a run on a
|
||||
larger screen gives different rectangles for the same panels.
|
||||
@@ -26,9 +26,10 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
drag pans, `f` fits, `Ctrl+0` resets**.
|
||||
Its widgets are created once and only shown, hidden and set by `math_dialog_sync()`, so no rebuild
|
||||
can invalidate a live callback handle -- the same shape the measurements dialog uses.
|
||||
`UDPSCOPE_PROBE_MATH_MS` defines two graphs and opens the editor (a construction crash test
|
||||
as much as a state check), and the dump prints each derived channel's extremes beside the
|
||||
source's. Read it on the fast mock, where the two assertions that matter are **`gaps=0`** (the
|
||||
`UDPSCOPE_PROBE_MATH_MS` defines three graphs -- the third a `Source` of the first channel into a
|
||||
`DeNoise` node, so the editor's median row is built and synced on a baseline run -- and opens the
|
||||
editor (a construction crash test as much as a state check), and the dump prints each derived
|
||||
channel's extremes beside the source's. Read it on the fast mock, where the two assertions that matter are **`gaps=0`** (the
|
||||
math is on the producer thread, so what it can break is packet capture, never the frame rate)
|
||||
and each `math '<name>': ... N samples vs M time slots` being **equal**, with `math x2` at
|
||||
`y=[-2, 2]`, `math lp` at `y=[-0.998, 0.998]` and `math3` at `y=[-2, 2]`. The `0 visible
|
||||
@@ -36,10 +37,42 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
`visible_samples()` reading row 0's axis after the window was destroyed, and
|
||||
`UDPSCOPE_EXIT_AFTER_MS` arms the trigger first, so the dump can land in `Waiting`. The live
|
||||
`ch N '<name>' ... y=[..]` lines are the race-free reading of the same thing.
|
||||
- **A file view has no producer, so a structural change would leave it empty.** `set_math_channels()`
|
||||
rebuilds the channels *and* the buffer, and a rebuild drops the history: right for a stream, whose producer
|
||||
refills it from the next packet, and wrong for a file, which has nothing to refill it with. Adding a math
|
||||
channel to an open waveform therefore emptied *every* channel -- `samples=0` on the sources too, which a user
|
||||
reads as "the plot is broken" -- and the same emptiness follows any structural change in a file view.
|
||||
`reload_file_view()` (in `ui.cpp`) re-reads the file after such a change, which is also what computes the
|
||||
derived channels over it: the load's own pushes run the math once the set is in place. It fires only when the
|
||||
buffer was *really* dropped -- `stored_slots() > 0` means the change found nothing to do -- and it says so
|
||||
on stderr (`reloaded <path> after a structural change`), because a second read of a file is worth
|
||||
accounting for. `set_math_channels()` in turn treats an identical list as no change at all, so a no-op
|
||||
application (the session apply, a re-open) neither rebuilds nor drops nor re-reads; the comparison leaves the
|
||||
nodes' `x`/`y` out on purpose, since those are the canvas's own and no op reads them -- a graph that only
|
||||
*moved* is not a change, and rebuilding for it would drop the buffer to store two numbers. It is why `--open`
|
||||
works at all with a session that defines math channels, since `main()` loads the file *before* the UI (and
|
||||
so before the session's graphs) exists.
|
||||
- **Every path that fills a store must skip a derived channel, not just the packet path.**
|
||||
`ChannelInfo::math` states the rule for the ingest; `push_loaded_slot()` broke it, so loading a file pushed
|
||||
its own value into each derived ring and `push_math_sample()` then pushed the computed one: two samples per
|
||||
slot, i.e. a trace at double rate. The reading that catches it is the one already in this file --
|
||||
`math '<name>': N samples vs M time slots` must be **equal** -- and a file view now has to be read as well
|
||||
as a live one (`--open <the file>` with `UDPSCOPE_PROBE_MATH_MS`). An export writes every *plotted* channel,
|
||||
so a file carries the derived ones as ordinary columns: those are real channels and are filled normally,
|
||||
which is why the fix is in the push and not in the loader.
|
||||
`math cost: S slots, N ns/slot (T timed), X ms total est` is the per-slot cost; see the
|
||||
math-cost invariant below for why it is sampled, why it is an upper bound, and why a figure
|
||||
anywhere in 78--101 ns/slot is one reading of the same thing.
|
||||
The probe also asserts the new editor mechanics. Each line carries `(want ...)` and the reading
|
||||
anywhere in 78--101 ns/slot is one reading of the same thing; the three-graph run reads **103**, the
|
||||
de-noise channel's per-sample median being the difference.
|
||||
The probe also asserts the new editor mechanics. `dialog build:` reads the identity row back as well --
|
||||
`name='math3' unit=''` -- which is the pair the `.ui` holds the widgets for and the code fills from the
|
||||
channel being edited, so a row that stopped being the one the code writes shows there.
|
||||
`editor frame:` is the same idea one level down: it reads the canvas's allocation, whether that canvas is
|
||||
*mapped*, and whether the canvas and the name entry are inside the dialog at all -- delayed by 500 ms, because
|
||||
an allocation only exists after a layout. A canvas that is visible but parentless never maps and stays `0x0`,
|
||||
and reading values off the widgets *inside* the editor cannot see that. It is the reading that catches
|
||||
`math_body` written as a child of the window instead of the box above it. Each line carries
|
||||
`(want ...)` and the reading
|
||||
taken from a real run is the reference:
|
||||
* `[math-probe] hit: inside node=0 which=-1, past-edge node=0 which=-1 (want 0 -1 0 -1)` — the
|
||||
output square straddles the box edge, so a hit test that recovers the node by a box test misses
|
||||
@@ -208,6 +241,24 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
* **A `Delete` key removes the selected *wire* and never falls through to the selected node.**
|
||||
A key whose target depends on an invisible selection state is how a graph gets destroyed by
|
||||
accident; the `delete node` button stays the only way to remove a node.
|
||||
- **`MathOp` is append-only, because `math_op_at()` *is* the serialisation.** A session stores a node's op
|
||||
as its index through that accessor, so an op inserted in the middle reinterprets every node in every file
|
||||
written before it; `DeNoise` is therefore index 12, after `Div`, and sits last in the dropdown.
|
||||
`math_op_uses_filter()` keeps its meaning of *a biquad* -- `math_op_uses_fc()` is the wider question
|
||||
("does this op read `fc` at all") and is what the dialog's corner row asks.
|
||||
- **The de-noise node's median window is *pooled* in `MathRuntime::median`, not held in `MathNodeRt`**, so a
|
||||
node's state stays on one cache line; nine doubles per node would push every node in the graph, filter or
|
||||
not, across two. `MathNodeRt::med_at` / `::med_n` / `::med_pos` / `::med_have` are that pool's slice,
|
||||
length, write position and fill, and the pool is sized in `math_prepare()`.
|
||||
- **Median *then* pole, and the order is the design.** The median kills an impulse outright -- one shorter
|
||||
than half its window cannot move it at all -- and the pole takes the broadband hash it leaves behind. The
|
||||
other order feeds the pole the impulse, which smears it over the pole's whole settling time and leaves the
|
||||
median a wide bump it cannot reject.
|
||||
- **The de-noise pole reuses `b0` and `z1`, deliberately.** `math_prepare()` carries `z1` across a
|
||||
re-prepare, so the pole keeps its history through a decimation change exactly as the biquads do -- while the
|
||||
median window starts from rest, the same choice the biquads' `z2` makes. `median_n()` rounds *up* to odd and
|
||||
caps at 9, and it is what enforces the window rather than the dialog: a session file holding 4 or 40 still
|
||||
loads. The cap is what `double sorted[9]` in the step is sized for.
|
||||
- **The math channels' cost is the *producer's*, and the instrument must not be part of it.**
|
||||
`push_math_sample()` runs once per stored slot -- up to the wire rate -- so what a regression
|
||||
here looks like is `gaps`, never a frame time. Three things were taken off that path and must
|
||||
@@ -233,3 +284,50 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
`MathNodeRt` was measured and bought **nothing** -- at six nodes the whole runtime is
|
||||
L1-resident either way. It was kept for the shape, not for a number; do not expect a cache
|
||||
argument to pay at this size.
|
||||
|
||||
## A gap is a gap in a filter's output, never the end of its state
|
||||
|
||||
A reserved hole is NaN in the source channel, and a biquad that integrates it poisons `z1`/`z2`
|
||||
for ever: every later sample comes out NaN, so a filtered trace died at the first drop and never
|
||||
came back. Relative time made it systematic — a counter gap reserves NaN slots by construction,
|
||||
where a stream-time gap is often absorbed by the stamps — but EMI caused it in both modes, and
|
||||
the reading was unambiguous: the memoryless channels held 95% finite samples while every
|
||||
`LowPass`/`HighPass`/`BandPass` channel held `finite=0`.
|
||||
|
||||
`biquad_step()` therefore returns NaN **without touching the state words** when its input is not
|
||||
finite. The hole stays visible in the filtered trace (the gap is the truth about the data), the
|
||||
filter's history remains the last *real* samples it saw, and the trace resumes after the hole
|
||||
with at most the transient a re-start costs. The exit dump's `math '<name>': finite=N/M` line is
|
||||
the assertion: `finite` must be about `M` minus the hole share, and `finite=0` beside non-zero
|
||||
`hole_slots` is this defect returned.
|
||||
|
||||
- **A math channel is a row of the *signal* list, under a `MATH` divider, and there is no math pane.**
|
||||
`append_math_signals_locked()` already puts every channel in `signals()`, so the pane was the same
|
||||
list drawn twice; the divider is what the pane uniquely contributed. It is non-selectable and
|
||||
non-activatable -- a heading that highlighted under the pointer would read as a row you can plot --
|
||||
and it carries the `+` that creates a channel, appended even when there are none, because a `+` that
|
||||
waited for a channel to exist could never create the first one. A math row's menu is *Edit graph…*,
|
||||
*Default colour*, *Delete*: no Information and no Calibrate, because calibration is applied at
|
||||
ingest and a derived channel has no stream to describe, and no rename, because the node editor owns
|
||||
a channel's name and unit and a second place to change them would be a second answer to the same
|
||||
question. A row's tick is the channel's own switch and must reach `App::math` -- the copy the
|
||||
session file is written from -- or the list and the file disagree about what "enabled" was.
|
||||
- **A graph *edit* does not move the plan; adding or removing a channel does.** The plan is a partition over
|
||||
the channels, so `set_math_channels()` takes a surgical path whenever the *set* is unchanged -- same channels,
|
||||
names and enables: the store is never touched, every other channel keeps its samples **and** its runtimes, and
|
||||
only the edited graphs are prepared again. `math_apply_locked()` computes the whole list on a recompute
|
||||
(the graphs are walked in order, so a channel's inputs must be there) but writes only the channels in the
|
||||
affected mask; the affected set is the edited channels plus whatever *names* them, since one channel's output
|
||||
is another's input. Before this, every edit rebuilt the store and every channel in the window started again,
|
||||
which is the "modifying one expression invalidates all the others" report.
|
||||
`[math-probe] graph edit: stored_slots N -> M` reads it: M must be at least N -- the store keeps filling
|
||||
while the edit runs -- and never 0.
|
||||
|
||||
- **A math channel whose source is switched off is switched off with it.** `Source` resolves to a *channel*, so
|
||||
a derived channel cannot be computed from a signal nobody acquires: left enabled it would silently compute
|
||||
nothing, which is what "all math signals depending on it do not compute anymore" looked like from outside.
|
||||
`on_signal_switch()` disables the channels that read that signal -- **transitively**, since one math channel
|
||||
may name another -- through the same path the pane's own switch uses (`MathChannel::enabled` and
|
||||
`apply_math_channels()`), and takes their traces off the plots along with the signal's. The channels that do
|
||||
not read it are untouched: `[enable-all-probe]`'s `math_on=[…]` is the half that reads this. Re-enabling the
|
||||
signal does **not** switch them back on, for the same reason re-enabling does not restore the traces.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Invariants: the measurements pane
|
||||
|
||||
Why the pane is refreshed on a cadence, why neither pass may read its window raw, and how a crossing is counted.
|
||||
Why the pane is refreshed on a cadence, why neither pass may read its window raw, how a crossing is counted, and which mean over a split is which.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
@@ -16,9 +16,10 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
frame; the window is materialized in bounded chunks (never two whole-window f32 temporaries)
|
||||
and the loop holds it **by reference** (copying both vectors per measurement was 300 MB of
|
||||
memcpy a frame); and `measure_stats()` is one shared pass carrying both extremes with their
|
||||
first positions, the sum and the count, so average/min/max/peak-to-peak/amplitude need no
|
||||
pass of their own. **Adding a type must not add a pass** -- that is what `compute_measure()`'s
|
||||
`stats` argument is for.
|
||||
first positions, the sum and the count, so average/min/max/peak-to-peak need no pass of their
|
||||
own -- `top`/`bottom`/`amplitude` take a second read of that same **bucket** list and never of
|
||||
the window (the flat-level invariant below). **Adding a type must not add a pass** -- that is
|
||||
what `compute_measure()`'s `stats` argument is for.
|
||||
The marks' *ink* is set where they are created (`install()` in `plot_host.cpp`): 0.75 px and
|
||||
dashed 4/4 through `Plot::set_cursor_stroke()`, which is per cursor because the trigger marker
|
||||
(also a cursor) keeps the solid `line_width_px` stroke. A new measure mark must take that
|
||||
@@ -57,10 +58,19 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
*finite* sample of the new side and an aggregate cannot say which that is); the caller then opens
|
||||
that bucket. **Nothing may be folded before a refusal** -- a refused bucket goes to
|
||||
`measure_accumulate()`, which adds the same samples again, and sums added above the `return false`
|
||||
double-counted every opened bucket, reading `top=0.614304` on a unit sine against the 2/pi it must
|
||||
be. Four things this rests on, each of which breaks it silently:
|
||||
double-counted every opened bucket. That read as `top=0.614304` on a unit sine against the 2/pi a
|
||||
mean over a half then gave; a *bucketed* window no longer reports `top` from those sums at all
|
||||
(see the flat-level invariant below), which makes the rule easier to break silently rather than
|
||||
less binding -- the sums are the fallback a window with no histogram reads, so a wrong one is a
|
||||
wrong answer waiting for that window. Four things this rests on, each of which breaks it silently:
|
||||
* **Exactness is the point, so nothing may stride or decimate.** `peak to peak` must still read
|
||||
exactly `2` on a unit sine and `top` exactly `0.636620` (2/pi). A bucket's sum is a sum.
|
||||
exactly `2` on a unit sine, and it and `average` still come straight out of the one shared
|
||||
`measure_stats()` pass. A bucket's sum is a sum. (`top` has no *exact* reading to protect any
|
||||
more: it is a density estimate and not a mean, so the assertion that used to be
|
||||
`top == 0.636620` on a sine now belongs to `above 50`, which *is* a mean over a side and reads
|
||||
exactly 2/pi again -- see the band-average invariant below, and the flat-level one for what
|
||||
`top` reads instead. Everything above is about the *bucket* path being a fast path and not an
|
||||
approximation, and that is unchanged.)
|
||||
* **The bucket index space must be the window's.** `ChannelBuffer::acquire()` calls
|
||||
`pyramid_.drop_before(values_->base())`, so a channel's pyramid is in the value ring's
|
||||
absolute index space -- the same space `live_window_locked()` answers in, since a channel's
|
||||
@@ -76,6 +86,126 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
the pyramid for nothing.
|
||||
`measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say
|
||||
anything.
|
||||
- **`Top`/`Bottom`/`Amplitude` read a *flat* level, and the density that finds it is the buckets',
|
||||
not the samples'.** A band-limited edge overshoots and rings -- the mock's `square` at 30
|
||||
harmonics reaches ±1.179 where its plateau is ±1 -- so a measurement that reads the extremes, or
|
||||
a mean over a side of a level, reports the ringing rather than the level the signal dwells at:
|
||||
that is what made this change (`amplitude` read `1.179350` and `top` `0.986496` on the rippled
|
||||
square, against `1.179350` of overshoot where the plateau is `1`). A flat level is a *density*
|
||||
question: the plateau holds most of the samples and the ringing a few percent. So
|
||||
`measure_scan_buckets()` folds a 64-bin histogram of the whole buckets' *midpoints* while it has
|
||||
the list in hand -- one observation per bucket, each bin carrying its buckets' **exact** sum and
|
||||
finite count -- and `measure_flat_levels()` takes, either side of the split, the densest bin that
|
||||
holds at least half the densest one's buckets, **the outermost of those**, and the exact mean of
|
||||
the samples of the buckets within **5 % of the window's peak-to-peak** of it. 5 % is *under* the
|
||||
~9 %-of-swing Gibbs overshoot, so the ringing's excursion falls outside the band while a
|
||||
plateau's own ripple stays inside it; the outermost-of-the-equally-dense rule is what makes a
|
||||
waveform with *no* plateau -- a sine, a ramp -- read its peak instead of an arbitrary mid-range
|
||||
bin that a shifted window would move. `Amplitude` is then `top - bottom`, a bench scope's
|
||||
`Vamplitude`, and **not** the half-swing it used to be, nor the peak-to-peak swing.
|
||||
The readings, fast mock, 2 s window, `[measure-trace]`, harmonics `0` → `30`: `top` `1.000000` →
|
||||
`0.999983`, `bottom` `-1.000000` → `-0.999983`, `amplitude` `2.000000` → `1.999966`, against
|
||||
`peak to peak` `2.000000` → `2.358701` -- the extremes keep the overshoot, and that separation
|
||||
*is* the measurement. On the probe's 1 Hz ±1 sine: `top` `0.957988` (it was `0.636620`, the
|
||||
half-wave mean), `amplitude` `1.915983`, and `peak to peak` still **exactly `2`**; `[stats-probe]`
|
||||
reads `top` `mean=0.957905 sd=0.0014 lo=0.951967 hi=0.962348` over a scrolling window, which is
|
||||
the stability the value band buys -- the answer does not follow the window's alignment.
|
||||
Five things this rests on, each of which breaks something silently:
|
||||
* **Nothing may be read raw for it.** The histogram is the *same* fetched bucket list, walked a
|
||||
second time inside `measure_scan_buckets()`; a histogram of the window's *samples* would be a
|
||||
second O(window) walk, which is exactly what the pyramid path exists to avoid. `measure cost:`
|
||||
is the guard and its `raw` does not move: the probe's pane reads
|
||||
`covered=59684972 buckets=932446 raw=47640 (0.1%)` against the baseline's
|
||||
`covered=61183221 buckets=955856 raw=47402 (0.1%)` -- the same `raw`, 0.44 → 0.50 ms a pass
|
||||
(and `--noise 0.1`, `2.1%` both sides, 0.52 → 0.65 ms).
|
||||
* **It is gated, and the gate is what keeps an unrelated pane free.** `measure_scan_buckets()`'s
|
||||
`flat_hist` comes from `App::MeasureWindow::flat_wanted`, set by the first loop over the pane
|
||||
and only for one of the three types. A pane of means and extremes pays nothing.
|
||||
* **The bin range is the *buckets'*, not the window's.** The window's partial edge buckets and the
|
||||
samples produced since `acquire_all()` are read raw and are not in the list, so
|
||||
`measure_scan_buckets()` tracks its own `blo`/`bhi` over the **non-empty** buckets only -- an
|
||||
all-non-finite bucket is an empty aggregate whose `lo`/`hi` say nothing, and taking them would
|
||||
put a bucket at some arbitrary value. A degenerate range (`bhi == blo`) leaves `folded` at 0,
|
||||
which is the caller's "no histogram at all".
|
||||
* **The *window's* peak-to-peak is what the band is a share of**, and the *split* is sized exactly
|
||||
as `measure_set_level()` sizes a level, so "the top" and "the level a period is counted about"
|
||||
are placed by one rule. The band is a *value* band, which is why the reading does not move when
|
||||
the window does.
|
||||
* **The fallback is the split, and it is what a filtered or sub-bucket window reads.** `hf_fc > 0`
|
||||
disables the pyramid (above) and a window shorter than a bucket has no bucket to fold, so
|
||||
`top`/`bottom` fall back to the mean of each side of the split -- and `amplitude` to the
|
||||
window's own peak to peak, which is the same number on a waveform with nothing to exclude.
|
||||
There is no flat level in that mode to subtract, so `amplitude == top - bottom` is *not* an
|
||||
invariant to lean on there. Measured on the rippled `square` at `hf_fc=100`:
|
||||
`top=0.973824 bottom=-0.991731 amplitude=2.354173 peak to peak=2.354173`, `buckets=0
|
||||
raw=200.0%` -- two raw walks and no density estimate, which is the honest reading of a window
|
||||
the pyramid cannot summarize.
|
||||
- **`Above`/`Below`/`Between` are the *means over a side of a share*, and they are the readings
|
||||
`Top`/`Bottom` gave before a flat level replaced them.** That change kept the split's two means as
|
||||
its *internal fallback* for a window the pyramid cannot summarize (above); these three types are
|
||||
that fallback, exposed and named, which is what lets the two different questions about one split be
|
||||
asked side by side -- on a unit sine `above 50` reads `0.636620` (2/pi) where `top` reads
|
||||
`0.957988`, and on the mock's band-limited square `above 50` reads `0.986496` where the plateau is
|
||||
`1.000000` and `top` `0.999983`: the edge's ring is *inside* this mean and not inside that level.
|
||||
Five rules, and the first two are what a reader is most likely to assume wrongly:
|
||||
* **A band is not a wider split, and needs no walk of its own.** `Above`/`Below` are
|
||||
`MeasureLevel::sum[1]`/`count[1]` and `sum[0]`/`count[0]` at the measurement's own share.
|
||||
`Between` is the **difference of two levels' upper sides** -- `sum[1](lo) - sum[1](hi)` over
|
||||
`count[1](lo) - count[1](hi)`, i.e. the samples above the lower edge and *not* above the upper
|
||||
one -- and no single level's two sums can bound a band, because a band's two edges are two
|
||||
thresholds. Both accumulators come out of one pass two over one window (the pane registers one
|
||||
level per edge, `w.pct`, and both are deduplicated into it), so the difference is exact rather
|
||||
than approximate. `v == level` is the below side, as everywhere, so the band is `(lo, hi]`.
|
||||
* **It rides the bucket fast path unchanged.** A bucket wholly inside the band is strictly above
|
||||
the lower level *and* strictly below the upper one, so both accumulators fold it whole; only the
|
||||
buckets that straddle either edge are opened, exactly as for one level. `measure cost:` is the
|
||||
guard: adding all three types to a pane of every type left `raw` at `0.1%` of `covered`
|
||||
(`113966` of `240719031` with the ten types, `134978` of `255372033` with the thirteen), and a
|
||||
band is *two entries of the level list*, not a second pass.
|
||||
* **`measure_level_pcts()` is the only place the shares are read**, and it clamps both to `[0,100]`
|
||||
and puts `Between`'s pair **in order**: "between 80 % and 20 %" is the band between 20 % and
|
||||
80 %. Normalized rather than refused because the shares name two *edges*, and the band does not
|
||||
depend on which the user typed first; the pane's dedup key and both accumulators are keyed on the
|
||||
ordered pair, so one band is one walk however it was written.
|
||||
* **Nothing to measure reads `--`**, exactly as for every other type: a side holding no sample (a
|
||||
share at `100 %`, whose level is the window's own maximum) and an *interior* holding none are
|
||||
both `invalid`/`--` and never `0`. The ideal `--square-harmonics 0` square is where the second
|
||||
shows -- every sample is exactly +/-1, so the band `(0, 0.5]` holds nothing -- against the same
|
||||
band on the band-limited square, which holds the edges' transitions (`between 50 75` =
|
||||
`0.302744`, `between 25 75` = `0.000000`: the symmetric band's two halves cancel, the transition
|
||||
being odd about its own midpoint).
|
||||
* **The reading is a mean over the window's *samples*, so its sixth decimal follows the window's
|
||||
boundary.** A live window is closed at both ends, so its oldest and newest sample share a phase
|
||||
and the mean carries one sample that is not in an open interval -- about one unit in the sixth
|
||||
decimal out of 2.6 M samples. Measured over one fast-mock run, `[measure-trace]`: `above 50` on
|
||||
the sine printed `0.636620` on 48 lines and `0.636621` on 2, `below 50` `-0.636620` on 37 and
|
||||
`-0.636619` on 18, `above 50` on the square `0.986496` on 32 and `0.986495` on 18, and
|
||||
`between 50 75` on the ramp `0.250000`/`0.250001`/`0.249999`. The old `Top`/`Bottom` spread the
|
||||
same way -- it is a property of a mean over a finite window, not of these types, and a reading
|
||||
that has to be exact to six figures is read off `[bandavg-probe]`'s nine.
|
||||
Measured on the fast mock, 2 s window, the mock's default 30-harmonic square, `[bandavg-probe]`
|
||||
(nine significant digits, then what the pane prints):
|
||||
`above 50` on the sine `0.636620262` -> `636.62 mV`; `below 50` on the sine `-0.636619283`;
|
||||
`between 50 75` on the sine `0.255873597` -- the analytic `3(2-sqrt 3)/pi = 0.255873`, which is
|
||||
neither the window's mean (`0`) nor the band's edge (`0.5`); `above 50` on the square
|
||||
`0.986494746` / `0.986495505` (one or two samples at the window's end); `between 50 75` on the
|
||||
`ramp` `0.250000385` and `above 50` `0.500000` -- a uniform density makes every band's mean that
|
||||
band's own centre, so a quarter and a half of the range are the shape's prediction and not a
|
||||
coincidence; `above 100` on the `ramp` `--`; and the ideal square's `between 50 75` `--`.
|
||||
Editing the sine's band through the dialog to `50/90` takes it to `0.431361697`
|
||||
(`0.8 / (2 asin 0.8)`), which is the check that the second share is the one the engine reads.
|
||||
- **`MeasureOptions::noise_pct` is a percentage, clamped in `measure_set_level()` to half the room to the
|
||||
nearer extreme**, so a band can never invert its own thresholds. It moves the **edge decision only**: the
|
||||
sums still split at the bare level, which is the *fallback* `Top`/`Bottom` read on a window with no
|
||||
bucket histogram -- a band that moved that split would change what a filtered or sub-bucket window
|
||||
reports, for no gain to a crossing count.
|
||||
- **`measure_accumulate` loops samples outer, levels inner, and that is not backwards.** The filter holds one
|
||||
state for the whole walk, so levels-outer would step it once per level *per sample* and hand every level but
|
||||
the last a different filter state.
|
||||
- **A non-zero `hf_fc` disables *both* bucket fast paths**, with the single
|
||||
`if (!filtered && reader.bucket_range(...))`. An empty `buckets` makes `bulk_from == bulk_to == n` *and*
|
||||
leaves `lo_search` / `hi_search` at 0, so `pin()` becomes a no-op -- and that is the right answer, because a
|
||||
bucket is an aggregate of unfiltered samples and cannot be mixed into a filtered walk in either direction.
|
||||
- **A crossing is counted with hysteresis, and the band moves the *edge decision* only.**
|
||||
`MeasureLevel::hysteresis` is a Schmitt trigger about `level`: an edge is counted once the signal
|
||||
has passed `level + h` going up or `level - h` going down, and a sample inside the band decides
|
||||
@@ -95,9 +225,13 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
decides that side and the measurement reads nothing at all, which is worse than reading
|
||||
something noisy. On a ringing 3.3 V logic signal at a 90 % split: `enters=0` with a fixed band,
|
||||
`enters=4 period=0.200000` with the clamp.
|
||||
* **The `sum`/`count` split stays at the bare `level`.** That split *is* `Top`/`Bottom` -- the mean
|
||||
above and below the threshold -- so a band there changes a documented exact reading. `top` on a
|
||||
unit sine must still be `0.636620`.
|
||||
* **The `sum`/`count` split stays at the bare `level`.** Four things read it and none may be
|
||||
moved by a band: a crossing's *side* is decided by the sample's own value against the level
|
||||
(`v == level` is the below side, as it always was), `Top`/`Bottom` fall back to the mean of
|
||||
each side on a window with no bucket histogram, and `Above`/`Below`/`Between` read it on
|
||||
*every* window -- their whole definition is that split, so a band folded into it would move a
|
||||
documented exact reading (2/pi on a sine) for no gain to any crossing count. The band is about
|
||||
*deciding a crossing*, not about where a sample lives.
|
||||
* **`h = 0` must reduce exactly to the single-level walk**, including the `v == level` skip, so the
|
||||
band is a parameter of the engine rather than a mode of it.
|
||||
The cost is the noise's, not the band's: the clean fast mock reads `raw=119412 (0.1%)` of 215 M at
|
||||
@@ -108,3 +242,21 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
`enters`, same `count[]`, identical `first_enter`/`last_enter` to 0.0 -- which is the assertion
|
||||
that the fast path is a fast path and not an approximation. The app's reading is
|
||||
`[measures-probe]` against `--noise 0.1`.
|
||||
- **A statistic's observation is one *recompute*, not one sample.** The pane already refreshes on
|
||||
a cadence (at most one pass per 100 ms while the window scrolls, exactly once when it
|
||||
settles), and the accumulator is fed from that pass's result — so `n` counts windows measured.
|
||||
Feeding it per sample would make `n` a sample count and the mean a mean of *samples*, which is
|
||||
not what any of these measurements measure: a frequency over a window is not a property of a
|
||||
sample. The reading that names this defect is `[stats-probe]`'s `n` in the thousands rather
|
||||
than the tens.
|
||||
- **The accumulator is Welford's, and the reason is the same one the time ring is `f64` for.** A
|
||||
measurement's values are routinely large with a small spread — an absolute stream time is the
|
||||
obvious case — and `Σx² - (Σx)²/n` cancels to noise there. The standalone check is that the
|
||||
spread of `{2,4,4,4,5,5,7,9}` survives a `+1e9` offset unchanged.
|
||||
- **What resets the statistics is "what is being measured changed", not "what is in view
|
||||
changed".** An explicit Reset, an add/edit/delete (all three funnel through
|
||||
`rebuild_measures_pane()`), a CONFIG, and a change of display window each change the
|
||||
*question*, so the history is cleared. A pause, a resume, a trigger latch, a pan and a zoom do
|
||||
not: they change which samples answer it, which is precisely what averaging over many windows
|
||||
is for. The pane-wide toggle does not reset either — it only shows and hides labels that are
|
||||
always created and always fed, so reading the plot for a moment does not cost the history.
|
||||
@@ -4,6 +4,41 @@ How `plot::Figure`/`plot::Plot` may and may not be driven, what a row's state be
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **A sidebar's size must be its own, never a divider's position.** `GtkPaned` keeps a child's size in
|
||||
`position`, which is a number of pixels **of the window**: the sidebar's width *was* `window - 940`, so on
|
||||
any window narrower than 940 px — a scaled display, where the logical width is half what the screen looks
|
||||
like, or a window a manager capped — the sidebar came up **0 px wide**, and the only way to see it was to
|
||||
drag the divider back. Every patch that kept the paned failed for the same reason (the position derived
|
||||
from the paned's own width, re-parenting the column, the property form of its children, size requests):
|
||||
the *mechanism* was the fault. It is an **`AdwOverlaySplitView`** now — what a utility sidebar is in
|
||||
GNOME, as against `AdwNavigationSplitView`, which is for navigation lists — with `min-sidebar-width` =
|
||||
`max-sidebar-width` = 320 in **pixels**, so the width is its own at any window size, and `collapsed` (set
|
||||
from the window's width in `tick()`) to *overlay* the plot when there is no room beside it rather than
|
||||
being squeezed. `[sidebar-probe]` reads both states: `window=756 sidebar=320` under a 2x scale is exactly
|
||||
the case that used to be zero.
|
||||
- **The sidebar's button is the only input; `AdwOverlaySplitView:show-sidebar` is an output.** The split
|
||||
view writes that property *itself* — at startup, and when it collapses — with no user involved, so reading
|
||||
it back as the user's wish is wrong: doing that made connecting a source leave the sidebar shut, because
|
||||
the widget's own `false` was recorded as the user asking for it. A `GBinding` from the property to the
|
||||
toggle is the same mistake in one line, and it cannot be told from a real change by a "is this our own
|
||||
write" flag either, since the toggle's signal may fire after the write returns. Instead `App::sidebar_wanted`
|
||||
holds the wish (written only by `on_sidebar_toggled`, guarded by `App::sidebar_syncing` around our own
|
||||
`set_active`), `update_side_visibility()` is the single place that turns that wish plus "are there signals"
|
||||
into the panel's *and* the button's state together, and `tick()` re-asserts them when the widget disagrees —
|
||||
which is also why a hide *gesture* does not stick: the panel stays where the button says it is. The
|
||||
no-signals case must leave the wish alone, or a source connecting later finds a sidebar the user never closed.
|
||||
- **`AdwOverlaySplitView:sidebar-width-fraction` is a 0..1 fraction of the window, whatever
|
||||
`sidebar-width-unit` says.** Setting a pixel count there (320) is a `GObject` critical, and it silently
|
||||
falls back to 0.25 — which reads as "the sidebar is mysteriously 560 px wide" on a wide window with no
|
||||
error anywhere the build would catch. Pin the width with `min-sidebar-width`/`max-sidebar-width`.
|
||||
- **A `GtkPaned`'s position is stated after both its sides are filled**, and that is a fact about the
|
||||
*function's order* rather than about the window's shape, which is why it is the one part of the
|
||||
window skeleton that cannot live in `main-window.ui`. `gtk_paned_set_position()` clamps against what
|
||||
its children ask for *now*: called before the sidebar's panels have been appended it is clamped
|
||||
against an empty pane, and the divider lands wherever that left it. Moving the call into the `.ui`
|
||||
moved the plot column by about 180 px, which the layout probe read as a different `done_box` and a
|
||||
taller figure.
|
||||
|
||||
- **`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
|
||||
@@ -104,6 +139,29 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
figure rebuild — which `assign()` already forces. A signal on no plot gets
|
||||
`RowSet::unplotted_color()` (grey) rather than no chip, so the panel's rows stay the
|
||||
same height.
|
||||
- **The invalid-span band is data-anchored and reserves no layout space.** `Plot::set_invalid_spans()`
|
||||
draws a tinted quad over the span it names; unlike `Plot::set_x_note()` (the `t0` caption), which
|
||||
is chrome drawn at a fixed corner of the axes regardless of the data, a band pans and zooms with
|
||||
the samples it accuses. The two are the complementary cases: the caption is chrome that data does
|
||||
not move, the band is annotation that data does. Both are alike in the second count: neither
|
||||
reserves layout space and neither can be picked, so a hole cannot move the layout the labels were
|
||||
measured into.
|
||||
|
||||
The band has a **one-device-pixel floor on its width**. A hole of one sample in a window of hundreds
|
||||
of thousands is a hundredth of a column, and a fill rounded to zero would drop exactly the case
|
||||
the band is most needed for — a sub-pixel hole is invisible as a gap in the polyline and would be
|
||||
invisible as a band too without the floor.
|
||||
|
||||
The band carries **three states** and they are a property of the hole, not of the plot: solid
|
||||
(`Style::invalid_fill`) while the hole still lacks packets, faint (`Style::invalid_repaired_fill`)
|
||||
once `Hole::repaired()` is true, absent once the hole's slots age out of the ring and
|
||||
`forget_holes_before()` drops it from the table. A repaired hole is **not** retired early: the band
|
||||
is a record of what the window was missing, and retiring it on repair would erase the report the
|
||||
moment it became explicable — the faint tier is how the display says "this was missing but arrived
|
||||
late". `RowSet` sends one `plot::InvalidSpan` per hole to every row — a gap is in the stream, not
|
||||
in one signal — and only in `Live` and `Capturing`, because a `Held` or `Paused` snapshot carries
|
||||
its own stamps that the live hole table does not address.
|
||||
|
||||
- **`RowSet::signal_color()` is the only route to a trace's colour.** The user can override the
|
||||
palette entry per signal (clicking its chip; `set_signal_color()` / `clear_signal_color()`,
|
||||
keyed by **name**, because a palette index is positional and a CONFIG renumbers), and that
|
||||
@@ -115,3 +173,12 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
because `relayout_areas()` is the one place that builds a figure and re-points the area at it.
|
||||
`UDPSCOPE_PROBE_COLOR_MS` reads the result back out of `plot::Plot::trace()` and out of the
|
||||
chip's own markup, since asking the resolver twice would assert nothing.
|
||||
|
||||
- **Switching a signal off takes its traces off every plot.** The switch means "do not acquire this", and a
|
||||
trace left behind draws nothing: it holds one of the four slots a plot has, reads `--`, and blocks the drop
|
||||
that would fill it with something live. `on_signal_switch()` clears every slot by the **signal name** the
|
||||
slot remembers -- never the channel index, which is exactly what the enable change renumbers -- and says what
|
||||
it did in a notice, because a trace that vanishes silently is its own kind of confusing. The follow-ups (a
|
||||
relayout, the panels' chips, the layout save) are done once by the caller rather than per slot. Re-enabling
|
||||
does **not** put the traces back: the arrangement is the user's, and guessing where they were is worse than
|
||||
leaving them off.
|
||||
@@ -32,6 +32,15 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
`crosshair_interpolated_mode_blends_the_two_samples` and
|
||||
`the_crosshair_mark_is_bigger_than_a_sample_dot`, plus `[cursors-probe]`'s `first mark at x=..
|
||||
y=..` agreeing with the pane's first value.
|
||||
- **A measurement with a non-zero `hf_fc` is the one exception**, and it is inherent rather than a rounding
|
||||
error. Its `min`, `max`, `peak to peak`, `average`, `top` and `bottom` are computed from a one-pole-filtered
|
||||
copy of the window, so the quoted value need not equal any stored sample -- and with `show_on_plot` its
|
||||
ruler sits at a level the drawn trace, which is raw, never reaches. This is what filtering *is*; it must
|
||||
not be "fixed" by snapping the readout to the nearest sample. Every measurement with `hf_fc == 0`, which is
|
||||
the default, still quotes a stored sample exactly as the rule above says. The filter is seeded with the
|
||||
window's **first sample** and never from history before the window, so a measurement never depends on data
|
||||
outside the window it reports on: the left-edge step transient is removed by the seeding, not by priming
|
||||
from earlier samples.
|
||||
- **`format_measure()` is the one formatter for both panes, and its SI prefix does not depend on
|
||||
there being a unit.** The cursors pane's values and the measurements pane's `text=` go through
|
||||
it, so a change reaches both. The prefix ladder used to be inside `if (!unit.empty())`, which
|
||||
@@ -45,6 +54,12 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
unit and a spaced `m` alone reads as metres; with one it stays spaced and the prefix joins the
|
||||
unit (`636.62 mV`). Below `1e-12` the ladder runs out, and `%.2f` would print `0.00` again, so
|
||||
that range falls back to `%.3g`.
|
||||
- **With the instruments off the readout blanks, and the panel stays on screen.** Cursors-off makes the
|
||||
pane *insensitive* rather than hiding it -- a panel you can still see is a panel you can read, and the
|
||||
settings you are about to switch back on must not vanish while you look for them -- and every cell in
|
||||
it is written `"--"`, the spelling an unavailable cell already uses. A readout quotes a stored sample;
|
||||
with nothing bracketing one there is no sample to quote, and keeping the last values would be quoting
|
||||
a sample the cursors no longer name. Two spellings of one state in one grid would be the defect.
|
||||
- **The cursors are read back from the plots only while a gesture is on that row.** `Cursor::x`
|
||||
is the *free* pointer position while a drag runs and the snapped value otherwise -- which is
|
||||
what the app itself wrote a frame earlier, or the snap of it. A tick that reads a row on
|
||||
|
||||
@@ -4,12 +4,33 @@ What a session persists and in what order, when a save is refused, and when an e
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **The trigger is persisted, settings and state both.** Its condition, its channel (by *name*), its
|
||||
pre-trigger share, hold-off and mode, and whether trigger mode was on — written even while it is off,
|
||||
because a trigger configured once and switched off is still the trigger you come back to. The two
|
||||
enumerations are stored as the *words the panel's dropdowns show*, not as their indexes: an index ties
|
||||
the file to a declaration order, which is the lesson `math_op_at()` carries, and the words are shared
|
||||
with the panel so the two cannot drift. The channel resolves when the acquired channels exist
|
||||
(`rebuild_trigger_signals()` picks up `App::pending_trigger_channel`, or `apply_trigger_layout()`
|
||||
resolves it itself when they are already there) — a name that resolved to nothing would silently move
|
||||
the trigger to channel 0.
|
||||
- **A session that loaded is what locks the two panes.** Locked is the returning user's mode: the
|
||||
trigger pane shows one compact line, the signal list only the signals that are on, and neither offers
|
||||
a control that changes the configuration. A *first run* — no session file for that source — never
|
||||
reaches `apply_layout()` and stays in configuration mode, which is what a first run is for. The mode
|
||||
is a mode and not a setting: it is deliberately not persisted, so a scope reopened tomorrow starts
|
||||
where a returning user expects to start. **Dragging a row onto a plot is not configuration and survives
|
||||
the lock**: filling plots is what the list is *for*, so a locked row keeps its `GtkDragSource` — from
|
||||
anywhere on it, the colour chip included, which in configuration mode has a click gesture that claims
|
||||
the sequence precisely so a drag cannot start there. `drag_sources` on the `signals panel:` line is the
|
||||
reading, and it must be `rows - 1`.
|
||||
- **The session is the layout.** `LayoutState` persists what makes a reopened scope look like the
|
||||
one that was closed: the window, the rows and their slot signals, the calibration, **the enabled
|
||||
signals**, **the chosen trace colours** (`[[color]]`, `name` + `rgb = "#RRGGBB"` — only the
|
||||
*overrides*, so a stream's new signals still take the positional palette and a saved session does
|
||||
not freeze a colour that is meant to follow the signal list), **the math channels with their
|
||||
graphs** and **the measurements pane**. Signal identity
|
||||
graphs**, **the measurements pane**, and **`measure_stats`** — the measurements statistics
|
||||
toggle; written as a bare top-level boolean (`measure_stats = true/false`), defaults to `false`
|
||||
when absent. Signal identity
|
||||
is the *name* throughout (a CONFIG renumbers channels), 'enabled' is read off the channels the
|
||||
model built -- an enabled signal is exactly one that produced a channel -- and `apply_layout()`
|
||||
installs the graphs **before** assigning the rows, since a row may hold a math channel's name and
|
||||
@@ -21,9 +42,55 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
was parsed into the last calibration record and the enable state was lost without a word.
|
||||
`mkdirs()` used to create only components with a trailing slash, so `~/.config/udpscope` was never
|
||||
made and the *first* save failed silently -- worth knowing when a layout change "does not stick".
|
||||
- **Measurement options persist under *named* keys -- `threshold`, `threshold2`, `noise` and `hf` -- so a
|
||||
file written before them loads at the defaults**, which are exactly the behaviour it was written against:
|
||||
`noise = 10` is the
|
||||
hysteresis band the app has always counted with, `hf = 0` is no filter, and a file with no
|
||||
`threshold2` -- every file written before `between` existed -- leaves that measurement at
|
||||
`threshold2 = 75`, which is the value the dialog offers a new one. The *pair* is stored as written
|
||||
rather than as ordered: `measure_level_pcts()` is what puts a reversed pair in order on read, so a
|
||||
file records what the user typed and a measurement file round-trips unchanged.
|
||||
- **The math node string is thirteen pipe fields, and the reader accepts twelve *or* thirteen.** `med_n` is
|
||||
the thirteenth, after `y`; a twelve-field node is a file written before the de-noise node existed, and its
|
||||
`med_n` stays at the struct's default -- which is what such a file meant. Field 0 is the op as a
|
||||
`math_op_at()` index, which is why `MathOp` may only be *appended* to.
|
||||
- **The export is collected at click time, not in the file-dialog callback.** The live
|
||||
view keeps scrolling while the chooser is up, so a late collection would write a
|
||||
window the user never saw. The writer is picked by the chosen path's extension.
|
||||
- **The MDF export is MDF 3.30 written by hand, and its layout is the reader's, not the
|
||||
standard's prose.** No dependency is added: MF4 is HDF5 and would need a library, while MF3 is a
|
||||
handful of fixed-size blocks, so `write_mdf()` spells them out. The field list comes from
|
||||
`asammdf` 8.8.27 (`blocks/v2_v3_blocks.py`, `blocks/mdf_v3.py`, which parses MF3 by explicit
|
||||
offsets) and was confirmed byte for byte against a known-good 3.30 file asammdf wrote (one
|
||||
group, a `time` master and two `V` channels). The shape, in file order: the **ID block** is 64
|
||||
bytes with *no id/length header* (`"MDF 3.30"`, byte order, version `330`) — a writer that
|
||||
emits `ID` + size + links at offset 0 produces something no reader accepts, since the first real
|
||||
block starts at 64; the **HD block** is 208 (164 common + 44 of the 3.2-era time fields) and
|
||||
points at the data group; a **TX** comment follows it; then the **samples**; the **DG** is 28; a
|
||||
**CC** (conversion, 46 — a `NONE` conversion is the common fields alone) then a **CN** (channel,
|
||||
228) precede each channel; and the **CG** (30) is last. Address fields are absolute file offsets
|
||||
written *forward*, so the whole address map is computed before a byte is written, and every
|
||||
block length is checked after it is built: a field at the wrong width does not fail the write, it
|
||||
leaves the reader in the middle of the next block.
|
||||
- **The data group's data address holds the raw records, with no `DT` header** — which is what
|
||||
asammdf's *writer* does and what its *reader* assumes (`DataBlock` is documented as "a pseudo
|
||||
block not defined by the MDF 3 standard"). This is the one place the file follows the reader
|
||||
rather than a narrow reading of the standard, and it is the assumption ScryLab compatibility
|
||||
rests on: a file with a `DT` block there would have its header read as sample bytes.
|
||||
- **In MDF the master is `abs_seconds`, named `time`, and the relative axis is a channel
|
||||
`time_relative`** — the same two numbers the `.npy`/`.csv` table holds, in the other roles. A
|
||||
master off the relative column is 0 at a different sample in each display mode, so it would put a
|
||||
live export and a held event's export on two timelines, which is the one thing the second column
|
||||
exists to avoid; both carry the unit `s`, and the signal columns follow in the `.npy`/`.csv`
|
||||
order. **The unit** is the client's CONFIG unit (a math channel's own), and it is written into
|
||||
the CC block because that is the only place MDF 3 keeps one — and the place ScryLab reads a
|
||||
signal's unit from. `ExportTable::Column` gained the field for it; the two older writers ignore
|
||||
it, and their bytes are unchanged. **The names** are sanitised and de-duplicated against each
|
||||
other *and* against the two reserved names before being cut to 31 bytes (the CN name field is 32
|
||||
with its NUL): ScryLab imports only the **first** occurrence of a channel name, so a cut made
|
||||
before de-duplication could silently drop a signal. `write_export()` is the one place the
|
||||
extension picks the writer (`.csv`, `.mf3`/`.mdf`, anything else `.npy`), and both the dialog and
|
||||
the self-check's export probe come through it so the two cannot offer different formats.
|
||||
- **The chooser runs before `ScopeModel` exists, and `--server` is what decides.** `Config` carries
|
||||
`server_specified`, set only by the `--server` branch, and `main()` asks only when it is false —
|
||||
so declining costs no buffer and no socket, and `ScopeModel`, `Acquisition`, `RowSet` and
|
||||
@@ -50,3 +117,48 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
out — a source whose streamer was merely down during one run would silently vanish from the
|
||||
only list it can be picked from, for ever. Keeping the old file loses nothing: a run that
|
||||
configured nothing had nothing worth persisting.
|
||||
- **It refuses again while `replaying` is set: a file view is not the source's session.** Same
|
||||
argument, different cause — this view configured nothing about the stream. A plain replay leaves
|
||||
the rows where the user put them, so its save would merely be pointless; a **group** rebinds every
|
||||
slot to its own renamed channels and the save wrote `signals = ["square (A)", …]` into the file.
|
||||
Nothing noticed until the way back: *Go live* re-applies the session, those names match no live
|
||||
signal, so nothing was enabled and **the live view came back with no channels at all** — on the
|
||||
window the poisoned file carried, which is also how it swallowed the window restore below it. The
|
||||
reading is `[events] live restored (after a group)`: the symptom was a stuck `pending=1` beside
|
||||
`channels=0`, because the restore waits for a channel list that was never going to arrive. The
|
||||
explicit *Save as* is deliberately **not** guarded: that one is a user's own answer to "save this
|
||||
view", and its dialog reports the outcome.
|
||||
|
||||
## A configuration is a named session, and a source may hold several
|
||||
|
||||
One source, several arrangements: the same streamer serves different experiments, and retyping a
|
||||
layout to look at the second one is how a session gets lost. Storage is therefore a **directory
|
||||
per source** — `~/.config/udpscope/<host>_<port>/` — holding one `<name>.toml` per configuration
|
||||
plus an `active` file naming the one the source is on. `configuration_path()` is the only
|
||||
expression of the scheme, and the name is `sanitize()`d exactly as the host is, so a name typed
|
||||
with a slash cannot escape the directory.
|
||||
|
||||
A configuration carries the whole session *and* the settings that can only be decided before the
|
||||
first socket: `buffer_mb`, `relative_time`, `capture_packets`. The startup chooser applies them
|
||||
(`apply_source()`), which works because `choose_source()` runs before `ScopeModel` is constructed
|
||||
— the one moment a buffer size can be applied at all. Switching configurations inside a running
|
||||
app applies what can change live and says in the status line that the rest takes effect on the
|
||||
next start; pretending otherwise would be a configuration that half-applies.
|
||||
|
||||
A configuration's *startup* settings reach the model from two directions, and both are needed:
|
||||
`apply_source()` for a source picked in the chooser, and `apply_stored_settings()` for a source
|
||||
named with `--server`, which never reaches the chooser at all. Without the second, a saved host's
|
||||
buffer size, clock and capture ring were silently ignored — the layout still applied, because the
|
||||
app loads that separately once a CONFIG names the signals — so an edit to any of the three read as
|
||||
one that never took effect. **A flag the user typed wins over the file** (`--buffer-mb`,
|
||||
`--relative-time`, `--capture-packets`): the file fills in what the command line did not say, and
|
||||
`Config::buffer_mb_specified` is what tells "not asked" from "asked for the default".
|
||||
|
||||
The `active` file is written on every save and by the chooser's pick, so "which configuration was
|
||||
I on" survives a restart. A session written before configurations existed is **imported once** as
|
||||
the configuration named `default`, and the old file is renamed to `<host>_<port>.toml.imported`
|
||||
rather than deleted: an import that cannot be undone by hand is a migration nobody can back out
|
||||
of.
|
||||
|
||||
Nothing is lost by switching: the dialog saves the current session *before* loading the picked
|
||||
one, which is the only ordering in which a switch cannot lose what it switches from.
|
||||
@@ -31,6 +31,21 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
reasons (an event pin) call `sync_window_display()` alone, which only restates the control and
|
||||
never moves the plot — calling `apply_time_window()` there would clamp a legitimately short
|
||||
capture up to the requested window.
|
||||
- **With nothing bound, `set_time_window()` frames `{-window, 0}` and never `{0, window}`.** Under
|
||||
a following x group a range states two things — its 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.
|
||||
When there is a live edge to read that is `{newest - window, newest}`; when there is not, the
|
||||
edge the live view would put at x = 0 is 0, so the equivalent statement is `{-window, 0}`.
|
||||
Asking for `{0, window}` there is an offset of a *whole window*. This is not a rare fallback:
|
||||
"no samples bound" is the state every structural change leaves behind, and
|
||||
`apply_time_window()` re-plans the rings and *then* calls this, so it is the axis a mode change
|
||||
is drawn with until the first sample lands. With `{0, window}` the two or three frames after
|
||||
every event or waveform open drew the time base one screen to the left of where the following
|
||||
frames drew it — a trace that jumps a screen sideways and comes back, which is what "opening
|
||||
events or even waveform sometime result in data flickering (like time base) and is unusable"
|
||||
was. The reading is the dump's traceless paint counts: `mode=live` 38 → 0 and `mode=waiting`
|
||||
2 → 40, the latter being the point — a waiting view now paints its empty axis in the *same*
|
||||
place every frame instead of twitching into a framed one.
|
||||
- **The x labels go relative on the tick *step against the label resolution at the axis'
|
||||
magnitude*, never on the span.** `RowSet::update_time_labels()`'s question is only ever "can
|
||||
this step be written at this magnitude": `TickFormat::Time` carries three significant figures
|
||||
@@ -75,3 +90,9 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
tells a real gesture from its own echo, so writing the group alone is reverted to the zoomed
|
||||
range on the next frame. `fit_all()` is the y fit plus this, shared rather than duplicated so
|
||||
the two cannot drift apart.
|
||||
- **Relative time is decided by the newest statement, not by the default.** `Config::relative_time_specified`
|
||||
is set by `--relative-time` and by the preferences — which the source chooser can reach before the run
|
||||
is planned, so that is the one place a preference still changes *this* run. Both the list's own copy
|
||||
(`apply_source()`) and a source's stored configuration (`apply_stored_settings()`) only fill in when
|
||||
nothing newer was said. Without the marker, clicking a source row after applying the preference
|
||||
silently reverted it — `[sources] picked ... relative=0`, with the dialog having just written `1`.
|
||||
@@ -18,6 +18,23 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
reports it as `zoom ... span=.. window=.. limited=1`.
|
||||
Its pan clamping is likewise gated on the data being wider than the view, or the empty
|
||||
left-hand side drags the live edge off the right-hand edge.
|
||||
- **The compact pane's arm control is built in the heading, and *one* function states both arm
|
||||
buttons.** Arming is a runtime decision and the locked pane is exactly where it is made, so the
|
||||
control cannot live in the body — that is the part which goes away. `sync_arm_buttons()` is what
|
||||
states the label, the sensitivity and the visibility of both buttons, and it is called from the four
|
||||
places any of its three inputs can change (the click, the toolbar toggle, the curator's re-read,
|
||||
`apply_pane_lock()`). Visibility is `enabled && locked`: with trigger mode off the line beside it
|
||||
already says "stream (trigger off)" and there is no engine to arm, and with the pane open the body's
|
||||
button is the one on screen — **two arm buttons at once would be two controls for one state**, which
|
||||
is why the probe reads the body's allocation as `-1` while the compact one is up.
|
||||
- **A widget's allocation is not readable in the tick it is first shown.** The compact arm control is
|
||||
created hidden and shown for the first time inside a signal handler, so `gtk_widget_get_width()` in
|
||||
that same tick reads `0` while `gtk_widget_is_visible()` already says `1` — and its sibling's stale
|
||||
allocation (270 px, from when the *body* was still on screen) reads as a live one, because
|
||||
`gtk_widget_get_visible()` is the widget's *own* flag and stays true under a hidden parent. The width
|
||||
only becomes the proof that a control is on the surface **one frame later**, which is why the probe
|
||||
has a `compact_arm_step()` of its own; and the control to compare against must be measured with
|
||||
`is_visible()` or not at all.
|
||||
- **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
|
||||
@@ -30,6 +47,33 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
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 engine identifies its channel by *name*, never by index.** `TriggerEngine::configure()`
|
||||
drops an open capture when the trigger moves to a different channel, and it is re-called from
|
||||
`Acquisition::on_data()` on **every packet** out of `model_.trigger()`. `TriggerConfig::channel`
|
||||
is an index into `channels()`, and that index is not stable: every rebuild of the channel list
|
||||
renumbers it — enabling a signal that sorts before the trigger's own is enough — and
|
||||
`ScopeModel::set_signal_enabled()` re-resolves the trigger by name and writes the *new* index
|
||||
back. Keying the reset on the index therefore made any such rebuild abandon the capture **and
|
||||
set no hold-off**, so the next edge inside the post window became t0: that is the "the trigger
|
||||
re-triggers during a capture and the hold-off does not apply" defect, and it needed no glitch
|
||||
and no user action beyond enabling a channel. The name changes when the source genuinely
|
||||
changes, so a deliberate re-point still resets. The rebuild also replaces the `BufferStore`, so
|
||||
the surviving capture's pre side is short and the snapshot reports its own `pre_s` — which may
|
||||
be **negative** when the new ring starts after the edge. That is a faithful reading, not a
|
||||
defect: `[-pre_s, post_s]` still describes the samples the snapshot holds. The guard is
|
||||
`UDPSCOPE_PROBE_TRIGRENUM_MS`, whose `t0_held=1` is the assertion.
|
||||
- **`EventCondition` carries the trigger's own rejection: `noise_band` and `hf_fc`.** Both are in the
|
||||
struct's `operator==`, so a change to either *is* a change to the condition. `EventDetector::configure()`
|
||||
takes `dt` and early-returns only when the condition **and** the interval are unchanged -- it runs on
|
||||
*every packet*, so a configure that reset unconditionally would restart the machine before any pulse could
|
||||
close. `dt` is `Partition::slot_dt()`, the *stored* interval and not the CONFIG's nominal rate, and comes
|
||||
from exactly three call sites (`acquisition.cpp`, `ui_trigger.cpp`, `ui_probe.cpp`); a zero there leaves
|
||||
the reject's coefficient at 1, which is a pass-through.
|
||||
- **A crossing is a change between two *decided* sides, never a change from the undecided one.** The band
|
||||
makes each sample decide above / below / undecided, and an edge is counted only when the signal has reached
|
||||
the far threshold of the side it is leaving. Seeding the previous side from `0` -- the undecided code --
|
||||
fires on a signal that starts *inside* the band and leaves it without ever reaching the level, an event the
|
||||
trigger is not supposed to see.
|
||||
- **A pinned window is *clamped* into the event every frame, and a zoom in is allowed.**
|
||||
While an event is held, a capture is filling, or the view is paused, `tick()` calls
|
||||
`RowSet::hold_pinned_window()`, which caps the span at `[-pinned_pre_, +pinned_post_]`'s and
|
||||
@@ -50,6 +94,30 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
data the scope never had. The two are one branch in `tick()` for that reason. A paused view
|
||||
pins the same way (`pin_pause_window()` sets `pinned_` from the frozen range), so the frozen
|
||||
picture is equally immovable; resuming or leaving trigger mode clears it.
|
||||
- **An opened file is a pinned view too, and *how* it is pinned matters as much as that it is.** The
|
||||
file view states its pin from the ring's own stamps every frame, and it used to do that with
|
||||
`pin_event_window()` -- which writes the group's range as well as the pin's numbers -- so a box zoom
|
||||
drew its frame and changed nothing: the controller's `set_x_range` went onto the *member*, the next
|
||||
frame's re-statement put the group's range back, and the branch it fell into (`clamp_to_data()`,
|
||||
because a file is neither `event_held` nor `Paused` nor `capturing`) never adopts a member into a
|
||||
group that is not following. That is the wheel-in-single-trigger defect again, in the one mode nobody
|
||||
re-tested. Two things hold it now: the branch is `Paused || capturing || rows->window_pinned()`, so a
|
||||
pinned view is always clamped by `hold_pinned_window()`; and the file's per-frame statement uses
|
||||
`set_data_pin()` -- the pin's numbers -- unless `pinned_window_is()` says the pin is a *different*
|
||||
window, which is a re-open with a new extent and is a new event, stated in full. `[boxzoom-probe]`'s
|
||||
`zoomed=` is the guard, and it is a reading to take in **both** modes: live and file must both read
|
||||
`1`.
|
||||
- **`set_data_pin()` takes a *range*; `pin_event_window()` and `pinned_window_is()` take the pre/post
|
||||
*split*.** The three are used in one breath by the file view's statement and by the held event's, and
|
||||
the two forms are not interchangeable: for data over `[lo, hi]` it is `set_data_pin(lo, hi)` against
|
||||
`pin_event_window(-lo, hi)`. Handing the split to `set_data_pin()` stores the pin as `[lo, -lo]`-ish,
|
||||
so the next frame's `pinned_window_is()` reads false, the pin is stated in full again, and the view
|
||||
alternates between the file's span and the corrupted pin at the frame rate -- the flicker in which no
|
||||
interaction survives, because every second frame rewrites the group's range. It is invisible on a file
|
||||
whose stamps are all positive (the corrupted pin is then *wider* than the data and
|
||||
`hold_pinned_window()` never clamps) and on one that ends at 0 (the pin inverts, and the clamp returns
|
||||
early), which is why it reached a user on a recording spanning `[-4..16]` and nothing before it.
|
||||
`[drop-probe]`'s `repins=` is the reading: **2 for a whole run**, not one per frame.
|
||||
- **A pinned view is navigable *within its data*, and that is the bound -- not the request.**
|
||||
Two things had to agree for this, and both were wrong in the same direction:
|
||||
the **pause** pinned on the window it asked for rather than on the snapshot it got (a cut
|
||||
@@ -107,3 +175,208 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
export.** Both need the same absolute index range over the time ring and every value
|
||||
ring, or an export and a capture of the same window would hold different samples. Call
|
||||
it under `mutex()`.
|
||||
- **The trigger engine is not fed a reserved hole's samples.** A NaN comparison is false anyway,
|
||||
so the engine would not latch; but the real reason the samples are withheld is the other one:
|
||||
feeding them would advance the hold-off clock across time nothing was sampled in. The probe
|
||||
asserts `trigger_in_hole=0` rather than relying on NaN's arithmetic properties.
|
||||
- **`EventDetector` owns every condition shape and holds no arming state; `TriggerEngine` owns
|
||||
the arming, the hold-off and the post window and holds no condition.** The split is what lets
|
||||
the buffer search ask the trigger's exact question without owning a trigger, and it is only
|
||||
true as long as neither half leaks into the other: a detector that knew about the hold-off
|
||||
would answer a search differently from the live trigger, which is the one thing the
|
||||
`agree=1` cross-check in `[search-probe]` exists to catch.
|
||||
- **The detector is fed on *every* sample, including during the hold-off and during a capture.**
|
||||
A pulse width, a window excursion and a dropout timeout are all *stateful over several
|
||||
samples*: skipping the feed while the engine is busy loses the opening edge, and the condition
|
||||
then measures from whatever sample the engine happened to resume on. The hold-off is applied
|
||||
to the detector's *answer*, not to its input — `process()` feeds first and tests
|
||||
`now_s < holdoff_until_s_` afterwards.
|
||||
- **An event's time is the detector's, not the sample's.** A pulse-width event is detected at
|
||||
the closing edge but could in principle be timed at the opening one, and a dropout is detected
|
||||
one sample after the timeout expires. `feed()` therefore returns the event's *own* time and
|
||||
`process()` uses that for `last_trigger_s_`, `pending_start_s_` **and** the pre-window gate —
|
||||
all three, or the marker, the cut and the gate would disagree with each other by the width of
|
||||
a pulse.
|
||||
|
||||
## A latch is also a file
|
||||
|
||||
The pane that lists the cache is **on demand**: the toolbar's event-history button and the pane are
|
||||
collapsed until it is asked for, because a column of stale events beside a live plot is what a user
|
||||
who has stopped looking at events does not want. What makes it *available* -- (recording is on) or
|
||||
(an event is on screen) -- is decided in one place, `sync_events_availability()`, so the button and
|
||||
the pane cannot disagree; a disabled cache with nothing being examined takes the button away *and*
|
||||
collapses the pane, because a visible empty column is what "off" must not look like. Trigger mode is
|
||||
deliberately **not** one of the conditions: a stored event being examined is a file, which has no
|
||||
trigger, and tying the pane to the trigger would close the list under the click that opened an event.
|
||||
The recording switch lives in the trigger panel, not in the pane it empties.
|
||||
|
||||
## Opening a file is a detour, so *Go live* is a restore
|
||||
|
||||
A file view overwrites the live view's settings and every overwrite has a reason: the window becomes
|
||||
the event's own span because that is what *framing* an event means, and the trigger goes off because
|
||||
a file has no edge to wait for (leaving it on kept a frozen capture on screen *instead* of the file,
|
||||
so the click looked like it did nothing at all). None of those reasons survive the way back. What
|
||||
the user had set is theirs, and "going back to live loses the trigger I spent a minute setting up"
|
||||
is the complaint this answers.
|
||||
|
||||
- **`snapshot_live_state()` on the way in, `restore_live_state()` on the way out.** The snapshot is
|
||||
taken by both ways into a file view -- `replay_event()` and `build_event_group()` -- *before* the
|
||||
acquisition is stopped, and is a **no-op while `replaying`**: a second event opened from the first,
|
||||
or a group opened from a replay, would otherwise snapshot a file's settings as "live", which are
|
||||
the very values the restore exists to undo. The group takes it after its argument checks, because a
|
||||
group that is refused leaves the live view alone.
|
||||
- **The trigger channel is stored by *name*.** Same reason `TriggerEngine::configure()` takes one: the
|
||||
index is a position in a channel list that a file view rebuilds, and a group **renames** its
|
||||
channels outright (`square (A)`). Putting an index back would select a different signal.
|
||||
- **`enabled` and `armed` are deliberately not restored from the snapshot.** They belong to the
|
||||
toolbar toggle, which owns an atomic mirror of `enabled` (`set_trigger_enabled()`) that a plain
|
||||
field write under the lock would leave stale. The restore writes the rest of the `TriggerConfig`
|
||||
and then moves the *toggle*, whose handler states both.
|
||||
- **The restore runs from the tick, not from `go_live_again()`, and *after* the deferred layout
|
||||
block.** Two writers would otherwise beat it:
|
||||
- `apply_layout()` ends in `apply_time_window(st.window_s)` -- the window the **session file**
|
||||
holds, not the one the live view had. `go_live_again()` clears `layout_loaded`, so that block
|
||||
fires on the next tick; a restore done inside `go_live_again()` is overwritten by the saved value
|
||||
a frame later. Being the next statement after it in `tick()` is the fix.
|
||||
- `rebuild_trigger_signals()`, finding a `channel` of `-1` after the rebuild renumbered the
|
||||
channels, silently settles on the first signal *and* re-defaults the level -- which is why
|
||||
`trigger_level_touched` is restored with the configuration.
|
||||
- **It waits for the trigger's channel *name* to resolve again, not for `generation()` to move.** The
|
||||
restored name has to be resolved against the live channels, and a file view that renamed its channels
|
||||
(`square (A)`) must be gone first or the name selects the wrong signal. `App::live_restore_pending`
|
||||
stays set until `channels()` is non-empty **and** a channel carries the snapshot's name (or the
|
||||
snapshot named none). Gating that on the generation was wrong in the common case: a *single* event
|
||||
keeps the stream's own channel names, and the restarted client's CONFIG is identical, which the model
|
||||
treats as **no change on purpose** (`same_stream_signals()`) -- so the generation never moved and the
|
||||
restore waited for something that could not happen. What shook it loose was toggling the trigger,
|
||||
which *is* a structural change: "click live, then toggle the trigger a couple of times" was the
|
||||
symptom. A `pending=1` that never clears is a reading about the *stream* (no channels yet), not about
|
||||
the restore -- see `invariants-session-and-export.md` for the save that made it stick at
|
||||
`channels=0`.
|
||||
- **Only the two controls a file view actually moved are restated** -- the signal dropdown and the
|
||||
level spin. The rest of the trigger panel was never touched, and writing to a control fires its
|
||||
handler, so the ones with nothing to put back are left alone. Both writes are inside
|
||||
`updating_panels`.
|
||||
- **`pinned` is not a pass/fail reading on the way back.** `apply_time_window()` drops the file's pin
|
||||
and re-arms the live follow, but if the restored trigger was *on* it can latch again within the
|
||||
frame or two before the probe reads -- and a held capture is pinned, correctly. That is what
|
||||
`held=` and `mode=` are beside it for.
|
||||
|
||||
## A comparison is two plots of one model
|
||||
|
||||
*Compare* and *accumulate* are still one model -- the buffers cannot hold two independent loads -- but a
|
||||
comparison is **two plots** over it, and which plot draws what is only a matter of which channel *names*
|
||||
its rows bind. `App::rows` is the primary (`RowSet`, area, gestures); `App::rows_b` is the second, with
|
||||
its own `RowSet` and its own hidden `PlotArea` in a paned, deliberately without the drop target, the
|
||||
right-click menu or the frame counter: those act on the primary's rows, and a menu on the second plot
|
||||
that rearranged the first plot's rows would be a menu lying about its subject.
|
||||
|
||||
Rules that cost a defect each:
|
||||
|
||||
- **The layout is the user's and is mirrored, never reinvented.** The group build must not call
|
||||
`set_row_count()`; it reads the rows' slot names and re-names the same slots for each layer
|
||||
(`… (A)`, `… (B)` for a comparison; a bare name for layer 0 and `… #2`, `… #3` for the extra layers of
|
||||
an accumulation). An *accumulation* puts its extra layers into free slots -- the row that holds the
|
||||
signal first, then any row with room -- so the user's traces never move. The default layout is four
|
||||
signals in one row, which is why `kSlotsPerRow` is 8: with four, "accumulate these two events" had
|
||||
nowhere to put the second copy and silently did nothing.
|
||||
- **A layer is not a signal.** A comparison's model holds one channel per (signal, layer), and the
|
||||
*panel* must not treat the copies as signals of their own: the signal list shows one row per signal
|
||||
(`layer_base_name()`, the shared rule that takes `(A)`, `(B)` and `#N` back off), `signal_color_index()`
|
||||
counts *signals* and not channels -- counting channels spread one signal's colour across the palette
|
||||
-- and both plots of a comparison bind their signal's layers to the *same* colour index, because two
|
||||
colours would say they were different signals. The reading is `list=4` in `[events] compare plots`
|
||||
for a two-event comparison of four signals.
|
||||
- **A slot name carries the last group's suffix, so it must be stripped before it is re-derived**:
|
||||
after a comparison the rows are named `sine (A)`, and a following accumulation that mapped that name
|
||||
again bound to no channel at all (`traces0=0`, a plot with nothing in it). `base_name()` takes
|
||||
`(A)`, `(B)` and `#N` back off.
|
||||
- **The axes are mirrored from the ranges, once per frame.** The plotter owns its window and a gesture
|
||||
reaches it through the plot's own setter with nothing announcing it, so the frame compares each row's
|
||||
signature with the previous frame's: whichever side moved is the side the user touched, and the other
|
||||
is set to match. The signature is `{span, y.min, y.max}` when the x axis has a **live edge**
|
||||
(`x_live_edge()`, not merely an armed follow: an armed axis with nothing streaming keeps a *stored*
|
||||
window, whose edges a pan moves and whose span never changes) and `{x.min, x.max, y.min, y.max}`
|
||||
otherwise. Comparing the edges under a live edge would read as "both sides moved" on every frame --
|
||||
the window is recomputed as a distance behind the newest sample -- and a zoom in one plot would be
|
||||
overwritten by the other's drift. When both moved the first plot is the reference, which is also what
|
||||
makes the frame after a build converge.
|
||||
- **`set_y_range()` clears that axis' auto-scale**, by design: a mirrored zoom is a user decision, and
|
||||
*Fit* re-arms it.
|
||||
|
||||
## A group of events is one model, built once
|
||||
|
||||
*Compare* and *accumulate* are the same machinery: several event files in **one** model, because the
|
||||
buffers cannot hold two independent loads -- `load_waveform_into_model` calls `set_signals()`, which
|
||||
rebuilds them, so a second load takes the first with it. So the group is built by hand: one channel per
|
||||
(signal, layer) pair, `set_target_span()` to cover the whole group *before* `set_signals()` (or the
|
||||
oldest samples are written past the ring's end), then all of the events pushed in one pass. A row is
|
||||
one **signal** and its slots are the events, which is what makes a comparison like for like and an
|
||||
accumulation a density; the trace alpha (0.35 while accumulating, 1 otherwise) is what turns agreement
|
||||
into brightness.
|
||||
|
||||
Two things every group must do, both of which cost a defect to learn:
|
||||
|
||||
- **Stop the acquisition first.** A live producer keeps pushing into the model it was started with --
|
||||
and it pushes *four* stream signals into the group's *eight* channels, so the group's own samples are
|
||||
the ones that go missing. The reading that catches it is the samples the busy channel holds
|
||||
(`samples0` in the `[events] compare` line): it must be exactly the number of stamps pushed, not
|
||||
that plus whatever the stream added.
|
||||
- **Free the slots the group does not fill.** A row carries the previous view's binding, and a trace
|
||||
left in slot 2 of a two-layer row is drawn as if it belonged to the group.
|
||||
|
||||
*Compare* shifts the first event so its span *ends* where the second's *begins*, and fills the half a
|
||||
sample does not belong to with **NaN** -- the plotter excludes non-finite samples from every series and
|
||||
from every pyramid bucket, so a NaN is a gap and not a repeat, and no line is drawn across the join.
|
||||
|
||||
**A replay changes the data and frames it once; it never rearranges the layout.** A row plots a signal
|
||||
*name*, and an event's export carries the same names the stream does, so loading the event into the
|
||||
model leaves every row, slot, colour and the row count pointing at the right signal by itself. So
|
||||
`replay_event()` must **not** rebind the rows (`bind_channels_to_rows()`, which is what a freshly opened
|
||||
*file* needs) -- that is the user's work, and a click on a list is not a request to rearrange it. The
|
||||
window is restated **once, on open** (`apply_time_window(span)`, the only path allowed to change the
|
||||
scale), so the whole event is framed the moment it is opened and the user's own zooms are left alone
|
||||
afterwards; never per frame. The measurements, the cursors and the readouts then report the stored
|
||||
event, because they read the model. What a replay *does* change is the model's contents and the trigger:
|
||||
it leaves trigger mode, without which the display stays `Held` and draws the old snapshot.
|
||||
|
||||
**A file view has nothing to acquire.** The signal list's acquire tick is *hidden* -- not disabled --
|
||||
whenever a file is on screen (`App::waveform_name` non-empty: a replay, a comparison, a `--open`run).
|
||||
The samples are stored, "acquire this signal" is a stream's question, and a greyed tick invites the user
|
||||
to look for a reason there is none for. Going live rebuilds the panel, so the ticks come back.
|
||||
|
||||
**"The same length" is the same count at the same rate, not the same span to the sample.** Two events
|
||||
this app recorded itself differ in span by the fraction of a sample the acquisition's window happened to
|
||||
fill to (0.9999 s of a 1 s pre-window is normal). Demanding one sample interval made a comparison of two
|
||||
just-recorded events refuse to open; the check allows four intervals or a millisecond, whichever is
|
||||
larger, and the merged stamp grid carries the difference as a gap.
|
||||
|
||||
**A replay must leave trigger mode.** `display_mode()` answers `Held` whenever the model holds a
|
||||
snapshot, and a held display draws *the snapshot*, not the live channels -- so replaying an event with
|
||||
trigger mode still armed loaded the file into the model and left the old capture on screen: a click
|
||||
that looks like it did nothing, with the event sitting in the ring unread. The replay also frames the
|
||||
view with `apply_time_window(report.span_s)`, so the click shows the whole event rather than the part
|
||||
of it that fell inside the live window.
|
||||
|
||||
The switch that decides recording belongs to the **trigger panel**, not to the pane it empties: it
|
||||
is a trigger option (it decides whether a latch is written at all), and a pane must not be the only
|
||||
place to reach the control that stops it.
|
||||
|
||||
|
||||
Every published snapshot is written to the event cache while recording is on: its table (the *export*
|
||||
format, so `--open` reads it back), a sidecar of what the list shows, and a preview drawn from the
|
||||
samples. Three rules keep that honest:
|
||||
|
||||
- **The record happens on the GTK thread, at the end of the tick that framed the snapshot**, not on
|
||||
the producer. The snapshot is taken by `shared_ptr` (a latch landing mid-write cannot pull the data
|
||||
away), and a `record_pending` flag defers the write to the end of the frame so the rows are already
|
||||
bound to the event when the picture is drawn. Recording from the producer would put file I/O
|
||||
between two `recv()` calls, which is the one thing the packet capture's design also refuses.
|
||||
- **Nothing a latch does may be expensive.** The write is a `write_npy` of the event's own window and
|
||||
a 480x160 raster; a 2 s, four-channel event is ~230 kB of picture and ~600 kB of samples. The pane
|
||||
is refreshed, not rebuilt, and a failure to write is a line on stderr -- a cache that cannot be
|
||||
written must not stop the scope.
|
||||
- **The preview is drawn from the data, never captured from the window.** The plot is a `GtkGLArea`:
|
||||
its ink is not in the widget's paint tree, so `gtk_snapshot_free_to_node()` on a widget paintable of
|
||||
it returns NULL and the "screenshot" would be an empty file. Drawing it in the store also keeps that
|
||||
class GTK-free, which is what makes the cache testable without a window.
|
||||
@@ -13,24 +13,37 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||||
./build/udpscope --server 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
Two baselines every run is read against. A clean build emits **four** warnings and no more: two
|
||||
`-Wmissing-field-initializers` on `MathNode::source` and `-Woverlength-strings` on **both**
|
||||
generated GResource `.c` files — the help pages' and the icons', whose blob crossed C99's
|
||||
4095-byte string-literal limit when the two app icons joined the four action ones (a note about a
|
||||
generated file, which GCC itself supports). The fifth warning that used to appear here was an unused
|
||||
Two baselines every run is read against. A clean build emits **three** warnings and no more: two
|
||||
`-Wmissing-field-initializers` on `MathNode::source` and one `-Woverlength-strings` on the icons'
|
||||
generated GResource `.c`, whose blob is **12352** against C99's 4095-byte string-literal limit with
|
||||
the seven action icons and the two app icons that remain (a note about a generated file, which GCC
|
||||
itself supports). The *fourth* was the help pages' generated `.c` (**516044** bytes); the manual is
|
||||
now a Mallard set installed for Yelp rather than a GResource compiled into the binary, so that
|
||||
bundle — and its warning — is gone, and the baseline moved from four to **three**. Re-measure by
|
||||
touching each `*.gresource.xml` under `src/` and rebuilding; a third `-Wmissing-field-initializers`
|
||||
or any other warning is a finding. (The fifth warning that used to appear here was an unused
|
||||
`output_built` in `math_probe()`; it was **deleted** as dead code — redundant with the
|
||||
`output_after_add` already printed on the same line — and that deletion is what dropped the baseline
|
||||
from five to four. Nothing started reading it. And an exit-dump run emits **no** `Gtk-CRITICAL` at
|
||||
all. A fifth warning, or any critical, is a finding.
|
||||
`output_after_add` already printed on the same line — which dropped the baseline from five to
|
||||
four.) And an exit-dump run emits **no** `Gtk-CRITICAL` at all, nor any `Gtk-WARNING`: the dump's
|
||||
`gtk warnings:` line counts GTK's own log (through a writer function — GTK is built with structured
|
||||
logging, so the handler API never sees its messages) and must read `0`; see
|
||||
[probe-readings.md](probe-readings.md).
|
||||
|
||||
An *incremental* build reads **two** of those four: the generated GResource `.c` files are not
|
||||
recompiled when only `src/` changed, so their `-Woverlength-strings` do not reappear.
|
||||
An *incremental* build reads **one** of those three: the generated GResource `.c` is not recompiled
|
||||
when only `src/` changed, so its `-Woverlength-strings` does not reappear.
|
||||
|
||||
A third reading every run offers for free: **how many `[udpscope] buffer allocated:` lines it
|
||||
printed.** The line is not behind a probe — it is in every build — and one line is one *real* change
|
||||
of buffer plan, each of which blocks the main thread for the time it takes to touch the whole
|
||||
`--buffer-mb`. A normal run prints two or three. Fifteen at startup is the defect it was added for;
|
||||
see [probe-readings.md](probe-readings.md).
|
||||
|
||||
That second baseline used to be "exactly two, `gtk_widget_get_height` and `gtk_widget_get_width`,
|
||||
from the dump asking a destroyed widget for its size", and tolerating them was a mistake: the dump
|
||||
runs from the destroy handler, so the widget pointer is **freed memory**, and a critical is only
|
||||
the lucky reading of it. A run that had built and destroyed other windows first —
|
||||
`UDPSCOPE_PROBE_HELP_MS`, which opens three — has had that memory reused, and the same two calls
|
||||
`UDPSCOPE_PROBE_HELP_MS`, which opens the About and shortcut dialogs and then closes them — has had
|
||||
that memory reused, and the same two calls
|
||||
then **SIGSEGV in the middle of the dump**, which reads as a truncated dump rather than as a
|
||||
crash in a debug line. Every size the dump prints is therefore captured on the *tick*, while the
|
||||
window is alive (`App::dbg_*`), and **no `gtk_widget_*` call may appear in the dump** — the
|
||||
@@ -44,6 +57,13 @@ precedent, not an exception.
|
||||
`UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes. `UDPSCOPE_PROBE_DISARM_MS` only
|
||||
*starts* its sequence: it switches the trigger on and then polls for a capture to open,
|
||||
so the Disarm lands ~300 ms into one rather than wherever a fixed delay happened to fall.
|
||||
- `UDPSCOPE_PROBE_TRIGRENUM_MS` — the trigger's capture must survive a **renumbering of its own
|
||||
channel**. It sets the reported geometry through the real widgets (1 s window, 10 % pre share,
|
||||
hold-off from `--holdoff`) on the mock's *square* alone, polls for a capture to open, and then
|
||||
enables the sine — which sorts before the square and so moves the trigger channel's index under
|
||||
the engine. Its three lines are in [probe-readings.md](probe-readings.md); **`t0_held=1` is the
|
||||
assertion**. Run it with `--holdoff 10`, which makes the stolen-t0 reading unmistakable: a second
|
||||
latch barely a second after the first cannot be a hold-off that applied.
|
||||
- `UDPSCOPE_PROBE_FIELDS_MS` — change the trigger panel's edge and Normal/Single
|
||||
dropdowns. As much a crash test as a state check (see the GTK signal-signature invariant
|
||||
below), so time it inside a capture: `[fields-probe] ... capturing=1` followed by no
|
||||
@@ -64,8 +84,18 @@ precedent, not an exception.
|
||||
collecting anything -- that is how the first baseline of the stall below read 0.4 ms).
|
||||
The values are in the exit dump as `measurements: <type>=<value>`; on the fast mock
|
||||
(`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`) a unit sine must read
|
||||
`peak to peak=2`, `amplitude=1`, `average=0` and **`top=0.636620`** -- 2/pi, the half-wave
|
||||
mean of a sine, which is the check that the threshold types are not merely plausible.
|
||||
`peak to peak=2` **exactly**, `average=0`, and the two flat-level types as their definition
|
||||
says: **`top=0.957988`** and `amplitude=1.915983`. `top` is no longer 2/pi -- it is the *flat*
|
||||
level of the upper side, read from the density of the window's buckets, and a sine has no
|
||||
plateau so the densest band is at its peak; `amplitude` is `top - bottom` (a scope's
|
||||
`Vamplitude`), not the half-swing it once was. The check that the flat levels are *levels* and
|
||||
not extremes is the same probe against the mock's `square` (`--square-harmonics 30`, the
|
||||
control being `0`): `top`/`bottom` `0.999983`/`-0.999983` rippled against `1.000000`/`-1.000000`
|
||||
ideal, `amplitude` `1.999966` against `2.000000`, while `peak to peak` reads `2.358701` rippled
|
||||
against `2.000000` ideal -- the ±1.179 edges'
|
||||
overshoot, kept by the extremes and excluded by the levels. The per-type values, the reason for
|
||||
each, and the cost of the histogram are in
|
||||
[invariants-measurements.md](invariants-measurements.md).
|
||||
`[measure-trace] view=[..] <type>=<value>... marks=N level:V/time:V...` is a repeating line
|
||||
(250 ms) from the probe: the pane's numbers beside the *current* window and the marks actually
|
||||
placed, which is the only way a stale result or a mark that should not be drawn shows up as a
|
||||
@@ -86,13 +116,49 @@ precedent, not an exception.
|
||||
(`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`), where a window is ~2 M samples.
|
||||
`--noise 0.1` there reads **2.0%** at 1.96 ms a recompute against the clean 0.1% at 1.41 ms, and
|
||||
that is the interferer's own cost rather than a regression -- the figure to compare a change
|
||||
against is the **clean** one.
|
||||
against is the **clean** one. `raw` is also the guard on the flat-level types, which are the one
|
||||
thing that reads a second time: `top`/`bottom`/`amplitude` fold a histogram out of the **same**
|
||||
fetched bucket list, so they add buckets and not samples (measured: `raw=0.1%` and `2.1%` with
|
||||
the interferer, against `0.1%`/`2.1%` before those types had a flat level, for 0.44→0.50 ms and
|
||||
0.52→0.65 ms a pass -- the buckets are the budget). It is the guard on the band averages for the
|
||||
same reason: `between` is a *second level in the same walk*, not a second pass, and measured on
|
||||
the fast mock at a 2 s window with the measurements probe adding one of **every** type, `raw`
|
||||
stayed at a fraction of a percent of `covered` —
|
||||
`covered=240719031 buckets=3760828 raw=113966 (0.0%)` for the ten types before, against
|
||||
`covered=255372033 buckets=3989978 raw=134978 (0.1%)` for the thirteen after, at 0.8 → 1.1
|
||||
ns/covered sample (`data` 156.85 → 178.38 ms: the pane holds three more measurements and one more
|
||||
level, and both walk the same fetched bucket list).
|
||||
Note the **final** `measurements:` line and `[measure-trace]`'s last lines may read `--`:
|
||||
`UDPSCOPE_EXIT_AFTER_MS` arms the trigger before it dumps, so the dump can land in `Waiting`
|
||||
with nothing bound and the axis at its default. The race-free reading is an earlier
|
||||
`[measure-trace]` line, which carries its own `view=`; those lines are also where the
|
||||
level-mark fallback is visible (an invalid pass must still print its `level:` marks).
|
||||
|
||||
- `[bandavg-probe]` (stderr, `UDPSCOPE_PROBE_BANDAVG_MS`) — the **band averages**:
|
||||
`above`/`below`/`between`, the plain means over one side of a share and over a band, which are
|
||||
*different numbers* from `top`/`bottom`'s flat levels (see the band-average invariant in
|
||||
[invariants-measurements.md](invariants-measurements.md)). It adds seven measurements, on the three
|
||||
shapes whose band means can be computed by hand, and the four figures to read are:
|
||||
|
||||
* the 1 Hz unit sine at a **2 s window**: `above 50` = **`0.636620`** (2/pi) and `below 50` =
|
||||
**`-0.636620`** — the exact figures the old `top`/`bottom` documented, and the reason this type
|
||||
exists at all; `between 50 75` = `0.255873` (`3(2-sqrt 3)/pi`), an interior mean no side and no
|
||||
whole-window statistic reports;
|
||||
* the mock's band-limited `square` (30 harmonics, its default): `above 50` = **`0.986496`**, the
|
||||
reading the commit that made `top` a flat level recorded as the *old* `top`;
|
||||
* the mock's `ramp`, whose density is uniform: `between 50 75` = `0.250000` and `above 50` =
|
||||
`0.500000` — a band's mean *is* that band's centre, so the shape predicts the number;
|
||||
* and the two nothing-to-measure readings: `above 100` on the `ramp` and `between 50 75` on
|
||||
`--square-harmonics 0` (whose samples are all exactly +/-1) must be `invalid`/`--`, never 0.
|
||||
|
||||
**Run it at a 2 s window** (the probe waits for a full one before it reads anything — the mock
|
||||
delivers about 0.5 MS/s of the 1.3 MS/s it declares, so a freshly enabled ring needs seconds of
|
||||
wall clock), and **not** with `UDPSCOPE_PROBE_MEASURE_MS` or `UDPSCOPE_PROBE_MEASURES_MS`: all
|
||||
three add measurements to one pane. `UDPSCOPE_EXIT_AFTER_MS=17000` covers its sequence. The
|
||||
command, both mock flavours and the lines are in [probe-readings.md](probe-readings.md):
|
||||
`--square-harmonics 30` for the `0.986496` and the `0.302744`, `--square-harmonics 0` for the
|
||||
`1.000000` and the empty band.
|
||||
|
||||
- `[pan-probe] asked=[..] was=[..]`, `[pan-probe] after=[..] held=D asked=[..]`, then
|
||||
`[pan-probe] zoom asked=[..] after=[..] span=S window=W limited=L`
|
||||
(stderr, `UDPSCOPE_PROBE_PAN_MS`) — **`held` must be 1**: a gesture must not move a window
|
||||
@@ -141,7 +207,7 @@ precedent, not an exception.
|
||||
- `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are
|
||||
measured on a still picture. It now also asserts what a pause *is*
|
||||
(`[pause-probe] <action> paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..]
|
||||
glance=[..]` on stderr):
|
||||
skips=N glance=[..]` on stderr):
|
||||
it presses the **button**, not `Acquisition::set_paused()`, because pausing is a display
|
||||
state as well as an acquisition one and the view snapshot and axis pin live in the
|
||||
handler. Across the three lines **`ring_produced` must grow** (the producer never
|
||||
@@ -161,14 +227,471 @@ precedent, not an exception.
|
||||
stops while paused and "how long have I been looking at this" is a question about the user, not
|
||||
about the stream; it is written through `format_elapsed()` so the field steps to a coarser unit
|
||||
instead of growing digits (see the layout rule).
|
||||
**`skips=` is the assertion for the render gate** (the end of `tick()`), cumulative over the
|
||||
session: the `live` line must read **0** and the `paused` line must read **more than 0**. A
|
||||
frozen view is bound to an immutable snapshot on a pinned axis, so a frame whose picture cannot
|
||||
have changed is not drawn again — and this is the only line that can say whether that happened,
|
||||
since a skipped frame is invisible in every other reading by construction. Non-zero on the
|
||||
`live` line is the gate's condition inverted, which would cost dropped samples' worth of
|
||||
picture; 0 on the `paused` line is the gate not engaging and the saving not being made.
|
||||
The gate cannot stall the picture (`kStaticRenderEvery` renders every few ticks regardless),
|
||||
so the renderer's own baselines below are unchanged by it: a measured run read `skips=0`,
|
||||
then 33, then 45, with `columns == graded`, `render blank frames: 0`, and the layout floor
|
||||
still converged (`y-gutter 104..104`).
|
||||
- `UDPSCOPE_PROBE_COLOR_MS` — recolour one plotted signal and read the colour back out of the
|
||||
trace and the chip, round-trip it through the session writer, then clear it. Schedule
|
||||
`UDPSCOPE_ENABLE_ALL_MS` before it: with nothing bound it has no trace to recolour and asserts
|
||||
nothing. Its four lines are in
|
||||
[probe-readings.md](probe-readings.md).
|
||||
- `UDPSCOPE_EXPORT_MS` (+ optional `UDPSCOPE_EXPORT_PATH`, default
|
||||
`/tmp/udpscope-export`) — collect the export table and write it both ways, skipping
|
||||
only the file chooser.
|
||||
`/tmp/udpscope-export`) — collect the export table and write it all three ways (`.npy`,
|
||||
`.csv`, `.mf3`), skipping only the file chooser.
|
||||
|
||||
- `UDPSCOPE_PROBE_CONDITION_MS` — arms a qualified trigger against the mock and reports the latched
|
||||
`t0`, the measured pulse or band width, and sample values for the window condition. Schedules its
|
||||
own arm and report stages. `UDPSCOPE_PROBE_CONDITION_SHAPE` picks the leg: `0` pulse width `<`
|
||||
(the glitch, the default, needs `--glitch-at`/`--glitch-width`), `1` pulse width `>` (uses a
|
||||
1.0 s window and 50 % pre so the opening edge of a 0.2 s pulse is captured), `2` window
|
||||
(uses `sine` with a 0.2 s window and 10 % pre), `3` dropout (needs `--quiet-at`; uses `sine`
|
||||
with a 1.0 s window and 50 % pre so the last crossing before the outage is captured).
|
||||
- `UDPSCOPE_PROBE_STATS_MS` — reports every measurement's accumulator (`n`, mean, sd, lo, hi),
|
||||
pauses, reports again with `dn=` per measurement, then resets and reports a third time. Three
|
||||
phases: `phase=0`, `phase=1` (after pause) and `phase=2` (after Reset). Needs
|
||||
`UDPSCOPE_ENABLE_ALL_MS` ahead of it and `UDPSCOPE_PROBE_MEASURE_MS` to add measurements first.
|
||||
- `UDPSCOPE_PROBE_SIDE_TAB_MS=3000` with `UDPSCOPE_EXIT_AFTER_MS=11000` — the measurements on the
|
||||
Measure tab, a forced pass, then the tab switched away and read again two seconds later. **`advanced=4`
|
||||
is the assertion** (every accumulator gained passes while its page was off screen); `advanced=0` is the
|
||||
defect where the accumulation is gated on the visible tab, which would leave a gap in min/max/avg for
|
||||
exactly as long as the trigger was on screen. It enables *and plots* its own signal, so it needs no other
|
||||
probe ahead of it. The reading's own entry is in `probe-readings.md`.
|
||||
- `UDPSCOPE_PROBE_SEARCH_MS` — runs a buffer search for the armed condition, cross-checks the live
|
||||
trigger's own `t0` against the nearest hit (`agree=1` is the load-bearing reading), and jumps to
|
||||
hit 0. Reports the resulting `mode=`, `axis=` and `marks placed=`.
|
||||
|
||||
The following mock waveform features are what the qualified-condition probes are checked against,
|
||||
as distinct from the fault injection below, which exercises ingest:
|
||||
|
||||
- `--glitch-at S` / `--glitch-width W` invert the `square` signal for `W` seconds at the start of
|
||||
every square period from stream second `S` onwards — a runt pulse of a known width, which is
|
||||
what a `<` pulse-width trigger and a glitch search are checked against.
|
||||
- `--quiet-at S` freezes `sine` at its held value from stream second `S` onwards, so it stops
|
||||
crossing zero: the dropout trigger's input.
|
||||
|
||||
- `UDPSCOPE_PROBE_RESEQ_MS` — one mid-run reading of the resequencing path. Five cases,
|
||||
**run each as its own process** — five back to back starve the mock past its 2 s silence
|
||||
timeout and every case then reads a reconnect instead of what it was testing. Common
|
||||
conditions for the first four: the mock at `--rate 10000 --batch 100 --freq 5`, udpscope with
|
||||
no extra options, under `xvfb-run` with
|
||||
`UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=600 UDPSCOPE_EXIT_DUMP=1
|
||||
UDPSCOPE_PROBE_RESEQ_MS=5000`. Delete `~/.config/udpscope/127.0.0.1_8000.toml` first: a
|
||||
session left by an earlier run restores its math channel, and five channels instead of four
|
||||
change `channels=`, `span=` and every count derived from them.
|
||||
|
||||
| case | mock extra flags | key reading |
|
||||
| --- | --- | --- |
|
||||
| **base** | *(none)* | every counter 0; proves the machinery costs nothing |
|
||||
| **reorder** | `--reorder-every 10` | `reseq=51` (mid-run) / `102` (dump); `holes=0`; resequencing inside the window leaves no reservation |
|
||||
| **late** | `--reorder-every 10 --reorder-delay-ms 50` | `bands_repaired` one short of `bands` with `nan_slots=100`; the band still solid is the one hole whose late packet is in flight, and it is the *only* unrepaired one |
|
||||
| **mixed** | `--drop-every 20 --reorder-every 10 --reorder-delay-ms 50` | `bands=43 bands_repaired=20 nan_slots=2200`; all three band states at once |
|
||||
| **re-plan** | `--rate 100000` with the `mixed` flags, and `UDPSCOPE_PROBE_WINDOW_MS=4000 UDPSCOPE_PROBE_RESEQ_MS=6000 UDPSCOPE_EXIT_AFTER_MS=9000` | four `buffer rebuilt` lines before the reading, then `bands=64 bands_repaired=16 nan_slots=7800`: **a band is still only marked repaired when its own slots hold values**. A `bands_repaired == bands` beside a non-zero `nan_slots` here is the hole table and the producer's `tick_` surviving a re-plan that reset `produced()` — a band marked settled over samples that are all still NaN. The non-zero `overflows` is this case too: 100 kS/s with a 50 ms reorder delay keeps about 50 packets in flight against 64 slots |
|
||||
|
||||
The re-plan case needs `--rate 100000`, not the other four's 10 000: at 10 kS/s the ring
|
||||
|
||||
- `UDPSCOPE_PROBE_CAPTURE_MS` — toggles the packet capture **through the same handler the
|
||||
status-bar button uses**, waits, then reads the file back off disk and prints one line:
|
||||
`enabled= path= slots= stored= resident= cursor= torn= truncated= header_ok= first_seq=
|
||||
last_seq= payload= file= open_ms= t0=`. `stored` is the datagrams-since-open counter and must
|
||||
equal the dump's datagram count from the toggle's instant; `resident` is what the file holds,
|
||||
which is the slot count once the ring has wrapped; `file` is the reserved size
|
||||
`64 + slot_count * slot_bytes` and is a constant for a given N, so a mismatch against the file
|
||||
on disk is a reading. Two cases, **run each as its own process**:
|
||||
|
||||
1. **enable at runtime** — the mock at `--rate 20000 --batch 200`, udpscope with
|
||||
`UDPSCOPE_ENABLE_ALL_MS=700 UDPSCOPE_PROBE_CAPTURE_MS=1500 UDPSCOPE_EXIT_AFTER_MS=9000`
|
||||
and no capture flag: `enabled=1 header_ok=1 torn=0 truncated=0 first_seq=1`, `stored` equal
|
||||
to the datagrams since the toggle, `open_ms` a few ms.
|
||||
2. **wrap** — the same with `--capture-packets=16` and
|
||||
`UDPSCOPE_PROBE_CAPTURE_MS=7000 UDPSCOPE_EXIT_AFTER_MS=10000`: `stored` (870 in the
|
||||
reference run) exceeds `slot_count`, `resident=16`, `last_seq - first_seq == 15`,
|
||||
`torn=0`, and `cursor` agrees with `seq` (counter mod slot count). A wrap that loses
|
||||
ordering, or a `cursor` that disagrees, shows here and nowhere else.
|
||||
|
||||
Then the converter over the wrap case's file: `tools/capture_to_pcap.py <capture> out.pcap`
|
||||
must exit 0, print a record count equal to `slot_count` with `torn` 0, and the pcap's first
|
||||
and last record timestamps must fall inside the run's wall-clock interval — both checked by a
|
||||
`struct.unpack` of the pcap headers, nothing outside the standard library.
|
||||
|
||||
- **A gap must not kill a filtered trace.** `--relative-time` with the mock dropping
|
||||
(`--drop-every 20`) and `UDPSCOPE_PROBE_MATH_MS=2500 UDPSCOPE_ENABLE_ALL_MS=600
|
||||
UDPSCOPE_EXIT_AFTER_MS=9000`: every derived channel's dump line
|
||||
`math '<name>': finite=N/4000` must read about 3800 — the tail minus the reserved holes —
|
||||
and **`finite=0` beside a non-zero `hole_slots` is the defect** (a biquad that integrated the
|
||||
hole's NaN, permanently). The filters' `sd` must stay near the no-drop value; run the same
|
||||
command without `--drop-every` as the control, where every `finite` reads 4000.
|
||||
- `UDPSCOPE_PROBE_CONFIGS_MS` — saves the running session as a configuration named `probe` with a
|
||||
note, through `save_configuration_as()` (the same entry point the dialog's button uses), then
|
||||
reads it back off disk. Reading: `save-as=1`; `names=` gains `probe`; `active=probe`; and the
|
||||
round trip must carry the note **and** the startup settings —
|
||||
`comment='written by the configs probe' buffer_mb=8 relative=0 capture=0`. A `buffer_mb=0` there
|
||||
is the per-source buffer size not being written, which is the one setting a user notices only
|
||||
after a capture has been sized wrong. Run it with a throwaway `XDG_CONFIG_HOME`; the probe
|
||||
deletes its own configuration at the end (`after forget names=…`).
|
||||
- `UDPSCOPE_PROBE_ROWMENU_MS` — gives row 0 a trace, opens the plot's context menu with
|
||||
`open_row_menu()` (the one entry point `on_right_click()` wraps) and walks its two views one step
|
||||
at a time (`main` → click **Remove signal** → `remove` → click **Back** → `after-back` → click
|
||||
**Remove signal** again → `second-open` → click a trace), measuring each view's rows:
|
||||
`[rowmenu] <view> N '<label>' h=18 label_x=26 accel_right=R/W ok=1` per row and one
|
||||
`rows=/bad=/labels=` line per view, then `[rowmenu] summary main_rows=5 main_bad=0 remove_rows=6
|
||||
remove_bad=0 after_back_rows=5 after_back_bad=0 second_rows=6 second_bad=0 remove_row_in_main=1
|
||||
remove_row_gone=1 can_open_again=1` and `[rowmenu] remove-clicked 'sine' trace_freed=1`.
|
||||
**Every `bad` must be 0** (the same compact row style as the app's own menu: one label column, a
|
||||
shortcut flush right, 18 px rows — the **Back** row's icon indents its label, which every menu
|
||||
does, so its `label_x` is 52 against the others' 26) and the three booleans must all read 1: the
|
||||
remove view *replaces* the main one (`remove_row_gone`, `remove_row_in_main`), and it can be
|
||||
entered again after leaving it (`can_open_again`) — that last one is the case a
|
||||
`GtkMenuButton`-owned submenu failed, its row going dead on the second click. Each view is
|
||||
measured a step after the click that swapped it in, because a popover allocates its new child on
|
||||
the next layout pass.
|
||||
- `UDPSCOPE_PROBE_HELPUI_MS` — the help affordance layer, end to end, in ten steps 300–400 ms apart:
|
||||
the **first-run auto-start** (run it once with a throwaway `XDG_CONFIG_HOME` to see `auto=1`, and
|
||||
again against the same one to see it not repeat), the menu's seven rows and the **four entries of
|
||||
the Help submenu**, the **contextual-help toggle** with the count of "(i)" affordances on either side
|
||||
of it, the tour **started from the probe** and stepped forwards, back and dismissed, and `help
|
||||
figures:` read in both of the trigger pane's modes. Its lines and what each asserts are in
|
||||
[probe-readings.md](probe-readings.md); the two things to read first are **`hints=0`** while the
|
||||
toggle is off (off is *gone*, not greyed) and **`controllers=12 (was 12)`** after the dismissal (no
|
||||
lingering input grab — a tour of ordinary overlay widgets takes none), and **`hint=12x12`** is the
|
||||
size the shrunk "(i)" must keep (the default menu-button mark is 34x34). The `collapsed search:` line
|
||||
is the header hint read on the search block's expander while it is collapsed. The chooser's own tour is
|
||||
the other half and has its own entry points — see the **chooser** bullet below. Give it
|
||||
`UDPSCOPE_EXIT_AFTER_MS=9500 UDPSCOPE_EXIT_DUMP=1` to get the `help layer:`/`help figures:` dump
|
||||
lines as well, and note the tour **auto-starts** in any run whose settings file does not carry
|
||||
`tour_seen` — **in the source chooser when there is one**, in the window otherwise (see the chooser
|
||||
bullet) — so a probe run against a fresh config directory will have it on screen for the first
|
||||
second and a half, and that start *writes* `tour_seen` to whichever config directory the run is
|
||||
using, which is why the second run against the same one reads `auto=0`.
|
||||
- `UDPSCOPE_PROBE_TOUR_SURFACE_MS` — the tour's *surface* switch: it puts the sidebar on **Measure**,
|
||||
starts the window's tour, and walks it a stop per tick to the signals stop, reading the sidebar's
|
||||
page and the spotlight/card where the walk arrives. It then puts the sidebar *away* and starts the
|
||||
tour again, which is the same filter's other case (a collapsed pane drops the stops a page switch
|
||||
could have revealed). `[toursurface] signals stop=4/5 … side_tab=signals … contains=1` and
|
||||
`[toursurface] collapsed: side_stack_mapped=0 steps=2 signals_stop=0` are the two assertions; its
|
||||
lines are in [probe-readings.md](probe-readings.md). Run it alone with
|
||||
`UDPSCOPE_EXIT_AFTER_MS=9000 UDPSCOPE_EXIT_DUMP=1` against a live source.
|
||||
- `UDPSCOPE_PROBE_DEFAULTS_MS` — opens **Calibrate** and reads every spin button in it and in the
|
||||
trigger panel: `[defaults] calibrate: 1 0 model=1 0 | trigger: 0.001 0.002 0 0.001 0 50 0.01`. A
|
||||
`GtkSpinButton` starts at its range's *minimum*, so a `-1e12` here is an unseeded button the
|
||||
dialog's own reader would then apply — which is exactly what the calibration offset did. It then
|
||||
calibrates signal 0 through `set_calibration()` and reopens the dialog:
|
||||
`[defaults] calibrated: 2.5 -1.25 model=2.5 -1.25` — **the dialog must show the signal's stored
|
||||
calibration**, and the two halves must match.
|
||||
- The **chooser**, display-free: `UDPSCOPE_PROBE_SOURCES=1` prints `[sources] N known:` with one
|
||||
entry per configuration (`host:port/name … buffer= relative= note='…'`), and
|
||||
`UDPSCOPE_PROBE_PICK_MS=800` opens the chooser, picks the first row and prints
|
||||
`[sources] picked host:port … buffer=64 relative=0 capture=0 config=… note='…'` — the settings the
|
||||
configuration carried, into the `Config` the model is built from. **With no `--server` and no probe
|
||||
the chooser blocks in its own nested loop** (no frame to end and no dump), so a chooser run is
|
||||
always this probe. It also prints the dialog's frame *and its own help layer*:
|
||||
`[sources] chooser ui: … hints=[2 {chooser_sources chooser_waveform}] hint=12x12` with the setting
|
||||
on, `hints=[0 {}] hint=0x0` with it off. `UDPSCOPE_PROBE_CHOOSER_HELP=1` drives the **Enable
|
||||
contextual help** row and reads the file back; `UDPSCOPE_PROBE_CHOOSER_TOUR=1` drives the **Guided
|
||||
tour** row and walks the chooser's own tour to its last stop, printing `[sources] chooser tour: …`
|
||||
and the handoff (`tour_pending=1`); `UDPSCOPE_PROBE_CHOOSER_TOUR_MS=<ms>` walks it a stop per tick
|
||||
instead, printing the page the stack shows at every stop and stepping **Back** off the stop that
|
||||
stands on the waveforms page — the reading for the surface a stop changes. It starts the walk
|
||||
itself when one is not running, so it does not need a first-run config; pair it with
|
||||
`UDPSCOPE_PROBE_PICK_MS` after the walk so the application's half follows. A working run: seed a
|
||||
source once (`./build/udpscope --server 127.0.0.1 --port N` against a mock, under the throwaway
|
||||
`XDG_CONFIG_HOME`), then run bare with
|
||||
`UDPSCOPE_PROBE_CHOOSER_TOUR_MS=2000 UDPSCOPE_PROBE_PICK_MS=4500 UDPSCOPE_EXIT_AFTER_MS=12000
|
||||
UDPSCOPE_EXIT_DUMP=1`. Its lines and what each asserts are in
|
||||
[probe-readings.md](probe-readings.md). A legacy file
|
||||
(`<host>_<port>.toml`) produces `layout loaded from …/<host>_<port>/default.toml`, an `active`
|
||||
holding `default`, and the old file renamed `.imported`.
|
||||
- **The loading window covers the gap, and only on the path that asked.** The same
|
||||
`UDPSCOPE_PROBE_PICK_MS` run must print
|
||||
`[sources] loading: title='Connecting to 127.0.0.1:<port>' detail='planning the N MB sample buffer'`
|
||||
**between** the `picked` line and the stream's `CONFIG:`, and `[sources] loading: closed` **after**
|
||||
it — the window is raised before the buffer is planned and taken down only once the real window has
|
||||
been presented, which is the whole of what it exists for. `UDPSCOPE_PROBE_WAVE=<path>` is the file
|
||||
leg of it (`title='Opening <basename>' detail='reading the recording'`, before `waveform loaded:`).
|
||||
A run that names its source with `--server` prints **no** `loading:` line at all: every other probe
|
||||
here is on that path and must stay on it.
|
||||
- **The event cache round-trips, and only where it is available.** `UDPSCOPE_PROBE_EVENTS_MS` reads
|
||||
the pane's availability first, in the state the app starts in -- `[events] live: button_visible=0
|
||||
pane=0 recording=1`, **no pane and no button without trigger mode** -- then turns trigger mode on
|
||||
through the real toggle (`[events] after trigger on: button_visible=1 pane=0`: the button appears,
|
||||
the pane stays collapsed) and clicks the button, waits for a latch, then reads the cache and replays
|
||||
the newest event through the pane's own row:
|
||||
`[events] dir=… n=1 newest: dur=2.000000 pre=1.000000 post=1.000000 edge=… signals=4 samples=20001
|
||||
rate=10000 seq=1 thumb_bytes=230454 recorded=1 pane=1`, then
|
||||
`[events] activated row0: replaying=1 thumbnail=1 thumb_size=108x36 thumb_visible=1` (the size is
|
||||
what says the picture is *on screen*: a paint-able alone is not), `[events] replayed: channels=4
|
||||
samples=20001 replaying=1 waveform=1` and `[events] live again: replaying=0 waveform=0`. It ends on
|
||||
the pane's availability, through the trigger panel's switch: `[events] recording off, trigger off` and
|
||||
`[events] recording off, trigger on` -- once the cache holds events (the probe's own run has written
|
||||
some by this point) both read `button_visible=1 pane=1 recording=0`, because a button that shows the
|
||||
cache must not go away with the switch that wrote it; against an *empty* cache the same two lines read
|
||||
`button_visible=0 pane=0`, which is the empty column the item exists to prevent. **`samples` must equal the
|
||||
export's row count for the same window** (`20001` for a 2 s event at 10 kS/s), `thumb_bytes` must be
|
||||
a real BMP (`54 + 480*160*3`-ish, i.e. hundreds of kB, not 0), `thumbnail` must be 1 (the row loaded
|
||||
the picture) and the last line must read `replaying=0` -- a replay with no way back is a one-way
|
||||
door. It then **waits for a second latch** (bounded: twelve retries) and drives the two group layouts
|
||||
through the buttons: `[events] compare buttons: cached=2 selected=2 compare=1 accumulate=1` (the
|
||||
buttons read the selection), `[events] halve`… `[events] halves a_first=20001 a_second=0 b_first=0
|
||||
b_second=20001` -- **the two halves of the ring, and the reading that says a comparison's gap is a
|
||||
gap: the first event's channel is finite only in the first half and the second's only in the second**
|
||||
-- `[events] compare: kind=1 rows=4 channels=8 samples0=40002 traces0=2 traces1=2 window=4.000
|
||||
recording=1 pane=1` (`samples0` must be exactly the stamps pushed, **not more**: a live producer left
|
||||
running pushes its own four signals into the group's eight channels), `[events] compare plots:
|
||||
area_b=1 rows_a=1 rows_b=1 x_a=[-2.000,0.000] x_b=[-2.000,0.000] same_x=1` (the second plot exists, is
|
||||
on screen, mirrors the row count, and both are on one x window), then the sync -- the probe sets a y
|
||||
range nobody would arrive at by accident on the *first* plot and reads the second a few frames later:
|
||||
`[events] compare sync: y_a=-3.500 y_b=-3.500 mirrored=1` -- and `[events] accumulate: kind=2 rows=1
|
||||
channels=8 samples0=33045 selected=2 traces0=8 window=2.000` (`traces0=8`: the four signals the layout
|
||||
holds plus one layer each for the second event, **in the same row**, which is what an accumulation
|
||||
means). Run it with a throwaway
|
||||
`XDG_CACHE_HOME` and `UDPSCOPE_EXIT_AFTER_MS` long enough for the second latch (20 s).
|
||||
- **A waveform round-trips.** Export from a live run (`UDPSCOPE_EXPORT_MS` with
|
||||
`UDPSCOPE_EXPORT_PATH`, against the mock at 10 kS/s), then open that file with `--open` and an
|
||||
exit dump. The load must print
|
||||
`[udpscope] waveform loaded: … signals=4 samples=20001 rows=20001 decim=1 span=2.000000s rate=10000 time=[-2.000000..0.000000]`
|
||||
— **the same row count, column count, span and time range the export probe reported**
|
||||
(`rows=20001 cols=4 time=[-2.000000..0.000000]`) — and the dump must show `channels=4`,
|
||||
`frames=0 packets=0` (no acquisition) and, per channel,
|
||||
`20001 samples vs 20001 time slots … y=[-1.0000, 1.0000]`. A file that loads with fewer samples
|
||||
than it was written with, or with `NaN` slots, is the reader mis-slicing its records.
|
||||
- **A recording that does not fit its buffer is *filtered* into it, not truncated.** The case needs a
|
||||
file bigger than the budget, which the mock cannot export in one window, so write one with NumPy —
|
||||
the dtype is the exporter's own, `[('time','<f8'), ('abs_seconds','<f8'), (<signal>,'<f4'), …]` — and
|
||||
open it against a small buffer:
|
||||
```bash
|
||||
python3 -c "import numpy as np; n=1000000; t=-4.0+np.arange(n)/50000.0; \
|
||||
a=np.zeros(n, dtype=[('time','<f8'),('abs_seconds','<f8'),('a','<f4'),('b','<f4'),('c','<f4'),('d','<f4')]); \
|
||||
a['time']=t; a['abs_seconds']=1000.0+t; a['d']=np.linspace(-1,1,n); np.save('/tmp/long.npy', a)"
|
||||
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=4000 ./build/udpscope --open /tmp/long.npy --buffer-mb 1
|
||||
```
|
||||
must read `samples=31250 rows=1000000 decim=32 span=19.999980s time=[-4.000000..15.999980]` and
|
||||
`stored=31250 … published=31250` per channel — **the whole span, at a thirty-second of the rate**.
|
||||
The ramp column is the assertion that the *head* is there: `y=[-0.999969, 0.999969]` is the file end
|
||||
to end, where the defect this exists for reads a fraction of it. With every row pushed regardless of
|
||||
the plan, this file arrived as its **last 0.9 s** and nothing in the log said so.
|
||||
- **A loaded file can be plotted.** `UDPSCOPE_PROBE_DROP_MS` drives the drop handler the way a drag
|
||||
from the signal panel does, on a file opened with `--open`: it must read
|
||||
`[drop-probe] signals=4 channels=4 rows=4 slots0=[sine|||] view=[-3.999690..15.999670] repins=2` —
|
||||
**`rows` equal to the column count**,
|
||||
one trace per row — and each drop must return `1` and add its trace (`dropped signal 1 -> 1
|
||||
slots=[sine|sine|square|]`). `rows=1 slots0=[sine|square|ramp|noise]` with every drop returning `0`
|
||||
is the defect this exists for: rows sized four signals to one row fill the only row, so the app
|
||||
reads as one that cannot plot anything. A file-mode run must also write **no session** for the
|
||||
default `127.0.0.1:8000` (`App::source_port` is 0), or it overwrites that source's saved layout.
|
||||
- **A file view states its pin once.** `repins` on the line above must read **2** whenever the probe
|
||||
is scheduled — at one second or at seven — and `view` must be the file's whole span. A file's extent
|
||||
does not change while it is open, so the tick refreshes the pin's *numbers* and writes the group's
|
||||
range only when the view first becomes an event. Reading it against the decimated file above
|
||||
(`/tmp/long.npy`, stamps `[-4..16]`) is the case that matters: with the pin written as a pre/post
|
||||
split where a range belonged it read `view=[3.999690..15.999670] repins=116` at three seconds —
|
||||
the axis alternating between the file's span and a corrupted pin every frame, which on screen is a
|
||||
flicker in which no pan, zoom or click survives to the next frame. A file whose stamps are all
|
||||
positive, or which ends at 0 (an export's own window), hides it: the corrupted pin is then wider
|
||||
than the data, or empty, and the clamp never fires. `UDPSCOPE_PROBE_PAN_MS` on the same run is the
|
||||
other half — `allowed=1` and a `view` that is still the zoomed range when the drop probe reads it
|
||||
later is interaction actually taking.
|
||||
- **The chooser's Waveforms page**: `UDPSCOPE_PROBE_WAVEFORMS=1` prints
|
||||
`[sources] waveforms: N recent <path>(ok|missing) … page=waveforms`, and `UDPSCOPE_PROBE_WAVE=<path>` sets
|
||||
the field the file dialog's callback sets and prints `[sources] picked waveform <path>` — then the
|
||||
load line above, with no socket involved.
|
||||
- **A `--server` launch reads the stored configuration.** With a throwaway `XDG_CONFIG_HOME`
|
||||
holding `127.0.0.1_8101/bench.toml` (`buffer_mb = 64`, `relative_time = true`,
|
||||
`capture_packets = 500`, `active` = `bench`), `./build/udpscope --server 127.0.0.1 --port 8101`
|
||||
must print `[sources] settings from configuration 'bench': buffer=64 relative=1 capture=500`, the
|
||||
`relative time:` line, and a `span=` of about 209 s (64 MB at 10 kS/s) — **not** the 200 MB
|
||||
default's ~655 s. The same command with `--buffer-mb 8` must read `buffer=8` and `span=26.214s`:
|
||||
**an explicit flag wins over the file**. Without this, the settings a host was edited to have were
|
||||
ignored whenever the address came from the command line.
|
||||
- The chooser's **form**, with `UDPSCOPE_PROBE_PICK_MS` scheduling it:
|
||||
`UDPSCOPE_PROBE_NEWHOST=host:port` fills it and prints
|
||||
`[sources] form: name='probe-host' note='…' buffer=32 relative=1 connect_sensitive=1`, then
|
||||
`[sources] configuration created: host:port/probe-host … buffer=32 relative=1` and a
|
||||
`picked … config=probe-host`; a following `UDPSCOPE_PROBE_SOURCES=1` must list it
|
||||
(`buffer=32 relative=1 note='…'`) — a configuration the form wrote that the chooser does not
|
||||
list cannot be edited next time. `UDPSCOPE_PROBE_EDITHOST=host:port` opens the same form on that
|
||||
configuration and prints `[sources] edit form: name='…' note='…' buffer=… relative=… button='Save'`
|
||||
— **the pre-fill is the assertion**: an edit form that opens empty cannot edit anything — then
|
||||
saves and reports the new values. `UDPSCOPE_PROBE_EDITNAME=new-name` renames it as well: one
|
||||
`known:` row afterwards, with the old file gone.
|
||||
- `UDPSCOPE_PROBE_RESEQ_MS`'s neighbour for **reconnect**: the mock gains an EMI case,
|
||||
`--blackout-at S --blackout D` — silent from second S for D seconds (D must exceed the
|
||||
client's 2 s silence timeout), resuming from *now* so the link's backlog is what the link
|
||||
ate. One run (`--blackout-at 4 --blackout 3.5`, `UDPSCOPE_EXIT_AFTER_MS=15000`) has to read:
|
||||
every reconnect's `CONFIG:` line followed by `CONFIG unchanged: keeping the buffer and its
|
||||
history` (a reconnect that re-plans wipes history, which is the defect this case exists for),
|
||||
no `buffer rebuilt` line after the first CONFIG, `frames` climbing again after the blackout
|
||||
with `gaps=0`, and a trigger armed *after* recovery (`UDPSCOPE_PROBE_KEYS_MS` past the
|
||||
blackout) latching — the dump's `CAPTURING`/`EVENT HELD` and `publish cost: N events` with
|
||||
N ≥ 1 are that proof.
|
||||
already covers 838 s, so no window the control can reach moves `time_stride`,
|
||||
`set_target_span()` returns without rebuilding, and the window probe reports `decim=1`
|
||||
throughout — it re-plans nothing. At 100 kS/s the ring covers 83.9 s and the probe's
|
||||
500 ms → 500 s → 120 s → 600 s → 60 s walk crosses the boundary four times.
|
||||
|
||||
Full measured readings (probe line first, then exit-dump):
|
||||
|
||||
```
|
||||
=== base mock: none
|
||||
[reseq-probe] reseq=0 late=0 stale=0 overflows=0 holes=0 hole_slots=0 backfilled=0 open_holes=0
|
||||
bands=0 bands_repaired=0 nan_slots=0 scanned=44200 trigger_in_hole=0
|
||||
[udpscope] frames=1032 packets=1033 gaps=0 channels=4 span=838.861s stride=1 reseq=0 late=0
|
||||
stale=0 overflows=0 holes=0 hole_slots=0 backfilled=0
|
||||
[udpscope] channel 0 'sine' stored=95700 published=95400
|
||||
|
||||
=== reorder mock: --reorder-every 10
|
||||
[reseq-probe] reseq=51 late=0 stale=0 overflows=0 holes=0 hole_slots=0 backfilled=0
|
||||
open_holes=0 bands=0 bands_repaired=0 nan_slots=0 scanned=44200
|
||||
trigger_in_hole=0
|
||||
[udpscope] frames=1027 packets=1028 gaps=0 channels=4 span=838.861s stride=1 reseq=102 late=0
|
||||
stale=0 overflows=0 holes=0 hole_slots=0 backfilled=0
|
||||
[udpscope] channel 0 'sine' stored=95300 published=95100
|
||||
|
||||
=== late mock: --reorder-every 10 --reorder-delay-ms 50
|
||||
[reseq-probe] reseq=157 late=46 stale=0 overflows=0 holes=40 hole_slots=4000 backfilled=15600
|
||||
open_holes=40 bands=40 bands_repaired=39 nan_slots=100 scanned=44000
|
||||
trigger_in_hole=0
|
||||
[udpscope] frames=1027 packets=1028 gaps=98 channels=4 span=838.861s stride=1 reseq=296
|
||||
late=98 stale=0 overflows=0 holes=91 hole_slots=9100 backfilled=36400
|
||||
[udpscope] channel 0 'sine' stored=95300 published=95000
|
||||
|
||||
=== mixed mock: --drop-every 20 --reorder-every 10 --reorder-delay-ms 50
|
||||
[reseq-probe] reseq=159 late=25 stale=0 overflows=0 holes=43 hole_slots=4300 backfilled=8400
|
||||
open_holes=43 bands=43 bands_repaired=20 nan_slots=2200 scanned=44100
|
||||
trigger_in_hole=0
|
||||
[udpscope] frames=978 packets=981 gaps=101 channels=4 span=838.861s stride=1 reseq=309 late=50
|
||||
stale=0 overflows=0 holes=94 hole_slots=9400 backfilled=18400
|
||||
[udpscope] channel 0 'sine' stored=95400 published=95400
|
||||
|
||||
=== re-plan mock: --rate 100000 --drop-every 20 --reorder-every 10 --reorder-delay-ms 50
|
||||
[reseq-probe] reseq=4374 late=72 stale=0 overflows=36 holes=315 hole_slots=31136
|
||||
backfilled=26800 open_holes=64 bands=64 bands_repaired=16 nan_slots=7800
|
||||
scanned=156600 trigger_in_hole=0
|
||||
[udpscope] frames=8949 packets=8953 gaps=614 channels=4 span=83.886s stride=1 reseq=8028
|
||||
late=144 stale=0 overflows=36 holes=603 hole_slots=59936 backfilled=55600
|
||||
[udpscope] channel 0 'sine' stored=588600 published=584600
|
||||
```
|
||||
|
||||
The `late` case's `bands_repaired` is one short of `bands` with `nan_slots=100` — exactly one
|
||||
packet's worth of slots at stride 1 — because the probe fires 50 ms-delayed releases into a
|
||||
window that always has one in flight. An earlier recording of this case read
|
||||
`bands_repaired=42 nan_slots=0`; both are healthy, and which one a run gets is where the
|
||||
probe's instant falls relative to the newest release. A `bands_repaired` *above* the count of
|
||||
holes whose slots hold values is the defect, in either direction.
|
||||
|
||||
`holes` far above `open_holes` in the re-plan case is the two bounds working: a re-plan clears
|
||||
the table (the old entries address a buffer that is gone) and `kMaxHoles` caps what survives
|
||||
at 64. The `mixed` case's `open_holes=43` against `bands=43` has no such spread because
|
||||
nothing re-planned. The per-field reference is in [probe-readings.md](probe-readings.md).
|
||||
|
||||
The mock also carries four **fault injections** that no probe schedules, because what they exercise
|
||||
is the ingest rather than the UI. The first: `--time-glitch-at <stream seconds> --time-glitch-by <seconds>` makes
|
||||
**one** packet's time signal jump ahead and then carries on from where the true timeline was, which
|
||||
is what a device whose clock glitches puts on the wire. Run it with the trigger armed, e.g.
|
||||
|
||||
```sh
|
||||
xvfb-run -a env UDPSCOPE_ENABLE_ALL_MS=400 UDPSCOPE_ARM_TRIGGER_MS=700 UDPSCOPE_EXIT_AFTER_MS=22000 \
|
||||
./build/udpscope --server 127.0.0.1 --port 8000 --holdoff 10
|
||||
# mock: --rate 10000 --batch 100 --freq 5 --time-glitch-at 6.0 --time-glitch-by 10.0
|
||||
```
|
||||
|
||||
Four readings, all off the once-a-second `[udpscope] trig engine:` line:
|
||||
|
||||
- exactly one `warning: ... jumped implausibly far ahead of the previous packet (16.01..16.0199
|
||||
against a previous 6.0099); bridging that packet ...` — the glitch was **caught**;
|
||||
- **no** `timing samples by arrival instead` — one bad packet must not change the time source;
|
||||
- `now_t` crosses the glitch continuously (`5.040 → 6.050 → 7.050`), and `history` keeps **growing**
|
||||
(`1.01 … 23.15`) instead of collapsing — the ring was not wiped;
|
||||
- the latches stay a hold-off apart: `edge at 1.060 / latched at 2.060 / holdoff_until=12.060`, then
|
||||
`edge at 12.260 / latched at 13.260 / holdoff_until=23.260` — **11.2 s** for a 10 s hold-off plus
|
||||
a 0.9 s post window.
|
||||
|
||||
Before the fix the same run read a fallback warning, `history` collapsing 4.03 → 0.95, `now_t`
|
||||
jumping 5.040 → 16.040, and two latches **1.2 s** apart. Note the window here comes from the saved
|
||||
session for that port; the figures above are the default 2 s window (50 % pre), so move
|
||||
`~/.config/udpscope/<host>_<port>.toml` aside to reproduce them.
|
||||
|
||||
The other two are **packet loss** (`--drop-every N`, `--drop-burst K`) and **wire inversion**
|
||||
(`--reorder-every N`). Loss withholds a datagram the counter and the stream clock have already
|
||||
advanced over; the client counts the forward jump as `counter_gaps`. An inversion withholds a
|
||||
packet until the one behind it is sent, so the receiver sees a later counter first; a packet that
|
||||
arrives within the 20 ms reorder window is resequenced with no hole, and one that arrives past it
|
||||
leaves a committed hole that it then backfills (`UDPSCOPE_PROBE_RESEQ_MS` exercises both paths).
|
||||
There is **no interpolation** anywhere below the ring.
|
||||
|
||||
The fourth is the counter itself: **`--counter-step N`** advances the header counter by `N` per
|
||||
datagram instead of one -- a device whose counter counts *samples*, or frames of an inner layer,
|
||||
rather than packets. Nothing is withheld and every datagram goes out on schedule, so the link is
|
||||
intact and the status bar is right to say `live`; what is wrong is the counter's claim, which the
|
||||
client cannot check (it has no clock) and only the app can (it has the arrival times). It is the
|
||||
one wire shape the mock could not otherwise produce, and the run that reads it is the reseq probe
|
||||
with relative time on (the mode that frames a counter gap as NaN by construction):
|
||||
|
||||
```sh
|
||||
# mock: --counter-step 10
|
||||
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=600 \
|
||||
UDPSCOPE_PROBE_RESEQ_MS=5000 UDPSCOPE_EXIT_DUMP=1 \
|
||||
./build/udpscope --server 127.0.0.1 --port 8000 --relative-time
|
||||
```
|
||||
|
||||
```
|
||||
# fixed: [reseq-probe] … holes=1 hole_slots=900 backfilled=0 gaps_refused=419 open_holes=0
|
||||
# bands=0 bands_repaired=0 nan_slots=0 scanned=21700 trigger_in_hole=0
|
||||
# status bar: glance=[● live · 9.97 kS/s · 2 s window]
|
||||
# broken: [reseq-probe] … holes=420 hole_slots=378000 backfilled=0 open_holes=64 bands=64
|
||||
# bands_repaired=0 nan_slots=198000 scanned=220000 trigger_in_hole=0
|
||||
# status bar: glance=[● live · 101 kS/s · 2 s window]
|
||||
```
|
||||
|
||||
**`gaps_refused` above 0 with `nan_slots` at 0 is the assertion** — the gap was refused and the
|
||||
samples were kept. The broken reading is `nan_slots` at 90% of `scanned` with `backfilled=0`: a
|
||||
reserved hole per datagram, `relative_time` framing each one as NaN, and nothing ever missing to
|
||||
backfill it, i.e. one missing-data band over the whole window with no trace in it, under a status
|
||||
bar reading `live` at ten times the stream's real rate. The same field
|
||||
must be **0** on every honest shape above (`--drop-every`, `--drop-burst 200`,
|
||||
`--reorder-delay-ms 50`, at 10 kS/s: `holes=210 hole_slots=21000 nan_slots=11000 scanned=22000
|
||||
gaps_refused=0` for `--drop-every 2`, `holes=3 hole_slots=60000 nan_slots=20000` for the 200-packet
|
||||
burst at 100 kS/s) — a non-zero reading there means the arrival test is refusing real loss, whose
|
||||
only cost is a gap with no shading.
|
||||
|
||||
All of the readings below are off `UDPSCOPE_EXIT_DUMP`'s `[udpscope] frames= packets= gaps=` line,
|
||||
over an 8 s run against `--rate 10000 --batch 100 --freq 5` (100 packets a second) except where the
|
||||
burst needs the faster stream. **`gaps` is the exact reading** and repeats to the packet; `frames`
|
||||
and `packets` move by one or two with the run's own timing. Run each of these on its own — four of
|
||||
them back to back in one script starved the mock past the 2 s silence timeout and every case,
|
||||
including the clean one, then read a reconnect instead of what it was testing:
|
||||
|
||||
- **clean**, as the reference: `frames=1029 packets=1030 gaps=0`, no warning.
|
||||
- `--drop-every 10`: `frames=925 packets=926 gaps=102`. Every tenth packet never reaches the wire,
|
||||
so `packets` falls with `frames` and `gaps` is the count of them. No warning: a one-packet hole is
|
||||
a one-packet jump in the stamps, far inside the glitch guard.
|
||||
- `--reorder-every 10`: `frames=1028 packets=1029 gaps=0`. The inverted packet lands inside the
|
||||
20 ms reorder window and is resequenced: every datagram arrives and `gaps` is 0. The
|
||||
pre-resequencing signature was `frames=925 packets=1028 gaps=102` — an inversion cost a counter
|
||||
gap and a discarded packet, which is exactly what resequencing removes. Adding
|
||||
`--reorder-delay-ms 50` pushes the arrival *past* the window and is the case the
|
||||
`UDPSCOPE_PROBE_RESEQ_MS` readings above cover; it is not this row.
|
||||
- `--drop-every 1000 --drop-burst 200` at `--rate 100000 --batch 100` (1000 packets a second):
|
||||
`frames=7000 packets=7001 gaps=1600`, eight bursts of 200, and still no warning.
|
||||
- `--drop-every 1000 --drop-burst 300`, same stream: `frames=6203 packets=6204 gaps=2400` **and the
|
||||
guard fires** — `warning: ... jumped implausibly far ahead of the previous packet (1.299..1.29999
|
||||
against a previous 0.99899)`, then `timing samples by arrival instead`. 300 packet spans is past
|
||||
`kGlitchPackets` (256), so real loss is read as a clock glitch; and since each bridge advances the
|
||||
anchor by one packet span while the true timeline is 300 ahead, the next four packets fail too and
|
||||
`kFailureLimit` throws the stream onto the arrival clock. A burst between 256 packets and the 2 s
|
||||
`silence_timeout_s` is the only window in which this is reachable, and it is a finding, not a
|
||||
baseline.
|
||||
|
||||
Keep a burst under 2 s of wire time whatever the rate: past that the client reconnects, the mock
|
||||
resets its counter and clock on the new CONNECT, and the run reads `gaps=0` with a *backwards* stamp
|
||||
warning (`does not advance across packets ... 0..0.0099 against a previous 0.3899`) — the reconnect
|
||||
path, not the loss path.
|
||||
|
||||
**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
|
||||
@@ -178,3 +701,67 @@ hold ports.
|
||||
|
||||
The probes are the regression net — when changing UI or plot-host behaviour, extend
|
||||
the probe/dump in `src/ui.cpp` rather than adding a separate harness.
|
||||
|
||||
## The noise probe needs a spiked mock
|
||||
|
||||
Every other probe runs against the plain mock. The noise probe reads what a *band* and an *HF reject* do to
|
||||
impulsive interference, so its mock must have some: an alternating-sign impulse train of 2 samples at
|
||||
amplitude 0.5 every 5 ms, added to `sine` alone (so a spiked channel can be read against a clean one in the
|
||||
same run).
|
||||
|
||||
```bash
|
||||
cd /home/martino/Projects/tools/udpscope
|
||||
python3 tools/mock_udpstreamer.py --port 8108 --rate 1300000 --batch 1000 --duration 20 \
|
||||
--spike-every 0.005 --spike-amp 0.5 --spike-width 2 --square-harmonics 0 \
|
||||
> /tmp/noise-mock.log 2>&1 &
|
||||
sleep 1.5
|
||||
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=18000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||||
UDPSCOPE_PROBE_NOISE_MS=4000 \
|
||||
./build/udpscope --server 127.0.0.1 --port 8108 2>&1 | grep -E 'noise-probe|CRITICAL'
|
||||
```
|
||||
|
||||
`--square-harmonics 0` is the one flag in that command that is not about the sine, and it is what keeps the
|
||||
probe's last reading exact: the group-delay leg measures a one-pole's *step* response on `square`, and the
|
||||
mock's square rings by default (see [build-and-run.md](build-and-run.md)). A band-limited edge moves that
|
||||
crossing off `tau*ln2` — measured `delay=0.002770 ratio=1.255` against the ideal step's `0.002206 / 1.000`,
|
||||
and the same figure on every run, so it is a property of the shape and not of the run. The leg's assertion is
|
||||
the `0.7..1.4` band, which the ripple keeps, but the ideal step is what keeps the *number* the line prints
|
||||
beside `want` as well. The other legs measure event counts on the sine, which the ripple does not touch; the
|
||||
counts do read a few percent lower under the ripple at this rate, for the producer-cost reason
|
||||
[build-and-run.md](build-and-run.md) gives.
|
||||
|
||||
`UDPSCOPE_PROBE_NOISE_MS` is **not the time its readings are taken**. It times the probe's first stage, which
|
||||
installs a math graph through `apply_math_channels()` -- and that is a *structural rebuild* that replaces the
|
||||
buffer and drops its history. Every reading the probe takes is over retained history, so the rebuild has to
|
||||
happen seconds *before* them, and the chain is what the number is really choosing: `4000` puts the graph in at
|
||||
4 s, `noise_probe` at 9 s (it enables the signals it measures itself and waits 2.5 s for the ring to refill
|
||||
after *that* change), the legs and the five measurements at about 11.5 s, the de-noise channel enabled at
|
||||
14 s, and the de-noise reading at 16.5 s -- hence the 18 s exit and the 20 s mock. **Collapsing the two
|
||||
stages into one is the failure mode**: a probe that rebuilt the buffer at 11.5 s and then scanned it
|
||||
immediately would read `bare=0` on every search leg, which looks exactly like a broken comparator.
|
||||
|
||||
**Why 4 s and not 3.** `enable_all_probe`'s last act is a timeout of its own, `ENABLE_ALL_MS + 2000` -- 3 s
|
||||
under this recipe -- which walks `signal_list` for the front channel's acquire tick and clicks it. A probe
|
||||
that installs a math graph at that same instant rebuilds the signal panel under it, and the off-step then
|
||||
emits `active` on a freed `GtkCheckButton`: `GLib-GObject-CRITICAL: instance with invalid (NULL) class
|
||||
pointer`, with the backtrace in `enable_all_probe()::{lambda}`. It is a real fragility of that probe's lookup
|
||||
and not of the feature, and it is why every documented probe time is offset from `ENABLE_ALL_MS + 2000`, not
|
||||
merely from each other.
|
||||
|
||||
The de-noise channel is installed *disabled* and enabled only for its own reading (stage 3). An acquired
|
||||
channel is a share of the ring, so a fifth one shortens every channel's retained span -- and the six leg
|
||||
counts and `pkpk bare` all scale with that span. The readings above the de-noise line belong to the older
|
||||
feature and are the proof it still works, so the new channel stays out of the acquisition plan until they
|
||||
have run. `--spike-every`/`--spike-amp`/
|
||||
`--spike-width` are the mock's own; `--batch` must stay at or below 1000 (2000 samples x 5 signals x f64 is
|
||||
80 kB, past UDP's ~65507-byte payload, and the mock dies on its first `sendto` while the run still exits 0).
|
||||
|
||||
The three lines and what each must read are in `probe-readings.md`.
|
||||
|
||||
`UDPSCOPE_PROBE_NOISE_MS`, `UDPSCOPE_PROBE_MEASURE_MS`, `UDPSCOPE_PROBE_MEASURES_MS`,
|
||||
`UDPSCOPE_PROBE_BANDAVG_MS` and `UDPSCOPE_PROBE_MATH_MS` must **not** be scheduled
|
||||
in the same run. The noise, measurement, measurements and band-average probes all push onto
|
||||
`app.measures`, and the later one would read
|
||||
the earlier one's rows as its own; the noise and math probes both write `app.math`, so the later one's graph
|
||||
lands on top of the earlier one's and both sets of readings describe a state neither probe set up. The noise probe carries
|
||||
its own measurement legs (see `probe-readings.md`), so nothing is lost by keeping them apart.
|
||||
@@ -0,0 +1,217 @@
|
||||
# Project review — 2026-10-13
|
||||
|
||||
A status review for performance, defects and gaps. Every claim below was read back against the
|
||||
current tree; where a candidate finding turned out to be wrong it is listed as dismissed, with the
|
||||
reason, rather than dropped — a false alarm that is not written down gets re-raised.
|
||||
|
||||
## Summary
|
||||
|
||||
The project is in good shape. All ten `todo.md` items are implemented, every deviation from
|
||||
`SPECS.md` is documented in README's *Review findings not (yet) implemented* and *Limitations*, there
|
||||
is no `TODO`/`FIXME`/`HACK` anywhere in `src/`, no dead command-line option, and no settings field
|
||||
that is written but never read. The tree builds clean.
|
||||
|
||||
- **Defects: none confirmed.** Four candidates were investigated and all four are correct as
|
||||
written (§2).
|
||||
- **Performance: one real win on the ingest hot path** (§1.1) — a 64-bit `div` per sample per
|
||||
channel that the code's own comment believes is already cheap. Then one real win on the idle
|
||||
frame path (§1.2), and three small ones. **§1.1 and §1.2 have since been implemented**; the three
|
||||
small ones (§1.3-§1.5) have not.
|
||||
- **Gaps: no missing requirement.** What is missing is only what was never asked for (§4), plus one
|
||||
stale sentence in README (§3).
|
||||
|
||||
## 1. Performance
|
||||
|
||||
### 1.1 A 64-bit division per sample per channel on the producer hot path
|
||||
|
||||
> **Implemented** (2026-10-13), as the fix below describes: `Acquisition::acc_mask_` holds the
|
||||
> per-channel masks, built where the accumulators are sized; `reserve_hole()` moved with it; the
|
||||
> misleading comment at `src/model.cpp:120-122` now says what the mask actually buys. Written up in
|
||||
> README under *What the ingest filter and a still frame cost*.
|
||||
|
||||
`src/acquisition.cpp:856-857`, in the innermost loop of `on_data()`:
|
||||
|
||||
```cpp
|
||||
const u64 period = g * part.stride[i];
|
||||
if (tick % period != period - 1) continue;
|
||||
```
|
||||
|
||||
Three facts make this the most expensive avoidable thing in the application:
|
||||
|
||||
1. **`part.stride[i]` is always 1.** `plan_partition()` assigns it (`src/model.cpp:49`) and then
|
||||
explicitly re-asserts it (`src/model.cpp:70`), with the comment above it explaining why
|
||||
per-channel strides were abandoned. So `period == g` for every channel, always — the load and
|
||||
the multiply are provably redundant.
|
||||
2. **`tick % period` is a real `div`.** `g` is a runtime `u64`, so the compiler cannot strength-
|
||||
reduce it, whatever its value. A 64-bit integer division is ~20-40 cycles of latency on x86.
|
||||
The intent was otherwise: `src/model.cpp:120-122` says a power-of-two stride "keeps `tick %
|
||||
period` cheap", which would be true of a compile-time constant and is not true of a variable.
|
||||
**`time_stride` being a power of two currently buys nothing here.**
|
||||
3. **The same condition is already computed.** `tick % g == g - 1` is evaluated at
|
||||
`src/acquisition.cpp:811` for the time ring, and again at `:881` for the math push — and
|
||||
`:857` is the same test once more per channel.
|
||||
|
||||
Cost model: `(n_channels + 2)` divisions per **raw** sample, i.e. per wire sample, not per stored
|
||||
one — the decimation filter deliberately reads every sample (`:850-852`). At the fast mock's
|
||||
1.3 MS/s with 4 channels that is ~7.8 M divisions a second on the producer thread, which is the
|
||||
thread whose budget overrun costs packets rather than frames.
|
||||
|
||||
Fix: compute the group boundary **once per sample** and reuse it. `g` is a power of two, so
|
||||
`const u64 mask = g - 1;` hoisted out of the packet loop turns all three sites into
|
||||
`(tick & mask) == mask`. To keep the per-channel generality the type still carries, precompute a
|
||||
per-channel mask vector when the partition is planned rather than per sample.
|
||||
|
||||
The same expression appears in the hole path at `src/acquisition.cpp:966` (inside
|
||||
`reserve_hole()`'s double loop). That one is cosmetic — it runs only on packet loss — but it should
|
||||
move with its twin so the two paths keep matching.
|
||||
|
||||
How to verify: the fast mock (`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`), reading the
|
||||
exit dump's `gaps=` and the producer's CPU share before and after. Expect the win to show as
|
||||
headroom rather than as a changed reading on a stream the producer already keeps up with.
|
||||
|
||||
### 1.2 The figure is re-rendered 60 times a second even when the picture cannot change
|
||||
|
||||
> **Implemented** (2026-10-13), conservatively: a signature of what a frozen view can be re-framed
|
||||
> by, opening the gate for `kSettleRenders` frames, plus a `kStaticRenderEvery` floor that renders
|
||||
> anyway every four ticks, and the layout editor excluded outright. `[pause-probe]`'s new `skips=`
|
||||
> field reports it: a measured run read 0 live, 33 paused, with `columns == graded`, `render blank
|
||||
> frames: 0` and the layout floor still converged.
|
||||
|
||||
`src/ui.cpp:3709` queues a GL render unconditionally at the end of every tick, and the tick is a
|
||||
fixed 16 ms timeout (`src/ui_app.hpp:1023`). In `Held` and `Paused` the bound data is an immutable
|
||||
snapshot and the axis is pinned, so with no pointer interaction the frame is provably identical to
|
||||
the last one — and it is still fully re-rendered. On llvmpipe, or on a laptop looking at a latched
|
||||
event, that is the whole GPU cost of the application for nothing.
|
||||
|
||||
Fix: queue the render only when something that can change the picture did — new samples acquired,
|
||||
a mode/epoch/pin change, a mark/cursor/label move, an interaction, a resize. Keep it conservative:
|
||||
render unconditionally while the layout is still settling, since the layout editor's buttons are
|
||||
placed from the *previous* frame's rects and several probes read a value one frame after the ask.
|
||||
|
||||
How to verify: the exit dump's per-plot painted-frame counts and `layout over N frames` are exactly
|
||||
the two readings that would catch an over-eager skip.
|
||||
|
||||
### 1.3 The event publish blocks the UI tick for 69-115 ms
|
||||
|
||||
Already documented, with measurements, at `README.md:2189-2191` — listed here only because the
|
||||
README prices exactly one remedy (a second live ring) and declines it for good reasons. Two
|
||||
options it does not price, both cheaper than a second budget's worth of memory:
|
||||
|
||||
- do the snapshot copy on the **producer** side of the handoff, so the stall lands where a stall is
|
||||
already tolerated at a latch (the pause publish already blocks the producer, 22-86 ms);
|
||||
- chunk the copy across frames behind a sequence number, publishing only when complete.
|
||||
|
||||
Worth a design note before any code — the README's own A/B (`gaps=0` either way) says this buys a
|
||||
smoother frame at a latch, not recovered packets, so it is a polish item, not a correctness one.
|
||||
|
||||
### 1.4 The status detail string is rebuilt and re-applied every frame
|
||||
|
||||
`src/ui.cpp:4063` calls `format_detail(snap)` every tick and `:4082` pushes the result into
|
||||
`gtk_widget_set_tooltip_text()` every tick. The popover is already guarded by its own visibility
|
||||
(`:4083-4087`); the tooltip is not, and the string is long enough that the code tracks its maximum
|
||||
length. Nothing reads it unless the pointer rests on the status label.
|
||||
|
||||
Fix: keep the last applied detail string and skip both the format and the setter when it is
|
||||
unchanged. Low risk, no behaviour change.
|
||||
|
||||
### 1.5 Search marks are re-filtered and re-sorted every frame
|
||||
|
||||
`src/ui.cpp:1760-1768`. Two heap vectors, a pass over every hit and an `std::sort` per frame for as
|
||||
long as a search result set exists. Correctly short-circuited when there are no hits
|
||||
(`:1753`), and being recomputed every frame is load-bearing for a *live* view (the marks are stored
|
||||
in stream seconds and the window rolls under them) — but in `Held`/`Paused` neither the hits nor
|
||||
the view range move, so the whole result can be cached and invalidated on a view-range or hit-set
|
||||
change.
|
||||
|
||||
### 1.6 Noted, not recommended: the model mutex spans all of `on_data()`
|
||||
|
||||
`src/acquisition.cpp:558` takes the lock and holds it to the end of the function (~line 913),
|
||||
covering the sample loop, the math evaluation, the trigger engine, and hole reserve/backfill. The
|
||||
UI tick takes the same mutex (`src/ui.cpp:3285`). This is the documented design ("taken once per
|
||||
DATA frame", `docs/claude/architecture.md:15-16`) and it is the right call at today's rates, but it
|
||||
is the coupling by which math-channel cost on the producer turns into UI frame jitter. The existing
|
||||
`math_timed` per-slot instrumentation is the thing to watch if math graphs get heavier.
|
||||
|
||||
## 2. Defect candidates investigated and dismissed
|
||||
|
||||
Recorded so they are not re-raised.
|
||||
|
||||
- **`capture_edge()` loading `memory_order_relaxed`** (`src/model.hpp:492`) while its partner
|
||||
`trigger_pending()` carries release/acquire. **Correct as written.** The consumer reaches
|
||||
`capture_edge()` at `src/ui.cpp:3275` only *after* `display_mode()` at `:3273` has done the
|
||||
acquire load of `trigger_pending_`; the acquire establishes the happens-before, so a relaxed load
|
||||
afterwards cannot see the stale edge, and an acquire also forbids the later load being reordered
|
||||
ahead of it. The probe path (`src/ui_probe.cpp:1129-1130`) reads the flag first too.
|
||||
*One fragility worth a line of doc:* the correctness is a property of **read order at the call
|
||||
site**, and `src/model.hpp:489-491` says a consumer that sees the flag sees the edge without
|
||||
saying that the consumer must therefore read the flag first. Tightening that comment is the whole
|
||||
of the work.
|
||||
- **`math_values_.data()` passed to `math_step()` across a possible reallocation**
|
||||
(`src/model.cpp:409-422`). **Safe.** `push_math_sample()` is only reached from `on_data()`
|
||||
(`src/acquisition.cpp:881`, `:978`), which holds the model mutex for its whole body, and
|
||||
`set_math_channels()` takes the same mutex.
|
||||
- **`model_.signals()` indexed by `channels[0].signal` after the lock was released**
|
||||
(`src/acquisition.cpp:593-595`). **Safe** — same reason: the lock is held from `:558` to the end
|
||||
of the function, so no CONFIG can re-shape the signal list underneath it.
|
||||
- **`events_thumb` left dangling across a list rebuild** (`src/ui_events.cpp:632`). **Already
|
||||
handled** — nulled before the rows are removed and rebound after, with the old texture unreffed.
|
||||
|
||||
Nothing in the trigger/pause/file-view/replay seams, the math-channel dependency handling, the
|
||||
measurement and cursor paths, or the export/session writers reproduced a wrong number or a lost
|
||||
action on inspection. That is a statement about a code read, not about a test run: README's own
|
||||
`Limitations` is right that these paths are checked headless, and the seams between two *user*
|
||||
actions are where the probes are thinnest.
|
||||
|
||||
## 3. One documentation defect
|
||||
|
||||
`README.md:2304-2307` says the interactive paths — "drag and drop, the dialogs, the right-click
|
||||
menu" — "were not driven by an automated test". That is now stale in all three particulars:
|
||||
`UDPSCOPE_PROBE_DROP_MS` drives the drop handler, `UDPSCOPE_PROBE_ROWMENU_MS` walks the
|
||||
right-click menu and clicks through its submenu, and `UDPSCOPE_PROBE_DEFAULTS_MS`,
|
||||
`UDPSCOPE_PROBE_CONFIGS_MS`, `UDPSCOPE_PROBE_PREFS_MS` and `UDPSCOPE_PROBE_HELP_MS` all open and
|
||||
read back dialogs. What remains genuinely undriven is the *gesture* layer (a real pointer drag, a
|
||||
real popover dismissal) rather than the handlers behind it, which is a much narrower claim and the
|
||||
one worth making.
|
||||
|
||||
## 4. Gaps
|
||||
|
||||
### Requirements: complete
|
||||
|
||||
`SPECS.md` was walked bullet by bullet. Everything is implemented, and the five places where the
|
||||
implementation differs are each documented with a reason at `README.md:2182-2240`: one live buffer
|
||||
plus an immutable `EventSnapshot` rather than two live rings; allocation at the first CONFIG rather
|
||||
than at startup; no per-channel decimation (the spec's formula aliased a 2 kHz sine on a 1 MHz clock
|
||||
to a flat line); pre-trigger % as a share of the event window; and a capture drawn as it fills.
|
||||
`todo.md`'s ten items are all really there, including item 9's full preference list — the
|
||||
light/dark/auto theme, the reset-to-defaults and the cache controls are all in `src/settings.hpp`
|
||||
and `src/ui_prefs.cpp`.
|
||||
|
||||
### Not asked for, worth considering
|
||||
|
||||
Ranked by value against cost, not by ambition:
|
||||
|
||||
1. **Save the current view as an image.** The cheapest of these by a wide margin: the event cache
|
||||
already has a sample-driven image writer (`write_preview()`, `src/event_store.cpp:266`), so a
|
||||
menu item and a file chooser is most of the work. A scope user screenshots constantly.
|
||||
2. **A draggable trigger level on the plot.** The trigger marker exists but is a vertical cursor
|
||||
and deliberately non-selectable (`src/plot_host.cpp:250`); the level is reachable only through
|
||||
the panel's spin button. A horizontal handle on the trigger channel's row is the standard
|
||||
gesture on every bench scope.
|
||||
3. **Per-trace vertical offset and scale.** Up to eight signals share one row's y axis, so two
|
||||
signals of different magnitudes cannot both be read. This is the gap a user hits soonest.
|
||||
4. **An FFT / spectrum row.** The largest of these, and the one a UDPStreamer user is most likely
|
||||
to ask for next; it fits the figure's row model (one row, one derived series) rather than
|
||||
fighting it.
|
||||
5. **XY mode**, and **undo for layout edits** — both real scope features, both a poor fit for the
|
||||
current one-x-window figure and the layout file respectively. Mentioned for completeness.
|
||||
|
||||
## 5. Suggested order
|
||||
|
||||
1. §1.1, the ingest-path division — small, local, the clearest win, and it corrects a comment that
|
||||
is currently misleading about why the power of two matters.
|
||||
2. §3, the stale README sentence — minutes, and it is wrong today.
|
||||
3. §1.4 and §1.5, the per-frame string and sort — small and safe.
|
||||
4. §2's doc tightening on `capture_edge()`.
|
||||
5. §1.2, the conditional render — the bigger win of the two performance items, but the one that
|
||||
needs care against the layout-settling probes, so it deserves its own change.
|
||||
6. §4.1, the screenshot — the cheapest real feature on the list.
|
||||
@@ -0,0 +1,101 @@
|
||||
# UI files and current GTK practice
|
||||
|
||||
Where the application's interface should be a `.ui` resource instead of widget construction, what is
|
||||
already there, and the order to finish it in. Written 2026-10-11.
|
||||
|
||||
## Why
|
||||
|
||||
Three reasons, in the order they bite.
|
||||
|
||||
1. **Some things can only be said in a UI file.** The shortcut overview is the proof: GTK's
|
||||
`GtkShortcutsShortcut` has exactly one gesture type that draws an `icon`, and libadwaita's
|
||||
`AdwShortcutsItem` has no icon property at all -- so a mouse gesture can only be shown as a glyph
|
||||
from a `.ui`. `src/ui/shortcuts.ui` is that, and it is the reason the file exists.
|
||||
2. **Structure is data.** A window's arrangement rewritten in C++ is a hundred lines of
|
||||
`gtk_box_append` that no one can read at a glance, cannot be compared with a diff, and cannot be
|
||||
previewed. A UI file is the same arrangement in the notation the GNOME ecosystem reads.
|
||||
3. **Current GTK and libadwaita have better widgets than this app uses.** The forms here are
|
||||
`GtkEntry`/`GtkSpinButton` in hand-built label rows. The modern equivalents -- `AdwEntryRow`,
|
||||
`AdwSpinRow`, `AdwSwitchRow`, `AdwComboRow`, inside `AdwPreferencesPage`/`AdwPreferencesGroup` in an
|
||||
`AdwPreferencesDialog` -- are what the preferences dialog should be, and they come from the same
|
||||
move.
|
||||
|
||||
## Conventions (established, and in `docs/claude/invariants-gtk-and-chrome.md`)
|
||||
|
||||
- `.ui` files live in `src/ui/` and are compiled by the third GResource (`udpscope-ui.gresource.xml`,
|
||||
`prefix=/org/udpscope/ui`), loaded with `gtk_builder_new_from_resource()`.
|
||||
- Every file carries `preprocess="xml-stripblanks"`, which is the only thing that catches a malformed
|
||||
file at build time.
|
||||
- A file that is valid XML but wrong for GTK **aborts the process** (`Gtk-ERROR`). The probe that opens
|
||||
every window is what catches that; there is no build-time substitute.
|
||||
- A `GIcon` property takes a nested object, not a name.
|
||||
- A `.ui` over 4095 characters needs `-Wno-overlength-strings` on its generated source.
|
||||
- A window built from a builder is replaced (not reused) on the next opening: it belongs to the
|
||||
builder's object list, and a destroyed `GtkWindow` cannot be presented again.
|
||||
- A deprecated *widget* is acceptable when no deprecated *function* is called.
|
||||
|
||||
## Acceptance for every slice
|
||||
|
||||
The same as any other change here: the build stays at **four warnings**, a run stays at **no
|
||||
`Gtk-CRITICAL`**, and the slice's own surface is read back by a probe rather than asserted. A new `.ui`
|
||||
needs a probe that proves it *loads* and that its contents match whatever drives it -- `[help]`'s
|
||||
`ui=` against `tables=` is the model.
|
||||
|
||||
## Already done
|
||||
|
||||
- `src/ui/main-window.ui` -- the main window's **skeleton**: the window, its two panes, the plot column
|
||||
with the status line under it, and the sidebar's scroller. The four subtrees that depend on the stream
|
||||
(the events pane, the two plot areas, the sidebar's panels, the status popover) are still built by
|
||||
code and put into the slots by id.
|
||||
- `src/ui/source-form.ui` -- the form a source is added or edited with: an `AdwDialog` of
|
||||
`AdwPreferencesGroup` rows, with its action in the header bar where the label says which mode it is in.
|
||||
- `src/ui/help.ui` -- the help window's chrome: an `AdwWindow` with a header bar and an
|
||||
`AdwNavigationSplitView` whose sidebar is the page list. The rows stay in C++ because the fourteen
|
||||
pages are data, not template.
|
||||
- `src/ui/preferences.ui` -- the preferences, as an `AdwPreferencesDialog` with `AdwEntryRow`,
|
||||
`AdwSpinRow`, `AdwSwitchRow`, `AdwComboRow` and `AdwButtonRow` rows. The Apply button is gone: a row
|
||||
applies as it changes, which is what those widgets are designed around, and `[prefs-probe]` reads the
|
||||
file back after every change.
|
||||
- `src/ui/shortcuts.ui` -- the shortcut overview, with the mouse rows drawn as an icon cell.
|
||||
- The `src/ui/` GResource and its build block.
|
||||
- `[help]`'s `ui=`/`ui_accel=`/`ui_icon=`/`ui_bare=` reading, which checks the file against the tables
|
||||
and asserts that no row is left without a visual.
|
||||
|
||||
## Remaining, smallest first
|
||||
|
||||
| where | what | why it is the right next step |
|
||||
|---|---|---|
|
||||
| `src/source_dialog.cpp` (the chooser's own window) | the chooser: `.ui` for the window, its two pages and its action row. Its **host form is done**; what is left is the list, the notebook and the buttons around it | the last hand-built UI outside `ui.cpp`, and the file is already open |
|
||||
| `build_measures_panel` (`src/ui.cpp`, ~82 lines) | the measurements panel: `.ui` + `tools/check_ui.py` after | small, self-contained, and its own probe |
|
||||
| `src/ui/ui_trigger.cpp` (~22 kB) | the trigger panel: the `.ui` holds the panel and its groups; the signal chips and the condition rows stay in code because they follow CONFIG | the largest user-visible panel |
|
||||
| `src/ui/ui_signals.cpp` (~20 kB) | the signals panel | mostly a list of data-driven rows, so the `.ui` holds the frame |
|
||||
| `src/ui/ui_math.cpp` (~48 kB) | the math panel and the node editor's chrome (the canvas itself is drawn, not declared) | the largest file, and the one where least of it is template |
|
||||
| `src/ui/ui_events.cpp` (~44 kB) | the events pane | the rows are events, the frame is not |
|
||||
| `src/ui/ui_layout.cpp` (~20 kB) | the layout editor's overlay buttons, which are positioned by code | only the buttons are template |
|
||||
| `build_toolbar` (`src/ui.cpp`, ~212 lines) | the header bar's layout, with every button still wired in code | delicate: the `[udpscope] header:` probe reads its geometry |
|
||||
|
||||
**Every row of the table above is done or accounted for.** The header bar went last and in one change, because
|
||||
`gtk_header_bar_pack_start()` appends after whatever a file has already packed -- staging it by group would
|
||||
have reordered the bar in an intermediate commit. Its frame is `src/ui/header-bar.ui`; the overflow menu's
|
||||
rows, the window control and every handler stayed in code, and the probe's header readings are byte-identical
|
||||
before and after.
|
||||
|
||||
**The layout editor's buttons are not a `.ui` row, and the note above is wrong about them.** Their count *is*
|
||||
the row count (one per row, one per boundary), and `GtkBuilder` instantiates a file's objects once, so a file
|
||||
can hold neither the number nor a prototype for the code to copy. What was real in that row was the
|
||||
duplication: the shared look is now one function (`layout_overlay_button()`), and the layout probe reads
|
||||
byte-identical before and after. See `invariants-gtk-and-chrome.md`.
|
||||
|
||||
**Done since, one row per commit:** the source chooser's window (`0a64e9e`), the measurements panel
|
||||
(`d344361`, `07fcf61`), the trigger panel (`1ccc70c`), the signals panel (`c37fbdd`), the math panel
|
||||
(`90a17eb`) and the math editor's window, identity row and canvas. What is left of that row is the palette
|
||||
and the buttons around the canvas, which stay in code for the reason the canvas's *drawing* does.
|
||||
|
||||
**Done since:** the sidebar is an `AdwOverlaySplitView` (`src/ui/main-window.ui`) rather than a paned's end
|
||||
child, so its width is its own 320 px and it overlays the plot instead of collapsing to zero — see
|
||||
`[sidebar-probe]`.
|
||||
|
||||
Each of these is one change on the pattern `src/ui/ui_cursors.cpp` now follows: `ui_load()` the file, resolve
|
||||
the ids, keep every value, signal and rebuild in code, and run the panel's own probe before committing.
|
||||
|
||||
Nothing in this list should be done in one pass. Each row is its own change, verified on its own.
|
||||
@@ -0,0 +1,437 @@
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
|
||||
# Three things a bench scope has and udpscope does not: qualified triggers, measurement statistics, and a search over the buffer
|
||||
|
||||
udpscope triggers on one thing — a level crossing — reports each measurement as a single
|
||||
number with no sense of how it has varied, and offers no way to ask where in the retained
|
||||
buffer something happened. A survey of what bench scopes and the open-source viewers provide
|
||||
turned up a longer list than this; it was narrowed deliberately, and what was cut is named in
|
||||
**Scope** below.
|
||||
|
||||
The three features chosen are not independent. A qualified trigger condition and a search over
|
||||
the buffer are the *same question* asked of live data and of recorded data, so they are built
|
||||
on one evaluator rather than two, and the probe that proves they agree is part of the work.
|
||||
Measurement statistics are separate but cheap: the pane's recompute pass already produces the
|
||||
number a statistic accumulates.
|
||||
|
||||
## Scope
|
||||
|
||||
**In:**
|
||||
|
||||
- A shared, allocation-free `EventDetector` extracted out of `TriggerEngine`, carrying four
|
||||
condition types: edge (as now), pulse width (with glitch as a comparison, not a fifth type),
|
||||
window, and dropout.
|
||||
- The trigger panel's new type row and the parameter rows each type needs.
|
||||
- Per-measurement statistics — count, mean, standard deviation, min, max — accumulated one
|
||||
observation per recompute, with a pane-wide toggle and a Reset.
|
||||
- A `Search` section in the trigger panel that scans the retained ring for the trigger's own
|
||||
condition, marks the hits, and navigates them by reusing the existing pause.
|
||||
- Four new probes, and the mock waveforms they need.
|
||||
- `SPECS.md` is not changed; `README.md` and `docs/claude/` are.
|
||||
|
||||
**Out, and deliberately:**
|
||||
|
||||
- **Reference waveforms** — declined explicitly.
|
||||
- **PNG export** — the `.npy`/`.csv` export covers the need that matters; a screenshot is the
|
||||
window manager's job.
|
||||
- **FFT / spectrum** — needs a second `plot::Figure` with its own x units, which is a project
|
||||
of its own and not a feature of this one.
|
||||
- **Byte-bounded event history** — a second retention policy beside the ring's, with its own
|
||||
eviction. Out for the same reason.
|
||||
- No new measurement types. No change to `measure_scan_buckets()` /
|
||||
`measure_accumulate_bucket()` or to the hysteresis band. No change to the decimation plan, the
|
||||
partition, or the two-thread split.
|
||||
|
||||
## Global constraints
|
||||
|
||||
These bind every task below and are not restated in each one.
|
||||
|
||||
- **Two threads only.** `EventDetector::feed()` runs on the producer inside
|
||||
`Acquisition::on_data()`, under `ScopeModel::mutex()`. It allocates nothing, throws nothing,
|
||||
does no I/O, and is `noexcept`. The statistics accumulator and the search driver run on the
|
||||
GTK main thread.
|
||||
- **Every new source file carries the SPDX MIT header**, matching the existing files.
|
||||
- **Stream seconds, end to end.** Nothing on the trigger or search path converts a span to a
|
||||
sample count, and nothing introduces a samples-per-second estimate. That conversion is the
|
||||
documented cause of the "the post window is empty" defect
|
||||
(`docs/claude/invariants-trigger-and-pinning.md`).
|
||||
- **The detector is fed the stored (decimated, averaged) value**, never the wire value — an
|
||||
event found on a sample the buffer does not hold cannot be cut from it.
|
||||
- **A pinned axis is pinned from the snapshot's own stamps, never from the request.** Applies to
|
||||
every pause the search publishes.
|
||||
- **There is no unit test suite.** Every assertion is a probe line on stderr, driven through the
|
||||
real widgets, read against the documented baselines: the documented build-warning count and
|
||||
zero `Gtk-CRITICAL`.
|
||||
- **No figure is written into a document unless it was measured on this machine.** Costs are
|
||||
printed by probes and quoted from those runs.
|
||||
- **Run one probe at a time.** The only exception is the search probe's internal
|
||||
scan-versus-armed pairing, which is one probe.
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Responsibility |
|
||||
| --- | --- |
|
||||
| `src/condition.hpp` / `.cpp` | **New.** `EventCondition`, `TriggerType`, `WidthCmp`, and `EventDetector` — the whole per-sample condition state machine and nothing else. No model, no GTK, no allocation. |
|
||||
| `src/trigger.hpp` / `.cpp` | `TriggerConfig` grows an `EventCondition`. `TriggerEngine` keeps arming, the hold-off, the pre-window gate, the post window and the mode, and delegates detection to `EventDetector`. |
|
||||
| `src/search.hpp` / `.cpp` | **New.** `BufferSearch` — the chunked scan driver over the retained ring, the hit list, the cursor into it, and the staleness state. Owns no widgets. |
|
||||
| `src/measure.hpp` / `.cpp` | `MeasureStatsAccum` (Welford) beside the existing result types. |
|
||||
| `src/plot_host.hpp` / `.cpp` | `kSearchMarks` cursors per row, created in `install()`, placed by a new `set_search_marks()`. |
|
||||
| `src/ui.cpp` | The trigger panel's type and parameter rows, the `Search` expander, the statistics toggle and second line, the chunk pump in the frame tick, and the four probes. |
|
||||
| `src/layout_store.hpp` / `.cpp` | One new `LayoutState` field: the statistics toggle. The trigger is not persisted today and is not persisted by this design. |
|
||||
| `tools/mock_udpstreamer.py` | `--glitch-at` / `--glitch-width` / `--quiet-at`. |
|
||||
| `README.md`, `docs/claude/*` | What was built, and the invariants it adds. |
|
||||
|
||||
`src/condition.*` and `src/search.*` are new files rather than additions to `src/trigger.cpp`
|
||||
and `src/ui.cpp` for two reasons: the detector must be includable by the search without
|
||||
dragging in the engine's arming state, and `src/ui.cpp` is already the largest file in the
|
||||
project.
|
||||
|
||||
## 1. The shared event condition
|
||||
|
||||
### What is extracted
|
||||
|
||||
`TriggerEngine::process()` (`src/trigger.cpp:38-106`) currently does three jobs. Lines 78-84
|
||||
detect a crossing; line 92 gates it on the pre-trigger window; lines 42-75 run the
|
||||
pending/post-window/hold-off/mode machine. Only the first is what a search means by "a
|
||||
condition", so only the first moves.
|
||||
|
||||
```cpp
|
||||
enum class TriggerType { Edge, PulseWidth, Window, Dropout };
|
||||
enum class WidthCmp { Less, Greater, Within, Outside };
|
||||
|
||||
struct EventCondition {
|
||||
TriggerType type = TriggerType::Edge;
|
||||
double level = 0.0;
|
||||
double level2 = 0.0; ///< Window: the band's other edge. Ignored otherwise.
|
||||
TriggerEdge edge = TriggerEdge::Rising;
|
||||
WidthCmp cmp = WidthCmp::Greater; ///< PulseWidth only.
|
||||
double width_s = 1.0e-3; ///< PulseWidth only.
|
||||
double width2_s = 2.0e-3; ///< PulseWidth, Within/Outside only.
|
||||
double timeout_s = 1.0e-3; ///< Dropout only.
|
||||
bool operator==(const EventCondition&) const noexcept;
|
||||
};
|
||||
|
||||
class EventDetector {
|
||||
public:
|
||||
/// Adopts the condition. Resets the state iff the condition differs.
|
||||
void configure(const EventCondition& c) noexcept;
|
||||
void reset() noexcept;
|
||||
/// One sample, in stream order. Returns the event's own stream time, or NaN for no event.
|
||||
double feed(double value, double t) noexcept;
|
||||
};
|
||||
```
|
||||
|
||||
`feed()` returns a **time** rather than a bool because for two of the four types the event's
|
||||
instant is not the sample being looked at: a pulse width is only known at the pulse's closing
|
||||
edge, and a dropout fires on an ordinary sample nowhere near an edge.
|
||||
|
||||
`TriggerEngine::process()` then reads:
|
||||
|
||||
```cpp
|
||||
const double event_t = detector_.feed(value, now_s);
|
||||
if (!std::isfinite(event_t)) return false;
|
||||
if (event_t - oldest_s_ < pre_seconds_) return false; // the gate, now on the event's own t
|
||||
last_trigger_s_ = event_t;
|
||||
pending_ = true;
|
||||
pending_start_s_ = event_t;
|
||||
```
|
||||
|
||||
Note the gate and `pending_start_s_` both move from `now_s` to `event_t`. For `Edge` they are
|
||||
the same value and the behaviour is bit-identical to today; for a long qualifying pulse they
|
||||
differ, and using `now_s` would measure the post window from the wrong instant.
|
||||
|
||||
`TriggerEngine::configure()` keeps its name-keyed reset exactly as it is
|
||||
(`docs/claude/invariants-trigger-and-pinning.md`) and additionally calls
|
||||
`detector_.configure(cfg.condition)`, which resets the detector only when the condition
|
||||
actually changed — a condition re-applied every packet must not restart the state machine, or a
|
||||
pulse-width condition could never see a pulse end.
|
||||
|
||||
### The four types
|
||||
|
||||
| Type | Fires when | The instant it calls t0 |
|
||||
| --- | --- | --- |
|
||||
| `Edge` | `level` is crossed in `edge`'s direction | the crossing sample |
|
||||
| `PulseWidth` | a pulse — a crossing in `edge`'s direction, then the crossing back — whose duration satisfies `cmp` against `width_s` (and `width2_s` for `Within`/`Outside`) | **the closing edge**: the width is not known before it |
|
||||
| `Window` | the signal enters or leaves `[min(level,level2), max(level,level2)]`; `Rising` = enters, `Falling` = leaves, `Both` = either | the crossing sample |
|
||||
| `Dropout` | `level` has not been crossed for longer than `timeout_s` | the first sample at which the timeout is observed |
|
||||
|
||||
**Glitch is `PulseWidth` with `cmp = Less`** and is not a separate type. That is how bench
|
||||
scopes spell it, and one state machine cannot disagree with itself about where a pulse ended.
|
||||
The panel may label the preset "glitch".
|
||||
|
||||
`Dropout`'s t0 is the sample at which the expiry is *observed*, not the exact expiry instant,
|
||||
for the same reason every readout in this app quotes a stored sample: t0 has to be a sample the
|
||||
buffer holds, because the cut is a binary search over the time ring and the axis is pinned to
|
||||
what the cut got.
|
||||
|
||||
**A zero-width band is refused by the panel, not special-cased in the detector.** The band test
|
||||
is `v >= lo && v <= hi` and the event is a change of side, so with `level == level2` the signal
|
||||
is "inside" only on exact float equality — which a crossing essentially never lands on, making
|
||||
the condition one that silently never fires. That is a worse failure than a refusal, and it
|
||||
cannot be fixed by special-casing the detector into an edge trigger: the two state machines
|
||||
answer different questions and conflating them is the divergence this design exists to prevent.
|
||||
So the second level spin is clamped to differ from the first by at least its own increment, the
|
||||
panel says why, and `EventDetector` keeps one unconditional band test.
|
||||
|
||||
### Panel and session
|
||||
|
||||
The trigger panel gains a **type** combo above the existing rows, and the rows below it change
|
||||
with the type: `Edge` shows the level, edge and pre-% rows it shows now; `PulseWidth` adds a
|
||||
comparison combo and one or two width spins; `Window` turns the single level spin into two;
|
||||
`Dropout` replaces the width with a timeout. Hold-off, pre-% and Normal/Single are common to
|
||||
all four and do not move.
|
||||
|
||||
No plot instrument is added. The trigger level has never been drawn on the plot — it is a spin
|
||||
button (`src/ui.cpp:135`, `:1604`) — so a band needs no second ruler.
|
||||
|
||||
`TriggerConfig` gains one `EventCondition` member; its `level` and `edge` replace the
|
||||
`TriggerConfig` fields of those names, and every reader moves with them.
|
||||
|
||||
**The condition is not persisted, because the trigger is not persisted.** `LayoutState`
|
||||
(`src/layout_store.hpp:19-63`) carries the window, rows, calibration, colours, enabled signals,
|
||||
the source, the math graphs and the measurements — and no trigger field at all; the trigger is
|
||||
built from `--holdoff` and the panel's own defaults on every launch. Adding persistence for it
|
||||
is a separate feature nobody asked for, and a session that silently restored an armed
|
||||
pulse-width condition would be a surprise rather than a convenience. The only session key this
|
||||
design adds is the measurements pane's **statistics toggle**, which belongs beside the
|
||||
`measures` it governs.
|
||||
|
||||
The level's existing default — the middle of the signal's declared range until the user edits it
|
||||
(`src/ui.cpp:2016-2054`) — applies to `Window` as a band about that middle, and `level2`
|
||||
defaults with it.
|
||||
|
||||
## 2. Measurement statistics
|
||||
|
||||
### What is accumulated
|
||||
|
||||
Not the samples: `Average` is already the mean of every visible sample. A statistic is of the
|
||||
**measurement's own reported value over time** — count, mean, standard deviation, min and max of
|
||||
every value that measurement has produced since the last reset.
|
||||
|
||||
```cpp
|
||||
/// The variation of one measurement's own value over time. Fixed size, no allocation: it is
|
||||
/// folded in the pane's existing recompute pass, which must not grow a second pass.
|
||||
struct MeasureStatsAccum {
|
||||
std::uint64_t n = 0;
|
||||
double mean = 0.0; ///< Welford's running mean
|
||||
double m2 = 0.0; ///< Welford's sum of squared deviations
|
||||
double lo = 0.0;
|
||||
double hi = 0.0;
|
||||
void add(double v) noexcept;
|
||||
void reset() noexcept;
|
||||
double sd() const noexcept; ///< sqrt(m2 / (n - 1)), 0 for n < 2
|
||||
};
|
||||
```
|
||||
|
||||
Welford rather than `sum` and `sum_of_squares`: on a value with a large offset — a 3.3 V logic
|
||||
level whose σ is a few millivolts — the naive form loses the variance entirely to cancellation
|
||||
in `Σv²`.
|
||||
|
||||
### One observation per recompute
|
||||
|
||||
`update_measures()` is throttled deliberately (`src/ui.cpp:3141-3162`): at most one pass per
|
||||
`kMeasureRefreshUs` while the window scrolls, four times that when a pass is heavy, and exactly
|
||||
**once when the window settles** — the `!changed` early return at `:3153` is what makes a pinned
|
||||
window recompute once rather than every frame. The accumulator takes one observation per pass,
|
||||
so:
|
||||
|
||||
- A scrolling live view contributes on the order of ten observations a second.
|
||||
- A paused view, a held event or a pinned window contributes **one**.
|
||||
- `n` is therefore a count of *recomputes* — not of samples, frames or seconds. The pane labels
|
||||
it `n` and its tooltip says so. Presenting it as a sample count would be a figure the user
|
||||
acts on and that is false.
|
||||
|
||||
Two exclusions, each of which would corrupt the statistic silently:
|
||||
|
||||
- An **invalid** result (`--`) contributes nothing, to neither `n` nor the extremes.
|
||||
- The **`measure_last_valid` fallback** (`src/ui.cpp:3363`) is **not** accumulated. It exists so
|
||||
a level mark does not blink off and is by construction a repeat of an observation already
|
||||
counted; folding it in pulls the mean toward whatever preceded a dropout and collapses σ.
|
||||
|
||||
### Reset
|
||||
|
||||
An explicit **Reset** button in the pane clears every accumulator. Three changes clear them
|
||||
implicitly, because each one makes the values before and after a different population:
|
||||
|
||||
- the measurement is edited (signal, type or options);
|
||||
- a CONFIG arrives — the signal list is rebuilt and a name may now resolve elsewhere;
|
||||
- the display window changes — a mean over 2 s and a mean over 600 s are different statistics.
|
||||
|
||||
Deliberately **not** cleared by a pause, a resume, an event latch, or a pan/zoom within the
|
||||
window. Each of those contributes its one settled observation.
|
||||
|
||||
Accumulators are not persisted. A statistic over a session that has ended is not a statistic;
|
||||
the **toggle's state is** persisted.
|
||||
|
||||
### Display
|
||||
|
||||
One `Statistics` check button at the top of the measurements pane. Off — the default — the pane
|
||||
is exactly as it is now. On, every row grows a second line beneath it, dimmed and smaller:
|
||||
|
||||
```
|
||||
● ● frequency 5.0003 Hz edit
|
||||
x̄ 5.0001 σ 0.0004 ▾ 4.9989 ▴ 5.0012 n 143
|
||||
```
|
||||
|
||||
Pane-wide rather than per-measurement, so that the second lines align and are comparable, and
|
||||
because the per-measurement dialog is already the busiest surface in the app.
|
||||
|
||||
The second line is a `GtkLabel` in a vertical box wrapping the existing horizontal row, built in
|
||||
`measures_row()` (`src/ui.cpp:2495`) and shown or hidden by the toggle — the toggle must **not**
|
||||
call `rebuild_measures_pane()`, which drops and recreates every label including the ones the
|
||||
frame tick holds pointers to. All five numbers go through the same `format_measure()` the
|
||||
value above them uses, so units and significant figures agree by construction.
|
||||
|
||||
## 3. Search and navigate
|
||||
|
||||
### What it searches, and with what
|
||||
|
||||
A `Search` expander at the bottom of the trigger panel, searching **the trigger's own
|
||||
`EventCondition`** read straight off `TriggerConfig`. There is no second condition UI: a
|
||||
condition is set up once and can either be armed to capture live or scanned for over the buffer
|
||||
already held. That is what the shared evaluator buys, and it gives the search all four types for
|
||||
free.
|
||||
|
||||
It runs on the **trigger's channel** over the **retained ring** — not the visible window, which
|
||||
the user can already read by looking.
|
||||
|
||||
### The scan is exact, chunked, and progressive
|
||||
|
||||
It feeds the same `EventDetector`, sample by sample, in stream order, raw. No bucket-skipping: a
|
||||
level-0 bucket whose `[lo,hi]` does not straddle the level cannot hold a crossing, so skipping
|
||||
it is tempting, but the detector is a sequential state machine and synthesising its previous
|
||||
value across a skipped span is precisely how the search and the live trigger would come to
|
||||
disagree about where an edge was. The shared evaluator exists to make that impossible.
|
||||
|
||||
A raw pass over the whole ring cannot be one pass under one lock — that holds
|
||||
`ScopeModel::mutex()` long enough to starve the producer. `BufferSearch` therefore advances in
|
||||
**bounded chunks pumped from the frame tick**, taking and releasing the lock per chunk, the same
|
||||
shape `update_measures()` uses to materialise its window in chunks. The detector's state carries
|
||||
across chunks because it is sequential. The panel shows progress and a Cancel.
|
||||
|
||||
Two limits, both reported in the panel rather than hidden:
|
||||
|
||||
- **The ring keeps filling during the scan** — a pause freezes the view, not the acquisition.
|
||||
Each chunk re-clamps to the ring's current base; if the base has passed the chunk's start the
|
||||
scan stops and the panel says so (`searched 412 s of 600 s — the oldest data was overwritten
|
||||
while scanning`). Reachable only at high rates on a small buffer.
|
||||
- **The hit list is capped** at `kSearchHitsMax = 10000`, and the panel says
|
||||
`stopped at 10000 matches`. A condition matching every sample is a mis-set condition; the
|
||||
answer is a message, not millions of doubles.
|
||||
|
||||
### The hits, and navigating them
|
||||
|
||||
Hits are a `std::vector<double>` of stream times, each the t0 its condition defines. The panel
|
||||
shows `147 matches` with `◀ prev`, `next ▶` and `3 / 147`, with key bindings while the panel has
|
||||
focus.
|
||||
|
||||
Navigation reuses the existing pause. Jumping to hit `t` pauses if not already paused and calls
|
||||
the primitive that is already there (`src/ui.cpp:2188-2199`):
|
||||
|
||||
```cpp
|
||||
model->publish_pause_view(t - w / 2.0, t + w / 2.0, /*x_offset=*/t);
|
||||
if (auto snap = model->pause_view(); snap && !snap->stamps.empty())
|
||||
rows->pin_pause_window(snap->stamps.front(), snap->stamps.back());
|
||||
```
|
||||
|
||||
`w` is the current display window. `x_offset = t` puts the hit at **x = 0**, exactly where a
|
||||
held event's edge sits, so the two ways of looking at an event read alike and the x-label logic
|
||||
needs no new case. The pin comes off the snapshot's own stamps and never off the request: a hit
|
||||
near the oldest retained sample yields a shorter cut, and pinning the request leaves an empty
|
||||
left margin that a zoom-in walks into.
|
||||
|
||||
Resuming or clearing the search resumes and unpins exactly as the pause button does. The hit
|
||||
list survives a resume — it describes data the ring may still hold — but goes stale, so the
|
||||
panel marks its age, and any structural rebuild (a CONFIG, an enable/disable, a calibration
|
||||
change, or a window change that moves `time_stride`) clears it outright, because those drop the
|
||||
history it refers to.
|
||||
|
||||
### Marks
|
||||
|
||||
Cursors are not dynamic: `RowSet` creates them up front in fixed arrays
|
||||
(`kMeasureMarks = 6`, `src/plot_host.hpp:55`) because a `Plot` cannot give one up. So this adds
|
||||
`kSearchMarks = 16` cursors per row, created in the same `install()` loop
|
||||
(`src/plot_host.cpp:185-188`) and taking the same read-only dashed 0.75 px stroke the measure
|
||||
marks take, parked when unused.
|
||||
|
||||
Every frame, the hits inside the visible window are placed on them, nearest to the view's centre
|
||||
first, with the current hit in a distinct colour. When more hits are in view than there are
|
||||
marks the panel reads `16 of 38 marks shown — zoom in to see the rest`, which is a true
|
||||
statement about the picture rather than a silent truncation. Like the measure marks they are
|
||||
re-placed every frame, which is what makes them read-only: a gesture moves one and this puts it
|
||||
back.
|
||||
|
||||
## 4. Verification
|
||||
|
||||
Four probes, each scheduled by an env var, each driving the real widgets, each printing tagged
|
||||
lines to stderr. Added to `docs/claude/verification.md` and `docs/claude/probe-readings.md` with
|
||||
the readings a correct run gives — measured, never assumed.
|
||||
|
||||
**The numbers in the sample lines below, and in the pane mock-up above, show the line's *shape*
|
||||
only.** None of them has been measured; nothing may copy them into a document as a reading. The
|
||||
figures that go into `verification.md` come from a run.
|
||||
|
||||
**`UDPSCOPE_PROBE_CONDITION_MS`** — for each of `Edge`, `PulseWidth` with `Less`, `PulseWidth`
|
||||
with `Greater`, `Window` and `Dropout`: set the type and its parameters through the panel, arm,
|
||||
wait for the latch, print
|
||||
|
||||
```
|
||||
[condition-probe] type=pulsewidth cmp=less width=0.0005 t0=4.3021 want=4.3020 ok=1
|
||||
```
|
||||
|
||||
`want` comes from the mock's injection schedule. `ok=1` is the reading.
|
||||
|
||||
**`UDPSCOPE_PROBE_STATS_MS`** — add a `frequency` measurement on the mock's sine, turn the
|
||||
statistics toggle on, let several recomputes land, print
|
||||
|
||||
```
|
||||
[stats-probe] n=14 mean=5.0001 sd=0.0004 lo=4.9989 hi=5.0012 toggle=1 line2=1
|
||||
```
|
||||
|
||||
then press Reset and print it again: `n=0` is the assertion. It must also hold a pause for a
|
||||
stretch and show `n` advancing by **exactly 1** across it — that is what distinguishes
|
||||
accumulating per recompute from accumulating per frame. With the `measure_last_valid` fallback
|
||||
wrongly accumulated, `n` climbs through an injected dropout while the value does not change and
|
||||
`sd` collapses; that is the second defect this probe exists for.
|
||||
|
||||
**`UDPSCOPE_PROBE_SEARCH_MS`** — configure a glitch condition, scan to completion against the
|
||||
injected pulse train, print
|
||||
|
||||
```
|
||||
[search-probe] matches=7 first=1.3020 last=6.8020 scanned=8.21/8.21 s truncated=0 capped=0
|
||||
[search-probe] jump 1/7 t=1.3020 axis=[-1.0000..1.0000] snap=[-1.0000..1.0000] inside=1 x0_hit=1
|
||||
```
|
||||
|
||||
for three successive `next` presses. `matches` is asserted against the injection count,
|
||||
`inside=1` is the pause-pin invariant, `x0_hit=1` is the hit sitting at x = 0.
|
||||
|
||||
**The cross-check that makes the shared evaluator a claim and not a hope.** The same probe run,
|
||||
same condition, same mock, **armed** instead of searched: the first latch's t0 must equal the
|
||||
scan's `first` exactly.
|
||||
|
||||
```
|
||||
[search-probe] armed t0=1.3020 vs scanned first=1.3020 agree=1
|
||||
```
|
||||
|
||||
A divergence between the live and the offline path appears here and nowhere else.
|
||||
|
||||
**Costs are printed, not asserted**, because an unmeasured figure drifts: `scan cost: N chunks,
|
||||
X ms total, worst chunk Y ms` — the **worst chunk** is the invariant worth watching, since it is
|
||||
what the producer waited on; the total is allowed to be long. Statistics accumulation is folded
|
||||
into the existing `measure cost:` line.
|
||||
|
||||
**Mock additions:** `--glitch-at <s>` / `--glitch-width <s>` inject a narrow pulse into a pulse
|
||||
train at a known instant, and `--quiet-at <s>` stops a signal crossing its level from a known
|
||||
instant. Both are permanent regression cases, like `--time-glitch-at`.
|
||||
|
||||
## Risks
|
||||
|
||||
- **`TriggerConfig`'s `level`/`edge` move into `EventCondition`**, so every reader changes —
|
||||
the panel, the probes and the status line. Mechanical, but wide, and the
|
||||
mitigation is that `Edge` behaviour must be bit-identical afterwards: the existing trigger
|
||||
probes are the regression test and are run before anything new is added.
|
||||
- **The scan's worst chunk is a latency the producer pays.** The chunk size is the one knob, it
|
||||
is picked from the measured figure, and the probe prints the worst chunk every run.
|
||||
- **`kSearchMarks = 16` is a fixed cost in cursors per row** on every plot whether or not a
|
||||
search is running, as `kMeasureMarks = 6` already is.
|
||||
@@ -0,0 +1,327 @@
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
|
||||
# A late packet leaves a hole it could have filled, and the clock it would be filed under is the wrong one
|
||||
|
||||
Two requests, and they are one design.
|
||||
|
||||
The first is that a reordered packet currently reads as permanent damage. `udps_client.c:504-514`
|
||||
orders the stream by the wrapping `uint32` counter and discards anything that arrives behind the
|
||||
newest counter — `stats.stale_packets++; return 0` — *before* spending anything on the payload.
|
||||
The samples are gone, the span they should have filled stays empty, and udpscope never learns
|
||||
there was a span at all. The asked-for remedy is to leave a hole of
|
||||
`<N samples> × <LAST_PACKET_ID − NEW_PACKET_ID − 1>`, mark it invalid, and fill it when the
|
||||
missing packets arrive.
|
||||
|
||||
The second is `todo.md` item 2: a relative-time mode in which the plot's x comes only from the
|
||||
declared sample rate and the packet number, with absolute stream time kept for export.
|
||||
|
||||
They are one design because **the counter is what makes a hole addressable**. A reserved hole has
|
||||
to be a known number of samples at a known position, and a late packet has to compute which
|
||||
position it belongs to. Absolute stamps cannot do either — a lost packet's stamps were never
|
||||
received, so its extent in time is a guess, and the guess is what the glitch bridge at
|
||||
`src/acquisition.cpp:274-292` is already making. The counter gives the extent exactly. Relative
|
||||
timing is not a cosmetic preference that happens to be requested at the same time; it is the
|
||||
coordinate system in which resequencing is arithmetic rather than inference.
|
||||
|
||||
Two facts from the existing code shape almost every decision below, and both say the work is
|
||||
smaller than it looks:
|
||||
|
||||
- **`frame->counter` and `frame->lost` are read nowhere in udpscope.** `frame.lost` is set by the
|
||||
client at `udps_client.c:560` and appears in `src/acquisition.cpp` only inside comments
|
||||
(`:309`, `:331`, `:336`). The client has been reporting the exact gap width for the life of the
|
||||
project and nothing has ever consumed it. Neither input needs inventing.
|
||||
- **`clamp_deadline()` already exists** (`udps_client.c:1102`), documented as *"Clamps `budget` so
|
||||
a pending deadline is not slept through"* and already used for the keepalive and the silence
|
||||
timeout. The reorder deadline is a third call to it.
|
||||
|
||||
## What was decided, and the constraint that decided it
|
||||
|
||||
The latency budget is **20 ms**, maximum. That one number settles the shape of the whole receive
|
||||
path, because it is a *time* budget and not a packet count: at 1000 pkt/s it is twenty packet
|
||||
spans of reorder depth, at 10 pkt/s it is a fifth of one. A hold policy counted in packets would
|
||||
either be useless at high rates or add 400 ms at low ones. A policy counted in wall time works at
|
||||
every rate, because network reorder is measured in milliseconds regardless of how fast the
|
||||
producer sends.
|
||||
|
||||
It also means a *contiguous* arrival must be released immediately. The 20 ms is spent only when
|
||||
there is a gap to wait on, never on the common case.
|
||||
|
||||
Past the window, a committed hole is still **backfilled for values only**. Math channels and the
|
||||
trigger are not replayed. A math channel takes NaN in and gives NaN out, so after a repair the
|
||||
raw trace is whole and the math trace derived from it keeps its gap. That is a deliberate,
|
||||
documented state, not an oversight: the display then says *"this region arrived too late to be
|
||||
part of the computation"*, which is true and is cheaper than any mechanism that could make it
|
||||
false.
|
||||
|
||||
Resequencing and hole reservation run in **both** time modes. In relative mode a hole's stamps
|
||||
are exact; in stream mode they are the declared cadence continued from the last good stamp, which
|
||||
is the existing bridge mechanism reused. Relative timing is therefore an independent toggle a user
|
||||
flips for jitter, not a prerequisite for recovery.
|
||||
|
||||
A hole is surfaced two ways: **status-bar counters** and a **shaded band** over the invalid span.
|
||||
A NaN run already breaks the polyline, but a one-packet hole at a 10 s window is sub-pixel, and a
|
||||
gap nobody can see is not a report.
|
||||
|
||||
## Relative timing
|
||||
|
||||
`build_stamps()` today picks its clock *by inspection*: `time_source_` latches 1 for the declared
|
||||
time signal or 2 for the arrival clock and never flips back (`src/acquisition.cpp:357-367`).
|
||||
Relative timing is not a third thing to infer. It is a user-selected mode that bypasses the
|
||||
inference: `--relative-time` on the command line, a toggle in the UI, persisted in the session
|
||||
file.
|
||||
|
||||
### The stamp
|
||||
|
||||
```
|
||||
stamps_[k] = double(slot_base_ + k) / rate
|
||||
```
|
||||
|
||||
`slot_base_` is a running `u64` of samples seen. Each packet the counter reports missing advances
|
||||
it by `batch`, **before** the frame's own samples are stamped; the delivered frame then advances it
|
||||
by its own `n`. The order is what puts the hole ahead of the data that revealed it.
|
||||
|
||||
This is the requested `counter × batch + sample_index` with the origin left at counter zero, but
|
||||
**accumulated rather than multiplied**, and the difference earns three things:
|
||||
|
||||
- `batch` is then only ever needed to size what is *absent*. That is exactly the
|
||||
`<n_samples_per_frame>` of the original formula, and it is the only place the value is load
|
||||
bearing.
|
||||
- The `uint32` wrap costs nothing. The counter is consumed only as a signed delta — the same
|
||||
arithmetic `udps_client.c:505` already performs — so no unwrapping bookkeeping is needed.
|
||||
- A frame whose `n` disagrees with the established `batch` cannot silently shift the whole
|
||||
timeline, because the timeline is built from the `n` values actually seen.
|
||||
|
||||
`batch` is established from the first DATA frame and held for the life of the stream. A later
|
||||
frame disagreeing is a genuine inconsistency in the producer, not a loss, so it warns once rather
|
||||
than re-deriving.
|
||||
|
||||
### The rate is the declared one, and only the declared one
|
||||
|
||||
`build_stamps()` currently overrides a declared cadence with the arrival-measured one when the two
|
||||
disagree by more than ±25% (`src/acquisition.cpp:152-158`), because a CONFIG declaring 1 MS/s
|
||||
while delivering a hundred samples every fifth of a second reads as a staircase in the x column.
|
||||
Relative mode must **not** do this. Discarding arrival timing is the entire point of the mode. The
|
||||
override stays live in stream mode and is skipped in relative mode.
|
||||
|
||||
### The constant offset is already absorbed
|
||||
|
||||
`src/acquisition.cpp:555` stamps `rel = stamps_[k] - epoch`, and the `t0 =` caption names the
|
||||
epoch. A counter that happens to read 500 000 on connect therefore shows up as an epoch value, not
|
||||
as a 50 000 s axis. No origin subtraction is needed in the formula itself.
|
||||
|
||||
### The x axis needs no change at all
|
||||
|
||||
`RowSet::update_time_labels()` already goes to a relative label form on the tick step against the
|
||||
label resolution at the axis' magnitude, with the `t0 =` caption naming the absolute anchor
|
||||
(`docs/claude/invariants-time-axis-and-window.md:34`). "Counting backwards, like it is now" is
|
||||
what that already does. Relative mode changes *where a stamp comes from* and nothing in the label
|
||||
layer.
|
||||
|
||||
### What relative mode makes unreachable
|
||||
|
||||
The degeneracy guard, the implausible-jump test, `kFailureLimit`, the bridge and the monotonic
|
||||
clamp are all dead in relative mode: the stamps are monotone by construction, so there is no bad
|
||||
stamp to detect and nothing to clamp. (`kGlitchPackets` itself survives — not as the threshold of
|
||||
that test, which is unreachable, but as the reservation cap below, where its value and its
|
||||
reasoning carry over unchanged.)
|
||||
|
||||
That is not tidiness, it is a fix. Measured last session: a loss burst past `kGlitchPackets` (256)
|
||||
is misread as a producer clock glitch; each bridge then advances the anchor by *one* packet span
|
||||
while the true timeline is 300 ahead, so the next four packets fail too, `kFailureLimit` trips,
|
||||
and the whole stream is thrown onto the arrival clock — the exact failure the bridge was added to
|
||||
prevent (`--drop-burst 300` reads
|
||||
`jumped implausibly far ahead ... (1.299..1.29999 against a previous 0.99899)` then
|
||||
`timing samples by arrival instead`). In relative mode a 300-packet gap is a known quantity and
|
||||
cannot be misdiagnosed.
|
||||
|
||||
### Absolute time for export
|
||||
|
||||
`todo.md` requires absolute stream time to remain available for export. There is exactly one f64
|
||||
time ring per `BufferStore`, shared by every channel of that buffer, and relative mode takes it.
|
||||
Export therefore needs a **second** f64 ring carrying the absolute stamp under the same group-mean
|
||||
rule, written in **relative mode only** and read by export and by nothing else — eight bytes per
|
||||
stored slot. The absolute ring is not kept unconditionally; it is budgeted along with the main
|
||||
ring and the value rings, which is why it halves the retained span rather than costing a
|
||||
proportional fraction (see SPECS.md for the measured figures and the power-of-two explanation).
|
||||
|
||||
This cost is paid rather than avoided. Without it, selecting relative mode silently degrades the
|
||||
one output `todo.md` says must keep absolute time.
|
||||
|
||||
The measured history cost: **83.886 s** in stream mode and **41.943 s** in relative mode, at
|
||||
100 kS/s, 4 channels, `--buffer-mb 200`. The plan's ~22% figure and the per-byte 75% figure are
|
||||
both wrong because `Partition::time_capacity` is floored to a power of two.
|
||||
|
||||
## The receive path
|
||||
|
||||
### In the client: a reorder ring of reassembled payloads
|
||||
|
||||
Every `udps_frame_t` sets `frame.signals = c->sigs; frame.values = c->vals`
|
||||
(`udps_client.c:566-567`) — one client-owned decode buffer, reused for every packet. A frame is
|
||||
therefore valid only inside the callback, and holding one requires a copy *wherever* the holding
|
||||
happens. The client has no advantage there.
|
||||
|
||||
It has a different one: it still holds the **raw datagram**. Resequencing undecoded wire bytes is
|
||||
strictly cheaper than resequencing decoded doubles — an `f32` or `i16` payload is two to four
|
||||
times smaller than what `c->valbuf` would hold — and the bytes exist nowhere else. That is why
|
||||
this sits in the client and not in `Acquisition`.
|
||||
|
||||
The hook is the `decode_data()` call site at `udps_client.c:599`: after fragment reassembly, which
|
||||
already tolerates arrival order through the slot table, and before decode. Each held entry carries
|
||||
the payload bytes, their length, the counter and `recv_time`.
|
||||
|
||||
The policy, against `next_counter = last_delivered + 1`:
|
||||
|
||||
| arrival | action | added latency |
|
||||
| --- | --- | --- |
|
||||
| `== next_counter` | decode and deliver now, then drain held entries that became contiguous | **none** |
|
||||
| `> next_counter` | copy into the ring; set the gap deadline to `now + 20 ms` if not already set | the newer packet waits |
|
||||
| `< next_counter` | the gap is already committed — decode and deliver, flagged **late** | n/a |
|
||||
| deadline fires | give up on the missing counters, advance `next_counter` to the lowest held entry, drain in order | capped at 20 ms |
|
||||
|
||||
The last row is where `stats.stale_packets++; return 0` at `:506-509` is replaced: a late packet
|
||||
is delivered flagged instead of discarded, which is the single change that makes backfill possible
|
||||
at all.
|
||||
|
||||
The deadline is served by a third `clamp_deadline()` call on the `select()` budget
|
||||
(`udps_client.c:1145-1152`). Without it, a held packet on a quiet stream would wait for the next
|
||||
arrival — 100 ms at 10 pkt/s — and blow the budget.
|
||||
|
||||
Two bounds on the ring, because 20 ms is a different number of packets at every rate: a fixed
|
||||
entry count and a fixed byte budget. Overflow commits the gap early rather than growing, and
|
||||
increments its own counter, so an undersized window is visible rather than silent.
|
||||
|
||||
The window itself is `udps_config_t::reorder_window_s`, defaulting to `0.020`, along
|
||||
`recv_buffer_bytes` and `silence_timeout_s` (`udps_client.c:982`). Zero disables resequencing and
|
||||
restores today's discard-on-late behaviour exactly, which is what makes the change bisectable
|
||||
against a reported regression. The policy value is udpscope's; the mechanism is the client's.
|
||||
|
||||
### In `Acquisition`: reservation
|
||||
|
||||
On a frame with `lost > 0`, before pushing the frame's own samples, run `lost × batch` NaN samples
|
||||
through the **existing** per-sample loop at `src/acquisition.cpp:553`.
|
||||
|
||||
That reuse is the design. `tick_` advances, so `tick % g == g - 1` keeps firing on the same phase
|
||||
and the decimation grid does not shift; the group mean still lands on the mean stamp; and
|
||||
`MinMaxPyramid::append` sees NaN, which its existing non-finite contract already handles as
|
||||
*advances the positional count, excluded from every aggregate*. Nothing bespoke is needed for
|
||||
decimation, stamping or the pyramid.
|
||||
|
||||
Stamps across the hole come from the active time source — exact in relative mode, the declared
|
||||
cadence continued from the last good stamp in stream mode, which is the bridge at `:274-292`.
|
||||
|
||||
Math channels take NaN and give NaN, so a math trace keeps its gap. The trigger engine's
|
||||
comparisons are false on NaN, so it cannot latch inside a hole; this is asserted by the probe
|
||||
rather than assumed.
|
||||
|
||||
**Reservation is capped at 256 missing packets**, reusing `kGlitchPackets` and its existing
|
||||
reasoning verbatim — *256 packet spans in one go is an outage, not loss*. Past the cap there is no
|
||||
reservation: log once and continue as today. That cap is also what stops a ten-minute outage from
|
||||
pushing billions of NaN samples through the ring and wiping all history to make room for a gap.
|
||||
|
||||
## Backfill
|
||||
|
||||
`plot::data::RingBuffer` and `MinMaxPyramid` are both strictly append-only today. Backfill needs
|
||||
an amend in each, and the two belong on different threads.
|
||||
|
||||
`plot_host.hpp:183` calls `acquire(ScopeModel&)` from the frame path, so the **pyramid is mutated
|
||||
only on the GTK main thread**, once per frame: the producer pushes into the ring and the main
|
||||
thread folds what was pushed into the pyramid. That split carries straight over to the amend.
|
||||
|
||||
- **Ring amend — producer thread.** `RingBuffer<T>::amend(absolute, v)`, bounds-checked against
|
||||
`[produced_ - capacity, produced_)`, a single relaxed store. The same race class as a push, and
|
||||
an `f32` cannot tear. Measurements and export read the ring directly, so they see a repair
|
||||
immediately.
|
||||
- **Pyramid amend — main thread, inside `acquire()`.** The producer appends amended `(slot, value)`
|
||||
pairs to a plain `std::vector<Amend>` member; `ChannelBuffer::acquire()` drains it **after** its
|
||||
append pass, so every amended index is already published. No SPSC queue is needed: `acquire()` is
|
||||
called under `model.mutex()` and the producer holds that same mutex for all of `on_data`, so the
|
||||
vector is correctly serialised without lock-free machinery.
|
||||
|
||||
The thread split is not a stylistic choice. A bucket is 32 bytes and is not atomic, so amending it
|
||||
from the producer would let a reader see a half-updated bucket whose `sum` does not match its
|
||||
`count` — a wrong mean from a structure whose readers trust `count == 0` to mean *absent*. On the
|
||||
main thread that cannot happen and no seqlock is needed.
|
||||
|
||||
`MinMaxPyramid::amend(index, v)` folds `v` into every covering bucket: at `count == 0`,
|
||||
`min = max = v`, `sum = v`, `sumsq = v²`, `count = 1`; otherwise widen `min`/`max` and accumulate
|
||||
`sum`/`sumsq`, `++count`. Its documented precondition is **non-finite → finite only**, which is
|
||||
what makes it purely additive and O(log n) with no rebuild. An index already dropped by
|
||||
`drop_before` is a no-op.
|
||||
|
||||
### The time ring is never amended
|
||||
|
||||
The hole was pre-stamped at reservation, so a backfill changes values only. `neighbours()` keeps
|
||||
the monotonic array its binary search requires and `window_x0_` stays valid. This is the reason
|
||||
reservation pre-stamps rather than leaving the ring short.
|
||||
|
||||
### Addressing a late packet
|
||||
|
||||
Reservation records `(counter_first, counter_last, tick_start, slot_start, slot_count)` in a
|
||||
bounded side table. A late frame's counter indexes straight into it:
|
||||
|
||||
```
|
||||
tick = tick_start + (counter - counter_first) * batch
|
||||
```
|
||||
|
||||
Only slots lying **wholly** inside the arrived packet's tick range are amended. A slot straddling
|
||||
a hole edge went NaN when its accumulator took a NaN, and its real members were never retained, so
|
||||
it stays NaN. A displayed hole is therefore at most one slot wider per edge than the true loss —
|
||||
invisible at any hole size worth repairing, and it makes a boundary slot need no special case.
|
||||
|
||||
The side table is bounded by the 256-packet reservation cap and a maximum entry count; it is also
|
||||
the source the shaded band reads.
|
||||
|
||||
## Surfacing
|
||||
|
||||
`StreamStats` gains `late_resequenced`, `late_committed` (gave up past 20 ms, or overflowed the
|
||||
reorder ring) and `late_backfilled`, and finally exposes `stale_packets` — which the client has
|
||||
counted since the beginning and which `src/status_text.cpp:70` has never shown, so an inversion
|
||||
today has to be inferred from `packets` and `frames` diverging. All four sit in the status bar's
|
||||
second tier.
|
||||
|
||||
The band is a new `Plot::set_invalid_spans()` in `deps/plotter`, drawn as a tinted quad behind the
|
||||
trace. Unlike the `t0` caption this *is* data-anchored — it must pan and zoom with the data it
|
||||
marks — so it is annotation-class, not chrome. The invariant it must respect is that it reserves
|
||||
no layout space and cannot be picked, so a hole cannot move the layout the labels were measured
|
||||
into.
|
||||
|
||||
## Verification
|
||||
|
||||
udpscope has no unit test suite; the only automated check is the application's own headless
|
||||
self-check (`CLAUDE.md`). This adds exit-dump lines and a `UDPSCOPE_PROBE_RESEQ_MS` probe,
|
||||
asserting: a reorder inside the window is resequenced with no hole; a reorder past the window
|
||||
leaves a hole and then backfills it; the trigger does not latch inside a hole; a 300-packet burst
|
||||
hits the cap and does not throw the stream onto the arrival clock; and relative mode's stamps are
|
||||
monotone with the declared spacing.
|
||||
|
||||
Runs are serial and short, against the two standing baselines: four build warnings on a clean
|
||||
build (two on an incremental build) and zero `Gtk-CRITICAL`. Batching several `xvfb` runs back to
|
||||
back starves the mock past its 2 s `silence_timeout_s` and reads as a reconnect, which is a
|
||||
measurement artefact and not a defect (`docs/claude/verification.md`).
|
||||
|
||||
**One gap in the mock has to be closed first.** `--reorder-every` releases the held packet
|
||||
immediately after the one that overtook it, so it is ~0 ms late and would always land inside the
|
||||
20 ms window. That exercises resequencing but leaves the **backfill path untestable**. The mock
|
||||
needs `--reorder-delay-ms` so a packet can be released deliberately past the window.
|
||||
|
||||
## Documentation this lands against
|
||||
|
||||
- `docs/claude/invariants-data-path.md` — append-only stops being the whole truth; the amend's
|
||||
precondition and its thread belong here.
|
||||
- `docs/claude/invariants-plot-and-layout.md` — the band, and the layout space it must not take.
|
||||
- `docs/claude/invariants-trigger-and-pinning.md` — the trigger does not latch inside a hole.
|
||||
- `docs/claude/verification.md` — the new probe and dump lines, and `--reorder-delay-ms`.
|
||||
- `docs/claude/build-and-run.md` — the vendored `udpsclient` diff: the reorder ring, the late
|
||||
delivery replacing the discard, and the third `clamp_deadline()`.
|
||||
- `README.md` and a `SPECS.md` deviation note for relative-time mode.
|
||||
|
||||
## Deliberately not in scope
|
||||
|
||||
- **Replaying math channels or the trigger over a backfilled region.** Decided against above.
|
||||
- **Interpolating across an unrepaired hole.** A hole is reported, not invented. There is no
|
||||
interpolation anywhere in the data path today and this does not add the first.
|
||||
- **Making relative mode the default.** It is a toggle. The default stays the declared time
|
||||
signal, so no existing session or run changes what it shows.
|
||||
- **The `(min, max, mean, rms)` gather mode** that would fix the separate solid-band degeneration
|
||||
at wide time windows. Related to the pyramid, unrelated to this; still open.
|
||||
@@ -0,0 +1,353 @@
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
|
||||
# A square wave's riser is sometimes drawn and sometimes not, and the trace is thinner than the library's own default
|
||||
|
||||
The report is that a vertical line — the rising or falling edge of `square` in
|
||||
`tools/mock_udpstreamer.py` — is too thin to see, and that it *glitches in and out of
|
||||
existence*. Those are two complaints, not one, and they have different causes. The second is a
|
||||
defect with a derivable mechanism and a single line of code behind it. The first is a styling
|
||||
choice this app made and then never revisited: udpscope asks for a trace **thinner** than
|
||||
`deps/plotter`'s own default.
|
||||
|
||||
The three remedies named in `todo.md` — grade with more strength, thicken the lines, enlarge the
|
||||
sample dots — are all taken. They are not, on their own, enough: the glitch is a row being
|
||||
dropped *before* any of those knobs is consulted, so a thicker, brighter trace would glitch in
|
||||
and out at a thicker, brighter width.
|
||||
|
||||
## The defect: `lit_floor` decides existence, and `intensity_floor` only decides alpha
|
||||
|
||||
`Renderer::stage_trace_density()` (`deps/plotter/src/render/renderer.cpp`) accumulates one
|
||||
column's dwell into `column_rows_`, where **a segment deposits one unit spread over the rows it
|
||||
spans** — `w = 1 / (r1 - r0 + 1)`. It then decides which rows are inked at all:
|
||||
|
||||
```cpp
|
||||
const f32 lit_floor = 0.01f * peak;
|
||||
...
|
||||
const bool lit = r < rows && cover > lit_floor;
|
||||
```
|
||||
|
||||
`peak` is the column's own maximum cover. That makes the existence test a *share of the
|
||||
brightest row in the same column*, and for a square wave the brightest row in a column is a
|
||||
flat level, whose segments each deposit a full 1.0 into one row.
|
||||
|
||||
Take a column that holds both a flat level and the edge. At ~105 samples a column the gather is
|
||||
on raw samples, so the column holds ~105 segments: all but one are flat and deposit a full 1.0
|
||||
into one row, while the riser is a single segment spread over the whole axis and deposits
|
||||
`1/312 = 0.0032` into each of 312 rows. `peak` is then ~50 and `lit_floor` ~0.5 — **over a
|
||||
hundred times the riser's own deposit**. Every row of the riser is unlit. The connector at the
|
||||
bottom of `emit_column()` does not rescue it either: the top run and the bottom run are both lit,
|
||||
so the column's `band_lo…band_hi` already spans the full height, neither gap test fires, and no
|
||||
connector is staged. The column is drawn as a bright mark at the top and a bright mark at the
|
||||
bottom with nothing between them.
|
||||
|
||||
In the phase where the riser lands in a column *by itself*, `peak` is the riser's own deposit,
|
||||
the floor is a hundredth of it, every row clears it, and the riser is drawn full height. Which
|
||||
case a frame lands in depends on the phase of the bucket grid against the pixel grid, and the
|
||||
trace scrolls, so the phase sweeps. That is the whole of "glitches in and out of existence" — not
|
||||
a redraw race and not a data gap.
|
||||
|
||||
### Measured, before writing any of this down
|
||||
|
||||
A true square wave (period 2098 samples, so ~20 columns a period), 60 000 x-units over the 572 px
|
||||
axis of `deps/plotter`'s own headless EGL harness — 104.8 samples a column — drawn twice, once
|
||||
with `intensity_grading` off and once on, with the pixels read back both times over the **same**
|
||||
axes rect. The reading counts the columns the *ungraded* frame inked as a full-amplitude band
|
||||
(the riser columns) and then asks how many of exactly those rows the graded frame left dark:
|
||||
|
||||
| | riser columns | inked rows over them | intact | gutted | worst dark run | segments |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| ungraded | 116 | 35 884 | — | — | — | 627 |
|
||||
| graded, before | 116 | **2 916** | 10 | **106** | **311 of 312 rows** | 684 |
|
||||
| graded, after | 116 | 35 572 | 115 | 1 | — | 732 |
|
||||
|
||||
So the graded path was drawing **8 %** of the ungraded path's ink over the riser columns, and 106
|
||||
of 116 of them were gutted outright while 10 survived — the 10 being the phase that put a riser
|
||||
in a column of its own, which is the flicker, measured. The fix restores 99.1 % of the ink and
|
||||
costs 48 segments on a 572 px axis.
|
||||
|
||||
Two notes on that table, both of which the committed test must carry rather than smooth over:
|
||||
|
||||
- **The one column still gutted after the fix is column 11**, the second column of the window's
|
||||
first riser. The ungraded path inks *two* full-height columns per riser (its per-column min/max
|
||||
plus the connectivity extension, and the gathered window's slack sample) where the graded path
|
||||
inks one, which is also why 116 riser columns appear for ~58 risers. The graded path is the
|
||||
more precise of the two here; the test must assert against the riser's own column rather than
|
||||
against every column the flat path happened to fill.
|
||||
- **The existing grading test is unchanged by the fix** — `a_dense_trace_can_be_graded_by_dwell`
|
||||
still reads `flat edge=191 middle=191` against `graded edge=191 middle=131` at `seg=8303`, the
|
||||
documented figures to the digit, and all 376 cases pass. The fix only reaches rows that were
|
||||
below the share floor, which is what makes it a correction and not a restyling.
|
||||
|
||||
The app-level A/B this design originally proposed — `UDPSCOPE_NO_PHOSPHOR=1` against the mock's
|
||||
`square` — was dropped in favour of the above: udpscope has no probe that can see a dropped row,
|
||||
so the app-level run could only have shown that the picture differed, while the library harness
|
||||
reads the pixels and names the rows. The mechanism is measured at the layer it lives in.
|
||||
|
||||
`Style::intensity_floor`'s own documentation already promised what `lit_floor` broke:
|
||||
|
||||
> Not 0: a column the signal merely passed through is still a column the signal *reached*, and a
|
||||
> riser dimmed out of existence is a square wave drawn as two disconnected lines.
|
||||
|
||||
`intensity_floor` guards a lit row's **alpha**. Nothing guarded its **existence**, and the two
|
||||
floors were never reconciled.
|
||||
|
||||
### The fix, already applied and measured
|
||||
|
||||
`lit_floor` may not exceed the smallest deposit the column actually made, so that **a row one
|
||||
segment covers is always lit**. The column's minimum `w` is tracked as the deposits are written
|
||||
— one scalar, reset by `emit_column()`, no allocation, one `min` added to the per-segment path —
|
||||
and the floor becomes:
|
||||
|
||||
```cpp
|
||||
const f32 lit_floor = std::min(0.01f * peak, 0.5f * w_min);
|
||||
```
|
||||
|
||||
The share term stays: it is what stops a row inking on the running-sum reconstruction's residue
|
||||
alone. The second term is what makes the test *about a measurement* rather than about a ratio to
|
||||
an unrelated row. The half is slack against that reconstruction, not a tuning knob.
|
||||
|
||||
This changes no geometry bound — the runs are still maximal stretches of equal tier, O(rows) per
|
||||
column at worst — and it touches neither the gather level, the pyramid, nor
|
||||
`stage_trace_columns()`. It is in the working tree (`deps/plotter/src/render/renderer.cpp`) with
|
||||
the figures above in the comment at its site, which is what Task 2 below is reduced to reviewing.
|
||||
|
||||
## The second defect, found by testing the first: grading was gated on the column reduction
|
||||
|
||||
The `lit_floor` fix was built and run against the real app, and the report back was that the
|
||||
edges are now always visible — and that a new thing is wrong: *"when I zoom in on an edge the
|
||||
brightness increases a lot compared to when I am looking at the whole waveform."* Clarified: the
|
||||
dimming itself is wanted, **a discontinuity should never be drawn as brightly as a flat top**, at
|
||||
any zoom. So the complaint is not about the amount of dimming but about its *inconsistency*.
|
||||
|
||||
Measured in the same harness, the riser's own ink at mid-height (the rows neither flat level can
|
||||
reach) against the flat level's 191, over a zoom sweep from 101 samples a pixel down to 0.01:
|
||||
|
||||
| samples/pixel | 101 | 33.7 | 10.1 | 3.4 | 1.01 | 0.34 | 0.10 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| riser ink, before | 71 | 71 | 71 | 71 | **191** | **191** | **191** |
|
||||
| riser ink, after | 71 | 71 | 71 | 71 | 71 | 71 | 68 |
|
||||
|
||||
A 2.7× jump in a single step, at `dense_threshold_spp = 1.5`. The cause is a coupling, not a
|
||||
constant: grading is staged *inside* `stage_trace_density()`, which only runs when the column
|
||||
reduction runs, and the reduction is gated on two gathered points per pixel column. That gate is
|
||||
a **density condition whose only purpose is removing the moiré beat** between the bucket pitch
|
||||
and the pixel pitch. Dwell has nothing to do with that beat, so zooming past the threshold
|
||||
switched the whole phosphor model off — and switched it off exactly where the user is looking at
|
||||
an edge closely, which is why it was reported as the edge *lighting up*.
|
||||
|
||||
### The fix: grade the polyline by each segment's slope
|
||||
|
||||
`Renderer::stage_trace_slopes()` stages the sparse polyline into the same `intensity_steps`
|
||||
batches, differing from the dense path only where it must:
|
||||
|
||||
- **The measure is `1 / (1 + |Δy_screen|)`** — dwell per row for a single segment. The `1 +` is
|
||||
not a division guard: a flat segment, span 0 and the commonest case in a sparse window, must be
|
||||
the *brightest* tier, and this makes the measure dimensionless and **absolute**. There is no
|
||||
per-column peak down here to normalise against, and that is an improvement — a sparse frame's
|
||||
tiers mean the same thing from frame to frame, so panning does not reshade the trace.
|
||||
- **No gather mode is excluded.** `grade` is not reused as the gate: it requires `Envelope`,
|
||||
because turning a bucket's min/max into a band answers a different question from the one a
|
||||
`Nearest` or `Mean` host asked. A segment's slope is a property of the polyline itself.
|
||||
- **The geometry is bit-for-bit `stage_xy()`'s**, same `snap = false`; only the batch differs. A
|
||||
segment with one non-finite end is dropped rather than clamped, which is the conclusion
|
||||
`add_polyline_xy()` reaches too.
|
||||
- **`FrameStats::slope_traces` is disjoint from `column_traces`**, so a host asserting
|
||||
`graded_traces == column_traces` keeps that reading unchanged.
|
||||
|
||||
Tier distribution sanity-checked by hand against the defaults (4 steps, gamma 0.45, floor 0.3):
|
||||
span 0 → tier 3, span 1 → 2, span 3 → 2, span 10 → 1, span 312 → 0. A gently sloping sparse sine
|
||||
therefore stays near the top tier and is not dimmed into invisibility, which was the risk.
|
||||
|
||||
### One false alarm, recorded because the metric was the bug
|
||||
|
||||
A first reading of this sweep showed the *frame's brightest pixel* falling from 191 to 167–172 on
|
||||
the sparse path whenever grading was on, which the arithmetic said was impossible (a flat segment
|
||||
is tier 3, alpha exactly 1.0). It was the measurement: the y range was −1…1, so both flat levels
|
||||
sat **on** the boundary rows and were half clipped, and the 191 the ungraded frame reported came
|
||||
from pixels near the riser where two segments overlap and double-composite — which grading splits
|
||||
across tiers, so they no longer saturate. With the range at −2…2 and the level read well clear of
|
||||
the riser, the level is 191 with grading on or off at every zoom. No regression existed. The
|
||||
committed test uses −2…2 and reads the level and the riser as two separate figures for this
|
||||
reason, stated at the site.
|
||||
|
||||
## The styling: the host asked for a thinner trace than the library's default
|
||||
|
||||
`RowSet::install()` (`src/plot_host.cpp:175`) builds every trace with `1.2f`. The library's
|
||||
`Trace::width` default is `1.5f`, and `Style::crosshair_width`'s documentation treats 1.5 as *the
|
||||
trace width a crosshair must read as thinner than*. So the app inverted its own stroke hierarchy
|
||||
at the only place it states it, and `1.2f` is also, in both staging paths, the **minimum span a
|
||||
short run is stroked at** — the `if (hi - lo < width)` widen — so it sets the floor on how
|
||||
visible a one-row run can ever be.
|
||||
|
||||
`marker_px` (3.0) and `marker_below_samples` (50) are library defaults udpscope never restated;
|
||||
`ui.cpp`'s one-shot style statement sets `intensity_grading` and nothing else.
|
||||
|
||||
## Scope
|
||||
|
||||
**In:**
|
||||
|
||||
- `deps/plotter`: the `lit_floor` correction in `stage_trace_density()`, a library test that fails
|
||||
without it, and the `PLOT_TEST_FILTER` the measurement needed.
|
||||
- `deps/plotter`: `Renderer::stage_trace_slopes()` — grading for the sparse polyline — plus
|
||||
`FrameStats::slope_traces` and a library test that fails without it.
|
||||
- `deps/plotter`: `Style::line_aa_px` and the grading constants remain *fields* — no new knob is
|
||||
added, and `intensity_grading` stays **one** switch covering both rasterisation paths. The one
|
||||
new field considered and rejected is named under **Out**.
|
||||
- udpscope: the trace width at `src/plot_host.cpp:175`, and the style one-shot in `ui.cpp`
|
||||
extended to state `marker_px`, `marker_below_samples`, `intensity_floor` and `line_aa_px`
|
||||
rather than inheriting them.
|
||||
- One new probe over the mock's `square`, and the `frame path:` reading extended to report the
|
||||
dropped-row case so a regression is visible rather than merely ugly.
|
||||
- `docs/claude/invariants-rendering.md`, `README.md`, and upstream's
|
||||
`docs/data-and-performance.md` + `README.md`.
|
||||
- `SPECS.md` is not changed.
|
||||
|
||||
**Out, and deliberately:**
|
||||
|
||||
- **A `Style` field for "always light a riser".** The dropped row is a bug in a test, not a
|
||||
policy a host should get to choose; a flag would preserve the broken behaviour as an option and
|
||||
oblige every host to know about it.
|
||||
- **Changing the connector's tier.** Drawing the inferred bridge at the faintest step is a
|
||||
deliberate honesty claim ("this is not a place the signal was measured to dwell"). Once
|
||||
`lit_floor` is fixed the riser is *real* ink at its own tier and the connector is back to
|
||||
bridging only genuine disjointness, which is what it was written for.
|
||||
- **Per-trace or per-row width.** One width for every trace in the app, as now.
|
||||
- **A separate switch for sparse grading.** Two switches would let a host reproduce the very
|
||||
inconsistency this fixes, and the user's own framing settles it: the dimming is wanted *at all
|
||||
zooms*, so it is one look and not two.
|
||||
- **Normalising the sparse tiers against the frame.** The dense path normalises per column
|
||||
because a frame-wide scale needs the whole image before anything can be drawn; the sparse
|
||||
measure needs no normalisation at all, and leaving it absolute is what makes a pan stop
|
||||
reshading the trace. Matching the dense path here would be matching a compromise.
|
||||
- **A user-facing preference for any of these values.** `todo.md` item 9 owns the preferences
|
||||
dialog; this work states defaults in code and leaves hooks for nothing.
|
||||
- No change to the gather level, `MinMaxPyramid`, `dense_threshold_spp`,
|
||||
`intensity_sub_columns`, the decimation plan, or the two-thread split.
|
||||
|
||||
## Global constraints
|
||||
|
||||
These bind every task below and are not restated in each one.
|
||||
|
||||
- **Every new source file carries the SPDX MIT header**, matching the existing files.
|
||||
- **`stage_trace_density()` stays allocation-free on the frame path.** `column_rows_` is already
|
||||
sized once per call and reused; the minimum-deposit tracking adds a scalar, not a container.
|
||||
- **No figure is written into a document unless it was measured on this machine.** The arithmetic
|
||||
above is derived and is labelled as derived; every number that reaches `README.md` or
|
||||
`docs/claude/` comes from a probe run quoted from that run.
|
||||
- **There is no unit test suite in udpscope.** Every assertion here is either a `deps/plotter`
|
||||
test (that project has one) or a probe line on stderr driven through the real widgets, read
|
||||
against the documented baselines: the documented build-warning count and zero `Gtk-CRITICAL`.
|
||||
- **Run one probe at a time.**
|
||||
- **The look is the user's call, the drop is not.** The `lit_floor` fix is correct or incorrect
|
||||
and a test says which. The four style values are judgments; they are stated in one place, with
|
||||
the before-value in a comment, so a second opinion is a one-line change.
|
||||
|
||||
## Task 0 — the test harness filter, already applied
|
||||
|
||||
`plottest::run_all()` ran all 376 cases unconditionally, so reading one case's output meant
|
||||
running the suite. `PLOT_TEST_FILTER` is a substring match honoured in `run_all()`
|
||||
(`deps/plotter/tests/harness.hpp`), unset meaning "all", which is what made the A/B above cheap
|
||||
enough to iterate on. Documented in upstream's `docs/building-and-testing.md`, including the trap
|
||||
that the summary line still counts the whole suite — so a filtered run says `377 cases, 0 failed`
|
||||
having run one, and is never evidence that the suite passes.
|
||||
|
||||
## Task 1 — the library test that fails now
|
||||
|
||||
Upstream, beside `a_dense_trace_can_be_graded_by_dwell`:
|
||||
`a_graded_column_keeps_a_riser_it_shares_with_a_flat_level`.
|
||||
|
||||
The scratch measurement above is the shape of it, and was deliberately **not** committed: it
|
||||
reports numbers rather than asserting, and it leans on the flat path as its reference, which the
|
||||
column-11 note shows is not exact. The committed test keeps the construction and replaces the
|
||||
reference.
|
||||
|
||||
The series and window are the measured ones — a true square of period 2098 over 60 000 x-units,
|
||||
~105 samples a column — because that density puts the gather on raw samples and makes a flat
|
||||
level's deposit a hundred times the riser's, which is the condition the defect needs. The riser
|
||||
must **share** its column, pinned by construction rather than by luck: it is the phase that
|
||||
survived in 10 of 116 columns, so a test that lets the phase float passes for the wrong reason
|
||||
about one time in twelve.
|
||||
|
||||
The assertion is *continuity over the riser's own column*, not a brightness profile — the
|
||||
existing grading test already profiles fifths and passes either way. Prefer the staged geometry
|
||||
over a pixel readback so the failure names the dropped rows rather than reporting that the
|
||||
picture changed, and have the failure print the gap's row range.
|
||||
|
||||
It must fail against the pre-fix `lit_floor`; verify that by reverting the one line, not by
|
||||
trusting the figures in this document.
|
||||
|
||||
**Done.** `a_graded_column_keeps_a_riser_it_shares_with_a_flat_level`, beside the dwell test.
|
||||
It chooses its riser columns by the property at stake — a band spanning more than half the axis,
|
||||
which on a square wave is a transition and nothing else — from the ungraded frame, then re-reads
|
||||
exactly those columns from the graded one and asserts 95 % of them are still tall. Reading
|
||||
`kept=115` of `flat=116`, and `kept=10` with `lit_floor` reverted to `0.01f * peak`, which is the
|
||||
required failure verified rather than assumed. The column-11 slack is in the assertion's 95 % and
|
||||
its reason is in the comment.
|
||||
|
||||
## Task 1b — the library test for slope grading
|
||||
|
||||
`a_sparse_trace_is_graded_by_slope_like_a_dense_one`: one window either side of
|
||||
`dense_threshold_spp` over the same transition at the same place, asserting that the two frames
|
||||
took the two paths (`graded_traces` against `slope_traces`), that **neither dims the level**
|
||||
(191 either way), that **both dim the riser** to under 55 % of it, and that the two riser
|
||||
readings agree to within one tier. Reading 71/191 against 71/191; with the slope staging gated
|
||||
off the sparse frame reads 191/191 and the path assertion fails first.
|
||||
|
||||
## Task 2 — review the fix
|
||||
|
||||
The change is in the tree. What is left is to read the comment at its site against the measured
|
||||
numbers it quotes, and to confirm `Style::intensity_floor`'s doc comment now describes reality,
|
||||
since it is cited above as having promised what the code did not deliver.
|
||||
|
||||
## Task 3 — the four stated values
|
||||
|
||||
`src/plot_host.cpp:175` from `1.2f` to at least the library's `1.5f`. The comment records that
|
||||
the app had been asking for less than the default and that this width is also the minimum span a
|
||||
short run is stroked at.
|
||||
|
||||
`ui.cpp`'s style one-shot (`src/ui.cpp:6149`) states, each with the inherited value in a comment:
|
||||
|
||||
- `marker_px` — `todo.md` 1.3, "the sample points should be bigger". The dots only appear below
|
||||
`marker_below_samples` raw samples in the window, so this is the deep-zoom reading and is
|
||||
independent of everything above.
|
||||
- `marker_below_samples` — raising it brings the dots back at windows that currently show a bare
|
||||
polyline.
|
||||
- `intensity_floor` and `line_aa_px` — the two readings of `todo.md` 1.1, "the antialiasing
|
||||
strategy with phosphore effect should be applied with more stregth", and they are different
|
||||
things: `intensity_floor` lifts the *faintest graded tier*, which is what a riser is drawn at
|
||||
even once it is lit; `line_aa_px` widens the anti-aliased fringe on **every** stroke in the
|
||||
library, which is literally more anti-aliasing and makes a thin line read fatter at the cost of
|
||||
a softer grid and axis. Both are stated; the values are the ones to look at on screen and
|
||||
adjust, and the task's report says what each one did on its own.
|
||||
|
||||
Nothing here is conditional on Task 2, and nothing in Task 2 is conditional on these.
|
||||
|
||||
## Task 4 — the probe
|
||||
|
||||
`UDPSCOPE_PROBE_EDGEINK_MS`: plot the mock's `square`, settle on a window dense enough for the
|
||||
graded path, and report — per the line reference in `docs/claude/probe-readings.md` — the edge
|
||||
column's staged span against the band it should cover, and the count of unlit rows inside it.
|
||||
**It must read 0 dropped rows**, and it must be run with and without `UDPSCOPE_NO_PHOSPHOR=1`:
|
||||
the ungraded path is the control, and the two must agree about where the trace is.
|
||||
|
||||
`frame path:` gains nothing new if the probe above covers the drop; the brief for this task
|
||||
decides that from the probe's output, and whichever way it goes,
|
||||
`docs/claude/probe-readings.md` gets the per-line entry.
|
||||
|
||||
## Task 5 — the documents
|
||||
|
||||
`docs/claude/invariants-rendering.md` already carries the grading invariant and the sentence
|
||||
"the floor so that a fast riser, one sub-bucket deep everywhere, is never dimmed out of
|
||||
existence". That is the claim this defect falsified, so the bullet gains the distinction the code
|
||||
was missing — **an alpha floor is not an existence floor** — and names the symptom, the phase
|
||||
dependence, and `UDPSCOPE_NO_PHOSPHOR=1` as the A/B that separates the two.
|
||||
|
||||
`README.md` records the stroke and marker values as deliberate deviations from the library's
|
||||
defaults, under the existing deviations section.
|
||||
|
||||
Upstream `docs/data-and-performance.md` and `README.md` carry the `lit_floor` rule and the slope
|
||||
path, since both are the library's contract and not this app's. **Done**, together with
|
||||
`Style::intensity_grading`'s own doc comment (which described the dense path as the whole of the
|
||||
feature), `docs/building-and-testing.md`'s `PLOT_TEST_FILTER` entry, and the case counts in all
|
||||
three places that quote them — 350 → 377, and 333 → 360 for the coverage preset.
|
||||
@@ -0,0 +1,328 @@
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
|
||||
# UDP packet capture: design
|
||||
|
||||
`todo.md` item 4: *optional udp frame storage for debugging of strange behaviours (store last
|
||||
`<N>`, default = 1000, udp packets binary content on disk for forensic analysis).*
|
||||
|
||||
The strange behaviours worth forensics on are all *wire* behaviours — a counter that skips, a
|
||||
datagram that arrives twice, a fragment that never completes, a CONFIG whose header does not parse,
|
||||
a burst that outruns the reorder ring. Every one of them is invisible by the time a
|
||||
`udps_frame_t` reaches `Acquisition::on_data`: the client has already validated the magic,
|
||||
reassembled the fragments, resequenced what it could and dropped what it could not. A capture that
|
||||
is to answer *what did the wire actually send* must therefore sit at `recv()`, and must store the
|
||||
bytes without interpreting them.
|
||||
|
||||
This is a debugging aid, not a feature of the instrument. It is off by default, it adds nothing to
|
||||
the glance tier of the status bar, and it must not be able to cost a single packet when it is on.
|
||||
|
||||
## The decisions taken, and what each one rules out
|
||||
|
||||
| Decision | Alternative rejected |
|
||||
| --- | --- |
|
||||
| Always-on rolling ring while enabled; the last N are on disk at all times | Keeping the ring in RAM and flushing on an anomaly or on demand — a crash or a `SIGKILL` then leaves nothing, which is the case a forensic capture exists for |
|
||||
| A fixed-size file of N slots, plus a converter to pcap in `tools/` | Writing pcap directly (grows, so "last N" needs two rotating files and the newest N is split across both) or one file per datagram (1000 files, an open/write/close per packet in the receive path) |
|
||||
| `mmap` once, `memcpy` per datagram | A `pwrite` per datagram (a syscall, and on a busy disk a blocking one, between two `recv()` calls) or a third thread with a queue (a second copy of every datagram, and this application's two-thread property is load-bearing) |
|
||||
| One path per source, previous capture kept one deep | A timestamped file per enable (needs the retention policy `todo.md` item 9 defines and this does not build) or a single path overwritten (re-enabling destroys the capture you enabled for) |
|
||||
| `--capture-packets[=N]` as well as the popover button | Button only — by the time the window is up and clickable, the first connect and its CONFIG are already history, and those are exactly the "strange behaviours" hardest to reproduce |
|
||||
|
||||
## Architecture
|
||||
|
||||
Three parts, each with one responsibility and a seam that does not leak the other two.
|
||||
|
||||
### 1. The client hands over raw datagrams
|
||||
|
||||
`deps/udpsclient` gains **one additive entry point**:
|
||||
|
||||
```c
|
||||
/** Every datagram as recv() returned it, before the magic check, reassembly or
|
||||
* resequencing: the bytes a capture has to store, and the only place malformed
|
||||
* or duplicate datagrams are still visible. Runs on the polling thread, inside
|
||||
* udps_client_poll(), like every other callback. `bytes` is valid until the
|
||||
* callback returns. */
|
||||
typedef void (*udps_datagram_cb)(const uint8_t *bytes, size_t len,
|
||||
double recv_time, void *user);
|
||||
|
||||
void udps_client_set_datagram_cb(udps_client_t *client,
|
||||
udps_datagram_cb cb, void *user);
|
||||
```
|
||||
|
||||
A separate setter, **not** a fourth parameter on `udps_client_set_callbacks`
|
||||
(`udps_client.h:283`): that signature has call sites, and a capture hook is no reason to change
|
||||
them.
|
||||
|
||||
The call site is `udps_client.c:1458`, immediately after
|
||||
|
||||
```c
|
||||
ssize_t n = recv(c->udp_fd, c->rxbuf, UDPS_RX_BUF_BYTES, 0);
|
||||
```
|
||||
|
||||
returns a positive `n`, and before anything is read out of `rxbuf`. `recv_time` is the same clock
|
||||
the client already stamps arrivals with, so a capture's times and the stream statistics agree.
|
||||
|
||||
**Boundary, stated rather than discovered later:** in multicast mode CONFIG arrives over TCP
|
||||
(`udps_client.c:1142`) and this hook does not see it. The capture is of the UDP socket. A malformed
|
||||
*multicast* CONFIG is therefore out of reach, and `README.md` will say so.
|
||||
|
||||
### 2. `PacketCapture` owns the file
|
||||
|
||||
New unit, `src/capture.hpp` / `src/capture.cpp`. One class, one responsibility — own the ring file
|
||||
and store a datagram into it. It includes no GTK, no `ScopeModel`, and no client types beyond
|
||||
`<cstdint>`; its whole interface is:
|
||||
|
||||
```cpp
|
||||
class PacketCapture {
|
||||
public:
|
||||
/// Renames an existing capture to `capture-prev.bin`, creates and reserves the new file,
|
||||
/// maps it. Returns false and leaves the object disabled on any failure, with `error()` set.
|
||||
bool open(const std::string& path, std::uint32_t slot_count);
|
||||
void close(); ///< unmap, close; idempotent
|
||||
bool enabled() const noexcept;
|
||||
|
||||
/// One datagram. Called only from the producer thread. Never allocates, never blocks,
|
||||
/// never fails: an over-long datagram is truncated to the slot and counted.
|
||||
void store(const std::uint8_t* bytes, std::size_t len, double recv_time) noexcept;
|
||||
|
||||
std::uint64_t stored() const noexcept; ///< datagrams since open()
|
||||
std::uint64_t truncated() const noexcept;
|
||||
std::uint32_t slot_count() const noexcept;
|
||||
const std::string& path() const noexcept;
|
||||
/// Copies the last open() failure out, or writes an empty string. Not a
|
||||
/// `const std::string&`: see "What crosses between the threads" below.
|
||||
void error(char* out, std::size_t cap) const noexcept;
|
||||
};
|
||||
```
|
||||
|
||||
#### What crosses between the threads
|
||||
|
||||
The producer thread writes this state and the GTK thread reads it every frame for the popover, so
|
||||
each field has to be publishable without a lock:
|
||||
|
||||
| Field | Type | Why it is safe |
|
||||
| --- | --- | --- |
|
||||
| `enabled_`, `stored_`, `truncated_` | `std::atomic<…>`, relaxed | counters and a flag, not a consistent set — the same treatment `StreamStats` already gets |
|
||||
| `path_` | `std::string` | written **once** by the GTK thread before it ever sets `capture_want_`, and only read afterwards |
|
||||
| the open failure | `char error_[128]` plus `std::atomic<bool> error_set_` | the producer fills the buffer and *then* sets the flag with a release store; the GTK thread copies the buffer only when the flag reads true |
|
||||
|
||||
The error is deliberately **not** a `std::string` accessor. A `std::string` written by the producer
|
||||
inside `open()` and read by the GTK thread on the next frame is a data race with a reallocating
|
||||
buffer — the kind that reads correctly for months. The fixed buffer with a flag published after it
|
||||
is the same release-store trick the slot's `seq` field uses, for the same reason.
|
||||
|
||||
#### The file
|
||||
|
||||
```
|
||||
offset 0, 64 bytes — header
|
||||
magic u8[8] "UDPSCAP\0"
|
||||
version u32 1
|
||||
slot_count u32 N
|
||||
slot_bytes u32 sizeof(record header) + UDPS_RX_BUF_BYTES, 8-byte aligned
|
||||
cursor u32 next slot index to write (a hint; see below)
|
||||
t0_realtime f64 CLOCK_REALTIME at open(), the epoch recv_time is relative to
|
||||
peer_addr u32 the source's IPv4, network order
|
||||
peer_port u16
|
||||
local_port u16
|
||||
reserved to 64 bytes, zeroed
|
||||
|
||||
offset 64 + i * slot_bytes, for i in [0, N) — slot i
|
||||
t_arrival f64 the client's recv_time
|
||||
len u32 bytes stored (after truncation, if any)
|
||||
orig_len u32 bytes the wire delivered, so a truncation is visible
|
||||
bytes u8[len]
|
||||
seq u64 1-based ordinal since open(); written LAST
|
||||
```
|
||||
|
||||
`seq` is written last, after the bytes, with a release store. Three things follow, and they are the
|
||||
reason the format is this and not simpler:
|
||||
|
||||
- **A slot carrying a non-zero `seq` is complete.** No `fsync` is needed for the file to be
|
||||
readable after a crash: the kernel's dirty pages are the file, and a half-copied slot still has
|
||||
`seq == 0`.
|
||||
- **`seq`, not `cursor`, is the order.** After the ring wraps, the newest slot is the one with the
|
||||
largest `seq`, and the reader needs no trust in a header field that is updated out of band.
|
||||
- **A torn write is detectable, not silent.** The reader reports the count of empty and torn slots
|
||||
rather than averaging over them.
|
||||
|
||||
`cursor` is maintained anyway, because a human running `xxd` on the file wants to know where to
|
||||
look.
|
||||
|
||||
#### Why the file is reserved, not sparse
|
||||
|
||||
`posix_fallocate` reserves the whole file at `open()`. The obvious alternative — `ftruncate` and
|
||||
let the file stay sparse, so it costs only the bytes actually written — is wrong with `mmap`: a
|
||||
store into a page the filesystem cannot back raises **`SIGBUS`**, which kills udpscope outright.
|
||||
A capture must not be able to take down the instrument it is observing, and an out-of-space
|
||||
condition must be an error at the moment the user clicks, not a crash ten minutes later.
|
||||
|
||||
At the default `N = 1000` and `UDPS_RX_BUF_BYTES = 65552`, the file is **~62.6 MiB**. That is
|
||||
stated in the help text, in the tooltip and in the popover row, because it is the one cost of
|
||||
switching the feature on.
|
||||
|
||||
### 3. `Acquisition` drives it, and the GTK thread only ever sets an atomic
|
||||
|
||||
`Acquisition` owns the `PacketCapture`, installs the datagram callback next to the three it already
|
||||
installs (`src/acquisition.cpp:62`), and does **every** file operation on the producer thread.
|
||||
|
||||
```
|
||||
GTK thread (the button) producer thread (the datagram callback)
|
||||
--------------------------- --------------------------------------------
|
||||
capture_want_.store(true) ──► reads capture_want_ at the top of each datagram
|
||||
capture_path_ written once rising edge → open() : rename, create, fallocate, mmap
|
||||
before the first store falling edge → close(): munmap, close
|
||||
then, if enabled, store(bytes, len, recv_time)
|
||||
```
|
||||
|
||||
No mutex, no queue, no second copy of any datagram, and the application still has exactly two
|
||||
threads.
|
||||
|
||||
The one-off cost of a toggle is the `open()`: a rename, a create, a `posix_fallocate` of 62.6 MiB
|
||||
and an `mmap`, on the producer thread. It is a user-initiated act that happens once per toggle, and
|
||||
`README.md` will say that it can cost the packets in flight during it. Moving it off the producer
|
||||
thread would need the lock or the thread this design exists to avoid, for an event that happens
|
||||
when a human clicks a button.
|
||||
|
||||
The duration is **measured, not asserted**: `open()` records its own wall time and the probe prints
|
||||
it as `open_ms`. `posix_fallocate` on a local extent-based filesystem is a metadata operation and
|
||||
should be a handful of milliseconds, but that is a claim about the filesystem, not about this code,
|
||||
and a reading above ~50 ms is a finding worth putting in `README.md` rather than a number to
|
||||
predict here.
|
||||
|
||||
### Error handling, and only at this boundary
|
||||
|
||||
This is a syscall boundary, so it is validated; nothing else here is.
|
||||
|
||||
| Condition | Behaviour |
|
||||
| --- | --- |
|
||||
| `open`, `posix_fallocate` or `mmap` fails (ENOSPC, EACCES, a read-only cache directory) | capture stays off, `error()` carries `strerror`, one line on stderr, the toggle button returns to off and the popover row shows the reason |
|
||||
| the cache directory does not exist | created with `g_mkdir_with_parents`, 0700; a failure there is the row above |
|
||||
| `len > UDPS_RX_BUF_BYTES` | impossible — the slot is sized to the client's own receive buffer — but `store()` truncates to the slot, records `orig_len`, and counts it in `truncated()` rather than trusting the impossibility |
|
||||
| the previous `capture-prev.bin` cannot be replaced | the capture still opens; the rename failure is reported, the old previous is kept |
|
||||
| capture enabled with no connection yet | the button is insensitive until connected, because the path is keyed on the source; `--capture-packets` defers `open()` to the first datagram, which is what arms it before the first CONFIG |
|
||||
|
||||
## Surfaces
|
||||
|
||||
### The popover button and row
|
||||
|
||||
The status bar's detail popover (`src/ui.cpp:7619-7672`) is a two-column grid: four bold headings
|
||||
at rows 0, 3, 5, 6 and seven value labels at rows 0..6, in `app->popover_label[11]`. It gains a
|
||||
fifth heading, **Capture**, and an eighth value label; the array becomes `[13]` and the
|
||||
`kHeadings` / `kHeadingRow` tables gain their entry. `update_status_popover`
|
||||
(`src/ui.cpp:6101`) writes the new label like the others — `gtk_label_set_text` only, no rebuild.
|
||||
|
||||
The row reads one of:
|
||||
|
||||
```
|
||||
off
|
||||
on · 412/1000 slots · 26.1 MiB · ~/.cache/udpscope/192.168.1.40-5000/capture.bin
|
||||
failed: No space left on device
|
||||
```
|
||||
|
||||
Below the grid, a `GtkToggleButton` labelled **Capture packets** with the
|
||||
`media-record-symbolic` icon and a tooltip naming the full path and the file's size. `toggled`
|
||||
sets the atomic and nothing else. Insensitive until connected.
|
||||
|
||||
Nothing is added to `format_detail()`'s note string: `src/status_text.cpp:86` is a `char
|
||||
note[160]` already close to its worst case, and the capture state is not something the exit dump's
|
||||
one line needs.
|
||||
|
||||
### The command line
|
||||
|
||||
```
|
||||
--capture-packets N store the last N raw datagrams on disk for forensics, in a
|
||||
ring under the cache directory (1000 slots is ~62.6 MiB);
|
||||
off unless given, and toggleable from the status bar
|
||||
```
|
||||
|
||||
Parsed in `src/config.cpp`'s existing style — both `--capture-packets N` and
|
||||
`--capture-packets=N`, `N` in 1..100000 — and the value is **required**, not optional.
|
||||
`--capture-packets` with an optional value cannot be told apart from `--capture-packets` followed
|
||||
by a value in this parser, and every other option in it takes the space-separated form, so a bare
|
||||
flag would turn `--capture-packets 500` into `unknown argument: 500`. The 1000 default is the
|
||||
slot count the **popover button** uses when the flag was not given; with the flag, the button uses
|
||||
the N the flag named.
|
||||
|
||||
`Config::capture_packets` is a `std::uint32_t`: 0 means the flag was absent, and the button then
|
||||
uses 1000.
|
||||
|
||||
The flag arms the ring before the first datagram; it is not persisted in the session file. That
|
||||
persistence belongs with `todo.md` item 3's work, alongside the relative-time toggle deferred to
|
||||
the same place.
|
||||
|
||||
### `tools/capture_to_pcap.py`
|
||||
|
||||
```
|
||||
$ tools/capture_to_pcap.py ~/.cache/udpscope/192.168.1.40-5000/capture.bin out.pcap
|
||||
1000 slots, 412 stored, 0 torn, 0 truncated → 412 packets, 26.1 MiB
|
||||
$ wireshark out.pcap
|
||||
```
|
||||
|
||||
Reads the header, validates the magic and version, walks the slots, orders by `seq`, skips
|
||||
`seq == 0`, and emits a classic pcap (`LINKTYPE_ETHERNET`, microsecond timestamps from
|
||||
`t0_realtime + t_arrival`) with a synthesised Ethernet/IPv4/UDP header per record built from the
|
||||
header's `peer_addr`, `peer_port` and `local_port`. Pure standard library — the mock already
|
||||
forces a hard `numpy` dependency on `tools/`, and this script has no reason to add to that.
|
||||
|
||||
It also prints the counts above to stderr, which is what makes it a diagnostic in its own right:
|
||||
`torn` or `truncated` above zero is a finding about the capture, before anything about the stream.
|
||||
|
||||
## Verification
|
||||
|
||||
udpscope has no unit test suite; the capture is verified by the application's own headless
|
||||
self-check, and the converter by running it.
|
||||
|
||||
`UDPSCOPE_PROBE_CAPTURE_MS` toggles capture **through the same handler the button uses**, waits a
|
||||
scheduled interval, then reads the file back off disk and prints one line:
|
||||
|
||||
```
|
||||
[capture-probe] enabled=1 path=… slots=1000 stored=412 cursor=412 torn=0 truncated=0
|
||||
header_ok=1 first_seq=1 last_seq=412 payload=1648000 file=65576064
|
||||
open_ms=3.1 t0=1760000000.123
|
||||
```
|
||||
|
||||
`payload` is the sum of every stored slot's `len` — the bytes the wire actually delivered, which is
|
||||
what tells you whether the ring is holding what you think. `file` is the reserved size,
|
||||
`64 + slot_count * slot_bytes`, and is a constant for a given N: it is in the line so that a
|
||||
mismatch against the file on disk is a reading rather than something to go and check.
|
||||
|
||||
What each field has to read, and the defect a wrong reading names, goes in
|
||||
`docs/claude/probe-readings.md` beside the other probes. Two cases in
|
||||
`docs/claude/verification.md`:
|
||||
|
||||
1. **enable at runtime** — the probe toggles on mid-run; `stored` must equal the datagrams the
|
||||
exit dump counts from that instant, `torn=0`, and `first_seq=1`.
|
||||
2. **wrap** — `--capture-packets=16` against the standard mock, run long enough to wrap several
|
||||
times; `stored` exceeds `slot_count`, `last_seq - first_seq == 15`, `torn=0`. A wrap that loses
|
||||
ordering, or a `cursor` that disagrees with `seq`, shows here and nowhere else.
|
||||
|
||||
Then the converter, run over the wrap case's file: exit 0, a record count equal to `slot_count`,
|
||||
and the pcap's own first and last record timestamps inside the run's wall-clock interval. The
|
||||
check is the converter's printed counts plus a `struct.unpack` of the pcap's first and last record
|
||||
headers — no Wireshark, no `capinfos`, nothing outside the standard library, so it runs wherever
|
||||
the self-check runs.
|
||||
|
||||
The standing baselines apply unchanged: a clean build at exactly four warnings, an incremental at
|
||||
two, and zero `Gtk-CRITICAL` in any headless run.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Replaying a capture back into udpscope.** That is `todo.md` item 5's territory (opening stored
|
||||
waveforms) and a different feature with a different shape.
|
||||
- **Retention beyond one deep.** `capture.bin` and `capture-prev.bin`, nothing more, until item 9
|
||||
defines the cache-size preferences.
|
||||
- **Session persistence of the enable state.** Deferred to item 3 with the relative-time toggle,
|
||||
so one piece of work adds the session fields rather than two.
|
||||
- **Capturing the multicast TCP CONFIG channel.** Stated as a limitation in `README.md`.
|
||||
- **Compression, encryption or rotation of the ring.** A fixed file of raw bytes is what a
|
||||
forensic reader wants, and `gzip` exists.
|
||||
|
||||
## Documentation to update
|
||||
|
||||
- `README.md` — the feature, the 62.6 MiB cost, the producer stall on toggle, the multicast CONFIG
|
||||
limitation, the converter's usage.
|
||||
- `SPECS.md` — nothing: this is a `todo.md` item, not a spec requirement, and not a deviation.
|
||||
- `todo.md` — item 4 struck, on one line, with the flag's name (GFM strikethrough is inline and
|
||||
cannot span list items).
|
||||
- `docs/claude/build-and-run.md` — the new option; `verification.md` and `probe-readings.md` — the
|
||||
probe and its two cases; `docs/claude/invariants-data-path.md` — one paragraph on why the hook is
|
||||
at `recv()` and why the file is reserved rather than sparse.
|
||||
@@ -0,0 +1,342 @@
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
|
||||
# Noise rejection: design
|
||||
|
||||
Noise rejection in three places — the measurements pane, the trigger, and a new math node — aimed
|
||||
at one problem: **electromagnetic peaks**. EM interference is *impulsive*. It arrives as a few
|
||||
samples of large excursion, not as a raised noise floor, and that distinction decides every choice
|
||||
below. An impulse recrosses a threshold several times per true crossing, so it inflates an edge
|
||||
count; it moves a `min`, a `max` and a peak-to-peak outright; and it is the one kind of noise a
|
||||
low-pass filter handles *badly*, smearing the spike across its settling time instead of removing
|
||||
it while rounding the real edge beside it.
|
||||
|
||||
Each of the three places therefore gets two independent, optional controls:
|
||||
|
||||
| | amplitude qualifier | frequency qualifier |
|
||||
| --- | --- | --- |
|
||||
| **Trigger** | hysteresis band about the level, in signal units | one-pole HF reject on the comparator's input, `fc` in Hz |
|
||||
| **Measurements** | hysteresis band, % of the window's peak-to-peak | one-pole HF reject over the measured window, `fc` in Hz |
|
||||
| **Math** | — | the `de-noise` node: median over N samples, then a one-pole at `fc` |
|
||||
|
||||
Nothing here changes a default. Every control is off, or set to the value the code already uses,
|
||||
so the whole documented reading set must come out of this work unchanged. That is the first
|
||||
verification assertion, not an afterthought.
|
||||
|
||||
## The decisions taken, and what each one rules out
|
||||
|
||||
| Decision | Alternative rejected |
|
||||
| --- | --- |
|
||||
| The trigger band is **absolute, in the channel's own units** | A percentage of a running peak-to-peak: the producer has no measured span, so it would need per-channel sliding-window state in the hot loop *and* a warm-up during which the band is wrong — which shows up as a missed or early latch, the one failure a trigger must not have |
|
||||
| `band == 0` takes today's crossing test **verbatim**, with the latched-side comparator used only when the band is non-zero | One unified latched-side comparator: at `v == level` exactly the two are *not* equivalent (today it fires, a zero-width band decides nothing), and `feed_window`'s own comment records a ±1 square against a `[1, 1]` band firing 19 times on exact float equality. `probe-readings.md`'s `[condition-probe]` table of four exact latch times is a regression suite in disguise; unifying would require re-measuring all of it to prove a change nobody asked for |
|
||||
| The band and the filter live **inside `EventDetector`** | Filtering in `Acquisition` before `engine.process()`: the buffer search deliberately runs the same evaluator (`condition.hpp:46-49`), so anything upstream of the detector has to be replicated there, and the live trigger and the search would drift apart |
|
||||
| No shared hysteresis primitive extracted | A common `side_of()` header used by `measure.cpp` and `condition.hpp`: the genuinely shared part is about five lines, and the two call sites want different things from it — `measure.cpp` `continue`s on an in-band sample while counting enters, widths and folded buckets, the detector needs a side to feed four state machines. Three similar lines beat a premature abstraction; the *invariant* is stated once and pointed at |
|
||||
| The measurement filter is a **real one-pole at a user `fc`**, and it disables the pyramid fast path | Measuring the pyramid's per-bucket means, which would be free and keep the fast path — but its corner is wherever the bucket size puts it (`fs/64`), so it is HF reject at one fixed, rate-dependent frequency rather than a cutoff the user can aim at the interference they actually have |
|
||||
| The filter runs **inside each of the two raw walks** | Materializing a filtered copy of the window: `MeasureChunk`'s comment (`measure.hpp:108-115`) records that widening the window cost 60 % of the pane's time before a single statistic had been computed, and 17 MB at a 2.2 M-sample window |
|
||||
| One combined `de-noise` node (median **then** pole) | A plain median node chained to the existing `LowPass`: two boxes for one idea, and it lets the user wire them in the wrong order — a pole *before* a median lets one spike contaminate the filter state for its whole settling time, which defeats the purpose |
|
||||
| The median window is **N samples, fixed** | A duration converted to N from `dt`: N would then grow with decimation, so a modest duration at a long window becomes a large sort in the producer's hot loop and needs a hidden cap the user did not set |
|
||||
| `MathOp::DeNoise` is **appended**, at index 12 | Inserting it beside the filters in `math_op_at()`, where it belongs in the dropdown: a node persists as its *index through that accessor* (`layout_store.cpp:358-365`), so inserting would silently reinterpret every node in every existing session file |
|
||||
| The median's ring is **pooled in `MathRuntime`** | A `double[9]` member of `MathNodeRt`: 72 bytes added to *every* node, doubling a struct whose shape README's math-cost section records as a deliberate choice, to serve the one op whose state size is a parameter |
|
||||
| A new **impulsive** mock source | Verifying against `--noise` (Gaussian): a median is specifically the wrong tool for a raised noise floor and the right one for impulses, so a Gaussian test would prove nothing about the feature |
|
||||
|
||||
## 1. The trigger
|
||||
|
||||
### Config
|
||||
|
||||
`EventCondition` (`condition.hpp:27`) gains two fields:
|
||||
|
||||
```cpp
|
||||
double noise_band = 0.0; ///< hysteresis half-width about `level`, signal units. 0 = off.
|
||||
double hf_fc = 0.0; ///< HF reject corner on the comparator's input, Hz. 0 = off.
|
||||
```
|
||||
|
||||
Both **must** join `operator==` (`condition.hpp:37-41`). This is not bookkeeping:
|
||||
`EventDetector::configure()` returns early when the condition compares equal
|
||||
(`condition.cpp:9`) and the producer calls it on every packet, so a field omitted from the
|
||||
comparison is a control that silently never takes effect.
|
||||
|
||||
The filter needs the sample interval to turn `fc` into a coefficient, and the detector is fed
|
||||
`(value, t)` pairs rather than a rate. `configure()` therefore takes `dt` as a second argument and
|
||||
keeps it as a `dt_` member, and the early return compares the new `dt` against `dt_` as well as the
|
||||
condition against the stored one — a re-plan changes `dt`, and a coefficient left over from the
|
||||
previous partition is a filter at the wrong corner.
|
||||
|
||||
### Comparator
|
||||
|
||||
`EventDetector` gains `int side_ = 0` (0 undecided, ±1 above/below) and one pole state word.
|
||||
`crossed_up`/`crossed_down` (`condition.hpp:77-82`) stop being pure functions of `prev_` and
|
||||
become flags computed **once per sample** in `feed()`:
|
||||
|
||||
- `noise_band == 0` → `prev_ < level && v >= level` and its mirror, bit for bit as today.
|
||||
- `noise_band > 0` → `v >= level + band` latches `+1`, `v <= level - band` latches `-1`, anything
|
||||
between decides nothing; a crossing *is* a change of latch.
|
||||
|
||||
Computing the pair once also removes a duplicated computation: `feed_edge`, `feed_pulse` and
|
||||
`feed_dropout` each call both predicates today.
|
||||
|
||||
`feed_window` (`condition.cpp:115`) does not use those predicates, so it takes the band
|
||||
explicitly on each edge of its own band: becoming *inside* requires `[lo+band, hi-band]`,
|
||||
becoming *outside* requires leaving `[lo-band, hi+band]`. A band wide enough to close that inner
|
||||
region is clamped to just under half the window's width — the same shape of clamp
|
||||
`measure_set_level()` already applies for the same reason, and it keeps the panel's existing
|
||||
refusal of a zero-width window meaningful.
|
||||
|
||||
`reset()` (`condition.cpp:14`) clears `side_` and the pole state.
|
||||
|
||||
### The filter
|
||||
|
||||
One pole on the comparator's input, `y += a·(x − y)`, with `a = 1 − exp(−2π·fc·dt)` built in
|
||||
`configure()` and clamped below Nyquist as `biquad()` does (`math_channel.cpp:28`). `fc = 0`
|
||||
gives `a = 1`, i.e. `y = x`, so "off" needs no branch in the inner loop.
|
||||
|
||||
Two consequences, both stated rather than discovered:
|
||||
|
||||
- **The latch moves later** by roughly `1/(2π·fc)` — the pole's group delay. This is inherent,
|
||||
it is what a bench scope's HF reject also does, and it is why the control is off by default.
|
||||
- **A NaN stays benign.** `feed()`'s comment (`condition.hpp:68-70`) records that a NaN sample
|
||||
cannot fire because every comparison against it is false, and that *rejecting* one would leave
|
||||
`prev_` stale and let the next sample fire a crossing that today cannot happen. The filter
|
||||
preserves this: a non-finite input returns NaN (which flows into the existing comparisons, all
|
||||
false) and leaves the pole state alone.
|
||||
|
||||
`last_value()` (`condition.hpp:73`) comes to report the comparator's input rather than the raw
|
||||
sample. Its only consumer is the `v=%.4g` field of the trig-engine diagnostic
|
||||
(`acquisition.cpp:918`), whose purpose is to say what the comparator saw.
|
||||
|
||||
### Panel
|
||||
|
||||
A `trigger_band_spin` and a `trigger_hf_spin` in `build_trigger_panel()` (`ui_trigger.cpp:219`),
|
||||
read in `publish_trigger_fields()` (`:56`), shown for all four condition types in
|
||||
`sync_trigger_rows()` (`:94`). The band takes the level spin's unit, digits and range, floored at
|
||||
0.
|
||||
|
||||
### The search gets both for free
|
||||
|
||||
`EventDetector` holds no arming state precisely so the buffer search can run the same evaluator
|
||||
over retained samples (`condition.hpp:46-49`). A band or a filter set in the panel therefore
|
||||
changes what a search finds, consistently, with no second code path and no second thing to keep
|
||||
in step.
|
||||
|
||||
### Not persisted
|
||||
|
||||
The trigger has never been persisted, so neither control is either; a restart comes up with both
|
||||
off. That is also what makes every current `[condition-probe]` latch time hold unchanged.
|
||||
|
||||
## 2. The measurements
|
||||
|
||||
### Config
|
||||
|
||||
`MeasureOptions` (`measure.hpp:37`) gains two fields:
|
||||
|
||||
```cpp
|
||||
double noise_pct = 10.0; ///< hysteresis half-width, % of the window's peak-to-peak. 0 = off.
|
||||
double hf_fc = 0.0; ///< HF reject corner, Hz. 0 = off.
|
||||
```
|
||||
|
||||
`noise_pct = 10.0` reproduces today's `kHysteresisFrac = 0.10` (`measure.cpp:36`) exactly, so
|
||||
every current reading is identical. `measure_set_level()` (`measure.hpp:195`,
|
||||
`measure.cpp:40`) already takes a `pct` — the *threshold* percent — so it gains a second,
|
||||
separately named `noise_frac` argument for the band, replacing its read of the file-scope
|
||||
`kHysteresisFrac`; the constant stays as `MeasureOptions::noise_pct`'s default so its
|
||||
measured-value rationale — 5 % reads a 0.1 interferer correctly and fails at
|
||||
0.2, 10 % holds to 0.3 and to white noise at σ=0.05 — does not become an orphan comment. The
|
||||
clamp to half the room between the level and the nearer extreme survives untouched: it is what
|
||||
keeps a threshold at 90 % measurable instead of reading `enters=0`.
|
||||
|
||||
### The band reaches the edge types only
|
||||
|
||||
`MeasureLevel::hysteresis`'s comment (`measure.hpp:165-169`) is explicit that the band moves the
|
||||
*edge decision* only, and that the `sum`/`count` split stays at the bare level — because that
|
||||
split **is** `Top`/`Bottom`, the mean of the samples above or below the threshold, and banding it
|
||||
would change the documented exact `top = 2/π` on a unit sine.
|
||||
|
||||
So `noise_pct` is meaningful for `PulseDuration`, `Period` and `Frequency`, and does nothing for
|
||||
`Top`/`Bottom`. The file's own rule (`measure.hpp:34-36`) is that an option which does nothing
|
||||
for a type is left out of the dialog rather than stored and ignored, so this adds a
|
||||
`measure_uses_noise()` beside `measure_uses_threshold()` and `measure_uses_side()`
|
||||
(`measure.hpp:242-245`), and `measure_dialog_sync()` (`ui_measure.cpp:90`) shows the field for
|
||||
those three types only.
|
||||
|
||||
A *raised* band also costs reads: `measure_accumulate_bucket()` already refuses a bucket where
|
||||
the signal passes `level ± hysteresis` (`measure.hpp:211-216`), so a wider band opens more
|
||||
buckets. The existing `measure cost:` line is where that shows up.
|
||||
|
||||
### HF reject reaches every type
|
||||
|
||||
The filter changes the samples rather than a decision, so `min`, `max`, `average`,
|
||||
`peak to peak`, `amplitude`, `top`, `bottom` and every crossing type all read the filtered
|
||||
signal. The control is therefore shown for all types.
|
||||
|
||||
It is seeded with the window's first sample, so the step transient at the left edge does not read
|
||||
as a measurement. (Priming from samples *before* the window is possible — the ring holds the
|
||||
history — and is deliberately not done: it would make a measurement depend on data outside the
|
||||
window it reports on.)
|
||||
|
||||
### What it costs
|
||||
|
||||
`hf_fc > 0` disables **both** bucket fast paths, `measure_scan_buckets()` (`measure.hpp:147`) and
|
||||
`measure_accumulate_bucket()` (`:217`): a bucket's `lo`/`hi`/`sum` describe raw samples and cannot
|
||||
answer for a filtered signal. Both passes walk raw, which is the 1.2–1.6 ms → ~20 ms step at a
|
||||
2.2 M-sample window that README's measurement-cost section was written about. Against the pane's
|
||||
250 ms cadence that is a few percent of a core and a visible frame hitch.
|
||||
|
||||
This is accepted deliberately: the control is per-measurement and off by default, so nothing gets
|
||||
slower unasked, and `measure cost:`'s `covered`/`buckets`/`raw` makes the cost a *reading* rather
|
||||
than a mystery. `raw ≈ covered` is the signature of a filtered measurement.
|
||||
|
||||
The filter runs inside each of the two raw walks — three flops on a sample the walk is already
|
||||
reading — rather than materializing a filtered window once. Filtering twice is deterministic and
|
||||
free compared with the alternative measured in `MeasureChunk`'s comment.
|
||||
|
||||
NaN keeps the existing rule: the walks already skip a non-finite sample, and the filter state is
|
||||
left alone, so a hole stays a hole and the filter resumes on real samples.
|
||||
|
||||
### Two documented deviations
|
||||
|
||||
1. `docs/claude/invariants-readouts-and-cursors.md` holds that a readout quotes a stored sample
|
||||
and never a value between two. A filtered `max` is a value that was never stored, and with
|
||||
`show_on_plot` its ruler sits at a level the drawn — raw — trace never reaches. This is
|
||||
inherent to filtering, so it becomes a stated exception in that invariant rather than a silent
|
||||
violation.
|
||||
2. The documented exact baselines (`peak to peak = 2`, `top = 0.636620`) hold with the filter
|
||||
off. With it on they move, correctly; the probe reads both.
|
||||
|
||||
### Persistence
|
||||
|
||||
Free. A measurement is written as keyed TOML (`layout_store.cpp:659-672`) and read key by key
|
||||
(`:544-556`), so two new keys load in an old session at their defaults.
|
||||
|
||||
## 3. The `de-noise` math node
|
||||
|
||||
### The op, appended
|
||||
|
||||
`MathOp::DeNoise` goes last in the enum (`math_channel.hpp:15-28`) and at index 12 in
|
||||
`math_op_at()` (`math_channel.cpp:99-114`), with `Div` given an explicit `case 11` so the current
|
||||
`default` no longer swallows it; `math_op_count()` becomes 13 and `math_op_name()` gains
|
||||
`"de-noise"`.
|
||||
|
||||
Indices 0–11 keeping their meaning is exactly what leaves every existing session file reading
|
||||
correctly, because a node persists as its index through that accessor. The price is that
|
||||
de-noise sits last in the dialog's dropdown rather than beside the filters, which is the right
|
||||
side of that trade.
|
||||
|
||||
### Parameters
|
||||
|
||||
`MathNode` (`math_channel.hpp:48-72`) gains `int med_n = 5;` — odd, 3 to 9, rounded to odd and
|
||||
clamped. `fc` is reused for the HF reject; `q` is unused. `fc = 0` means median only, and it
|
||||
expresses "off" without a branch: the pole's coefficient `a = 1 − exp(−2π·fc·dt)` is exactly 1 at
|
||||
`fc = 0`.
|
||||
|
||||
The coefficient is built in `math_prepare()` (`math_channel.cpp:140-165`) where `dt` is known and
|
||||
rebuilt on a `dt` change, exactly as the biquads are, with the same sub-Nyquist clamp
|
||||
(`:28`).
|
||||
|
||||
### Predicates
|
||||
|
||||
`math_op_uses_filter()` (`math_channel.cpp:135`) keeps meaning **biquad** — it gates coefficient
|
||||
construction at `:162`, and a de-noise node must not be given biquad coefficients. The dialog
|
||||
gets a new `math_op_uses_fc()` (filter ‖ de-noise) and a `math_op_uses_median()` for the new spin
|
||||
row. `math_dialog_sync_form()` (`ui_math.cpp:889-981`) already shows and hides one widget set per
|
||||
op, and `q`'s row being visible for `BandPass` alone is the precedent for a per-op subset of the
|
||||
filter row.
|
||||
|
||||
### Node text
|
||||
|
||||
`math_node_text()` (`layout_store.cpp:358`) emits a 13th pipe field. `math_node_from_text()`
|
||||
accepts **12 or 13** fields, defaulting `med_n` when it reads 12, so existing sessions load
|
||||
unchanged. A file written by this build and read by an older one is rejected by that build's
|
||||
`f.size() != 12u` check (`:385`); there is no forward-compatibility requirement here, but it is
|
||||
recorded rather than left as a surprise.
|
||||
|
||||
### Runtime state, pooled
|
||||
|
||||
`MathRuntime` (`math_channel.hpp:115-120`) gains one `std::vector<double>` holding the median
|
||||
rings of whichever nodes need them; `MathNodeRt` gains an offset into it, a ring position and the
|
||||
effective N. Not a fixed array member, for the reason in the decisions table. One indirection on
|
||||
a path already doing N compares is free, and the choice is reversible if measurement disagrees.
|
||||
|
||||
### Evaluation
|
||||
|
||||
In `math_step()` (`math_channel.cpp:167-230`), the new case is: push the input into the ring,
|
||||
take the median, then one pole step. **Median first** — de-spiking before smoothing is the whole
|
||||
point, and the reverse lets one spike contaminate the filter state for its entire settling time.
|
||||
|
||||
The median is an insertion sort of at most nine doubles in a local array: ~20 compares worst
|
||||
case, ~4 at N=3, against a documented 80–100 ns per slot for a whole three-graph runtime. That
|
||||
ratio is measured and reported through the existing math-cost line, not assumed.
|
||||
|
||||
**Warm-up:** before the ring holds `med_n` samples the node outputs the median of what it holds,
|
||||
which is a correct median of a shorter window. Deliberately not NaN — in this application a NaN
|
||||
output *means a hole in the data* (`math_channel.cpp:64-70`), and spending that meaning on a
|
||||
start-up transient would make a gap ambiguous.
|
||||
|
||||
**NaN** matches `biquad_step` exactly: a non-finite input returns NaN and leaves both the median
|
||||
ring and the pole state alone, so the hole stays visible and the node resumes on real samples.
|
||||
|
||||
## 4. Verification
|
||||
|
||||
There is no unit test suite; the check is the application's own headless self-check under
|
||||
`xvfb-run` against `tools/mock_udpstreamer.py`, read against two baselines (an exact warning
|
||||
count, and no `Gtk-CRITICAL`).
|
||||
|
||||
### The regression assertion, first
|
||||
|
||||
Every default is today's behaviour, so the documented reading set must be **unchanged**:
|
||||
|
||||
- `[condition-probe]`'s four exact latch times (`t0=1.710000`, `2.160100`, `1.706700`,
|
||||
`4.120200`);
|
||||
- `peak to peak=2`, `amplitude=1`, `average=0`, `top=0.636620`;
|
||||
- `measure cost:` with `raw ≪ covered`;
|
||||
- the build's warning count — adding a field to `MathNode` leaves
|
||||
`ui_probe.cpp:304` and `:313` at one `-Wmissing-field-initializers` each, so four on a clean
|
||||
build and two on an incremental one still holds.
|
||||
|
||||
### The mock needs an impulsive source
|
||||
|
||||
`tools/mock_udpstreamer.py` offers `--noise` (Gaussian), `--glitch-at`/`--glitch-width` (a single
|
||||
glitch at one time) and `--clock-jitter`. None of them is a *stream* of EM-like peaks, and a
|
||||
median is the wrong tool for Gaussian noise, so none of the three pieces could be shown to work
|
||||
against the mock as it stands.
|
||||
|
||||
Add `--spike-every S --spike-amp A --spike-width N`: a recurring impulse of N samples at
|
||||
amplitude A, every S seconds. One flag set, and it is what every reading below is measured on.
|
||||
|
||||
### The four readings
|
||||
|
||||
1. **Trigger band** — a new `[condition-probe]` leg on a slow sine plus spikes. With
|
||||
`noise_band = 0` the latch lands on a spike; with the band above the spike amplitude it lands
|
||||
on the sine's own crossing time. The latched `t0` is the assertion, and the four existing
|
||||
shapes are re-read unchanged in the same run.
|
||||
2. **Trigger HF reject** — the same leg with the band off and `hf_fc` set. The latch moves
|
||||
*later* by roughly `1/(2π·fc)`, reported beside the prediction. A latch that does not move is
|
||||
the filter not being in the comparator's path.
|
||||
3. **Measurements** — `Frequency` on the spiky mock read three ways: `noise_pct = 0` (counts
|
||||
spikes, frequency far too high), `= 10` (the true frequency), and `hf_fc` set (the true
|
||||
frequency *and* `raw ≈ covered`, which is the fast-path loss as a reading). Plus `peak to
|
||||
peak` with the filter off and on, since that is the number the filter is supposed to move.
|
||||
4. **The de-noise node** — `math_probe` (`ui_probe.cpp:304`) gains a de-noise graph on the spiky
|
||||
channel. Its output's peak-to-peak must fall to the clean waveform's while a `LowPass` at the
|
||||
same `fc` does not: that contrast is the entire justification for the node existing. Plus the
|
||||
per-slot cost through the existing math-cost line.
|
||||
|
||||
## 5. Documentation
|
||||
|
||||
| File | What it gains |
|
||||
| --- | --- |
|
||||
| `README.md` | A noise-rejection section covering the three places, and the filtered-readout deviation stated rather than quietly taken |
|
||||
| `docs/claude/invariants-readouts-and-cursors.md` | The stated exception: a filtered readout is not a stored sample, and its on-plot ruler can sit where the raw trace never goes |
|
||||
| `docs/claude/invariants-measurements.md` | A filter on a measurement disables both bucket fast paths, and `raw ≈ covered` is its signature |
|
||||
| `docs/claude/invariants-trigger-and-pinning.md` | The band, the `band == 0` verbatim path and why, and the latch delay a filter adds |
|
||||
| `docs/claude/invariants-math-channels.md` | Op indices are append-only because the session format stores them; the pooled median state |
|
||||
| `docs/claude/verification.md`, `docs/claude/probe-readings.md` | The four readings above and what a wrong value names |
|
||||
| `docs/claude/build-and-run.md` | The new mock flags |
|
||||
|
||||
## 6. Out of scope
|
||||
|
||||
- Persisting the trigger. The trigger has never been persisted; the band and the filter follow
|
||||
that, rather than this change being the one that introduces trigger persistence.
|
||||
- Any averaging acquisition mode. Noise rejection here is per-measurement, per-trigger and
|
||||
per-node; nothing changes what the ingest stores, so a raw trace stays raw and every reading
|
||||
remains traceable to stored samples.
|
||||
- Noise rejection on the cursors or the search's own UI. The search inherits the trigger's
|
||||
condition and so inherits both controls; nothing further is added.
|
||||
@@ -0,0 +1,199 @@
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
|
||||
# A tabbed side panel, and one channel list: design
|
||||
|
||||
The side panel stacks five sections in one scrolling column — trigger, cursors, measurements,
|
||||
math, signals — each behind a `GtkRevealer` opened by its own toolbar toggle. With trigger,
|
||||
measurements, cursors and math all open the column is unusable: every section is short of room,
|
||||
and finding one means scrolling past three others.
|
||||
|
||||
Two changes, aimed at that one problem:
|
||||
|
||||
1. **The column becomes two tabs.** *Measure* holds the cursors readout and the measurements;
|
||||
*Signals* holds the trigger and the channel list. One tab is on screen at a time, so no
|
||||
section competes with four others for height.
|
||||
2. **The math list is folded into the signal list.** A math channel already *is* a channel in
|
||||
the model, and the two lists were the same list drawn twice. One list, with a `MATH`
|
||||
divider in it.
|
||||
|
||||
A third change falls out of the first. A tab is one-of-N, so it can no longer carry an on/off:
|
||||
**no toggle hides a pane any more.** Trigger-off and cursors-off put their pane in a disabled
|
||||
state and leave it where it is.
|
||||
|
||||
## The decisions taken, and what each one rules out
|
||||
|
||||
| Decision | Alternative rejected |
|
||||
| --- | --- |
|
||||
| `AdwViewStack` + `AdwViewSwitcher` | `GtkStack` + `GtkStackSwitcher`: plain buttons beside an `AdwOverlaySplitView`, which is what the sidebar already is. `GtkNotebook`: draws its own tab strip, which costs width in a 320 px sidebar and reads as off-platform. libadwaita ≥ 1.8 is already a hard requirement (`CMakeLists.txt:22`), so the platform widget costs nothing new |
|
||||
| **A scroller per page**, inside the stack | One shared scroller around the stack: the two pages are different heights, so a tab switch would leave the scroll position somewhere the new page did not ask for |
|
||||
| The switcher sits **above** the scrolled area | Inside it: the one control that chooses what you are looking at must not be the first thing to scroll away |
|
||||
| **Two** tabs, exactly as grouped | A tab per section (five tabs): that is the stack again, laid sideways, and a 320 px sidebar has no room for five switcher pills. Measurements read what the cursors bracket, and the trigger picks a channel from the list beside it — the grouping follows the way the panes are used |
|
||||
| Nothing inside a tab collapses | Keeping a revealer per section inside the tab, or a `GtkPaned` between them: the first reintroduces exactly the state-management the tabs remove, two sections at a time instead of five; the second adds a divider position to persist per tab for a column that already scrolls |
|
||||
| Trigger-off and cursors-off **disable** their pane | Hiding it, which is today's behaviour: a pane you can see is a pane you can read, and the settings you are about to turn back on should not vanish while you look for them |
|
||||
| The `measurements` and `math` toolbar toggles are **deleted** | Keeping them as tab selectors: two buttons that select the same tab, whose pressed state duplicates the switcher's |
|
||||
| The switcher shows **titles only**, and the two icons the deleted toggles used are **removed** | Giving each page an `icon-name`: the measurements icon would carry over, but *Signals* has no glyph — reusing the trigger icon for a page that is mostly a channel list misnames it, and drawing a new one is new artwork under the icon authoring rules for a label two words long |
|
||||
| One row widget for both kinds of channel, differing **only in its menu** | Inline edit and delete buttons on math rows (today's math pane): math rows then carry visibly more controls than signal rows, and the list stops reading as one column |
|
||||
| A `+` on the `MATH` divider **only** | A `+` on the signals header too: a signal comes from CONFIG and cannot be added, so the button would have nothing to do |
|
||||
| The statistics accumulator keeps running on the **hidden** page | Gating `update_measures()` on the visible tab: the accumulator is history, so min/max/avg would gain a gap for exactly as long as you looked at the trigger |
|
||||
| The probe reads the **page name and sensitivity** | Dropping the two revealer readings: the defect they catch — a panel that is built but not on screen — still exists, it is just spelled differently |
|
||||
|
||||
## 1. The container
|
||||
|
||||
The sidebar child of `side_view` becomes a vertical `GtkBox`:
|
||||
|
||||
```
|
||||
AdwOverlaySplitView.side_view
|
||||
sidebar: GtkBox (vertical)
|
||||
AdwViewSwitcher <- never scrolls
|
||||
AdwViewStack
|
||||
page "measure" GtkScrolledWindow -> GtkBox: cursors, separator, measurements
|
||||
page "signals" GtkScrolledWindow -> GtkBox: trigger, separator, signal list
|
||||
```
|
||||
|
||||
`side_scroll` moves inside the pages; `App::side_box` becomes the stack, and the five revealer
|
||||
fields in `ui_app.hpp` are removed with the code that sets them (`ui.cpp:4728-4767`).
|
||||
|
||||
The switcher carries the two page titles and no icons. `udpscope-measure-symbolic` and
|
||||
`udpscope-math-symbolic` lose their last user with the toggles that used them, so both leave
|
||||
`src/icons/scalable/actions/` and `udpscope.gresource.xml`: the bundled set goes from six
|
||||
action icons to four, which `invariants-gtk-and-chrome.md` counts and must be corrected with
|
||||
the change.
|
||||
|
||||
The pages are built by the functions that build them today — `build_trigger_panel()`,
|
||||
`build_cursors_panel()`, `build_measures_panel()`, `build_signal_panel()` — unchanged except
|
||||
that they are appended to a page's box instead of to a revealer.
|
||||
|
||||
## 2. State instead of visibility
|
||||
|
||||
| Control | Today | After |
|
||||
| --- | --- | --- |
|
||||
| `trigger_toggle` | trigger mode on/off **and** reveals the trigger pane (`ui_trigger.cpp:198`) | trigger mode on/off; the pane's body goes sensitive/insensitive |
|
||||
| `cursors_toggle` | cursors on/off **and** reveals the readout (`ui_cursors.cpp:124-126`) | cursors on/off; the readout goes insensitive and its values blank to `—` |
|
||||
| `cursors_reset` | hidden while cursors are off (`ui_cursors.cpp:122`) | insensitive while cursors are off |
|
||||
| `measures_toggle` | reveals the measurements (`ui.cpp:4541-4550`) | **deleted** |
|
||||
| `math_toggle` | reveals the math pane (`ui.cpp:4551-4558`) | **deleted** |
|
||||
| `sidebar_toggle`, `events_toggle`, `pause_button` | — | unchanged |
|
||||
|
||||
The two deleted toggles leave `src/ui/header-bar.ui` with their handlers, so the bar is
|
||||
declared with five controls and no id resolves to a button nothing listens to.
|
||||
|
||||
`update_side_visibility()` (`ui.cpp:2830-2850`) keeps its job — the whole sidebar is still the
|
||||
split view's to show, still gated on a CONFIG having named a signal — minus the line that showed
|
||||
`signal_toggle`, which no longer exists.
|
||||
|
||||
Everything else those handlers do is untouched: `tc.armed`, the epoch rule and the
|
||||
`set_time_window()` call in `on_trigger_toggled()`, `sync_events_availability()`, the
|
||||
Arm/Disarm label, and `rows->set_cursors_visible()` with its `reset_cursors()`.
|
||||
|
||||
Blanking the cursor readout to `—` rather than leaving the last values is the readout invariant
|
||||
restated: a readout quotes a stored sample, and with the instruments off there is no sample it
|
||||
quotes.
|
||||
|
||||
## 3. One channel list
|
||||
|
||||
**The duplication is literal, and the merge is mostly a deletion.**
|
||||
`rebuild_signal_list()` is handed `model.signals()`, and `append_math_signals_locked()`
|
||||
(`model.cpp:252-269`) has already appended one synthetic entry per math channel — *"they go
|
||||
through the ordinary signal path on purpose: a derived channel is a channel"*, says its own
|
||||
comment. Nothing in the list filters `sig.math`, so **every math channel is already a row in
|
||||
the signal list**, with its tick, chip, name, unit and menu. The math pane listed the same
|
||||
channels a second time.
|
||||
|
||||
What the list gains:
|
||||
|
||||
- a non-selectable `MATH` divider row before the first math row, carrying the `+` that opens
|
||||
the node editor on a new channel;
|
||||
- two items in a math row's menu.
|
||||
|
||||
| | signal row | math row |
|
||||
| --- | --- | --- |
|
||||
| tick | acquire (hidden in a file view) | enabled |
|
||||
| colour chip | clickable while plotted | unchanged |
|
||||
| unit | from CONFIG | the channel's own |
|
||||
| menu | Information, Default colour, Calibrate… | **Edit graph…**, Default colour, **Delete** |
|
||||
|
||||
Information and Calibrate… are a *streamed* signal's — calibration is applied at ingest — so a
|
||||
math row carries neither. No rename item either: the node editor owns a math channel's name and
|
||||
unit (`src/ui/math-editor.ui:37-43`), so *Edit graph…* is where both are changed, and a second
|
||||
place to change them would be a second answer to the same question.
|
||||
|
||||
Two things already work and must not be rebuilt: the tick is already the right switch —
|
||||
`enable_locked()` (`model.cpp:626-639`) mirrors it into the `MathChannel`'s own `enabled` flag —
|
||||
and the row's `GtkDragSource` already lets a math channel be dropped onto a plot row, which is
|
||||
how `math1` reached row 0 in the session this work started from.
|
||||
|
||||
One defect to fix while merging: `App::math` is the copy the session file is written from, so a
|
||||
tick on a math row must mirror `enabled` into it before `schedule_layout_save()`. Today the pane's
|
||||
checkbox did that (`ui.cpp:2276`) and the list's tick did not, so the two controls disagreed
|
||||
about what gets saved.
|
||||
|
||||
Deleted: `build_math_panel()` (`ui_math.cpp:1044`), `rebuild_math_pane()` (`ui.cpp:2251-2319`),
|
||||
`src/ui/math-panel.ui` and its entry in `src/ui/udpscope-ui.gresource.xml`, `App::math_revealer`,
|
||||
`App::math_list`, `App::math_toggle`, `App::measures_toggle`, `App::signal_revealer`, and
|
||||
`App::signal_toggle` with `on_signals_toggled()` (`ui_signals.cpp:303-307`) and its overflow-menu
|
||||
row (`ui.cpp:4452-4455`) — the list is a page now, so "show or hide the signal list" has nothing
|
||||
to hide. The node editor dialog itself is untouched; only the button that opens it moves.
|
||||
|
||||
## 4. Persistence
|
||||
|
||||
One key, top level in the session file beside `window_s` and `measure_stats`
|
||||
(`layout_store.cpp`):
|
||||
|
||||
```toml
|
||||
side_tab = "signals" # or "measure"
|
||||
```
|
||||
|
||||
Written on save; applied once where the rest of the layout is applied, so an unknown or missing
|
||||
value leaves the stack on its first page. Nothing else about the panel is persisted today and
|
||||
nothing else needs to be: no revealer states remain, and the 320 px width is still the `.ui`'s.
|
||||
|
||||
## 5. What runs on a hidden page
|
||||
|
||||
`update_cursors_pane()` and `update_measures(force=false, accumulate=true)` are called every
|
||||
frame from the tick (`ui.cpp:3275-3276`) and keep being called whichever page is current.
|
||||
Writing a label on an unrealized widget is legal; **reading its allocation is not**. Every
|
||||
allocation read in the sidebar is a debug capture — `dbg_measures_pane_w/h`
|
||||
(`ui.cpp:3237-3239`), `dbg_signal_w/h` and `dbg_trigger_w/h` (`ui_probe.cpp:77-80`) — and each
|
||||
reads 0 on the page that is not current. That is a change in what the reading means, which §6
|
||||
handles; it is not a defect, and no functional code may start depending on those numbers.
|
||||
|
||||
This is the revealer trap in a new widget. `invariants-gtk-and-chrome.md:174-181` records that
|
||||
an unrevealed `GtkRevealer` measures 0×0 and is still given the leftover width; a stack's
|
||||
hidden page measures 0×0 too, and the rule it teaches — ask about *state*, not about size —
|
||||
carries over unchanged.
|
||||
|
||||
## 6. Verification
|
||||
|
||||
Readings that change, and what each must say instead:
|
||||
|
||||
| Reading | Was | Becomes |
|
||||
| --- | --- | --- |
|
||||
| `dbg_signal_revealed` (`ui_probe.cpp:90`) | the signal revealer's child is revealed | the `signals` page is current **and** its list is non-empty |
|
||||
| `dbg_trigger_revealed` (`ui_probe.cpp:92`, `:2190`) | the trigger revealer's child is revealed | the `signals` page is current **and** the trigger body is sensitive |
|
||||
| `dbg_measures_pane_w/h` | the pane's size while revealed | the pane's size while its page is current; 0 otherwise, and read as such |
|
||||
| the panel-toggle reading (`ui_probe.cpp:104`) | both menu toggles are visible | the switcher is visible, which is the one control those two became |
|
||||
|
||||
One new probe, `UDPSCOPE_PROBE_SIDE_TAB_MS`: select the page that is not current, then dump the
|
||||
page name, the measurement row count and the accumulator's sample count. The count must have
|
||||
advanced across the switch — that is the §5 rule under test, and the defect it names is a
|
||||
statistics pane that silently restarts its history every time you look at the trigger.
|
||||
|
||||
New reading for the merged list, on the existing signal-panel probe: the row count must be
|
||||
signals + 1 + math channels, and the math rows must carry the same widget shape as a signal
|
||||
row. A count short by the divider is a list that lost its `+`.
|
||||
|
||||
Baselines, unchanged and re-read on every run: exactly four warnings on a clean build, no
|
||||
`Gtk-CRITICAL`, `test-ui` 15/15.
|
||||
|
||||
Docs to update with the work: `probe-readings.md` (the three changed lines and the two new
|
||||
ones), `verification.md` (a bullet for the tab switch), `invariants-gtk-and-chrome.md` (the
|
||||
sidebar is a view stack, the hidden-page trap replaces the unrevealed-revealer one for it, the
|
||||
bar carries five controls and the bundled set four action icons),
|
||||
`invariants-math-channels.md` (the list lives in the signal panel), `invariants-readouts-and-cursors.md`
|
||||
(the readout blanks when the instruments are off), and a README note recording the deviation
|
||||
from `SPECS.md` where it names the panes.
|
||||
|
||||
## Out of scope
|
||||
|
||||
The events pane left of the plot, export, the node editor's internals, and the status bar.
|
||||
@@ -0,0 +1,87 @@
|
||||
<page xmlns="http://projectmallard.org/1.0/"
|
||||
type="topic" style="task"
|
||||
id="cursors">
|
||||
|
||||
<info>
|
||||
<link type="guide" xref="index#analysis"/>
|
||||
<link type="seealso" xref="measurements"/>
|
||||
<desc>The shared vertical cursor pair and the per-row horizontal rulers, their four modes,
|
||||
and how a reading is formatted.</desc>
|
||||
<credit type="author copyright">
|
||||
<name>Martino Ferrari</name>
|
||||
</credit>
|
||||
</info>
|
||||
|
||||
<title>Cursors</title>
|
||||
|
||||
<p>Cursors read values off the traces. There are two kinds: one <em>shared vertical
|
||||
pair</em> that every row carries at the same time, and <em>horizontal rulers</em>, one pair
|
||||
per row, that belong to that row alone.</p>
|
||||
|
||||
<section id="reach">
|
||||
<title>How to reach it</title>
|
||||
<steps>
|
||||
<item>
|
||||
<p>Press the cursors button in the toolbar, or <key>c</key>.</p>
|
||||
</item>
|
||||
<item>
|
||||
<p>The pane below the plot shows the readings and owns a <gui>Reset</gui> button.
|
||||
Drag a marker to move it.</p>
|
||||
</item>
|
||||
<item>
|
||||
<p>The mode dropdown offers four modes: <gui>vertical</gui> (the shared time pair),
|
||||
<gui>horizontal</gui> (the per-row rulers), <gui>both</gui>, and <gui>mix</gui>.</p>
|
||||
</item>
|
||||
</steps>
|
||||
</section>
|
||||
|
||||
<section id="knowing">
|
||||
<title>Worth knowing</title>
|
||||
<list>
|
||||
<item><p>The vertical pair is <em>one</em> time for the whole figure, so moving it on
|
||||
any row moves it on all of them. That is what makes <code>dt</code> meaningful across
|
||||
rows.</p></item>
|
||||
<item><p>A ruler belongs to its row and nothing shares it, so two rows' rulers are
|
||||
independent.</p></item>
|
||||
<item><p><gui>mix</gui> is the reading mode: one line per <em>bound</em> trace, with the
|
||||
trace's own values at each marker and their difference. A slot with a name but no
|
||||
channel behind it is not a trace and gets no line.</p></item>
|
||||
<item><p>A value is the <em>nearest stored sample's</em>, never a number between two
|
||||
samples. Zoom in past the sample spacing and the line between two samples is something
|
||||
the plot drew, not data — so a reading off it would be the drawing. The hover crosshair
|
||||
reads the same way.</p></item>
|
||||
<item><p>Every reading carries a <em>metric prefix</em> so the digits sit in
|
||||
<code>1..999</code>: <code>10.52 mV</code>, not <code>0.01 V</code>. Two decimals is all
|
||||
an instrument landing on an <code>f32</code> sample can claim, so without the prefix a
|
||||
small value would read <code>0.00</code> and the reading would be gone. A stream that
|
||||
declares no unit gets the prefix anyway, written tight against the number
|
||||
(<code>585.24m</code>) — the space separates the number from its unit, and a spaced
|
||||
<code>m</code> with nothing after it would read as metres.</p></item>
|
||||
<item><p>A marker past the newest sample reads <code>--</code>. With only one marker
|
||||
over data the row reads <code>partial</code>: a difference between a number and nothing
|
||||
is not a number.</p></item>
|
||||
<item><p>In <gui>mix</gui> each marker also places a crossing annotation per trace,
|
||||
which is what lets you see on the plot which sample a number came from. It is <em>on
|
||||
that sample</em>, so when the marker falls between two it sits a little off its own
|
||||
line: the line is where you put the marker, the dot is the datum being quoted. The hover
|
||||
crosshair's mark snaps the same way, and is drawn larger than a trace's sample dots so
|
||||
it does not read as one more sample.</p></item>
|
||||
<item><p>The time row exists only in the modes that show the vertical pair.
|
||||
<gui>horizontal</gui> has no time row, by design.</p></item>
|
||||
<item><p>A cursor <em>stays where you put it</em> while a live view rolls under it,
|
||||
which is what a bench scope does: its <code>T0</code> is an age, so a marker left at
|
||||
<code>-1.5s</code> keeps reading the sample a second and a half old as the stream
|
||||
advances. A measurement's marks do the opposite — they travel with the sample they
|
||||
measured — because a cursor is a question you asked and a mark is an answer about one
|
||||
particular sample.</p></item>
|
||||
</list>
|
||||
</section>
|
||||
|
||||
<section id="example">
|
||||
<title>Example</title>
|
||||
<p>Three traces in <gui>mix</gui> mode with both markers over data: 3 lines and 6 crossing
|
||||
marks, each line reading like <code>sine 0.31 V -0.84 V -1.15 V</code>. Move the second
|
||||
marker past the newest sample and the third column reads <code>--</code>.</p>
|
||||
</section>
|
||||
|
||||
</page>
|
||||
@@ -0,0 +1,78 @@
|
||||
<page xmlns="http://projectmallard.org/1.0/"
|
||||
type="topic" style="task"
|
||||
id="export">
|
||||
|
||||
<info>
|
||||
<link type="guide" xref="index#output"/>
|
||||
<link type="seealso" xref="sessions"/>
|
||||
<desc>Writing the visible range of every plotted trace to NumPy, CSV or MDF 3.30, and what
|
||||
each format carries.</desc>
|
||||
<credit type="author copyright">
|
||||
<name>Martino Ferrari</name>
|
||||
</credit>
|
||||
</info>
|
||||
|
||||
<title>Saving and export</title>
|
||||
|
||||
<p>Export writes the <em>visible range</em> of every plotted trace to a file: NumPy
|
||||
<file>.npy</file>, <file>.csv</file>, or <gui>MDF 3.30</gui> (<file>.mf3</file> or
|
||||
<file>.mdf</file>), chosen by the extension you type.</p>
|
||||
|
||||
<section id="reach">
|
||||
<title>How to reach it</title>
|
||||
<steps>
|
||||
<item>
|
||||
<p>Pick <gui>Export visible range…</gui> in the overflow menu, or press
|
||||
<keyseq><key>Ctrl</key><key>E</key></keyseq>.</p>
|
||||
</item>
|
||||
<item>
|
||||
<p>A file chooser asks where; the format follows the path's extension.</p>
|
||||
</item>
|
||||
</steps>
|
||||
</section>
|
||||
|
||||
<section id="knowing">
|
||||
<title>Worth knowing</title>
|
||||
<list>
|
||||
<item><p>The table is collected at the moment you click, not when the chooser returns. A
|
||||
live view keeps scrolling while a dialog is up, so a late collection would write a window
|
||||
you never saw.</p></item>
|
||||
<item><p>The rows are the <gui>stored samples</gui>, never the LOD buckets the plot draws
|
||||
from. A row count far below the span times the stored rate means something cut the table
|
||||
from the pyramid.</p></item>
|
||||
<item><p>The stored rate is not always the wire rate. A window long enough to need
|
||||
decimation is stored averaged at a lower rate, and the export carries <em>that</em> —
|
||||
read the status bar's ⓘ popover for the figure before trusting a sample count.</p></item>
|
||||
<item><p>Two time columns. <code>time</code> is the display timeline — negative ages
|
||||
ending at 0 while streaming, offsets from the trigger edge while an event is held — so it
|
||||
is the x you read a value at on screen. <code>abs_seconds</code> is the source's own
|
||||
clock for the same sample, which is what lets two exports taken in different modes line
|
||||
up. Both are strictly increasing and evenly spaced; duplicate or backwards stamps would
|
||||
mean the file held two clocks.</p></item>
|
||||
<item><p>An export taken <em>mid-capture</em> writes the partly filled window that is on
|
||||
screen, which is shorter than the event span. Export outside a capture if you want the
|
||||
whole event.</p></item>
|
||||
<item><p><gui>MDF</gui> exists for handing a window to another tool, and its shape follows
|
||||
the contract that reader uses: one channel group, every channel record-interleaved, the
|
||||
master channel as the x axis and the unit in the channel metadata. So there the
|
||||
<em>master is <code>abs_seconds</code></em>, named <code>time</code> — the clock that is
|
||||
the same in every display mode — and the display axis goes out beside it as an ordinary
|
||||
channel, <code>time_relative</code>. Both are seconds; each signal carries the source's
|
||||
own unit. That is also what <gui>ScryLab</gui> reads: channel name → signal name, master
|
||||
→ x axis, unit → the signal's unit.</p></item>
|
||||
</list>
|
||||
</section>
|
||||
|
||||
<section id="example">
|
||||
<title>Example</title>
|
||||
<p>A 2 s window at 10 kS/s over 3 traces writes 20001 rows and 5 columns
|
||||
(<code>time</code>, <code>abs_seconds</code>, three signals). The <file>.npy</file> is
|
||||
<code>64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)</code> bytes. Read it back with
|
||||
<code>pd.DataFrame(np.load(path))</code>. The <file>.mf3</file> is bigger — a block per
|
||||
channel plus a comment — <code>272 + comment + rows·record + 28 + channels·274 + 30</code>
|
||||
bytes, with <code>record = 16 + 4·cols</code> and <code>channels = cols + 2</code> for the
|
||||
master and the relative axis. Read that one back with <code>asammdf.MDF(path)</code> or in
|
||||
ScryLab.</p>
|
||||
</section>
|
||||
|
||||
</page>
|
||||
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 123 KiB |
@@ -0,0 +1,67 @@
|
||||
<page xmlns="http://projectmallard.org/1.0/"
|
||||
type="guide" style="task"
|
||||
id="index">
|
||||
|
||||
<info>
|
||||
<desc>udpscope plots a live UDPStreamer source as one figure of up to eight stacked
|
||||
plot rows sharing a single time axis, with a trigger, cursors, measurements, math
|
||||
channels and an export. This manual covers connecting, the window and its panels,
|
||||
navigating and pausing the plot, the trigger and its event cache, analysis, and
|
||||
saving and exporting.</desc>
|
||||
</info>
|
||||
|
||||
<title>udpscope</title>
|
||||
|
||||
<section id="main">
|
||||
<title>Getting started</title>
|
||||
<list>
|
||||
<item><p><link xref="overview">Overview</link> — what udpscope is, how it reaches a
|
||||
source, the window and its panels, and a first five minutes.</p></item>
|
||||
<item><p><link xref="source">Choosing a source</link> — the command line, the
|
||||
chooser, and opening an exported waveform instead of a live stream.</p></item>
|
||||
<item><p><link xref="signals">Signals and calibration</link> — how a signal becomes
|
||||
a channel, what starts acquisition, and how a scale and offset are applied.</p></item>
|
||||
<item><p><link xref="layout">Rows, layout and drag & drop</link> — up to eight
|
||||
rows, four traces each, and the layout editor.</p></item>
|
||||
</list>
|
||||
</section>
|
||||
|
||||
<section id="viewing">
|
||||
<title>Viewing</title>
|
||||
<list>
|
||||
<item><p><link xref="window">Time window</link> — the one control that changes the
|
||||
window, and the decimation a long window causes.</p></item>
|
||||
<item><p><link xref="navigate">Navigating the plot</link> — panning, zooming and
|
||||
fitting with the mouse and the keyboard.</p></item>
|
||||
<item><p><link xref="pause">Pause and play</link> — freezing the display without
|
||||
stopping the stream, and how long you have been looking.</p></item>
|
||||
<item><p><link xref="trigger">Trigger</link> — the four trigger shapes, arming,
|
||||
hold-off, the event cache, and searching the buffer.</p></item>
|
||||
</list>
|
||||
</section>
|
||||
|
||||
<section id="analysis">
|
||||
<title>Analysis</title>
|
||||
<list>
|
||||
<item><p><link xref="cursors">Cursors</link> — the shared vertical pair and the
|
||||
per-row horizontal rulers.</p></item>
|
||||
<item><p><link xref="measurements">Measurements</link> — a statistic of the visible
|
||||
window, its on-plot marks, and its statistics line.</p></item>
|
||||
<item><p><link xref="math">Math channels</link> — building a derived channel from a
|
||||
graph of source nodes, operations and one output.</p></item>
|
||||
</list>
|
||||
</section>
|
||||
|
||||
<section id="output">
|
||||
<title>Output and state</title>
|
||||
<list>
|
||||
<item><p><link xref="export">Saving and export</link> — the visible range to
|
||||
<file>.npy</file>, <file>.csv</file> or <file>.mf3</file>.</p></item>
|
||||
<item><p><link xref="sessions">Sessions and settings</link> — the per-source
|
||||
configuration files and what they carry.</p></item>
|
||||
<item><p><link xref="status">Status bar and diagnostics</link> — the glance line, the
|
||||
ⓘ popover, and the activity badge.</p></item>
|
||||
</list>
|
||||
</section>
|
||||
|
||||
</page>
|
||||
@@ -0,0 +1,71 @@
|
||||
<page xmlns="http://projectmallard.org/1.0/"
|
||||
type="topic" style="task"
|
||||
id="layout">
|
||||
|
||||
<info>
|
||||
<link type="guide" xref="index#main"/>
|
||||
<link type="seealso" xref="signals"/>
|
||||
<desc>How the figure's rows and traces are arranged, how to drop a signal onto a row, and
|
||||
how the layout editor adds and removes rows.</desc>
|
||||
<credit type="author copyright">
|
||||
<name>Martino Ferrari</name>
|
||||
</credit>
|
||||
</info>
|
||||
|
||||
<title>Rows, layout and drag & drop</title>
|
||||
|
||||
<p>The figure is a stack of rows, 1 to 8 of them, each holding up to 4 traces. Rows share
|
||||
one time axis and one pair of side margins, so their y labels line up, their data areas
|
||||
start and end on the same two columns, and their windows cannot drift apart. The left
|
||||
margin holds the y labels; the right one is empty, and is there so the newest tick's label
|
||||
and the newest sample's ink have somewhere to land instead of sitting on the frame.</p>
|
||||
|
||||
<section id="reach">
|
||||
<title>How to reach it</title>
|
||||
<steps>
|
||||
<item>
|
||||
<p>Drag a signal's chip out of the side panel (<key>s</key>) and drop it on a row to
|
||||
add a trace.</p>
|
||||
</item>
|
||||
<item>
|
||||
<p>Press <key>l</key>, or pick <gui>Layout editor</gui> from the overflow menu, to add
|
||||
and remove rows: a <gui>×</gui> appears at the top left of each row and a <gui>+</gui>
|
||||
on each boundary between rows, including above the first and below the last.</p>
|
||||
</item>
|
||||
<item>
|
||||
<p>The editor's own button in the top-right corner leaves it.</p>
|
||||
</item>
|
||||
</steps>
|
||||
</section>
|
||||
|
||||
<section id="knowing">
|
||||
<title>Worth knowing</title>
|
||||
<list>
|
||||
<item><p>A plot's own <gui>right-click menu</gui> (the same compact rows as the app's
|
||||
menu) holds the y-axis scale, the two fits, and <gui>Remove signal</gui>, which swaps
|
||||
the menu to that plot's traces plus <gui>All traces on this plot</gui> — with a
|
||||
<gui>Back</gui> row to return. That is how a trace comes off a row without going through
|
||||
the layout editor.</p></item>
|
||||
<item><p>A drop <em>adds</em> a trace to the row's next free slot; it does not replace
|
||||
one. With four traces already on a row the drop is refused.</p></item>
|
||||
<item><p>Inserting a row moves the rows below it <em>with their contents</em>. Inserting
|
||||
above row 1 puts what row 1 plotted onto row 2 and leaves the new row empty — the rows
|
||||
are not renumbered under you.</p></item>
|
||||
<item><p>A trace is remembered by the signal's <em>name</em>, not by its channel number,
|
||||
because a new CONFIG or an enable renumbers the channels and an index would silently
|
||||
re-point the trace.</p></item>
|
||||
<item><p>Adding or removing a row rebuilds the figure, which is cheap, but it also
|
||||
re-deals every trace. A frozen trigger event survives the rebuild: the pinned window is
|
||||
restated rather than re-framed on the live edge.</p></item>
|
||||
</list>
|
||||
</section>
|
||||
|
||||
<section id="example">
|
||||
<title>Example</title>
|
||||
<p>With three traces on row 1, pressing <key>l</key> shows 3 buttons (one <gui>×</gui>,
|
||||
two <gui>+</gui>). Clicking the <gui>+</gui> above row 1 gives 2 rows and 5 buttons, with
|
||||
the three traces now on row 2 — the layout probe reports exactly this as
|
||||
<code>rows=2 buttons=5 traces=[0:-|1:sine,ramp,noise]</code>.</p>
|
||||
</section>
|
||||
|
||||
</page>
|
||||
@@ -0,0 +1,110 @@
|
||||
<page xmlns="http://projectmallard.org/1.0/"
|
||||
type="topic" style="task"
|
||||
id="math">
|
||||
|
||||
<info>
|
||||
<link type="guide" xref="index#analysis"/>
|
||||
<link type="seealso" xref="signals"/>
|
||||
<desc>Building a derived channel from a graph of source nodes, operations and one output,
|
||||
and how the graph is evaluated.</desc>
|
||||
<credit type="author copyright">
|
||||
<name>Martino Ferrari</name>
|
||||
</credit>
|
||||
</info>
|
||||
|
||||
<title>Math channels</title>
|
||||
|
||||
<p>A math channel is a signal computed from other signals. You build it as a small graph —
|
||||
source nodes, operations, one output — and udpscope then treats the result as an ordinary
|
||||
channel.</p>
|
||||
|
||||
<section id="reach">
|
||||
<title>How to reach it</title>
|
||||
<steps>
|
||||
<item>
|
||||
<p>Press the math button in the toolbar, or <key>g</key>.</p>
|
||||
</item>
|
||||
<item>
|
||||
<p>The pane's <gui>+</gui> adds a channel, and opening one shows the graph editor:
|
||||
nodes as boxes, click a box to select it and edit its parameters beside the canvas.</p>
|
||||
</item>
|
||||
<item>
|
||||
<p>Inside the editor, <gui>add node</gui> is a menu button whose popover picks the op,
|
||||
so the node arrives as the operation you asked for.</p>
|
||||
</item>
|
||||
</steps>
|
||||
</section>
|
||||
|
||||
<section id="gestures">
|
||||
<title>Graph gestures</title>
|
||||
<p><gui>drag</gui> from an output port to an input port to wire them; drag a wired input
|
||||
port off its connection to detach it; click a wire and press <key>Delete</key> to remove
|
||||
it; drag a box to move it. <gui>Wheel</gui> zooms about the pointer. <gui>Drag the
|
||||
background</gui> to pan. <key>f</key> fits all nodes into view;
|
||||
<keyseq><key>Ctrl</key><key>0</key></keyseq> resets the view. <gui>tidy</gui>, the button
|
||||
beside <gui>fit</gui> in the editor, lays the graph out in topological columns and frames
|
||||
it. The orange dot marks the output node; click <gui>set as output</gui> to move it.</p>
|
||||
</section>
|
||||
|
||||
<section id="knowing">
|
||||
<title>Worth knowing</title>
|
||||
<list>
|
||||
<item><p>The graph is evaluated as an ordered list, so a node may only read ones
|
||||
<em>above</em> it. A wire drawn right to left re-sorts the list silently so the graph
|
||||
stays valid. A cycle is unrepresentable: the connection is refused rather than created,
|
||||
and the line beneath the form says why — a loop, or a node asked to read itself.</p></item>
|
||||
<item><p>A derived channel <em>is</em> a channel. It appears in the signal panel, can be
|
||||
plotted, measured, exported, and used as a source for another math channel.</p></item>
|
||||
<item><p>The math runs on the <em>stored</em> samples — decimated and averaged — so a
|
||||
cutoff you type in Hz means the rate the filter will actually run at. Change the window
|
||||
across a decimation boundary and the coefficients are rebuilt for the new rate.</p></item>
|
||||
<item><p>A source may only name an <em>acquired</em> channel. The stream's timestamp
|
||||
signal is not one: a graph sourced from it validates and then reads NaN for ever, so the
|
||||
dropdown does not offer it.</p></item>
|
||||
<item><p>The output defaults to the <em>last</em> node, and a node added to a linear
|
||||
graph becomes the output. Choosing an earlier node explicitly is respected.</p></item>
|
||||
<item><p>Every numeric field starts at its own identity value — gain 1, offset 0 — not
|
||||
at the bottom of its range.</p></item>
|
||||
</list>
|
||||
</section>
|
||||
|
||||
<section id="why">
|
||||
<title>Why an operation sometimes does nothing</title>
|
||||
<p>A graph's <em>output</em> is the node whose value becomes the channel, and it is drawn
|
||||
as the orange dot. A new graph is built source, then operation, and the last node added
|
||||
becomes the output — but a graph whose output is left on the source is valid and simply
|
||||
passes its input through, which is the usual reason a gain or a filter appears to have no
|
||||
effect. <gui>Set as output</gui> moves the dot.</p>
|
||||
|
||||
<p><gui>Apply</gui> is insensitive while the graph is incomplete. A line beneath the form
|
||||
says why: an input that is not connected, no output chosen, or a <em>channel</em> node
|
||||
naming a signal the scope is not acquiring. Only the channels being acquired are offered,
|
||||
because a source that is not acquired reads as no data for ever — and a graph that reads
|
||||
NaN is harder to diagnose than one that would not save.</p>
|
||||
|
||||
<p>The filters are real filters. A math channel's samples are produced as the stream
|
||||
arrives, once per stored slot, so a low pass carries the history a filter needs. A cutoff
|
||||
typed in hertz is against the <em>stored</em> rate, which is the wire rate until a wide
|
||||
window makes the buffer decimate — the status line's rate is that number, and the filters
|
||||
are rebuilt whenever it changes.</p>
|
||||
|
||||
<figure>
|
||||
<title>The math channel editor</title>
|
||||
<desc>Nodes as boxes on a canvas, the orange dot marking the output, and the selected
|
||||
node's parameters beside it.</desc>
|
||||
<media type="image" mime="image/png" src="figures/math-editor.png" width="938" height="420">
|
||||
<p>A sine source wired through a gain into a filter, with the output on the last
|
||||
node.</p>
|
||||
</media>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section id="example">
|
||||
<title>Example</title>
|
||||
<p><code>[sine] → [gain x2]</code> gives a channel reading <code>y=[-2, 2]</code>. A
|
||||
100 Hz low pass on the same source passes 50 Hz at 0.97 and 2 kHz at 0.002; a 1 kHz band
|
||||
pass with Q=2 passes 200 Hz at 0.05, 1 kHz at 0.95 and 4 kHz at 0.05. Each derived channel
|
||||
must hold exactly as many samples as the shared clock has time slots.</p>
|
||||
</section>
|
||||
|
||||
</page>
|
||||
@@ -0,0 +1,149 @@
|
||||
<page xmlns="http://projectmallard.org/1.0/"
|
||||
type="topic" style="task"
|
||||
id="measurements">
|
||||
|
||||
<info>
|
||||
<link type="guide" xref="index#analysis"/>
|
||||
<link type="seealso" xref="cursors"/>
|
||||
<desc>A measurement is a statistic of the visible window of one signal, with optional
|
||||
on-plot marks and a per-measurement statistics line.</desc>
|
||||
<credit type="author copyright">
|
||||
<name>Martino Ferrari</name>
|
||||
</credit>
|
||||
</info>
|
||||
|
||||
<title>Measurements</title>
|
||||
|
||||
<p>A measurement is a statistic of the <em>visible window</em> of one signal, recomputed as
|
||||
that window moves. The pane lists them with their values; each can also draw its result on
|
||||
the plot.</p>
|
||||
|
||||
<section id="reach">
|
||||
<title>How to reach it</title>
|
||||
<steps>
|
||||
<item>
|
||||
<p>Press the measurements button in the toolbar, or <key>m</key>.</p>
|
||||
</item>
|
||||
<item>
|
||||
<p>The pane's <gui>+</gui> adds one; clicking a row opens the dialog where the signal,
|
||||
the type, the threshold share or shares and the on-plot marks are set, with
|
||||
<gui>Save</gui> and <gui>Delete</gui>.</p>
|
||||
</item>
|
||||
</steps>
|
||||
</section>
|
||||
|
||||
<section id="knowing">
|
||||
<title>Worth knowing</title>
|
||||
<list>
|
||||
<item><p>The types are average, top, bottom, min, max, peak to peak, amplitude, above,
|
||||
below, between, pulse duration, period and frequency. <gui>top</gui> and <gui>bottom</gui> are the waveform's
|
||||
<em>flat</em> high and low levels: the level the signal dwells at, taken from the densest
|
||||
band of the window on each side of the split. A band-limited edge overshoots and rings —
|
||||
a nominal 2 V square reaches about ±1.18 at its edges — and a ripple or an overshoot is
|
||||
therefore <em>not</em> what these read. <gui>amplitude</gui> is the delta between those two
|
||||
levels, and <gui>peak to peak</gui> is the swing between the extremes (so on a rippled
|
||||
square <gui>amplitude</gui> reads 2 V where <gui>peak to peak</gui> reads 2.36 V).</p></item>
|
||||
<item><p><gui>above</gui>, <gui>below</gui> and <gui>between</gui> are the opposite kind of
|
||||
number: the plain <em>mean of the samples</em> above a share, below it, or between two
|
||||
shares. That is a different question from <gui>top</gui>/<gui>bottom</gui> even about the
|
||||
same split, and it is the older one — on a unit sine <gui>above 50%</gui> reads 0.64 V
|
||||
(2/π, the mean of a half cycle) where <gui>top</gui> reads 0.96 V (near the peak, which is
|
||||
where a sine dwells most), and on a band-limited square <gui>above 50%</gui> reads 0.99 V
|
||||
where <gui>top</gui> reads the plateau, 1.00 V: the edge's ring is inside the first and not
|
||||
inside the second. <gui>between</gui> takes <em>two</em> shares — <gui>from %</gui> and
|
||||
<gui>to %</gui> — and reports the mean of the samples inside that band, which no single
|
||||
split can answer; the order you type them in does not matter, since a band between 80 % and
|
||||
20 % is the band between 20 % and 80 %.</p></item>
|
||||
<item><p>A mean over a side carries whatever is on that side, so these three are the right
|
||||
numbers when you want the <em>energy</em> or the <em>offset</em> of part of the waveform,
|
||||
and the wrong ones when you want the level a plateau sits at. Like every other measurement,
|
||||
one with nothing to measure reads <code>--</code> rather than a made-up number: a share at
|
||||
100 % has no sample above it, and a band can hold none at all — on an ideal
|
||||
(non-ringing) square every sample is exactly ±1, so a band between 50 % and 75 % is
|
||||
empty.</p></item>
|
||||
<item><p><gui>top</gui> and <gui>bottom</gui> also read <em>near the peak</em> on a
|
||||
waveform that has no flat part, which is the honest reading rather than a failure: a unit
|
||||
sine has no plateau, so the densest band is at its peak and these read about ±0.96 — not
|
||||
the 0.64 a mean over each half would give, and not ±1 either. Only a level the signal
|
||||
actually dwells at is a level.</p></item>
|
||||
<item><p>The <em>split</em> says which side a level is read from, so any share between the
|
||||
two levels reads the same number; it is not a boundary the value depends on. The one case
|
||||
where it is read directly is a window the scope cannot summarize by level — a window
|
||||
shorter than a level's own resolution, or one read through <gui>hf</gui> — where
|
||||
<gui>top</gui>/<gui>bottom</gui> fall back to the mean of each side of the split and
|
||||
<gui>amplitude</gui> to the window's peak to peak.</p></item>
|
||||
<item><p>The on-plot marks come in two kinds and the mix is the reading: a <em>level</em>
|
||||
mark is a ruler at a value, a <em>time</em> mark is a marker at a position. An average
|
||||
draws one level mark; a max draws a level mark <em>and</em> a time mark at the first
|
||||
sample that reached it; <gui>peak to peak</gui> draws a ruler at each extreme and
|
||||
<gui>amplitude</gui> a ruler at each of its two flat levels — the pair is the span, so
|
||||
the two rulers <em>are</em> the measurement.</p></item>
|
||||
<item><p>Marks cannot be dragged. A measurement mark says what the measurement measured,
|
||||
so it is created unselectable — as is the trigger marker.</p></item>
|
||||
<item><p>A pass is throttled to a cadence rather than run every frame, so a ruler over a
|
||||
live view is <em>stepped</em>. The number and the ruler come from the same pass, so they
|
||||
always agree.</p></item>
|
||||
<item><p>An invalid pass keeps its <em>level</em> marks and drops its <em>time</em>
|
||||
marks. A level is a property of the signal and outlives the window; a position inside
|
||||
the window does not. That is what stops the rulers blinking off at a mode change.</p></item>
|
||||
<item><p>A crossing is counted with <em>hysteresis</em>, not on a bare level: the
|
||||
thresholds are a tenth of the window's peak to peak either side of the share, and an
|
||||
edge is counted only once the signal has passed the far one. Period, frequency and pulse
|
||||
duration all depend on it. The mid level is exactly where a waveform moves fastest, so
|
||||
any interferer faster than the fundamental recrosses a single level several times per
|
||||
real crossing — a 5 Hz sine with a 500 Hz tone at a tenth of the amplitude reads 39 Hz
|
||||
without the band and 5 Hz with it. The band holds to an interferer a third of the
|
||||
waveform's own size.</p></item>
|
||||
<item><p>The band is clamped to stay inside the data, which is what keeps a threshold set
|
||||
near an extreme usable: a fixed band there would sit above every sample and the
|
||||
measurement would read nothing. A share near an extreme still leaves the band little
|
||||
room, so it rejects less — which is one reason period and frequency offer no share to
|
||||
set.</p></item>
|
||||
<item><p>A window with too few crossings reads <code>invalid</code> / <code>--</code>
|
||||
rather than a plausible invented number. Frequency over a window holding one edge is the
|
||||
usual case.</p></item>
|
||||
<item><p><gui>frequency</gui> is reported in Hz and <gui>period</gui>/<gui>pulse
|
||||
duration</gui> in seconds; every other type carries the channel's own unit. A value is
|
||||
written with a <em>metric prefix</em> so its digits sit in <code>1..999</code> —
|
||||
<code>636.62 mV</code>, not <code>0.64 V</code> — including when the stream declares no
|
||||
unit at all, which is always the case for a math channel: there the prefix goes tight
|
||||
against the number (<code>636.62m</code>), since two decimals of an unprefixed small
|
||||
value is <code>0.00</code>.</p></item>
|
||||
<item><p>Cost is bounded by reading the summary pyramid the producer already maintains,
|
||||
not the raw window: a bucket's sum is a sum, so the mean, the extremes and a level's own
|
||||
mean are exact. <gui>top</gui>, <gui>bottom</gui> and <gui>amplitude</gui> read the
|
||||
<em>density</em> of the window out of that same summary — which is what tells a flat level
|
||||
from a ripple — so those three cost a second read of the summary and never of a sample, and
|
||||
<gui>between</gui>'s second share is one more level in the same summary walk rather than a
|
||||
walk of its own.</p></item>
|
||||
<item><p><gui>Statistics</gui> adds a second line to every measurement showing
|
||||
<code>n</code>, the mean, the standard deviation and the range. Each observation is one
|
||||
<em>recompute</em> of the pane, so <code>n</code> counts windows measured, not samples —
|
||||
a mean frequency over many windows is what that claims to be. <gui>Reset</gui> clears
|
||||
every accumulator and starts fresh. A change of display window, a CONFIG or a
|
||||
measurement edit also resets; a pause, a resume, a zoom, a pan and the toggle itself do
|
||||
not, so turning the line off to read the plot and back on again keeps the history.</p></item>
|
||||
</list>
|
||||
</section>
|
||||
|
||||
<section id="example">
|
||||
<title>Example</title>
|
||||
<p>On a 1 Hz ±1 sine: <code>average=0</code>, <code>top=0.957988</code>,
|
||||
<code>bottom=-0.957988</code> (the flat levels, near the peaks — a sine has no plateau),
|
||||
<code>max=1</code>, <code>min=-1</code>,
|
||||
<code>peak to peak=2</code>, <code>amplitude=1.915983</code>, <code>pulse duration=0.5 s</code>
|
||||
(duty 0.5), <code>period=1 s</code>, <code>frequency=1 Hz</code>. On the same stream's
|
||||
band-limited <code>square</code>, whose edges ring out to ±1.179: <code>top=0.999983</code>,
|
||||
<code>bottom=-0.999983</code>, <code>amplitude=1.999966</code> against
|
||||
<code>peak to peak=2.358701</code> — the levels are the plateau's and the extremes are the
|
||||
edges'. Over a 2 M-sample window those passes read about 0.1% of the samples.</p>
|
||||
<p>The mean-over-a-side types on the same two windows: on the sine
|
||||
<code>above 50=0.636620</code>, <code>below 50=-0.636620</code> and
|
||||
<code>between 50 75=0.255873</code>; on the rippled square <code>above 50=0.986496</code> and
|
||||
<code>between 50 75=0.302744</code>, against <code>1.000000</code> for the same
|
||||
<code>above 50</code> on an ideal (non-ringing) square. A <code>ramp</code>, whose density is
|
||||
uniform, is the shape that names what a band mean <em>is</em>: <code>above 50=0.500000</code>
|
||||
and <code>between 50 75=0.250000</code>, each band's own centre.</p>
|
||||
</section>
|
||||
|
||||
</page>
|
||||
@@ -0,0 +1,104 @@
|
||||
<page xmlns="http://projectmallard.org/1.0/"
|
||||
type="topic" style="task"
|
||||
id="navigate">
|
||||
|
||||
<info>
|
||||
<link type="guide" xref="index#viewing"/>
|
||||
<link type="seealso" xref="window"/>
|
||||
<desc>Panning, zooming and fitting the plot with the mouse and the keyboard, and what a
|
||||
pinned view refuses to do.</desc>
|
||||
<credit type="author copyright">
|
||||
<name>Martino Ferrari</name>
|
||||
</credit>
|
||||
</info>
|
||||
|
||||
<title>Navigating the plot</title>
|
||||
|
||||
<p>The plot pans and zooms with the mouse and with the keyboard. Every gesture acts on the
|
||||
row the pointer is over, and because the rows share one time axis, an x change moves all of
|
||||
them.</p>
|
||||
|
||||
<section id="reach">
|
||||
<title>How to reach it</title>
|
||||
<terms>
|
||||
<item>
|
||||
<title>Wheel</title>
|
||||
<p><gui>Wheel</gui> zooms both axes · <gui>Shift</gui>+<gui>Wheel</gui> zooms x only ·
|
||||
<gui>Ctrl</gui>+<gui>Wheel</gui> zooms y only.</p>
|
||||
</item>
|
||||
<item>
|
||||
<title>Drag</title>
|
||||
<p><gui>Drag</gui> pans · <gui>Shift</gui>+<gui>Drag</gui> is a box zoom on both axes ·
|
||||
<gui>Ctrl</gui>+<gui>Drag</gui> on x only · <gui>Alt</gui>+<gui>Drag</gui> on values
|
||||
only. The area being chosen is framed while you drag it, so you can see what the
|
||||
release will apply.</p>
|
||||
</item>
|
||||
<item>
|
||||
<title>Keyboard</title>
|
||||
<p><key>+</key> and <key>-</key> zoom both axes; <key>Ctrl</key> with them restricts
|
||||
to x, <key>Alt</key> to y. <gui>Arrow keys</gui> pan, <key>f</key> fits,
|
||||
<key>Escape</key> cancels a gesture in progress.</p>
|
||||
</item>
|
||||
</terms>
|
||||
|
||||
<p>The full table is in the shortcut overview (<keyseq><key>Ctrl</key><key>/</key></keyseq>).</p>
|
||||
</section>
|
||||
|
||||
<section id="knowing">
|
||||
<title>Worth knowing</title>
|
||||
<list>
|
||||
<item><p>The keyboard zoom's modifiers are <em>not</em> the wheel's, and that is
|
||||
deliberate: on common layouts <key>+</key> <em>is</em> <keyseq><key>Shift</key><key>=</key></keyseq>,
|
||||
so a bare zoom-in already arrives with Shift set and Shift could not mean anything.
|
||||
Ctrl/Alt mirror the box-zoom drags instead.</p></item>
|
||||
<item><p>A <em>pinned</em> view is navigable within its own data but cannot leave it.
|
||||
While an event is held, a capture is filling, or the view is paused, a zoom <em>in</em>
|
||||
works normally — "not outside the event" is not "not readable" — but a pan or a zoom out
|
||||
is clamped to the data.</p></item>
|
||||
<item><p>Nothing you do on the plot changes the <em>time window</em> — only the header
|
||||
control does. A zoom or a pan moves the view inside the window; <key>f</key> (and
|
||||
<gui>Fit X axis</gui>) is the <em>undo</em> of a zoom, so a 2 s window zoomed into reads
|
||||
2 s again. The window is more than the axis: it is also the trigger's event span and how
|
||||
the samples are stored, so a wheel notch that redefined it would re-plan the buffer and
|
||||
drop your history.</p></item>
|
||||
<item><p>The axis does not scroll — the <em>data</em> does. A live axis is a fixed
|
||||
<code>[-window .. 0]</code> with the newest sample at 0, so the labels stand still and
|
||||
the trace rolls under them. The cursors stand still with the labels (they are positions
|
||||
on screen, so a cursor you placed stays where you put it, over newer data each frame),
|
||||
while a measurement's marks travel with the sample they measured and leave the view when
|
||||
it does.</p></item>
|
||||
<item><p>A live view is clamped to the window you asked for, not to what the ring
|
||||
happens to hold. That is why a buffer that has only just started filling leaves its
|
||||
left-hand side empty instead of squeezing the window down to the first few
|
||||
samples.</p></item>
|
||||
<item><p>Zooming hard into a held capture is exact. The renderer finds the visible
|
||||
samples by binary search over the real timestamps, never over a nominal sample grid, so
|
||||
a capture whose clock wandered does not drift away from its axis as you zoom.</p></item>
|
||||
<item><p>A trace holding far more samples than the plot has pixels is drawn <em>one
|
||||
pixel column at a time</em>: each column is a single vertical line from the lowest to
|
||||
the highest value that falls in it. Drawn as a polyline instead, such a window puts
|
||||
three or four near-vertical lines in every column at slightly different sub-pixel
|
||||
positions, and how they overlap beats against the pixel grid — which reads as bands of
|
||||
light and dark that <em>crawl</em> as you pan. One line per column removes the beat, and
|
||||
it is also much faster: a 1 MS/s view costs a couple of thousand line segments a frame
|
||||
instead of a couple of hundred thousand.</p></item>
|
||||
<item><p>Those columns are <em>graded by brightness</em>, the way a digital-phosphor
|
||||
scope reads: bright where the signal spent its time, faint where it only passed through.
|
||||
A column's highest and lowest value are the same whether the samples in it are spread
|
||||
evenly or piled at its two ends, so the grading is what tells the two apart — the flat
|
||||
parts of a square wave come out solid and the edges between them faint. It is read from
|
||||
a finer division of each column rather than from the line that is drawn, and it costs
|
||||
about a millisecond a frame.</p></item>
|
||||
</list>
|
||||
</section>
|
||||
|
||||
<section id="example">
|
||||
<title>Example</title>
|
||||
<p>Pause a live view, then press <keyseq><key>Ctrl</key><key>+</key></keyseq>: x narrows
|
||||
and y does not move. Press <keyseq><key>Alt</key><key>+</key></keyseq>: y narrows and x
|
||||
does not. Eight doublings into a held 2 s capture, the trace still reaches both margins —
|
||||
the zoom probe reports <code>gap_px</code> and <code>end_px</code> within a pixel or two
|
||||
at every step.</p>
|
||||
</section>
|
||||
|
||||
</page>
|
||||
@@ -0,0 +1,192 @@
|
||||
<page xmlns="http://projectmallard.org/1.0/"
|
||||
type="topic" style="task"
|
||||
id="overview">
|
||||
|
||||
<info>
|
||||
<link type="guide" xref="index#main"/>
|
||||
<desc>What udpscope plots, the four parts of the window, and one pass through the
|
||||
application from connecting to saving.</desc>
|
||||
<credit type="author copyright">
|
||||
<name>Martino Ferrari</name>
|
||||
</credit>
|
||||
</info>
|
||||
|
||||
<title>Overview</title>
|
||||
|
||||
<section id="what">
|
||||
<title>What udpscope is</title>
|
||||
<p>udpscope plots a live UDPStreamer source. One window holds up to eight rows of plots,
|
||||
each with up to four traces, and every row shares one time axis, so the whole figure
|
||||
scrolls together. Acquisition runs on its own thread and writes a ring buffer sized from
|
||||
<cmd>--buffer-mb</cmd>; the drawing thread reads whatever that ring holds when a frame
|
||||
starts, which is why a trace never makes the window stutter.</p>
|
||||
|
||||
<p>The stream is the clock. Every interval you read on screen is measured between
|
||||
timestamps the source sent, never your machine's, so a stalled source freezes the view
|
||||
instead of scrolling it — and a source whose clock steps still gets the window you asked
|
||||
for.</p>
|
||||
|
||||
<p>The time axis is <em>relative to now</em>. While streaming it is a fixed
|
||||
<code>[-window .. 0]</code> with the newest sample at 0, so x reads as an age:
|
||||
<code>-1.5s</code> is a sample a second and a half old. In trigger mode 0 is the event's
|
||||
edge instead, and a paused view keeps the ages it was frozen with. The source's own clock
|
||||
is still there when you need it — an export writes it as a second column.</p>
|
||||
|
||||
<figure>
|
||||
<title>The udpscope window</title>
|
||||
<desc>The header bar above the plots, the side panel on the right, and the status bar
|
||||
along the bottom.</desc>
|
||||
<media type="image" mime="image/png" src="figures/main-window.png" width="1280" height="800">
|
||||
<p>One figure of three rows with four signals enabled, the Measure tab open in the
|
||||
side panel, and the status bar reading <gui>live</gui>.</p>
|
||||
</media>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section id="reach">
|
||||
<title>How to reach it</title>
|
||||
<p>Start the app with a source
|
||||
(<cmd>udpscope --server 127.0.0.1 --port 8000</cmd>), or start it with no
|
||||
<cmd>--server</cmd> and pick one from the chooser, which lists the sessions you have
|
||||
opened before. A unicast source serves one client at a time: if a second viewer connects,
|
||||
they take turns and the status bar says so. To watch one source from several instances,
|
||||
use <cmd>--multicast</cmd> with the group and <cmd>--interface</cmd> with the local
|
||||
address.</p>
|
||||
|
||||
<p>The signals arrive <em>disabled</em>. Nothing is acquired until you turn one on in the
|
||||
signal list, and enabling is what starts acquisition — so a freshly opened scope with an
|
||||
idle side panel is working correctly, not failing to connect.</p>
|
||||
|
||||
<note style="tip">
|
||||
<p>See <link xref="source">Choosing a source</link> for the chooser and the multicast
|
||||
options in full.</p>
|
||||
</note>
|
||||
</section>
|
||||
|
||||
<section id="window">
|
||||
<title>The window</title>
|
||||
<terms>
|
||||
<item>
|
||||
<title>The header bar</title>
|
||||
<p>Left to right: the time window (<gui>2 s</gui>), the trigger and the pause, the
|
||||
events, cursors and side-panel switches, and the menu, which holds
|
||||
<gui>Fit to data</gui>, the <gui>Layout editor</gui>, <gui>Export</gui>,
|
||||
<gui>Keyboard shortcuts</gui>, <gui>Help</gui> and <gui>About</gui>.</p>
|
||||
</item>
|
||||
<item>
|
||||
<title>The side panel</title>
|
||||
<p>Two tabs. <gui>Measure</gui> holds the cursors readout and the measurements
|
||||
derived from it; <gui>Signals</gui> holds the trigger and the channel list, where a
|
||||
math channel is a row of that one list under a <gui>MATH</gui> heading whose
|
||||
<gui>+</gui> creates one. One row per channel: its colour, an enable switch, its name
|
||||
and its unit. Channels are dragged from here onto a plot row.</p>
|
||||
</item>
|
||||
<item>
|
||||
<title>The plot</title>
|
||||
<p>Up to eight rows, each with its own y axis and up to four traces, all on one shared
|
||||
time axis. Right-click a row for its own menu (fit, add a signal, remove a trace).</p>
|
||||
</item>
|
||||
<item>
|
||||
<title>The status bar</title>
|
||||
<p>Directly above the plot's bottom edge — the state word, the stored sample rate and
|
||||
the window, and the ⓘ beside it for packets, gaps and the buffer's own figures. The
|
||||
second screenshot below is what a held capture looks like: <gui>event held</gui>, the
|
||||
axis pinned to the event, and the trigger marker at the edge.</p>
|
||||
</item>
|
||||
</terms>
|
||||
|
||||
<figure>
|
||||
<title>A held capture</title>
|
||||
<desc>The trigger has latched, the state reads <gui>event held</gui>, and the axis is
|
||||
pinned to the event rather than to the live edge.</desc>
|
||||
<media type="image" mime="image/png" src="figures/trigger-capture.png" width="1280" height="800">
|
||||
<p>The trigger marker sits at x = 0 and the pre-trigger share of the window is drawn
|
||||
to its left.</p>
|
||||
</media>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section id="first">
|
||||
<title>The first five minutes</title>
|
||||
<steps>
|
||||
<item>
|
||||
<p><em>Connect.</em> With no <cmd>--server</cmd>, pick a stored session or type a host
|
||||
and port.</p>
|
||||
</item>
|
||||
<item>
|
||||
<p><em>Enable a signal</em> in the signal list. The plot stays empty for the first
|
||||
fraction of a second while the first window fills — the buffer holds only what has
|
||||
arrived.</p>
|
||||
</item>
|
||||
<item>
|
||||
<p><em>Drag it onto a row.</em> Drop a second signal on another row, or the same
|
||||
signal again for a second view of it. The colour is per signal, so it is the same one
|
||||
wherever it appears.</p>
|
||||
</item>
|
||||
<item>
|
||||
<p><em>Set the window</em> with the control in the header bar: type a number, pick a
|
||||
unit, or step a decade. The trigger's event window is a share of this span, so a
|
||||
window change moves it.</p>
|
||||
</item>
|
||||
<item>
|
||||
<p><em>Pause</em> (<key>space</key>) to look at a still picture; the producer keeps
|
||||
filling the buffer behind it, and resuming shows what arrived meanwhile rather than a
|
||||
hole.</p>
|
||||
</item>
|
||||
<item>
|
||||
<p><em>Arm the trigger</em> (<key>a</key>) and wait for a latch: the view freezes on
|
||||
one event, and you can zoom in on it. Disarming mid-capture keeps what the capture
|
||||
got.</p>
|
||||
</item>
|
||||
<item>
|
||||
<p><em>Measure it</em> — the measurements pane computes a statistic of the visible
|
||||
window, and <gui>show on plot</gui> marks it as a ruler or a pair of markers.</p>
|
||||
</item>
|
||||
<item>
|
||||
<p><em>Save the data</em> with <gui>Export visible range</gui>, which writes the
|
||||
plotted signals over the window at the stored rate to <file>.npy</file>,
|
||||
<file>.csv</file> or <file>.mf3</file> (MDF 3.30).</p>
|
||||
</item>
|
||||
</steps>
|
||||
</section>
|
||||
|
||||
<section id="knowing">
|
||||
<title>Worth knowing</title>
|
||||
<list>
|
||||
<item><p>A signal nobody enables costs nothing — no buffer, no socket work, no
|
||||
measurements.</p></item>
|
||||
<item><p>The window remembers itself. Rows, traces, the time window, calibration, which
|
||||
signals were enabled, the math channels with their graphs, and the measurements are
|
||||
written per host and port, and reloaded the next time you open that source.</p></item>
|
||||
<item><p>A very wide window on a fast stream is drawn from averaged groups of samples,
|
||||
so the rate in the status bar falls: that is the anti-alias filter, not a fault —
|
||||
striding would fold a fast signal back as a slow one, which looks entirely plausible on
|
||||
screen.</p></item>
|
||||
<item><p>Every instrument the app computed — the trigger marker, the measurement marks
|
||||
— is locked to the pointer's reach. The cursors are yours to drag; those are not.</p></item>
|
||||
</list>
|
||||
</section>
|
||||
|
||||
<section id="installing">
|
||||
<title>Installing this manual</title>
|
||||
<p>The manual is a help set the desktop viewer reads off disk, so <key>F1</key> opens it only
|
||||
once a copy sits where <cmd>help:</cmd> resolves. Either of two targets installs it on its own,
|
||||
without building the application, and each prints where it wrote. Prefer the system-wide one:
|
||||
it is the copy every viewer can read, and the reason is worth knowing before you pick the
|
||||
other:</p>
|
||||
<list>
|
||||
<item><p>For a system-wide or packaged install:
|
||||
<cmd>cmake --build build --target install-help</cmd> writes it under the configured
|
||||
<cmd>CMAKE_INSTALL_PREFIX</cmd>, so point that at <file>/usr</file>
|
||||
(<cmd>cmake -DCMAKE_INSTALL_PREFIX=/usr ..</cmd>) and wrap the target in
|
||||
<cmd>sudo</cmd>. A viewer reads its figures as separate files, and its sandbox exposes the
|
||||
system help directories, so this copy always shows them.</p></item>
|
||||
<item><p>For your own account, needing no root:
|
||||
<cmd>cmake --build build --target install-help-user</cmd> writes it under
|
||||
<file>$XDG_DATA_HOME</file> (default <file>~/.local/share</file>). Be aware that the same
|
||||
sandbox does <em>not</em> expose the user's data directory: those pages open, but a viewer
|
||||
such as Yelp shows them without their figures.</p></item>
|
||||
</list>
|
||||
</section>
|
||||
|
||||
</page>
|
||||
@@ -0,0 +1,61 @@
|
||||
<page xmlns="http://projectmallard.org/1.0/"
|
||||
type="topic" style="task"
|
||||
id="pause">
|
||||
|
||||
<info>
|
||||
<link type="guide" xref="index#viewing"/>
|
||||
<link type="seealso" xref="trigger"/>
|
||||
<desc>Pausing freezes the display without stopping the stream, and the status bar says how
|
||||
long you have been paused.</desc>
|
||||
<credit type="author copyright">
|
||||
<name>Martino Ferrari</name>
|
||||
</credit>
|
||||
</info>
|
||||
|
||||
<title>Pause and play</title>
|
||||
|
||||
<p>Pause freezes the <em>display</em>, not the acquisition. The producer keeps filling the
|
||||
ring at full rate; the view binds an immutable snapshot of the window that was on screen,
|
||||
and the axis is pinned to it.</p>
|
||||
|
||||
<section id="reach">
|
||||
<title>How to reach it</title>
|
||||
<steps>
|
||||
<item>
|
||||
<p>Press the pause button in the toolbar, or <key>Space</key>.</p>
|
||||
</item>
|
||||
<item>
|
||||
<p>Press it again to resume, which rebinds the live ring and puts the window back on
|
||||
the newest data.</p>
|
||||
</item>
|
||||
</steps>
|
||||
</section>
|
||||
|
||||
<section id="knowing">
|
||||
<title>Worth knowing</title>
|
||||
<list>
|
||||
<item><p>Nothing is lost while paused. Because the producer never stops, resuming shows
|
||||
current data with no hole in the history behind it.</p></item>
|
||||
<item><p>The axis is pinned to the <em>snapshot's own stamps</em>, not to the window that
|
||||
was requested. A window reaching back past the oldest retained sample yields a shorter
|
||||
snapshot, and the pinned axis is that shorter span — so you see a narrower view rather
|
||||
than an empty margin.</p></item>
|
||||
<item><p>A paused view is pinned exactly as a held trigger event is: zoom in freely, but
|
||||
a pan or a zoom out is clamped to the snapshot's data.</p></item>
|
||||
<item><p>Pausing is the easiest way to measure a layout or read a cursor precisely,
|
||||
because nothing under the pointer moves between frames.</p></item>
|
||||
<item><p>The status bar says how long you have been paused — wall time, since the stream
|
||||
itself never stopped. It is there so a frozen picture cannot be mistaken for a live one
|
||||
that went quiet.</p></item>
|
||||
</list>
|
||||
</section>
|
||||
|
||||
<section id="example">
|
||||
<title>Example</title>
|
||||
<p>Pause with a 2 s window over a ring holding 1.4 s: the frozen axis reads
|
||||
<code>[-1.40 .. 0.00]</code> rather than <code>[-2.00 .. 0.00]</code> with 0.6 s of blank.
|
||||
The pause probe reports <code>ring_produced</code> still growing across the pause and
|
||||
<code>event_size</code> non-zero exactly while paused.</p>
|
||||
</section>
|
||||
|
||||
</page>
|
||||
@@ -0,0 +1,93 @@
|
||||
<page xmlns="http://projectmallard.org/1.0/"
|
||||
type="topic" style="task"
|
||||
id="sessions">
|
||||
|
||||
<info>
|
||||
<link type="guide" xref="index#output"/>
|
||||
<link type="seealso" xref="source"/>
|
||||
<desc>Per-source configuration files: where they live, what they persist, and how the
|
||||
chooser and the Configurations dialog use them.</desc>
|
||||
<credit type="author copyright">
|
||||
<name>Martino Ferrari</name>
|
||||
</credit>
|
||||
</info>
|
||||
|
||||
<title>Sessions and settings</title>
|
||||
|
||||
<p>udpscope stores named sessions — <em>configurations</em> — per source, so reopening a
|
||||
source gives you the scope you closed, and one source can hold several arrangements for
|
||||
different experiments. There is no global preferences file: the session <em>is</em> the
|
||||
settings.</p>
|
||||
|
||||
<section id="reach">
|
||||
<title>How to reach it</title>
|
||||
<p>Nothing to open — the file is written as you work. Its path is in the About dialog, and
|
||||
the directory is <file>$XDG_CONFIG_HOME/udpscope</file>, or <file>~/.config/udpscope</file>
|
||||
when that is unset. Each source has a directory <file><host>_<port>/</file>
|
||||
holding one <file><name>.toml</file> per configuration and an <file>active</file>
|
||||
file naming the one it is on.</p>
|
||||
</section>
|
||||
|
||||
<section id="configs">
|
||||
<title>Configurations</title>
|
||||
<p><gui>Configurations…</gui> in the menu lists the running source's configurations: click
|
||||
a row to open it, <gui>×</gui> to delete it, or type a name and a note and press
|
||||
<gui>Save this session</gui> to keep the current arrangement under a new one. Opening a
|
||||
configuration saves the current session first, so switching never loses what it switches
|
||||
from.</p>
|
||||
|
||||
<p>The startup chooser (start with no <cmd>--server</cmd>) lists one row per configuration,
|
||||
with its note and its settings, so two arrangements of one source are told apart before
|
||||
they open.</p>
|
||||
|
||||
<p>A configuration also carries the settings that can only be decided when the buffers are
|
||||
allocated: the <gui>buffer size</gui> (<cmd>--buffer-mb</cmd>), relative time, and the
|
||||
packet capture's ring size. Opening a configuration from the chooser applies all three;
|
||||
switching inside a running app applies the rest and says in the status line that these take
|
||||
effect on the next start.</p>
|
||||
|
||||
<p>The chooser's form sets the first two for a new host, and its pencil button edits them
|
||||
on an existing configuration — along with the name and the note. Renaming a configuration,
|
||||
or editing the address it points at, moves it: the old file is removed rather than left
|
||||
behind.</p>
|
||||
</section>
|
||||
|
||||
<section id="knowing">
|
||||
<title>Worth knowing</title>
|
||||
<list>
|
||||
<item><p>What a configuration persists: the time window, the rows and each row's slot
|
||||
signals, the calibration, the trace colours, which signals were enabled, the math
|
||||
channels with their graphs, the measurements pane — and the startup settings: buffer
|
||||
size, relative time and the packet capture's ring size.</p></item>
|
||||
<item><p>Signal identity is the <em>name</em> throughout, because a new CONFIG renumbers
|
||||
the channels.</p></item>
|
||||
<item><p>A file written by a run against a source whose streamer was down would name no
|
||||
signals at all, so such a run does <em>not</em> write: a configured session is never
|
||||
overwritten by a run that configured nothing. (The chooser hides such a file for the same
|
||||
reason.)</p></item>
|
||||
<item><p>The multicast group and the interface live <em>inside</em> the file, because its
|
||||
name cannot carry them. A file from an older build has neither key and loads as unicast
|
||||
on the default route.</p></item>
|
||||
<item><p>The chooser lists only configurations that name at least one signal, which is
|
||||
what keeps it the sessions you configured rather than every port you have ever tested
|
||||
against.</p></item>
|
||||
<item><p>Deleting a configuration is a supported way to start over; the chooser's
|
||||
<gui>×</gui> and the dialog's do exactly that.</p></item>
|
||||
<item><p>A session from before configurations existed is imported once as the
|
||||
configuration <code>default</code>, and the old file is kept beside the directory as
|
||||
<file><host>_<port>.toml.imported</file>.</p></item>
|
||||
</list>
|
||||
</section>
|
||||
|
||||
<section id="example">
|
||||
<title>Example</title>
|
||||
<p>Connect to <code>127.0.0.1:8000</code>, drop three signals on row 1, set a 2 s window,
|
||||
and save the session as <code>bench</code> with a note.
|
||||
<file>~/.config/udpscope/127.0.0.1_8000/bench.toml</file> now holds
|
||||
<code>window_s=2 comment="…"</code> with
|
||||
<code>signals = ["sine", "ramp", "noise", ""]</code>, and the next run reports
|
||||
<code>layout loaded from …/127.0.0.1_8000/bench.toml</code> with those signals already
|
||||
acquired.</p>
|
||||
</section>
|
||||
|
||||
</page>
|
||||
@@ -0,0 +1,70 @@
|
||||
<page xmlns="http://projectmallard.org/1.0/"
|
||||
type="topic" style="task"
|
||||
id="signals">
|
||||
|
||||
<info>
|
||||
<link type="guide" xref="index#main"/>
|
||||
<link type="seealso" xref="layout"/>
|
||||
<desc>How the source's CONFIG becomes channels, what enabling a signal starts, and how a
|
||||
signal's scale and offset are calibrated.</desc>
|
||||
<credit type="author copyright">
|
||||
<name>Martino Ferrari</name>
|
||||
</credit>
|
||||
</info>
|
||||
|
||||
<title>Signals and calibration</title>
|
||||
|
||||
<p>The source's CONFIG message describes its signals: a name, a unit and a type. udpscope
|
||||
turns each enabled signal into a <em>channel</em> — a ring of values beside the shared time
|
||||
ring — and a channel is what a trace plots. Calibration is a scale and an offset applied at
|
||||
ingest.</p>
|
||||
|
||||
<section id="reach">
|
||||
<title>How to reach it</title>
|
||||
<steps>
|
||||
<item>
|
||||
<p>Open the side panel (<key>s</key>), which lists every signal with a switch.</p>
|
||||
</item>
|
||||
<item>
|
||||
<p>The <gui>Signal list</gui> item in the overflow menu opens the editor where the
|
||||
unit and the calibration live.</p>
|
||||
</item>
|
||||
</steps>
|
||||
</section>
|
||||
|
||||
<section id="knowing">
|
||||
<title>Worth knowing</title>
|
||||
<list>
|
||||
<item><p>Signals start disabled, and enabling one is what starts acquiring it. A trigger
|
||||
on a signal nobody acquires can never fire, so arming a trigger enables its signal for
|
||||
you.</p></item>
|
||||
<item><p><gui>Calibrate</gui> in a signal's row menu opens the gain and offset it is
|
||||
<em>currently</em> drawn through, so a trace already scaled by a gain of 2 shows 2 —
|
||||
pressing <gui>Apply</gui> confirms it rather than resetting it. A signal nobody
|
||||
calibrated shows the identity, 1 and 0.</p></item>
|
||||
<item><p>Calibration is applied <em>at ingest</em>, so changing it re-plans the buffer
|
||||
and drops the history. So does enabling or disabling a signal, and so does a new
|
||||
CONFIG. An empty plot for a second after one of those is the ring refilling, not a
|
||||
fault.</p></item>
|
||||
<item><p>The stream's first signal is usually its <em>timestamp</em>, and that one is
|
||||
never acquired. It cannot be plotted, measured or used as a math source, and udpscope
|
||||
refuses to put it in a row rather than binding a trace that would read nothing for
|
||||
ever.</p></item>
|
||||
<item><p>A signal's colour follows the <em>signal</em>, not the slot, so the same signal
|
||||
on two rows is one colour and the side panel's chip identifies it unambiguously. A
|
||||
signal on no row gets a grey chip rather than none, so the panel's rows keep their
|
||||
height.</p></item>
|
||||
<item><p><em>Click a coloured chip to change that signal's colour.</em> A grey chip
|
||||
names no trace, so it is not clickable. The colour is saved with the session;
|
||||
<gui>Default colour</gui> in the signal's ⋯ menu gives the palette entry back.</p></item>
|
||||
</list>
|
||||
</section>
|
||||
|
||||
<section id="example">
|
||||
<title>Example</title>
|
||||
<p>A source declaring <code>time</code>, <code>sine</code>, <code>ramp</code> and
|
||||
<code>noise</code> yields three plottable signals. Enable all three and the exit dump
|
||||
reads <code>3 signals, 3 channels</code>; <code>time</code> is not among them.</p>
|
||||
</section>
|
||||
|
||||
</page>
|
||||
@@ -0,0 +1,75 @@
|
||||
<page xmlns="http://projectmallard.org/1.0/"
|
||||
type="topic" style="task"
|
||||
id="source">
|
||||
|
||||
<info>
|
||||
<link type="guide" xref="index#main"/>
|
||||
<link type="seealso" xref="sessions"/>
|
||||
<desc>How udpscope is pointed at a source: the command line, the startup chooser, and
|
||||
opening an exported waveform instead of a live stream.</desc>
|
||||
<credit type="author copyright">
|
||||
<name>Martino Ferrari</name>
|
||||
</credit>
|
||||
</info>
|
||||
|
||||
<title>Choosing a source</title>
|
||||
|
||||
<p>A source is a host and a port, optionally reached through a multicast group on a named
|
||||
interface. Pass it on the command line, or let udpscope ask.</p>
|
||||
|
||||
<section id="reach">
|
||||
<title>How to reach it</title>
|
||||
<steps>
|
||||
<item>
|
||||
<p>Run with <cmd>--server</cmd> and the app connects straight away.</p>
|
||||
</item>
|
||||
<item>
|
||||
<p>Run <em>without</em> <cmd>--server</cmd> and a chooser appears first: one row per
|
||||
stored <em>configuration</em> (<code>host:port · name</code>, its note, and the
|
||||
settings it carries), newest first, plus a <gui>New host…</gui> form that takes the
|
||||
address <em>and</em> the configuration it belongs to — its name and note, the
|
||||
<gui>buffer size</gui>, and whether time is relative or absolute. A row's pencil
|
||||
button opens the same form on that configuration, pre-filled, and changes to its name
|
||||
or address move it (the old file is deleted).</p>
|
||||
</item>
|
||||
<item>
|
||||
<p>The second page, <gui>Waveforms</gui>, opens an <em>exported</em> run instead of a
|
||||
live source: the files opened before (newest first; one that has since been deleted is
|
||||
marked <em>missing</em>) and an <gui>Open…</gui> button for a new one. The same file
|
||||
can be opened straight from the command line with <cmd>--open FILE</cmd>. A waveform
|
||||
is a source that never arrives — no socket, no acquisition, no starvation notice — and
|
||||
every instrument works on it: cursors, measurements, math channels and another export.
|
||||
Only <file>.npy</file> files are read.</p>
|
||||
</item>
|
||||
</steps>
|
||||
|
||||
<p>The chooser runs before any buffer is allocated and before any socket is opened, so
|
||||
declining it costs nothing.</p>
|
||||
</section>
|
||||
|
||||
<section id="knowing">
|
||||
<title>Worth knowing</title>
|
||||
<list>
|
||||
<item><p>The list is the sessions that <em>named a signal</em>. A session whose streamer
|
||||
was down stores empty slot names and is filtered out, which is what keeps the list the
|
||||
sources you actually configured rather than every port you have ever typed.</p></item>
|
||||
<item><p>A stored row reconnects the way it was reached. The multicast group and the
|
||||
interface are written inside the session file, because the file's name
|
||||
(<file>host_port.toml</file>) cannot carry them.</p></item>
|
||||
<item><p><cmd>--multicast</cmd> still needs <cmd>--server</cmd>: the CONFIG message is
|
||||
fetched from the server, not from the group.</p></item>
|
||||
<item><p>The <gui>×</gui> beside a row deletes that session file. Nothing else is
|
||||
removed.</p></item>
|
||||
</list>
|
||||
</section>
|
||||
|
||||
<section id="example">
|
||||
<title>Example</title>
|
||||
<p><cmd>udpscope</cmd> with two stored sessions lists
|
||||
<code>127.0.0.1:8000 plots=1 signals=3</code> and
|
||||
<code>10.0.0.5:8001 plots=2 signals=4 multicast=239.1.2.3 interface=10.0.0.9</code>.
|
||||
Activating the first reports <code>[sources] picked 127.0.0.1:8000</code> and continues
|
||||
into the scope with that session's layout already loaded.</p>
|
||||
</section>
|
||||
|
||||
</page>
|
||||
@@ -0,0 +1,72 @@
|
||||
<page xmlns="http://projectmallard.org/1.0/"
|
||||
type="topic" style="task"
|
||||
id="status">
|
||||
|
||||
<info>
|
||||
<link type="guide" xref="index#output"/>
|
||||
<desc>The status bar's two tiers, the activity badge, the fixed state words, and the ⓘ
|
||||
detail popover.</desc>
|
||||
<credit type="author copyright">
|
||||
<name>Martino Ferrari</name>
|
||||
</credit>
|
||||
</info>
|
||||
|
||||
<title>Status bar and diagnostics</title>
|
||||
|
||||
<p>The status bar is two tiers. The bar itself is one glance line — the state, the rate and
|
||||
the window — and everything else is behind the ⓘ button at its right-hand end.</p>
|
||||
|
||||
<section id="reach">
|
||||
<title>How to reach it</title>
|
||||
<p>The bar is always visible under the plot. Click <gui>ⓘ</gui> for the detail popover.</p>
|
||||
</section>
|
||||
|
||||
<section id="knowing">
|
||||
<title>Worth knowing</title>
|
||||
<list>
|
||||
<item><p>The badge is one glyph in four colours: green <em>alternating</em> lit and dim
|
||||
means data is arriving, and a steady colour means it is not. A badge that never blinks is
|
||||
not a working badge. Of the two steady colours, <em>grey</em> is no stream at all or a
|
||||
pipeline you stopped (the tooltip says which), and <em>yellow</em> is a source that
|
||||
answered but has sent no configuration.</p></item>
|
||||
<item><p>The state field is one word from a fixed set: <code>connecting</code>,
|
||||
<code>live</code>, <code>paused</code>, <code>armed</code>, <code>capturing</code>,
|
||||
<code>event held</code>, <code>disarmed</code>. Two states add one field each: in trigger
|
||||
mode, how long ago the last event was; while paused, how long you have been paused.</p></item>
|
||||
<item><p><code>connecting</code> means there is no <em>signal list</em> yet — either no
|
||||
session or a session with no configuration behind it. Every other word claims a signal
|
||||
list, so until one arrives there is nothing for them to describe: nothing can be enabled
|
||||
and no data is owed.</p></item>
|
||||
<item><p>The paused figure is <em>wall</em> time, not stream time — the producer never
|
||||
stops while paused, so it answers "how long have I been looking at this picture".</p></item>
|
||||
<item><p>Nothing on the bar may change width with the stream, because the bar sits
|
||||
directly above the plot and a wider label would relayout it. That is why the rate
|
||||
collapses to one figure or a range rather than listing per channel, and why an elapsed
|
||||
time steps to a coarser unit instead of growing digits.</p></item>
|
||||
<item><p><code>gaps</code> counts packets the receiver knows it missed. It is the number
|
||||
that matters: a non-zero count means samples are absent from the buffer, not merely
|
||||
late.</p></item>
|
||||
<item><p><code>reconnects</code> counts sessions re-established with the source. Over
|
||||
unicast a climbing count usually means a second client is taking the source's single
|
||||
session.</p></item>
|
||||
<item><p>The starvation notice — <em>connected, but no data is arriving</em> — is that
|
||||
same situation seen from the other side: the signal list is here, so something took the
|
||||
stream away. A source that has sent <em>no</em> configuration gets a different notice,
|
||||
<em>trying to connect</em>, because nothing was taken away — pointing the app at a port
|
||||
where nothing answers is that case, and the usual cause is a streamer that is not running
|
||||
or is serving another port.</p></item>
|
||||
<item><p><code>stored</code> versus <code>wire</code> in the popover is the decimation.
|
||||
They differ exactly when the window is too long to store at full rate.</p></item>
|
||||
</list>
|
||||
</section>
|
||||
|
||||
<section id="example">
|
||||
<title>Example</title>
|
||||
<p>The bar reads <code>● live · 10 kS/s · 2 s window</code>. Behind ⓘ:
|
||||
<code>Stream connected, configured / packets 717 · frames 716 · gaps 0 / 3 signals · 3
|
||||
channels</code>, <code>Buffer 200.0 MB · 1677 s span · time x1 / stored 10 kS/s (wire
|
||||
10 kS/s)</code>, <code>Plot 1 3 traces · 195000 samples</code>,
|
||||
<code>Reconnects 0</code>.</p>
|
||||
</section>
|
||||
|
||||
</page>
|
||||
@@ -0,0 +1,198 @@
|
||||
<page xmlns="http://projectmallard.org/1.0/"
|
||||
type="topic" style="task"
|
||||
id="trigger">
|
||||
|
||||
<info>
|
||||
<link type="guide" xref="index#viewing"/>
|
||||
<link type="seealso" xref="pause"/>
|
||||
<desc>The trigger's four shapes, arming and hold-off, the event cache with its replay,
|
||||
compare and accumulate, and searching the buffer for a condition.</desc>
|
||||
<credit type="author copyright">
|
||||
<name>Martino Ferrari</name>
|
||||
</credit>
|
||||
</info>
|
||||
|
||||
<title>Trigger</title>
|
||||
|
||||
<p>The trigger watches one signal for a condition and captures a window around it. The
|
||||
<gui>Shape</gui> dropdown chooses what counts as an event; the arming, hold-off, pre/post
|
||||
window and Normal/Single mode are the same for all four shapes. The captured window is
|
||||
frozen on screen until you re-arm.</p>
|
||||
|
||||
<section id="reach">
|
||||
<title>How to reach it</title>
|
||||
<steps>
|
||||
<item>
|
||||
<p>The trigger button in the toolbar (<key>t</key>) reveals the trigger panel: the
|
||||
signal, the level, the shape, the edge (rising, falling, either), the mode (Normal or
|
||||
Single) and the pre-trigger share.</p>
|
||||
</item>
|
||||
<item>
|
||||
<p><gui>Arm</gui> starts watching; <key>a</key> arms and disarms from the keyboard.
|
||||
When the pane is locked with its <gui>[edit]</gui> button — so that it states what the
|
||||
trigger is set to instead of showing the controls — <gui>Arm/Disarm</gui> is in its
|
||||
heading, and the trigger can be armed or disarmed without unfolding the panel. It is
|
||||
offered only while trigger mode is on, because a trigger that is off has no engine to
|
||||
arm.</p>
|
||||
</item>
|
||||
<item>
|
||||
<p><gui>Search the buffer</gui> is at the foot of the same panel.</p>
|
||||
</item>
|
||||
</steps>
|
||||
|
||||
<figure>
|
||||
<title>A held capture</title>
|
||||
<desc><gui>event held</gui> in the status bar, the axis pinned to the event, and the
|
||||
trigger marker at x = 0.</desc>
|
||||
<media type="image" mime="image/png" src="figures/trigger-capture.png" width="1280" height="800">
|
||||
<p>The pre-trigger share is drawn to the left of the marker.</p>
|
||||
</media>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section id="shapes">
|
||||
<title>The four shapes</title>
|
||||
<terms>
|
||||
<item>
|
||||
<title>Edge</title>
|
||||
<p>A crossing of the level in the chosen direction, the classic behaviour.</p>
|
||||
</item>
|
||||
<item>
|
||||
<title>Pulse width</title>
|
||||
<p>A crossing and its return, qualified by how long the excursion lasted: less than,
|
||||
greater than, within or outside a pair of widths; the event is timed at the closing
|
||||
edge.</p>
|
||||
</item>
|
||||
<item>
|
||||
<title>Window</title>
|
||||
<p>The signal entering or leaving a band between two levels; the panel keeps the two
|
||||
levels at least one increment apart, because a zero-width band is not a band: the
|
||||
condition becomes an exact float-equality test against the level, which an ordinary
|
||||
signal never satisfies and a signal sitting at the level satisfies on every
|
||||
transition.</p>
|
||||
</item>
|
||||
<item>
|
||||
<title>Dropout</title>
|
||||
<p>The absence of a crossing for longer than a timeout; one event per outage, re-arms
|
||||
when a crossing returns.</p>
|
||||
</item>
|
||||
</terms>
|
||||
|
||||
<p>The shape's own rows are the only ones shown; the rest are hidden, so the panel never
|
||||
offers a pulse width to a window trigger.</p>
|
||||
</section>
|
||||
|
||||
<section id="cache">
|
||||
<title>The event cache, replay and compare</title>
|
||||
<p><em>Every triggered acquisition is kept.</em> Latches are written to the event cache
|
||||
(<file>~/.cache/udpscope/events/<source>/</file>) as a <file>.npy</file> of the
|
||||
event, a <file>.txt</file> of what it was, and a <file>.bmp</file> picture drawn from the
|
||||
samples. The <gui>Events</gui> pane on the left — opened from the toolbar's
|
||||
event-history button, which appears while recording is enabled, and collapsed until you
|
||||
ask for it — lists the last 32 newest first (time, duration, signals, rate), each row with
|
||||
its own picture beside the numbers.</p>
|
||||
|
||||
<p>Clicking a row <em>replays</em> it: the stored event is loaded exactly like an exported
|
||||
file, <em>without changing the layout</em> — the rows, the colours, the window, the
|
||||
cursors and the measurements stay where they are and read the stored data instead. A
|
||||
replay leaves trigger mode, because a file has no edge to wait for, and the pane stays
|
||||
open. Drag the pane's edge to resize it. <gui>Live</gui> goes back to the stream; the
|
||||
status bar names the file until you do.</p>
|
||||
|
||||
<p>Select two or more events in the list and the two buttons under the header act on them:
|
||||
<gui>Compare</gui> puts two events in two plots side by side, keeping the layout you have
|
||||
— one plot per event, the axes synchronised, so zooming a row in one zooms the same row in
|
||||
the other — and <gui>Accumulate</gui> overlays up to four of them in the same traces,
|
||||
translucent, so where they agree the trace is brighter. Your own traces stay where they
|
||||
are in both. They need events with the same signals and the same length. A single click
|
||||
selects, a <em>double</em> click replays.</p>
|
||||
|
||||
<p><gui>Record events</gui>, in the trigger panel with the trigger's other options, is
|
||||
what decides whether anything is kept. It is <em>off by default</em> — nothing is written
|
||||
to disk until you ask — and with it off the history button goes with it.</p>
|
||||
</section>
|
||||
|
||||
<section id="search">
|
||||
<title>Search the buffer</title>
|
||||
<p><gui>Search the buffer</gui>, at the foot of the trigger panel and collapsed until you open
|
||||
it, answers the question the trigger answers — <em>where does this condition hold?</em> — over
|
||||
the <em>whole</em> retained buffer rather than at the live edge. It runs the trigger's own
|
||||
evaluator over the ring in stream order, so a <gui>hit</gui> is not a level you point at: it is
|
||||
an <em>instant</em> the trigger would have fired on, timed the way an acquisition would have
|
||||
timed it. A pulse-width finding, for instance, is the closing edge of a qualifying excursion,
|
||||
not the excursion itself.</p>
|
||||
|
||||
<p>The condition is the one the rest of the panel is set to — the same signal, shape, level and
|
||||
options — so there is no second condition to keep in step: change the panel and the next search
|
||||
asks the new question. Nothing is searched until you ask, and the status line says
|
||||
<gui>not searched</gui>.</p>
|
||||
|
||||
<steps>
|
||||
<item>
|
||||
<p>Set the condition above, then press <gui>Search</gui>. Covering the whole ring takes a
|
||||
moment, and the scan runs in chunks so the stream keeps arriving while it does; the status
|
||||
line counts the ground covered — <gui>scanning 1.2 s of 8.0 s, 40 found</gui>.
|
||||
<gui>Stop</gui> ends a scan early and keeps the hits found so far.</p>
|
||||
</item>
|
||||
<item>
|
||||
<p>Step through the hits with the arrows either side of the position readout
|
||||
(<gui>3 of 40</gui>). The first <gui>next</gui> goes to the first hit, and the ends do not
|
||||
wrap — a wrap from the last hit to the first would look exactly like the view not having
|
||||
moved. Each step <em>pauses</em> on its hit, centred, exactly as the Pause button does, so
|
||||
what you read is the frozen window cut around that instant; press the pause button again to
|
||||
go back to the stream.</p>
|
||||
</item>
|
||||
<item>
|
||||
<p>Every plot row marks the hits that fall in the window, up to sixteen of them. When more
|
||||
are in view the pane says so and suggests zooming in — <gui>16 of 38 marks shown — zoom in
|
||||
to see the rest</gui> — and when the selected hit has scrolled off the window it says that
|
||||
instead. A hit the live stream has since overwritten reads <gui>no longer in the buffer</gui>
|
||||
beside its position.</p>
|
||||
</item>
|
||||
</steps>
|
||||
|
||||
<p>How a scan ended is part of the answer: <gui>N found in 8.0 s</gui> when it covered the whole
|
||||
ring; <gui>N found; the stream overtook the oldest samples</gui> when the producer overwrote
|
||||
samples before the scan reached them, so the count is of what was left; and
|
||||
<gui>N found (the limit) in the newest …</gui> when a condition that holds almost everywhere —
|
||||
an edge on a noisy signal — reached the ten-thousand-hit ceiling. The last is a stop, not a
|
||||
crash: a hit list nobody could navigate is not worth building.</p>
|
||||
|
||||
<p>A result describes the ring it was taken from, so it is <em>dropped</em> when that ring is
|
||||
replaced: a new <gui>CONFIG</gui> from the source, or a change to the signal set, re-describes
|
||||
the stream and clears the hits. The condition above is untouched — only the answer to it
|
||||
is.</p>
|
||||
</section>
|
||||
|
||||
<section id="knowing">
|
||||
<title>Worth knowing</title>
|
||||
<list>
|
||||
<item><p>Arming <em>enables</em> the signal if it was off, because a trigger on a signal
|
||||
nobody acquires can never fire.</p></item>
|
||||
<item><p>The pre-trigger share is a share of the <em>display window</em>, not of the
|
||||
ring. A 2 s window at 50% captures 1 s before the edge and 1 s after.</p></item>
|
||||
<item><p>There are four display states, and the status bar names each: <gui>armed</gui>
|
||||
(watching, nothing drawn yet), <gui>capturing</gui> (the edge was accepted and the post
|
||||
window is filling <em>in view</em>, with the marker at x = 0), <gui>event held</gui>
|
||||
(the frozen capture) and <gui>disarmed</gui>. A held capture is never quietly replaced
|
||||
by live data.</p></item>
|
||||
<item><p>The first edge after arming is only accepted once the history reaches back a
|
||||
full pre-trigger window, so a capture may legitimately be shorter than you asked for.
|
||||
The axis is pinned to what the capture actually got.</p></item>
|
||||
<item><p>Disarming <em>during</em> a capture publishes what the capture got to, so the
|
||||
picture on screen at that moment stays on screen. No event fired, so the event count
|
||||
does not move.</p></item>
|
||||
<item><p>The trigger reads the <em>stored</em> signal — decimated and averaged — not the
|
||||
wire signal. A spike narrower than one decimation group is filtered away rather than
|
||||
triggering on data the buffer does not contain.</p></item>
|
||||
</list>
|
||||
</section>
|
||||
|
||||
<section id="example">
|
||||
<title>Example</title>
|
||||
<p>A 1 Hz sine, level 0, rising, 2 s window at 50%, Single. The capture spans
|
||||
<code>[-1.000 .. 1.000]</code> with <code>n=20001</code> samples at 10 kS/s — the span
|
||||
times the real rate, plus one — and the marker sits at x = 0 throughout.</p>
|
||||
</section>
|
||||
|
||||
</page>
|
||||
@@ -0,0 +1,66 @@
|
||||
<page xmlns="http://projectmallard.org/1.0/"
|
||||
type="topic" style="task"
|
||||
id="window">
|
||||
|
||||
<info>
|
||||
<link type="guide" xref="index#viewing"/>
|
||||
<link type="seealso" xref="navigate"/>
|
||||
<desc>The time window control: how to set it, and why a long window is decimated rather
|
||||
than refused.</desc>
|
||||
<credit type="author copyright">
|
||||
<name>Martino Ferrari</name>
|
||||
</credit>
|
||||
</info>
|
||||
|
||||
<title>Time window</title>
|
||||
|
||||
<p>The window is how much time the plot shows. Every row shares one window: the figure has
|
||||
a single x link group, so the rows cannot drift apart. The trigger's event span follows it,
|
||||
and the pre-trigger share is a share of <em>this</em> window.</p>
|
||||
|
||||
<section id="reach">
|
||||
<title>How to reach it</title>
|
||||
<steps>
|
||||
<item>
|
||||
<p>The header bar's window button opens a popover: type a number, pick a unit under
|
||||
it, or step a decade with the arrows.</p>
|
||||
</item>
|
||||
<item>
|
||||
<p>The button's own label is a reading of the current window in a natural unit,
|
||||
width-locked so a change of window cannot widen the header bar.</p>
|
||||
</item>
|
||||
</steps>
|
||||
</section>
|
||||
|
||||
<section id="knowing">
|
||||
<title>Worth knowing</title>
|
||||
<list>
|
||||
<item><p>A window too long to store at the wire rate is <em>decimated, not refused</em>.
|
||||
The shared clock takes a power-of-two stride, and each group of samples is
|
||||
<em>averaged</em> rather than strided, so a tone above the stored Nyquist is attenuated
|
||||
instead of aliasing to a false low frequency.</p></item>
|
||||
<item><p>The stamp stored for a group is the group's <em>mean</em>, which is where the
|
||||
averaging puts the value. Stamping at the end of a group would shift every trace half a
|
||||
stored sample late.</p></item>
|
||||
<item><p>Moving the window across a decimation boundary rebuilds the buffer and drops
|
||||
the history. Walking it inside one decade is free.</p></item>
|
||||
<item><p><em>This control is the only thing that changes the window.</em> A zoom, a pan
|
||||
or a fit moves the view <em>inside</em> it and leaves it alone — which is the point of
|
||||
the previous bullet: a wheel notch that redefined the window could re-plan the buffer
|
||||
and throw your samples away.</p></item>
|
||||
<item><p>The trigger sees the <em>stored</em> (filtered) signal, not the wire signal,
|
||||
because an edge found on a sample that is not in the buffer could not be cut out of
|
||||
it.</p></item>
|
||||
</list>
|
||||
</section>
|
||||
|
||||
<section id="example">
|
||||
<title>Example</title>
|
||||
<p>1 MS/s with 200 MB of buffer. A 2 s window stores every sample
|
||||
(<code>time x1</code>). Widen it to 600 s and the stride becomes <code>x16</code>: 625 S/s
|
||||
stored, anti-aliased at 312.5 Hz. A 2 kHz sine seen through that window reads
|
||||
<code>y=[-0.059, 0.079]</code> — attenuated to about 6%, not folded down to a
|
||||
full-amplitude 125 Hz one.</p>
|
||||
</section>
|
||||
|
||||
</page>
|
||||
@@ -3,8 +3,17 @@
|
||||
|
||||
#include "acquisition.hpp"
|
||||
|
||||
#include <limits>
|
||||
|
||||
#include <udps_client.h>
|
||||
|
||||
#include "layout_store.hpp"
|
||||
|
||||
#include <glib/gstdio.h>
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
@@ -14,6 +23,12 @@
|
||||
namespace udpscope {
|
||||
namespace {
|
||||
|
||||
/// How far a packet's stamps may run ahead before the jump is read as a clock fault rather
|
||||
/// than a stream gap. Also the cap for hole reservation: 256 packet spans in one go is an
|
||||
/// outage, not a loss, and reserving one would push the entire retained history out of the
|
||||
/// ring to make room for samples that are not coming.
|
||||
constexpr double kGlitchPackets = 256.0;
|
||||
|
||||
SignalInfo to_info(const udps_signal_t& s) {
|
||||
SignalInfo info;
|
||||
info.name = s.name;
|
||||
@@ -37,6 +52,12 @@ Acquisition::Acquisition(ScopeModel& model, const Config& cfg) : model_(model),
|
||||
Acquisition::~Acquisition() { stop(); }
|
||||
|
||||
void Acquisition::start() {
|
||||
// A started -- or *restarted* -- client is a new stream. Going live from a file view is the restart that
|
||||
// matters: the store still holds the event's samples, and the new stream's clock can sit behind their
|
||||
// stamps, so the guard below has to re-base rather than clamp. `ring=[2.5200..2.5200]` with `n` climbing
|
||||
// was the symptom -- every packet pinned flat at the file view's last stamp.
|
||||
model_.reset_stream();
|
||||
last_stamp_ = -std::numeric_limits<double>::infinity();
|
||||
udps_client_config_t c;
|
||||
udps_client_config_init(&c);
|
||||
c.server_addr = cfg_.server.c_str();
|
||||
@@ -55,6 +76,13 @@ void Acquisition::start() {
|
||||
}
|
||||
udps_client_set_callbacks(client_, &Acquisition::config_trampoline, &Acquisition::data_trampoline,
|
||||
&Acquisition::event_trampoline, this);
|
||||
udps_client_set_datagram_cb(client_, &Acquisition::datagram_trampoline, this);
|
||||
// `--capture-packets`: armed before the first datagram, so the first CONNECT and
|
||||
// its CONFIG -- the hardest strange behaviours to reproduce -- are in the ring.
|
||||
// The open itself still happens on the producer thread, at the first datagram.
|
||||
if (cfg_.capture_packets > 0) {
|
||||
set_capture_want(true, cfg_.capture_packets);
|
||||
}
|
||||
|
||||
started_ = std::chrono::steady_clock::now();
|
||||
running_.store(true, std::memory_order_release);
|
||||
@@ -85,11 +113,69 @@ void Acquisition::run() {
|
||||
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.resequenced.store(s.late_resequenced, std::memory_order_relaxed);
|
||||
out.late.store(s.late_delivered, std::memory_order_relaxed);
|
||||
out.stale.store(s.stale_packets, std::memory_order_relaxed);
|
||||
out.overflows.store(s.reorder_overflows, 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::set_capture_want(bool want, std::uint32_t slots) {
|
||||
if (want) {
|
||||
if (slots > 0) capture_slots_ = slots;
|
||||
if (capture_path_.empty()) {
|
||||
// Keyed on the source: one path per source, the previous capture kept one deep.
|
||||
std::string host = cfg_.server;
|
||||
for (char& ch : host) {
|
||||
if (ch == ':' || ch == '/' || ch == ' ') ch = '_';
|
||||
}
|
||||
std::string dir = cache_dir() + "/" + host + "-" + std::to_string(cfg_.port);
|
||||
g_mkdir_with_parents(dir.c_str(), 0700);
|
||||
capture_path_ = dir + "/capture.bin";
|
||||
std::uint32_t addr = 0;
|
||||
struct ::in_addr a;
|
||||
if (::inet_pton(AF_INET, cfg_.server.c_str(), &a) == 1) {
|
||||
addr = a.s_addr; // already network order, which is what the header wants
|
||||
}
|
||||
capture_.set_peer(addr, cfg_.port);
|
||||
}
|
||||
}
|
||||
capture_want_.store(want, std::memory_order_release);
|
||||
}
|
||||
|
||||
void Acquisition::on_datagram(const std::uint8_t* bytes, std::size_t len, double recv_time) {
|
||||
const bool want = capture_want_.load(std::memory_order_acquire);
|
||||
if (want && !capture_.enabled()) {
|
||||
// A user-initiated act that happens once per toggle, on this thread: a rename, a
|
||||
// create, a 62.6 MiB reserve and an mmap. It can cost the packets in flight, and
|
||||
// the measured cost is `capture_.open_ms()`.
|
||||
if (!capture_.open(capture_path_, capture_slots_)) {
|
||||
char err[128];
|
||||
capture_.error(err, sizeof(err));
|
||||
std::fprintf(stderr, "[udpscope] packet capture failed to open: %s\n", err);
|
||||
// Stay off rather than retrying every datagram against a full disk; the GTK
|
||||
// thread reads the flag back and returns the button to off.
|
||||
capture_want_.store(false, std::memory_order_release);
|
||||
return;
|
||||
}
|
||||
std::fprintf(stderr, "[udpscope] packet capture on: %s (%u slots, %.1f ms to open)\n",
|
||||
capture_.path().c_str(), capture_.slot_count(), capture_.open_ms());
|
||||
} else if (!want && capture_.enabled()) {
|
||||
capture_.close();
|
||||
std::fprintf(stderr, "[udpscope] packet capture off\n");
|
||||
}
|
||||
if (capture_.enabled()) {
|
||||
capture_.store(bytes, len, recv_time);
|
||||
}
|
||||
}
|
||||
|
||||
void Acquisition::datagram_trampoline(const std::uint8_t* bytes, std::size_t len,
|
||||
double recv_time, void* user) {
|
||||
static_cast<Acquisition*>(user)->on_datagram(bytes, len, recv_time);
|
||||
}
|
||||
|
||||
void Acquisition::on_config(const udps_signal_t* signals, std::uint32_t count,
|
||||
std::uint8_t publish_mode) {
|
||||
std::vector<SignalInfo> info;
|
||||
@@ -112,6 +198,19 @@ void Acquisition::on_config(const udps_signal_t* signals, std::uint32_t count,
|
||||
sig.time_signal_idx, sig.unit.c_str());
|
||||
}
|
||||
std::fflush(stderr);
|
||||
// The warn-once latches are per *connection*, not per process. A reconnect re-enters here, and
|
||||
// a diagnostic that goes quiet after the first occurrence of a condition that recurs on the
|
||||
// next session is the failure mode worth avoiding in a feature whose whole job is diagnosis.
|
||||
warned_batch_ = false;
|
||||
warned_outage_ = false;
|
||||
warned_phantom_gap_ = false;
|
||||
warned_bridge_ = false;
|
||||
warned_time_source_ = false;
|
||||
// The clock choice is per connection too: a reconnect re-derives it from the frames
|
||||
// the new session actually delivers, rather than trusting a decision made about a
|
||||
// session that dropped.
|
||||
time_source_ = 0;
|
||||
reset_session_state();
|
||||
model_.stats().have_config.store(true, std::memory_order_relaxed);
|
||||
model_.set_signals(std::move(info));
|
||||
}
|
||||
@@ -122,10 +221,84 @@ void Acquisition::on_event(int event, const char* detail) {
|
||||
model_.stats().connected.store(true, std::memory_order_relaxed);
|
||||
} else if (event == UDPS_EVENT_DISCONNECTED) {
|
||||
model_.stats().connected.store(false, std::memory_order_relaxed);
|
||||
// The session dropped mid-flight: everything the producer knows about *that*
|
||||
// session's numbering and clocks is now a lie about the next one. Clearing it here
|
||||
// is what makes the next frame re-anchor instead of reading as late, as a glitch or
|
||||
// as an outage -- and nothing here touches the buffer, whose history survives the
|
||||
// drop (see `ScopeModel::set_signals`'s unchanged-CONFIG path).
|
||||
reset_session_state();
|
||||
}
|
||||
}
|
||||
|
||||
std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_signal) {
|
||||
void Acquisition::reset_session_state() noexcept {
|
||||
have_counter_ = false;
|
||||
last_counter_ = 0;
|
||||
have_stream_anchor_ = false;
|
||||
last_stream_anchor_ = 0.0;
|
||||
stamp_failures_ = 0;
|
||||
// The arrival clock's *anchor* stays -- stamps are small seconds the ring's timeline
|
||||
// is already scaled against, and re-anchoring mid-stream would move x -- but the
|
||||
// smoothed interval and the previous arrival are per-connection measurements, and
|
||||
// carrying them across a multi-second outage makes the first new frame read as a
|
||||
// clock fault rather than as the resumption it is.
|
||||
have_arrival_interval_ = false;
|
||||
arrival_interval_ = 0.0;
|
||||
arrival_prev_ = 0.0;
|
||||
have_prev_frame_recv_ = false;
|
||||
prev_frame_recv_ = 0.0;
|
||||
}
|
||||
|
||||
void Acquisition::note_frame_arrival(double recv_time) noexcept {
|
||||
const bool first = !have_prev_frame_recv_;
|
||||
const double elapsed = first ? 0.0 : recv_time - prev_frame_recv_;
|
||||
prev_frame_recv_ = recv_time;
|
||||
have_prev_frame_recv_ = true;
|
||||
// The arrival *cadence* belongs to the arrival clock, and `build_stamps()`'s arrival branch keeps
|
||||
// it whenever that clock is the one in play (`time_source_ == 2`, decided once per connection and
|
||||
// sticky). Every other case needs it here: relative mode returns before that branch, and a stream
|
||||
// whose declared time signal checks out never reaches it either -- while the reservation test
|
||||
// below weighs the counter's claim against a measured cadence whatever clock the display uses,
|
||||
// because a counter that does not count packets lies in all of them. The two writers are therefore
|
||||
// exclusive by `time_source_`, and this is the branch's own update (0.8/0.2) so the estimate does
|
||||
// not jump at the one frame where the clock changes hands.
|
||||
if (first || time_source_ == 2) return;
|
||||
if (elapsed > 0.0 && std::isfinite(elapsed)) {
|
||||
arrival_interval_ =
|
||||
have_arrival_interval_ ? 0.8 * arrival_interval_ + 0.2 * elapsed : elapsed;
|
||||
have_arrival_interval_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
bool Acquisition::gap_plausible(std::uint32_t missing, double elapsed, const Partition& part,
|
||||
u64 g) const {
|
||||
if (missing == 0) return true;
|
||||
// The ring cannot hold a hole bigger than itself, and this test needs no measurement: the
|
||||
// invariant's own rule is that the reservation's *sample* cost -- `missing * batch_`, not the
|
||||
// packet cap -- is what has to be weighed against the ring. A hole that laps the ring erases the
|
||||
// history it is meant to annotate, can never be backfilled (its slots are already overwritten),
|
||||
// and is never forgotten (`forget_holes_before()` waits for slots the ring has passed), so it
|
||||
// leaves a band over a buffer that is all NaN.
|
||||
if (g == 0 || part.time_capacity == 0) return false;
|
||||
if (u64(missing) * u64(batch_) / g >= part.time_capacity) return false;
|
||||
// And the counter has to agree with the wire about how long the loss took. The counter is a claim
|
||||
// about the stream; the arrival clock is a *measurement* of it: `missing` packets would have taken
|
||||
// `missing` frame intervals to arrive, and the frame before this one is the witness of how long
|
||||
// actually passed. A device whose counter counts samples, or frames of an inner layer, reports a
|
||||
// forward jump on *every* datagram while the wire delivers on time -- and believing that booked a
|
||||
// NaN hole per packet, i.e. a band over the whole window filled with samples that were never
|
||||
// missing, which no backfill can repair because no packet was ever lost. The factor is a half and
|
||||
// not one: the socket clock jitters and a drain can compress two frames' arrival times, so only a
|
||||
// claim the wire flatly contradicts (half the time it needs, or less) is refused. A refused gap
|
||||
// costs a shading, never data.
|
||||
if (have_arrival_interval_ && arrival_interval_ > 0.0 && elapsed > 0.0 &&
|
||||
elapsed < 0.5 * double(missing) * arrival_interval_) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_signal,
|
||||
bool counter_gap_refused) {
|
||||
// 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 --
|
||||
@@ -135,6 +308,74 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
|
||||
n = std::max(n, udps_signal_num_elements(&frame->signals[i]));
|
||||
}
|
||||
stamps_.assign(n, 0.0);
|
||||
abs_stamps_.assign(n, 0.0);
|
||||
|
||||
// Samples per packet, needed in both modes: relative stamping must advance `slot_base_`
|
||||
// past the reserved hole before placing this frame's own stamps, and `on_data` must size
|
||||
// the hole before calling this function -- both operations require knowing how many samples
|
||||
// a missing packet represents, which is only knowable from the first frame that arrives.
|
||||
if (batch_ == 0) {
|
||||
batch_ = n;
|
||||
if (cfg_.relative_time) {
|
||||
std::fprintf(stderr,
|
||||
"[udpscope] relative time: batch=%u samples/packet, rate=%.6g S/s declared\n",
|
||||
batch_, model_.partition().base_dt > 0.0 ? 1.0 / model_.partition().base_dt
|
||||
: 0.0);
|
||||
std::fflush(stderr);
|
||||
}
|
||||
} else if (n != batch_) {
|
||||
// A frame whose length disagrees with the first one is an inconsistency in the
|
||||
// producer, not a loss: the counter still names one packet. Warn once and keep the
|
||||
// established batch, which is only used to size holes -- the timeline itself is built
|
||||
// from the lengths actually seen, so it stays exact for this frame either way.
|
||||
if (!warned_batch_) {
|
||||
warned_batch_ = true;
|
||||
std::fprintf(stderr,
|
||||
"[udpscope] warning: packet carries %u samples but the stream's first "
|
||||
"carried %u; keeping %u as the packet length a lost packet is worth.\n",
|
||||
n, batch_, batch_);
|
||||
std::fflush(stderr);
|
||||
}
|
||||
}
|
||||
|
||||
// Relative mode: the stamp is a pure function of position and the *declared* rate. Nothing
|
||||
// below this block runs -- not the arrival-cadence override, not the degeneracy guard, not
|
||||
// the glitch bridge, not the monotonic clamp's reason for existing -- because a position
|
||||
// cannot jitter, cannot fail to advance and cannot jump.
|
||||
if (cfg_.relative_time) {
|
||||
double dt = model_.partition().base_dt;
|
||||
if (!(dt > 0.0)) dt = 1.0e-3;
|
||||
// The reserved hole for the missing packets advances `slot_base_` so this frame's
|
||||
// own stamps land *after* the hole rather than on top of it. The same conditions
|
||||
// the reservation call uses: not late, gap present, batch known, within the cap --
|
||||
// and not one the arrival clock or the ring refused, because the advance and the
|
||||
// reservation are the *same* claim about the wire: believing a phantom gap here
|
||||
// would stretch the timeline by exactly the loss that did not happen.
|
||||
if (!frame->late && frame->lost > 0 && batch_ > 0 && !counter_gap_refused &&
|
||||
double(frame->lost) <= kGlitchPackets) {
|
||||
slot_base_ += u64(frame->lost) * batch_;
|
||||
}
|
||||
for (std::uint32_t k = 0; k < n; ++k) {
|
||||
stamps_[k] = double(slot_base_ + k) * dt;
|
||||
}
|
||||
slot_base_ += n;
|
||||
// The absolute clock is still read, for export only. When the stream carries no usable
|
||||
// time signal there is no absolute clock to record and the relative stamp is the honest
|
||||
// answer -- an export then says what the counter says, which is all anyone had.
|
||||
if (time_signal >= 0 && time_signal < static_cast<int>(frame->num_signals)) {
|
||||
const udps_signal_t& ts = frame->signals[time_signal];
|
||||
const bool scalar_time = udps_signal_num_elements(&ts) == 1;
|
||||
for (std::uint32_t k = 0; k < n; ++k) {
|
||||
abs_stamps_[k] =
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
for (std::uint32_t k = 0; k < n; ++k) abs_stamps_[k] = stamps_[k];
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
double period = model_.partition().base_dt;
|
||||
if (!(period > 0.0)) period = 1.0e-3;
|
||||
@@ -208,47 +449,89 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
|
||||
}
|
||||
}
|
||||
|
||||
// A stream-time signal may be declared in a time unit rather than seconds ('ns' is
|
||||
// the common one). The scale has to be known *here*, before the checks below, because
|
||||
// they weigh the stamps' own spacing against the declared cadence, which is in
|
||||
// seconds: on a 'ns' stream an unscaled comparison is off by a factor of 1e9, so
|
||||
// every packet would read as an implausible jump.
|
||||
double unit_scale = 1.0;
|
||||
if (time_signal >= 0 && time_signal < static_cast<int>(model_.signals().size())) {
|
||||
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 (used_stream_time && n > 0) {
|
||||
// One sample's worth of the time signal's *own* unit, which is what a synthesised
|
||||
// continuation has to be spaced by and what a jump is measured against.
|
||||
const double step = cadence / unit_scale;
|
||||
// How many packets in a row must fail before the declared time signal is abandoned
|
||||
// for the arrival clock. A glitch is one packet; a signal that is not a time base
|
||||
// at all fails every one of them.
|
||||
constexpr unsigned kFailureLimit = 4;
|
||||
|
||||
// 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]);
|
||||
const char* fault = nullptr;
|
||||
double lo = stamps_[0];
|
||||
double hi = stamps_[0];
|
||||
for (std::uint32_t k = 1; usable && k < n; ++k) {
|
||||
if (!std::isfinite(stamps_[0])) fault = "carries a stamp that is not finite";
|
||||
for (std::uint32_t k = 1; fault == nullptr && k < n; ++k) {
|
||||
if (!std::isfinite(stamps_[k])) {
|
||||
usable = false;
|
||||
fault = "carries a stamp that is not finite";
|
||||
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 {
|
||||
if (fault == nullptr && !(hi > lo)) fault = "carries no span (every stamp is equal)";
|
||||
if (fault == nullptr && have_stream_anchor_) {
|
||||
// The stamps must also ADVANCE across packets, and by a plausible amount. 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 (!(stamps_[n - 1] > last_stream_anchor_)) {
|
||||
fault = "does not advance across packets";
|
||||
} else if (stamps_[0] - last_stream_anchor_ > kGlitchPackets * double(n) * step) {
|
||||
fault = "jumped implausibly far ahead of the previous packet";
|
||||
}
|
||||
}
|
||||
if (fault == nullptr) {
|
||||
stamp_failures_ = 0;
|
||||
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;
|
||||
} else if (have_stream_anchor_ && ++stamp_failures_ < kFailureLimit) {
|
||||
// A short run of bad packets is bridged rather than believed: the stamps are
|
||||
// replaced by a continuation of the accepted timeline at the declared cadence,
|
||||
// so neither the monotonic mark nor the anchor is poisoned, and the next good
|
||||
// packet -- which carries on from where the true timeline was -- is accepted
|
||||
// normally. Believing one glitching packet instead is what condemned the *next*,
|
||||
// perfectly good one and threw the whole stream onto the arrival clock.
|
||||
const double from = last_stream_anchor_;
|
||||
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = from + double(k + 1) * step;
|
||||
last_stream_anchor_ = stamps_[n - 1];
|
||||
if (!warned_bridge_) {
|
||||
warned_bridge_ = 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);
|
||||
"[udpscope] warning: the stream's time signal %s (%g..%g against a "
|
||||
"previous %g); bridging that packet at the declared cadence and "
|
||||
"keeping the stream clock.\n",
|
||||
fault, lo, hi, from);
|
||||
}
|
||||
} else {
|
||||
used_stream_time = false; // not a time base; fall back to arrival
|
||||
if (!warned_time_source_) {
|
||||
warned_time_source_ = true;
|
||||
std::fprintf(stderr,
|
||||
"[udpscope] warning: the stream's time signal %s (%g..%g); timing "
|
||||
"samples by arrival instead. Check the time signal's mode and index "
|
||||
"in the CONFIG dump above.\n",
|
||||
fault, lo, hi);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -306,34 +589,30 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
|
||||
if (time_source_ == 0) {
|
||||
time_source_ = used_stream_time ? 1 : 2;
|
||||
} else if (time_source_ == 1 && !used_stream_time) {
|
||||
// The declared time signal failed its checks: keep the arrival clock from here
|
||||
// on, discard the stamps already recorded on the old timeline, and make the
|
||||
// arrival clock continue from where the mark is, so the axis has no step.
|
||||
// The declared time signal failed its checks for good: keep the arrival clock from
|
||||
// here on, anchored so that it continues from the monotonic mark. The two timelines
|
||||
// then share a zero *by construction*, which is the whole point of deriving the
|
||||
// epoch from `last_stamp_` -- so the recorded history is still on one timeline and
|
||||
// is kept. It used to be wiped here, on the reasoning that the old stamps belonged
|
||||
// to a dead clock; they do not, and the wipe is what made a single glitching packet
|
||||
// look like the stream restarting from zero.
|
||||
time_source_ = 2;
|
||||
const double end = frame->recv_time;
|
||||
arrival_epoch_ = end - (last_stamp_ + model_.epoch());
|
||||
arrival_prev_ = end;
|
||||
last_stamp_ = 0.0;
|
||||
history_reset_pending_ = true;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// The declared unit, applied last so the x axis is in seconds either way. It 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 && unit_scale != 1.0) {
|
||||
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] *= unit_scale;
|
||||
}
|
||||
}
|
||||
|
||||
// Stream mode: `time()` already holds the absolute clock, so the parallel ring repeats it
|
||||
// rather than carrying a second timeline. One rule for the export either way.
|
||||
for (std::uint32_t k = 0; k < n; ++k) abs_stamps_[k] = stamps_[k];
|
||||
|
||||
return n;
|
||||
}
|
||||
@@ -343,10 +622,33 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
||||
std::lock_guard<std::mutex> lock(model_.mutex());
|
||||
if (!running_.load(std::memory_order_relaxed)) return;
|
||||
|
||||
// The counter is this frame's identity and the hole's address. A *late* frame's counter is
|
||||
// behind the sequence, so it must not move the mark -- the hole it belongs in was addressed
|
||||
// from the counter that revealed it.
|
||||
if (!have_counter_ || static_cast<std::int32_t>(frame->counter - last_counter_) > 0) {
|
||||
last_counter_ = frame->counter;
|
||||
have_counter_ = true;
|
||||
}
|
||||
|
||||
const Partition& part = model_.partition();
|
||||
const std::vector<ChannelInfo>& channels = model_.channels();
|
||||
if (part.time_capacity == 0 || channels.empty()) return;
|
||||
|
||||
// A frame delivered behind the sequence: the slots it owns were reserved as NaN by
|
||||
// `reserve_hole()`, so it writes values into them and does nothing else. It is deliberately
|
||||
// not stamped, does not advance `tick_`, and never reaches the trigger or the math channels:
|
||||
// the time ring was pre-stamped at reservation and those consumers are stateful and have
|
||||
// already run past this region. That is the trade the design takes -- the raw trace repairs,
|
||||
// a derived trace keeps the gap, and nothing that was computed from the gap is retrofitted.
|
||||
//
|
||||
// Placed here and not earlier because `part`/`channels` are not in scope above, and not
|
||||
// later because `build_stamps()` is stateful: a late frame's stamps would walk the clock
|
||||
// backwards and can throw the whole stream onto the arrival clock.
|
||||
if (frame->late) {
|
||||
backfill_from(frame, part, channels, model_.live(), part.time_stride);
|
||||
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.
|
||||
@@ -369,14 +671,21 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
||||
}
|
||||
}
|
||||
}
|
||||
const std::uint32_t n = build_stamps(frame, time_signal);
|
||||
// The wire's own cadence, and the verdict on the counter's claim, both taken *before* the
|
||||
// stamps: relative mode's `slot_base_` advance and the reservation below are the same claim
|
||||
// about the wire (see `build_stamps`), so they have to agree about it. The verdict is taken
|
||||
// before the cadence is updated, too: folding this frame's own arrival into the estimate would
|
||||
// let one burst raise the very number that is supposed to corroborate it -- a 200-packet outage
|
||||
// smooths the interval to 40 ms and then refuses itself.
|
||||
const bool first_frame = !have_prev_frame_recv_;
|
||||
const double since_prev = first_frame ? 0.0 : frame->recv_time - prev_frame_recv_;
|
||||
const std::uint32_t missing = frame->lost;
|
||||
const bool gap_refused =
|
||||
missing > 0 && !gap_plausible(missing, since_prev, part, part.time_stride);
|
||||
note_frame_arrival(frame->recv_time);
|
||||
|
||||
const std::uint32_t n = build_stamps(frame, time_signal, gap_refused);
|
||||
if (n == 0) return;
|
||||
// A change of time source invalidated the recorded history: empty the rings before
|
||||
// anything more goes in, so no reader can find two timelines under one index.
|
||||
if (history_reset_pending_) {
|
||||
history_reset_pending_ = false;
|
||||
model_.reset_live_history_locked(); // this thread holds the model lock
|
||||
}
|
||||
|
||||
if (model_.epoch() == 0.0) {
|
||||
model_.set_epoch_if_unset(stamps_[0]);
|
||||
@@ -394,6 +703,31 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
||||
// event window out into an immutable `EventSnapshot`.
|
||||
BufferStore& store = model_.live();
|
||||
const u64 g = part.time_stride;
|
||||
// `tick % g == g - 1` as a mask. `plan_partition()` doubles `time_stride` from 1, so `g` is
|
||||
// a power of two -- but it is a *runtime* value, so the modulo compiled to a real 64-bit
|
||||
// division rather than the cheap test its comment there claims. See `acc_mask_` for the
|
||||
// per-channel form and for what that cost on the hot loop.
|
||||
const u64 g_mask = g - 1;
|
||||
|
||||
// A re-plan replaced the store, so every ring's `produced()` restarted at 0 while `tick_`
|
||||
// kept counting. `backfill_from()` derives a slot index from a hole's `tick_begin`, which is
|
||||
// only an absolute slot index while `tick_ / period == produced()`, so the tick counter has
|
||||
// to restart with the buffer -- otherwise the amend lands outside the ring, repairs nothing,
|
||||
// and the hole is still marked repaired, which is the band saying the opposite of the truth.
|
||||
// The generation is the signal rather than the stride/channel-count comparison below: only
|
||||
// a CONFIG that *changes* the stream re-plans the store -- a reconnect's identical CONFIG
|
||||
// keeps it, history and all (see `ScopeModel::set_signals`).
|
||||
// `slot_base_` is deliberately *not* reset -- it is relative mode's clock origin, samples
|
||||
// since the stream began, and relative time must not jump backwards when the user retypes
|
||||
// the window.
|
||||
if (const unsigned gen = model_.plan_generation(); gen != seen_plan_generation_) {
|
||||
seen_plan_generation_ = gen;
|
||||
tick_ = 0;
|
||||
last_tick_ = ~std::uint64_t{0};
|
||||
// Zeroing the stride makes the guard below fire too: the accumulators belong to the group
|
||||
// `tick_` was counting through, so they are stale for the same reason.
|
||||
acc_stride_ = 0;
|
||||
}
|
||||
|
||||
// The filter's groups are `g` long, so a re-plan has to start a fresh group rather than
|
||||
// finish the one it was in with the wrong length.
|
||||
@@ -403,10 +737,68 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
||||
value_acc_.assign(channels.size(), 0.0);
|
||||
value_acc_n_.assign(channels.size(), 0);
|
||||
math_sample_.assign(channels.size(), std::nanf(""));
|
||||
// The group boundaries, as masks, for the per-sample loops below. Here rather than in
|
||||
// them: `g` and the strides are properties of the partition, and a partition change is
|
||||
// exactly what this block already reacts to.
|
||||
acc_mask_.resize(channels.size());
|
||||
for (std::size_t i = 0; i < channels.size(); ++i) {
|
||||
acc_mask_[i] = g * std::max<u64>(1, part.stride[i]) - 1;
|
||||
}
|
||||
time_acc_ = 0.0;
|
||||
abs_time_acc_ = 0.0;
|
||||
time_acc_n_ = 0;
|
||||
}
|
||||
|
||||
// The gap the counter reports, reserved as NaN before this frame's own samples are
|
||||
// stamped: the hole belongs *ahead* of the data that revealed it. Running the missing
|
||||
// samples through the same per-sample loop below is the whole trick -- `tick_` advances
|
||||
// so the decimation phase does not shift, the group mean lands on the mean stamp, and
|
||||
// `MinMaxPyramid::append` sees NaN, which its non-finite contract already stores as an
|
||||
// empty aggregate every reader treats as absent.
|
||||
//
|
||||
// Capped at `kGlitchPackets`: 256 packet spans in one go is an outage, not loss, and
|
||||
// reserving one would push the entire retained history out of the ring to make room for
|
||||
// samples that are not coming.
|
||||
//
|
||||
// This block must follow the re-plan above: reserve_hole() runs the missing samples
|
||||
// through the same decimation accumulators (value_acc_, value_acc_n_, math_sample_),
|
||||
// which must already be sized to the current channel count and stride before any
|
||||
// per-sample write.
|
||||
if (missing > 0 && gap_refused) {
|
||||
// A claim the wire contradicted, or a hole the ring cannot hold: framing it as NaN is what
|
||||
// turns one phantom gap into a band over the whole window (and, at a batch big enough, into
|
||||
// minutes of the producer's time under this lock). The samples are kept, no hole is booked,
|
||||
// and the timeline above was not advanced either -- so the picture shows the data and the gap
|
||||
// shows as the x discontinuity it is. Said once per connection with the numbers, because it is
|
||||
// otherwise invisible: the status bar's `gaps` climbs either way.
|
||||
model_.stats().gaps_refused.fetch_add(1, std::memory_order_relaxed);
|
||||
if (!warned_phantom_gap_) {
|
||||
warned_phantom_gap_ = true;
|
||||
std::fprintf(stderr,
|
||||
"[udpscope] warning: the counter reports %u packet(s) missing (%llu samples, "
|
||||
"%.3f s at the measured cadence of %.6f s/frame) but only %.3f s passed since "
|
||||
"the previous frame, or the hole is larger than the ring (%llu slots); "
|
||||
"refusing the reservation and keeping the samples. A counter that does not "
|
||||
"count packets -- samples, or frames of an inner layer -- reports a jump on "
|
||||
"every datagram and reads exactly like this.\n",
|
||||
missing, static_cast<unsigned long long>(std::uint64_t(missing) * batch_),
|
||||
double(missing) * arrival_interval_, arrival_interval_, since_prev,
|
||||
static_cast<unsigned long long>(u64(missing) * u64(batch_) / part.time_stride));
|
||||
std::fflush(stderr);
|
||||
}
|
||||
} else if (missing > 0 && batch_ > 0 && double(missing) <= kGlitchPackets) {
|
||||
reserve_hole(missing, part, channels, store, g);
|
||||
} else if (missing > 0 && !warned_outage_) {
|
||||
warned_outage_ = true;
|
||||
std::fprintf(stderr,
|
||||
"[udpscope] warning: %u packets missing in one go, past the %g-packet "
|
||||
"reservation cap; treating it as an outage and carrying on rather than "
|
||||
"reserving %llu samples of hole.\n",
|
||||
missing, kGlitchPackets,
|
||||
static_cast<unsigned long long>(std::uint64_t(missing) * batch_));
|
||||
std::fflush(stderr);
|
||||
}
|
||||
|
||||
// Calibration is read per frame; it only changes under the same lock.
|
||||
TriggerEngine& engine = engine_;
|
||||
const int trig_channel = tc.channel;
|
||||
@@ -427,7 +819,15 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
||||
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 channel's *name* goes with it, because that is what identifies it across a
|
||||
// rebuild of the channel list (see `TriggerEngine::configure`).
|
||||
static const std::string kNoTriggerChannel;
|
||||
const std::string& trig_name =
|
||||
trig_channel >= 0 && trig_channel < static_cast<int>(channels.size())
|
||||
? channels[static_cast<std::size_t>(trig_channel)].name
|
||||
: kNoTriggerChannel;
|
||||
engine.configure(tc, trig_name, post_want, event_span - post_want, oldest_s,
|
||||
model_.partition().slot_dt());
|
||||
// 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.
|
||||
@@ -508,6 +908,9 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
||||
|
||||
for (std::uint32_t k = 0; k < n; ++k) {
|
||||
const u64 tick = tick_++;
|
||||
// The shared clock's group boundary, read once and reused by the time ring, the channel
|
||||
// loop's own per-channel test and the math push: three tests of the same thing.
|
||||
const bool group_end = (tick & g_mask) == g_mask;
|
||||
const double rel = std::max(stamps_[k] - epoch, last_stamp_);
|
||||
last_stamp_used = rel;
|
||||
last_stamp_ = rel;
|
||||
@@ -516,10 +919,18 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
||||
// boxcar's group delay puts the averaged value, and stamping it at the end of the
|
||||
// group would shift every trace half a stored sample late.
|
||||
time_acc_ += rel;
|
||||
last_abs_stamp_ = abs_stamps_[k] - epoch;
|
||||
abs_time_acc_ += last_abs_stamp_;
|
||||
++time_acc_n_;
|
||||
if (tick % g == g - 1) {
|
||||
if (group_end) {
|
||||
store.time().push(time_acc_ / static_cast<double>(time_acc_n_));
|
||||
time_acc_ = 0.0;
|
||||
// The absolute clock on the same slot and under the same group mean, so an export can
|
||||
// pair value, relative stamp and absolute stamp by index with no interpolation.
|
||||
if (plot::data::RingBuffer<f64>* abs = store.abs_time(); abs != nullptr) {
|
||||
abs->push(abs_time_acc_ / static_cast<double>(time_acc_n_));
|
||||
}
|
||||
abs_time_acc_ = 0.0;
|
||||
time_acc_n_ = 0;
|
||||
}
|
||||
|
||||
@@ -556,8 +967,8 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
||||
value_acc_[i] += raw * ch.gain + ch.offset;
|
||||
++value_acc_n_[i];
|
||||
|
||||
const u64 period = g * part.stride[i];
|
||||
if (tick % period != period - 1) continue;
|
||||
const u64 mask = acc_mask_[i];
|
||||
if ((tick & mask) != mask) continue;
|
||||
const f32 value =
|
||||
static_cast<f32>(value_acc_[i] / static_cast<double>(value_acc_n_[i]));
|
||||
value_acc_[i] = 0.0;
|
||||
@@ -581,7 +992,7 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
||||
// `publish_event()` clamps its snapshot to every value ring's `produced()`, so a derived
|
||||
// channel pushed after it would be missing its newest sample from the capture -- the same
|
||||
// one-sample clamp the deferred latch above exists for.
|
||||
if (tick % g == g - 1) model_.push_math_sample(math_sample_);
|
||||
if (group_end) model_.push_math_sample(math_sample_);
|
||||
if (latch_here) latch(latch_edge);
|
||||
}
|
||||
last_stamp_ = last_stamp_used;
|
||||
@@ -604,16 +1015,179 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
||||
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",
|
||||
"holdoff_until=%.3f ring=[%.4f..%.4f] n=%zu\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());
|
||||
engine.last_t(), engine.holdoff_until(),
|
||||
// The store's own retained span, beside the engine's view of the clock. Going live
|
||||
// restarts the client, and the question a stalled display raises is whether the *time
|
||||
// ring* is moving at all: `n` climbing while `ring` stands still is a clock problem, and
|
||||
// `ring` moving while `now_t` does not is a feed problem. Neither can be told from the
|
||||
// engine's own fields.
|
||||
store.time().size() > 0 ? double(store.time()[0]) : 0.0,
|
||||
store.time().size() > 0 ? double(store.time()[store.time().size() - 1]) : 0.0,
|
||||
store.time().size());
|
||||
std::fflush(stderr);
|
||||
}
|
||||
}
|
||||
|
||||
void Acquisition::reserve_hole(std::uint32_t missing, const Partition& part,
|
||||
const std::vector<ChannelInfo>& channels, BufferStore& store,
|
||||
u64 g) {
|
||||
const u64 raw = u64(missing) * u64(batch_);
|
||||
Hole h;
|
||||
h.tick_begin = tick_;
|
||||
h.slot_begin = store.time().produced();
|
||||
// `lost` counts the packets missing *immediately before* the frame that revealed them
|
||||
// (see `udps_frame_t::lost`), and the counter mark at the top of `on_data` has already
|
||||
// moved `last_counter_` to that frame's own counter -- so the hole spans
|
||||
// [counter - missing, counter). This read `last_counter_ + 1u`, which addressed a span
|
||||
// one past the revealing frame and `missing + 1` too high: no late packet's counter could
|
||||
// fall in it, so every backfill lookup missed and `backfilled` read 0 against 154 holes.
|
||||
h.counter_first = last_counter_ - missing;
|
||||
h.packets = missing;
|
||||
|
||||
double dt = part.base_dt;
|
||||
if (!(dt > 0.0)) dt = 1.0e-3;
|
||||
// The same group tests as the live loop, written the same way: a hole's samples go through
|
||||
// the same accumulators, so the two phases have to agree sample for sample.
|
||||
const u64 g_mask = g - 1;
|
||||
for (u64 j = 0; j < raw; ++j) {
|
||||
const u64 tick = tick_++;
|
||||
const bool group_end = (tick & g_mask) == g_mask;
|
||||
// The hole is stamped at the declared cadence from the last good stamp -- exact in
|
||||
// relative mode, and in stream mode the same continuation the glitch bridge makes.
|
||||
// It must be *stamped* rather than left short: the time ring is binary-searched by
|
||||
// `ChannelBuffer::neighbours()`, so it has to stay monotone and dense, and backfill
|
||||
// then changes values only and never a stamp.
|
||||
const double rel = last_stamp_ + dt;
|
||||
last_stamp_ = rel;
|
||||
last_abs_stamp_ += dt;
|
||||
if (j == 0) h.x_begin = rel;
|
||||
h.x_end = rel;
|
||||
|
||||
time_acc_ += rel;
|
||||
abs_time_acc_ += last_abs_stamp_;
|
||||
++time_acc_n_;
|
||||
if (group_end) {
|
||||
store.time().push(time_acc_ / double(time_acc_n_));
|
||||
// Null in stream mode, where `time()` already is the absolute clock.
|
||||
if (plot::data::RingBuffer<f64>* abs = store.abs_time(); abs != nullptr) {
|
||||
abs->push(abs_time_acc_ / double(time_acc_n_));
|
||||
}
|
||||
time_acc_ = 0.0;
|
||||
abs_time_acc_ = 0.0;
|
||||
time_acc_n_ = 0;
|
||||
}
|
||||
for (std::size_t i = 0; i < channels.size(); ++i) {
|
||||
if (channels[i].math) continue;
|
||||
// A NaN in the accumulator makes the whole group NaN, which is why a slot straddling
|
||||
// a hole edge is NaN too: its real members were averaged into a sum that is now
|
||||
// non-finite and were never retained, so the backfill cannot recover it and must not
|
||||
// pretend to. One slot per edge, against a hole thousands of slots wide.
|
||||
value_acc_[i] += std::numeric_limits<double>::quiet_NaN();
|
||||
++value_acc_n_[i];
|
||||
const u64 mask = acc_mask_[i];
|
||||
if ((tick & mask) != mask) continue;
|
||||
store.values(i).push(static_cast<f32>(value_acc_[i] / double(value_acc_n_[i])));
|
||||
value_acc_[i] = 0.0;
|
||||
value_acc_n_[i] = 0;
|
||||
}
|
||||
// The math channels are fed NaN like everything else, so a derived trace keeps the gap
|
||||
// its source had. The trigger is *not* fed: a NaN comparison is false, so the engine
|
||||
// could not latch here anyway, and feeding it would advance its hold-off clock over
|
||||
// time nothing was sampled in.
|
||||
if (group_end) {
|
||||
std::fill(math_sample_.begin(), math_sample_.end(), std::nanf(""));
|
||||
model_.push_math_sample(math_sample_);
|
||||
}
|
||||
}
|
||||
h.slot_count = store.time().produced() - h.slot_begin;
|
||||
model_.note_hole(h);
|
||||
model_.stats().holes.fetch_add(1, std::memory_order_relaxed);
|
||||
model_.stats().hole_slots.fetch_add(h.slot_count, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void Acquisition::backfill_from(const udps_frame_t* frame, const Partition& part,
|
||||
const std::vector<ChannelInfo>& channels, BufferStore& store,
|
||||
u64 g) {
|
||||
// `batch_` is learned in `build_stamps()`, which a late frame never reaches. A late frame
|
||||
// before any good one therefore has no packet length to size its span with, and no hole
|
||||
// could have been reserved for it either.
|
||||
if (batch_ == 0) return;
|
||||
|
||||
// Which hole this packet belongs to, by counter: that is the whole reason relative timing
|
||||
// and resequencing are one design. A stamp could not answer it -- the packet's own stamps
|
||||
// are a claim about a timeline the ring has moved past -- but the counter indexes the
|
||||
// reservation table directly.
|
||||
// By signed delta, the idiom the client uses throughout: the counter is a wrapping uint32, so
|
||||
// a raw `>=`/`<` pair matches nothing for a hole straddling the 2^32 wrap and every one of its
|
||||
// late packets would be dropped on the next line.
|
||||
Hole* hole = nullptr;
|
||||
for (Hole& h : model_.holes()) {
|
||||
const std::int32_t into = static_cast<std::int32_t>(frame->counter - h.counter_first);
|
||||
if (into >= 0 && into < static_cast<std::int32_t>(h.packets)) {
|
||||
hole = &h;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hole == nullptr) return; // its hole has already slid out of the ring, or never existed
|
||||
|
||||
// The packet's own span in absolute base ticks. `tick_begin` is the hole's first raw sample
|
||||
// and the packets inside a hole are consecutive, so the counter's distance from
|
||||
// `counter_first` places this one exactly.
|
||||
const u64 lo = hole->tick_begin + u64(frame->counter - hole->counter_first) * u64(batch_);
|
||||
const u64 hi = lo + u64(batch_);
|
||||
|
||||
u64 filled = 0;
|
||||
for (std::size_t i = 0; i < channels.size(); ++i) {
|
||||
// A derived channel has no packet signal to read, and the design does not retrofit it:
|
||||
// its gap stays.
|
||||
if (channels[i].math) continue;
|
||||
const u64 period = g * part.stride[i];
|
||||
// Only slots lying *wholly* inside this packet's span are repaired. A slot straddling the
|
||||
// span's edge mixes this packet's samples with ones still missing, and its real members
|
||||
// were never retained -- `reserve_hole()` averaged a NaN into them -- so it stays NaN, and
|
||||
// a displayed hole is at most one slot wider per edge than the true loss.
|
||||
const u64 first_slot = (lo + period - 1) / period;
|
||||
const u64 last_slot = hi / period;
|
||||
for (u64 s = first_slot; s < last_slot; ++s) {
|
||||
// The same boxcar mean the live path stores, over the same group of raw samples: the
|
||||
// whole group is inside this packet, so the average is exact and not an approximation
|
||||
// of what the live path would have written.
|
||||
double acc = 0.0;
|
||||
for (u64 j = 0; j < period; ++j) {
|
||||
const u64 k = s * period + j - lo; // this packet's own sample index, 0..batch_-1
|
||||
const double raw =
|
||||
channels[i].elements > 1
|
||||
? udps_frame_value(frame, static_cast<std::uint32_t>(channels[i].signal), 0,
|
||||
std::min<std::uint32_t>(static_cast<std::uint32_t>(k),
|
||||
channels[i].elements - 1))
|
||||
: udps_frame_value(frame, static_cast<std::uint32_t>(channels[i].signal),
|
||||
static_cast<std::uint32_t>(k), 0);
|
||||
acc += raw * channels[i].gain + channels[i].offset;
|
||||
}
|
||||
const f32 value = static_cast<f32>(acc / double(period));
|
||||
// The ring first, by the producer that owns its writes; the pyramid is queued for the
|
||||
// consumer (see `ChannelBuffer::queue_amend`). A slot the ring has already discarded
|
||||
// returns false, and then there is no aggregate to amend either.
|
||||
if (store.values(i).amend(s, value)) {
|
||||
store.channel(i).queue_amend(s, value);
|
||||
++filled;
|
||||
}
|
||||
}
|
||||
}
|
||||
// This packet's share of the hole has been delivered, whatever the ring still retains of it.
|
||||
// Marked on *arrival* and not on `filled`: a decimation coarse enough that no whole slot lies
|
||||
// inside one packet repairs no slot at all, and keying off `filled` would leave such a hole
|
||||
// permanently unrepairable -- a solid band over data that is all there, which is the exact
|
||||
// defect this task closes.
|
||||
++hole->filled_packets;
|
||||
model_.stats().backfilled.fetch_add(filled, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "capture.hpp"
|
||||
#include "config.hpp"
|
||||
#include "model.hpp"
|
||||
#include "trigger.hpp"
|
||||
@@ -12,6 +13,7 @@
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
@@ -37,6 +39,19 @@ class Acquisition {
|
||||
void set_paused(bool paused) noexcept { paused_.store(paused, std::memory_order_relaxed); }
|
||||
bool paused() const noexcept { return paused_.load(std::memory_order_relaxed); }
|
||||
|
||||
/// Turns the packet capture on or off -- the whole GTK side of the feature. The
|
||||
/// path is built here, once, *before* the flag is published, so the producer reads
|
||||
/// a complete string whenever it reads a true flag. `slots` is the ring's size;
|
||||
/// zero means the 1000-slot default. The producer does the open or the close at the
|
||||
/// top of the next datagram, so a toggle costs no lock and no second thread.
|
||||
void set_capture_want(bool want, std::uint32_t slots = 0);
|
||||
bool capture_want() const noexcept {
|
||||
return capture_want_.load(std::memory_order_acquire);
|
||||
}
|
||||
/// The capture, for the popover's row and the probe. Counters and errors are read
|
||||
/// cross-thread through PacketCapture's own published fields.
|
||||
const PacketCapture& capture() const noexcept { return capture_; }
|
||||
|
||||
private:
|
||||
void run();
|
||||
void on_config(const udps_signal_t* signals, std::uint32_t count, std::uint8_t publish_mode);
|
||||
@@ -47,11 +62,45 @@ class Acquisition {
|
||||
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);
|
||||
static void datagram_trampoline(const std::uint8_t* bytes, std::size_t len,
|
||||
double recv_time, void* user);
|
||||
/// Drops the producer state that is per *connection*: the counter mark, the time
|
||||
/// anchors and the clock choice. A reconnect re-sends the CONFIG and the stream may
|
||||
/// have restarted its numbering; carrying the old session's marks across makes the
|
||||
/// first new frames read as late, as glitches or as an outage, which is recovery
|
||||
/// failing exactly when it matters. Called on the producer thread, from on_event().
|
||||
void reset_session_state() noexcept;
|
||||
/// The producer side of the toggle: open on a rising edge, close on a falling one,
|
||||
/// then store if enabled. Every file operation happens here, on this thread.
|
||||
void on_datagram(const std::uint8_t* bytes, std::size_t len, double recv_time);
|
||||
|
||||
/// 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);
|
||||
std::uint32_t build_stamps(const udps_frame_t* frame, int time_signal,
|
||||
bool counter_gap_refused);
|
||||
|
||||
/// Pushes NaN for the `missing` packets' worth of samples that were never delivered,
|
||||
/// advances the decimation accumulators and tick_, records the hole in the model, and
|
||||
/// bumps the hole counters. Called before the frame's own samples so the gap sits
|
||||
/// *ahead* of the data that revealed it.
|
||||
void reserve_hole(std::uint32_t missing, const Partition& part,
|
||||
const std::vector<ChannelInfo>& channels, BufferStore& store, u64 g);
|
||||
|
||||
/// Writes a late frame's values into the NaN-filled holes their counter addresses,
|
||||
/// amending the value ring directly (producer path) and queuing pyramid amends for
|
||||
/// `ChannelBuffer::acquire()` to apply on the consumer thread.
|
||||
void backfill_from(const udps_frame_t* frame, const Partition& part,
|
||||
const std::vector<ChannelInfo>& channels, BufferStore& store, u64 g);
|
||||
|
||||
/// Keeps the wire's own cadence up to date from the arrival times, in the modes where
|
||||
/// `build_stamps()`'s arrival branch does not (see the definition).
|
||||
void note_frame_arrival(double recv_time) noexcept;
|
||||
|
||||
/// Whether the counter's claim of `missing` lost packets can be believed *and* booked: the
|
||||
/// arrival clock has to corroborate it and the ring has to be able to hold it. See the
|
||||
/// definition for both tests and for what believing a claim that fails them looks like.
|
||||
bool gap_plausible(std::uint32_t missing, double elapsed, const Partition& part, u64 g) const;
|
||||
|
||||
ScopeModel& model_;
|
||||
Config cfg_;
|
||||
@@ -59,6 +108,12 @@ class Acquisition {
|
||||
std::thread thread_;
|
||||
std::atomic<bool> running_{false};
|
||||
std::atomic<bool> paused_{false};
|
||||
PacketCapture capture_;
|
||||
std::atomic<bool> capture_want_{false};
|
||||
std::uint32_t capture_slots_ = 1000;
|
||||
/// Written once, by the GTK thread, before `capture_want_` is ever published true;
|
||||
/// the producer reads it only after the flag, so no lock is needed.
|
||||
std::string capture_path_;
|
||||
TriggerEngine engine_;
|
||||
|
||||
std::uint64_t tick_ = 0; ///< base ticks consumed since the epoch
|
||||
@@ -70,25 +125,73 @@ class Acquisition {
|
||||
/// included -- so the stream source is dropped in favour of arrival time.
|
||||
double last_stream_anchor_ = 0.0;
|
||||
bool have_stream_anchor_ = false;
|
||||
/// Consecutive packets whose stream-time stamps failed those checks. A single bad
|
||||
/// packet is a glitch in the producer's clock and is bridged at the declared cadence;
|
||||
/// only a run of them means the declared signal is not a time base, and only then is
|
||||
/// the arrival clock taken. Any accepted packet resets it.
|
||||
unsigned stamp_failures_ = 0;
|
||||
/// 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;
|
||||
/// Smoothed interval between two frames' arrivals, and the previous arrival: the
|
||||
/// spine of the arrival-anchored clock (see `build_stamps`).
|
||||
/// spine of the arrival-anchored clock (see `build_stamps`). In relative mode no frame
|
||||
/// reaches that branch, so `on_data` keeps the same pair up to date itself -- the
|
||||
/// reservation's plausibility test below needs a measured cadence whatever clock the
|
||||
/// user chose, and the two writers are mutually exclusive by mode.
|
||||
double arrival_interval_ = 0.0;
|
||||
double arrival_prev_ = 0.0;
|
||||
bool have_arrival_interval_ = false;
|
||||
/// The previous *accepted* frame's receive time, for the one measurement the reservation
|
||||
/// test needs: how much wall time actually passed. Kept apart from `arrival_prev_` on
|
||||
/// purpose -- that one is the arrival clock's own bookkeeping and is re-anchored by the
|
||||
/// clock-choice code, while this is a bare witness of the wire and must never be moved by
|
||||
/// a mode change.
|
||||
double prev_frame_recv_ = 0.0;
|
||||
bool have_prev_frame_recv_ = false;
|
||||
/// The stream's time source, chosen once and sticky: 0 undecided, 1 the declared
|
||||
/// time signal, 2 arrival. Re-evaluated only on a CONFIG. A per-frame choice
|
||||
/// interleaved two timelines in one ring -- some frames stamped from a declared
|
||||
/// signal carrying volt-like values, others from the arrival clock -- which is a
|
||||
/// time base no reader can make sense of (see `build_stamps`).
|
||||
int time_source_ = 0;
|
||||
/// Set when the source changed under the recorded history: the rings have to be
|
||||
/// emptied before more samples go in.
|
||||
bool history_reset_pending_ = false;
|
||||
/// Relative-time mode's position counter: samples seen since the stream began, which is
|
||||
/// what the counter names. Advanced by `batch_` for every packet the counter reports
|
||||
/// missing (before the frame's own samples) and then by the frame's own `n`, so it is
|
||||
/// exactly `counter * batch + sample_index` for a constant batch and degrades gracefully
|
||||
/// when a frame's length disagrees.
|
||||
std::uint64_t slot_base_ = 0;
|
||||
/// Samples per DATA packet, fixed by the first frame. Only ever needed to size what is
|
||||
/// *absent*: a delivered frame carries its own length. 0 until the first frame.
|
||||
std::uint32_t batch_ = 0;
|
||||
/// Latches the one warning about a frame whose length disagrees with the stream's first.
|
||||
bool warned_batch_ = false;
|
||||
/// The newest counter seen, and the hole's address. Tracked here rather than inferred from
|
||||
/// `frame->lost` because a *late* frame's counter is behind the sequence and must not move
|
||||
/// the mark forward.
|
||||
std::uint32_t last_counter_ = 0;
|
||||
bool have_counter_ = false;
|
||||
/// Last absolute stamp written, epoch-relative, so a reserved hole can continue the
|
||||
/// absolute clock at the declared cadence. The packets that would have carried the real
|
||||
/// stamps never arrived, so a cadence continuation is the only honest value available.
|
||||
double last_abs_stamp_ = 0.0;
|
||||
/// Warn-once latch for a loss past the reservation cap. A member, not a function-local
|
||||
/// `static`, matching the latch Task 2 added for the same reason.
|
||||
bool warned_outage_ = false;
|
||||
/// Warn-once latch for a reservation the arrival clock did not corroborate, or that the ring
|
||||
/// could not hold (see `gap_plausible()`). Once per connection, like the two above.
|
||||
bool warned_phantom_gap_ = false;
|
||||
/// Warn-once latch for the one bridged packet whose time signal glitched.
|
||||
bool warned_bridge_ = false;
|
||||
/// Warn-once latch for the declared time signal failing for good, so the arrival clock is
|
||||
/// taken instead.
|
||||
bool warned_time_source_ = false;
|
||||
/// The re-plan generation this object last reacted to. The buffer is re-planned whenever the
|
||||
/// model rebuilds it, and the state tied to the old store (`tick_` and the accumulators) is
|
||||
/// reset when this falls behind `ScopeModel::plan_generation()`.
|
||||
unsigned seen_plan_generation_ = 0;
|
||||
std::vector<double> stamps_;
|
||||
std::vector<double> abs_stamps_;
|
||||
/// The decimation filter's state: a running sum per channel (and one for the shared
|
||||
/// clock) over the group of base samples that becomes one stored sample. A boxcar
|
||||
/// average, which is what turns decimation into a *resampling* rather than a stride:
|
||||
@@ -100,7 +203,17 @@ class Acquisition {
|
||||
/// once the slot's samples have all been pushed. One entry per channel, refilled every slot.
|
||||
std::vector<f32> math_sample_;
|
||||
std::vector<std::uint64_t> value_acc_n_;
|
||||
/// Per channel, `time_stride * stride[i] - 1`: the group boundary as a **mask**, so the
|
||||
/// innermost loop tests `(tick & mask) == mask` instead of `tick % period == period - 1`.
|
||||
/// The divisor is a runtime value, so that modulo compiled to a real 64-bit `div` once per
|
||||
/// channel per *wire* sample -- the filter reads every sample by design, so it was the
|
||||
/// producer's largest avoidable cost. Both factors are powers of two (`plan_partition()`
|
||||
/// doubles `time_stride` from 1, and every `stride[i]` is 1), which is what makes the mask
|
||||
/// equivalent; it is sized and filled with the accumulators below, so a re-plan cannot
|
||||
/// leave it addressing the previous partition's groups.
|
||||
std::vector<std::uint64_t> acc_mask_;
|
||||
double time_acc_ = 0.0;
|
||||
double abs_time_acc_ = 0.0;
|
||||
std::uint64_t time_acc_n_ = 0;
|
||||
/// The partition the accumulators were sized for, so a re-plan resets them rather than
|
||||
/// averaging across the boundary.
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include "capture.hpp"
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
|
||||
#include <chrono>
|
||||
#include <vector>
|
||||
|
||||
namespace udpscope {
|
||||
namespace {
|
||||
|
||||
/// The file header is one 64-byte block: fixed fields first, zeroed padding after.
|
||||
constexpr std::uint64_t kHeaderBytes = 64;
|
||||
/// `seq` lives at the *end* of a slot, at a fixed offset, because the reader must
|
||||
/// find it without trusting any other field.
|
||||
constexpr std::uint64_t kSeqBytes = 8;
|
||||
|
||||
void put_u32(std::uint8_t* p, std::uint32_t v) { std::memcpy(p, &v, 4); }
|
||||
void put_u16(std::uint8_t* p, std::uint16_t v) { std::memcpy(p, &v, 2); }
|
||||
void put_f64(std::uint8_t* p, double v) { std::memcpy(p, &v, 8); }
|
||||
std::uint32_t get_u32(const std::uint8_t* p) {
|
||||
std::uint32_t v;
|
||||
std::memcpy(&v, p, 4);
|
||||
return v;
|
||||
}
|
||||
double get_f64(const std::uint8_t* p) {
|
||||
double v;
|
||||
std::memcpy(&v, p, 8);
|
||||
return v;
|
||||
}
|
||||
|
||||
double wall_seconds() {
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_REALTIME, &ts);
|
||||
return double(ts.tv_sec) + double(ts.tv_nsec) * 1e-9;
|
||||
}
|
||||
|
||||
/// `seq` is published with a release store *after* the bytes are down, so a reader
|
||||
/// that sees a non-zero `seq` sees the whole slot -- the reason no `fsync` is needed
|
||||
/// for the file to be readable after a crash. Plain __atomic builtins, not
|
||||
/// `std::atomic_ref`: the target is mmap'd memory the class does not own as an object.
|
||||
void publish_seq(std::uint8_t* slot, std::uint64_t seq) {
|
||||
__atomic_store_n(reinterpret_cast<std::uint64_t*>(slot + kSlotBytes - kSeqBytes), seq,
|
||||
__ATOMIC_RELEASE);
|
||||
}
|
||||
|
||||
std::uint64_t read_seq(const std::uint8_t* slot) {
|
||||
return __atomic_load_n(reinterpret_cast<const std::uint64_t*>(slot + kSlotBytes - kSeqBytes),
|
||||
__ATOMIC_ACQUIRE);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void PacketCapture::set_error(const char* what) noexcept {
|
||||
std::snprintf(error_, sizeof(error_), "%s", what);
|
||||
error_set_.store(true, std::memory_order_release);
|
||||
}
|
||||
|
||||
void PacketCapture::error(char* out, std::size_t cap) const noexcept {
|
||||
if (cap == 0) return;
|
||||
out[0] = '\0';
|
||||
if (!error_set_.load(std::memory_order_acquire)) return;
|
||||
std::snprintf(out, cap, "%s", error_);
|
||||
}
|
||||
|
||||
bool PacketCapture::open(const std::string& path, std::uint32_t slot_count) {
|
||||
close();
|
||||
path_ = path;
|
||||
slot_count_ = slot_count;
|
||||
seq_ = 0;
|
||||
cursor_ = 0;
|
||||
stored_.store(0, std::memory_order_relaxed);
|
||||
truncated_.store(0, std::memory_order_relaxed);
|
||||
error_set_.store(false, std::memory_order_relaxed);
|
||||
|
||||
const std::chrono::steady_clock::time_point t0 = std::chrono::steady_clock::now();
|
||||
|
||||
// Keep the previous capture one deep: re-enabling must not destroy the capture the
|
||||
// user enabled *for*. A failure here is reported, and the old previous kept.
|
||||
std::string prev = path;
|
||||
const std::size_t slash = prev.rfind('/');
|
||||
const std::size_t dot = prev.rfind('.');
|
||||
if (dot != std::string::npos && (slash == std::string::npos || dot > slash)) {
|
||||
prev.insert(dot, "-prev");
|
||||
} else {
|
||||
prev += "-prev";
|
||||
}
|
||||
::rename(path.c_str(), prev.c_str());
|
||||
|
||||
fd_ = ::open(path.c_str(), O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC, 0600);
|
||||
if (fd_ < 0) {
|
||||
set_error(strerror(errno));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reserved, not sparse: a store into a page the filesystem cannot back raises
|
||||
// SIGBUS and kills udpscope outright, and an out-of-space condition must be an
|
||||
// error at the moment the user clicked, not a crash ten minutes later.
|
||||
const std::uint64_t total = kHeaderBytes + std::uint64_t(slot_count) * kSlotBytes;
|
||||
if (::posix_fallocate(fd_, 0, off_t(total)) != 0) {
|
||||
set_error(strerror(errno));
|
||||
::close(fd_);
|
||||
fd_ = -1;
|
||||
::unlink(path.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
map_ = static_cast<std::uint8_t*>(
|
||||
::mmap(nullptr, total, PROT_READ | PROT_WRITE, MAP_SHARED, fd_, 0));
|
||||
if (map_ == MAP_FAILED) {
|
||||
map_ = nullptr;
|
||||
set_error(strerror(errno));
|
||||
::close(fd_);
|
||||
fd_ = -1;
|
||||
::unlink(path.c_str());
|
||||
return false;
|
||||
}
|
||||
map_bytes_ = total;
|
||||
|
||||
std::uint8_t* h = map_;
|
||||
std::memset(h, 0, kHeaderBytes);
|
||||
std::memcpy(h, "UDPSCAP\0", 8);
|
||||
put_u32(h + 8, 1); // version
|
||||
put_u32(h + 12, slot_count);
|
||||
put_u32(h + 16, kSlotBytes);
|
||||
put_u32(h + 20, 0); // cursor: a hint, `seq` is the order
|
||||
put_f64(h + 24, wall_seconds()); // the epoch recv_time is relative to
|
||||
put_u32(h + 32, peer_addr_);
|
||||
put_u16(h + 36, peer_port_);
|
||||
put_u16(h + 38, 0); // local_port: the client does not expose its own socket
|
||||
const double ms =
|
||||
std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
|
||||
open_ms_.store(ms, std::memory_order_relaxed);
|
||||
|
||||
enabled_.store(true, std::memory_order_release);
|
||||
return true;
|
||||
}
|
||||
|
||||
void PacketCapture::close() noexcept {
|
||||
if (map_ != nullptr) {
|
||||
::munmap(map_, map_bytes_);
|
||||
map_ = nullptr;
|
||||
}
|
||||
if (fd_ >= 0) {
|
||||
::close(fd_);
|
||||
fd_ = -1;
|
||||
}
|
||||
map_bytes_ = 0;
|
||||
enabled_.store(false, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void PacketCapture::store(const std::uint8_t* bytes, std::size_t len, double recv_time) noexcept {
|
||||
if (!enabled_.load(std::memory_order_relaxed) || map_ == nullptr) return;
|
||||
|
||||
std::uint8_t* slot = map_ + kHeaderBytes + std::uint64_t(cursor_) * kSlotBytes;
|
||||
std::uint32_t orig = std::uint32_t(len);
|
||||
std::size_t fit = len;
|
||||
if (fit > kMaxDatagramBytes) {
|
||||
fit = kMaxDatagramBytes;
|
||||
truncated_.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
put_f64(slot, recv_time);
|
||||
put_u32(slot + 8, std::uint32_t(fit));
|
||||
put_u32(slot + 12, orig);
|
||||
std::memcpy(slot + kRecordHeaderBytes, bytes, fit);
|
||||
++seq_;
|
||||
publish_seq(slot, seq_); // last: a non-zero `seq` is what makes a slot complete
|
||||
|
||||
cursor_ = (cursor_ + 1) % slot_count_;
|
||||
put_u32(map_ + 20, cursor_); // a hint for a human in xxd; `seq` is the order
|
||||
stored_.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
bool read_capture(const std::string& path, CaptureReadStats* out) {
|
||||
if (out == nullptr) return false;
|
||||
*out = CaptureReadStats{};
|
||||
|
||||
FILE* f = std::fopen(path.c_str(), "rb");
|
||||
if (f == nullptr) return false;
|
||||
std::fseek(f, 0, SEEK_END);
|
||||
const long size = std::ftell(f);
|
||||
std::fseek(f, 0, SEEK_SET);
|
||||
out->file_bytes = std::uint64_t(size < 0 ? 0 : size);
|
||||
|
||||
std::uint8_t h[kHeaderBytes];
|
||||
if (std::fread(h, 1, sizeof(h), f) != sizeof(h)) {
|
||||
std::fclose(f);
|
||||
return false;
|
||||
}
|
||||
out->header_ok = std::memcmp(h, "UDPSCAP\0", 8) == 0 && get_u32(h + 8) == 1;
|
||||
out->slot_count = get_u32(h + 12);
|
||||
out->slot_bytes = get_u32(h + 16);
|
||||
out->cursor = get_u32(h + 20);
|
||||
out->t0_realtime = get_f64(h + 24);
|
||||
if (!out->header_ok || out->slot_count == 0 || out->slot_bytes < kRecordHeaderBytes + 8) {
|
||||
std::fclose(f);
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::uint32_t payload = out->slot_bytes - kRecordHeaderBytes - kSeqBytes;
|
||||
std::vector<std::uint8_t> slot(out->slot_bytes);
|
||||
for (std::uint32_t i = 0; i < out->slot_count; ++i) {
|
||||
if (std::fseek(f, long(kHeaderBytes + std::uint64_t(i) * out->slot_bytes), SEEK_SET) != 0) {
|
||||
break;
|
||||
}
|
||||
if (std::fread(slot.data(), 1, slot.size(), f) != slot.size()) break;
|
||||
const std::uint64_t seq = read_seq(slot.data());
|
||||
const std::uint32_t len = get_u32(slot.data() + 8);
|
||||
const std::uint32_t orig = get_u32(slot.data() + 12);
|
||||
if (seq == 0 && len == 0 && orig == 0) {
|
||||
++out->empty;
|
||||
continue;
|
||||
}
|
||||
if (seq == 0) {
|
||||
++out->torn; // bytes down, ordinal never published: a capture that died mid-copy
|
||||
continue;
|
||||
}
|
||||
++out->stored;
|
||||
out->payload += len;
|
||||
if (orig > payload) ++out->truncated;
|
||||
if (out->first_seq == 0 || seq < out->first_seq) out->first_seq = seq;
|
||||
if (seq > out->last_seq) out->last_seq = seq;
|
||||
}
|
||||
std::fclose(f);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -0,0 +1,124 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace udpscope {
|
||||
|
||||
/// The largest datagram the client's socket can deliver, and therefore the payload
|
||||
/// capacity of one capture slot. Kept equal to the client's own receive buffer: an
|
||||
/// over-long datagram is impossible by construction, and `store()` counts the
|
||||
/// impossibility rather than trusting it.
|
||||
inline constexpr std::uint32_t kMaxDatagramBytes = 65535 + 17;
|
||||
|
||||
/// One slot's stride: the fixed record header, the largest datagram, and the `seq`
|
||||
/// word that is written last. 8-byte aligned.
|
||||
inline constexpr std::uint32_t kRecordHeaderBytes = 16; ///< t_arrival f64, len u32, orig_len u32
|
||||
inline constexpr std::uint32_t kSlotBytes =
|
||||
kRecordHeaderBytes + kMaxDatagramBytes + 8; ///< the trailing 8 are `seq`
|
||||
|
||||
/// Owns the ring file a packet capture stores raw datagrams into, and stores a
|
||||
/// datagram into it. It includes no GTK, no `ScopeModel`, and no client types: its
|
||||
/// whole world is bytes, a clock reading and one file.
|
||||
///
|
||||
/// Everything here runs on the producer thread except `open()`'s caller choice and
|
||||
/// the GTK thread's reads of the counters and the error, which are publishable
|
||||
/// without a lock: see the field table in the design note
|
||||
/// (`docs/superpowers/specs/2026-10-09-udp-packet-capture-design.md`).
|
||||
class PacketCapture {
|
||||
public:
|
||||
PacketCapture() = default;
|
||||
~PacketCapture() { close(); }
|
||||
|
||||
PacketCapture(const PacketCapture&) = delete;
|
||||
PacketCapture& operator=(const PacketCapture&) = delete;
|
||||
|
||||
/// Renames an existing capture to `<path>` minus extension plus `-prev.bin`,
|
||||
/// creates and reserves the new file, maps it, writes the header. Returns false
|
||||
/// and leaves the object disabled on any failure, with `error()` set.
|
||||
bool open(const std::string& path, std::uint32_t slot_count);
|
||||
|
||||
/// Unmaps and closes. Idempotent; keeps the counters, which read "since open()".
|
||||
void close() noexcept;
|
||||
|
||||
bool enabled() const noexcept { return enabled_.load(std::memory_order_relaxed); }
|
||||
|
||||
/// One datagram. Called only from the producer thread. Never allocates, never
|
||||
/// blocks, never fails: an over-long datagram is truncated to the slot and counted.
|
||||
void store(const std::uint8_t* bytes, std::size_t len, double recv_time) noexcept;
|
||||
|
||||
std::uint64_t stored() const noexcept { return stored_.load(std::memory_order_relaxed); }
|
||||
std::uint64_t truncated() const noexcept {
|
||||
return truncated_.load(std::memory_order_relaxed);
|
||||
}
|
||||
std::uint32_t slot_count() const noexcept { return slot_count_; }
|
||||
const std::string& path() const noexcept { return path_; }
|
||||
|
||||
/// The source's IPv4, in network order, and its port: written into the header at
|
||||
/// open() so the pcap converter can synthesise a UDP header. Call before open(),
|
||||
/// from the thread that owns the decision -- the same write-once discipline as the
|
||||
/// path. `local_port` stays zero: the client does not expose its own socket.
|
||||
void set_peer(std::uint32_t addr_net_order, std::uint16_t port) noexcept {
|
||||
peer_addr_ = addr_net_order;
|
||||
peer_port_ = port;
|
||||
}
|
||||
|
||||
/// Wall milliseconds `open()` took, measured rather than asserted: the one cost a
|
||||
/// toggle has, and a reading above ~50 ms is a finding about the filesystem.
|
||||
double open_ms() const noexcept { return open_ms_.load(std::memory_order_relaxed); }
|
||||
|
||||
/// Copies the last open() failure out, or writes an empty string. Deliberately not
|
||||
/// a `const std::string&`: the producer writes the fixed buffer and *then* publishes
|
||||
/// the flag with a release store, and the GTK thread copies only when the flag reads
|
||||
/// true -- a `std::string` shared like that is a data race with a reallocating
|
||||
/// buffer, the kind that reads correctly for months.
|
||||
void error(char* out, std::size_t cap) const noexcept;
|
||||
|
||||
private:
|
||||
void set_error(const char* what) noexcept;
|
||||
|
||||
std::string path_;
|
||||
std::uint32_t slot_count_ = 0;
|
||||
int fd_ = -1;
|
||||
std::uint8_t* map_ = nullptr; ///< whole file, header first
|
||||
std::uint64_t map_bytes_ = 0;
|
||||
std::uint64_t seq_ = 0; ///< 1-based ordinal since open(); producer-only
|
||||
std::uint32_t cursor_ = 0; ///< next slot index to write; producer-only
|
||||
std::uint32_t peer_addr_ = 0; ///< the source's IPv4, network order
|
||||
std::uint16_t peer_port_ = 0;
|
||||
std::atomic<bool> enabled_{false};
|
||||
std::atomic<std::uint64_t> stored_{0};
|
||||
std::atomic<std::uint64_t> truncated_{0};
|
||||
std::atomic<bool> error_set_{false};
|
||||
std::atomic<double> open_ms_{0.0};
|
||||
mutable char error_[128] = {0};
|
||||
};
|
||||
|
||||
/// What a capture file says when read back off disk. `stored` counts slots with a
|
||||
/// non-zero `seq`; `torn` counts slots with bytes but no `seq` (a capture that died
|
||||
/// mid-copy); `empty` counts all-zero slots, which a freshly reserved file is full of.
|
||||
struct CaptureReadStats {
|
||||
bool header_ok = false;
|
||||
std::uint32_t slot_count = 0;
|
||||
std::uint32_t slot_bytes = 0;
|
||||
std::uint32_t cursor = 0;
|
||||
double t0_realtime = 0.0;
|
||||
std::uint64_t stored = 0;
|
||||
std::uint64_t torn = 0;
|
||||
std::uint64_t empty = 0;
|
||||
std::uint64_t truncated = 0; ///< slots whose `orig_len` exceeds the payload capacity
|
||||
std::uint64_t payload = 0; ///< sum of stored slots' `len`: the bytes the wire delivered
|
||||
std::uint64_t first_seq = 0;
|
||||
std::uint64_t last_seq = 0;
|
||||
std::uint64_t file_bytes = 0;
|
||||
};
|
||||
|
||||
/// Reads a capture file back and validates it. Pure POSIX plus `<cstdio>`; used by the
|
||||
/// headless probe, and `tools/capture_to_pcap.py` re-implements the walk in Python.
|
||||
bool read_capture(const std::string& path, CaptureReadStats* out);
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -31,10 +31,9 @@ ChannelBuffer::ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>>
|
||||
|
||||
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;
|
||||
}
|
||||
// Nothing new skips the append pass only: the backfill drain at the end of this function
|
||||
// runs either way, because a late packet can land in a frame interval that pushed nothing.
|
||||
if (produced > published_) {
|
||||
// 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.
|
||||
@@ -61,11 +60,22 @@ void ChannelBuffer::acquire() noexcept {
|
||||
// place in the object's life where that sample's slot is safe to read.
|
||||
if (pyramid_.size() > 0) window_x0_ = time_of(absolute(0));
|
||||
}
|
||||
// Backfills, applied after the append pass above: an amend is only ever queued for a slot
|
||||
// the value ring already produced, so the append has reached it and the pyramid's bounds
|
||||
// test will accept it. Unconditional, outside the `produced > published_` guard -- a late
|
||||
// packet can land in a frame interval that pushed nothing new, and the plan's placement
|
||||
// behind the old early return would have left the pyramid stale until the next push.
|
||||
if (!pending_amends_.empty()) {
|
||||
for (const Amend& a : pending_amends_) pyramid_.amend(a.absolute, a.value);
|
||||
pending_amends_.clear();
|
||||
}
|
||||
}
|
||||
|
||||
void ChannelBuffer::invalidate() noexcept {
|
||||
published_ = 0;
|
||||
window_x0_ = 0.0;
|
||||
pyramid_.clear();
|
||||
pending_amends_.clear();
|
||||
}
|
||||
|
||||
f64 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
|
||||
|
||||
@@ -49,6 +49,17 @@ class ChannelBuffer final : public plot::data::Series {
|
||||
/// Consumer thread: forgets the published window (after a rebind or a reset).
|
||||
void invalidate() noexcept;
|
||||
|
||||
/// Producer thread, under `ScopeModel::mutex()`: records that absolute sample `absolute`
|
||||
/// has been backfilled, for `acquire()` to fold into the pyramid.
|
||||
///
|
||||
/// The pyramid is mutated *only* on the consumer thread, because a bucket is a 32-byte
|
||||
/// non-atomic aggregate the renderer reads unlocked at paint time: amending it from the
|
||||
/// producer would let a reader see a half-updated bucket whose `sum` did not match its
|
||||
/// `count`, which is a wrong mean out of a structure whose readers trust `count == 0` to
|
||||
/// mean absent. The value ring is amended by the producer, which owns its writes; the
|
||||
/// pyramid waits for here.
|
||||
void queue_amend(u64 absolute, f32 v) { pending_amends_.push_back({absolute, v}); }
|
||||
|
||||
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.
|
||||
@@ -100,6 +111,13 @@ class ChannelBuffer final : public plot::data::Series {
|
||||
/// Timestamp of channel sample `absolute_index`, in seconds since `epoch_`.
|
||||
f64 time_of(u64 absolute_index) const noexcept;
|
||||
|
||||
struct Amend {
|
||||
u64 absolute;
|
||||
f32 value;
|
||||
};
|
||||
/// Backfills the producer has written into the value ring and the pyramid has not seen yet.
|
||||
std::vector<Amend> pending_amends_;
|
||||
|
||||
std::shared_ptr<const plot::data::RingBuffer<f32>> values_;
|
||||
std::shared_ptr<const plot::data::RingBuffer<f64>> times_;
|
||||
u64 stride_ = 1;
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include "condition.hpp"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace udpscope {
|
||||
|
||||
void EventDetector::configure(const EventCondition& c, double dt) noexcept {
|
||||
if (c == cond_ && dt == dt_) return;
|
||||
cond_ = c;
|
||||
dt_ = dt;
|
||||
// a = 1 - exp(-2*pi*fc*dt), clamped just under Nyquist so a never exceeds 1.
|
||||
// fc == 0 leaves a == 1, which is a pass-through.
|
||||
pole_a_ = 1.0;
|
||||
if (cond_.hf_fc > 0.0 && dt > 0.0) {
|
||||
const double nyquist = 0.5 / dt;
|
||||
const double fc = cond_.hf_fc < nyquist * 0.99 ? cond_.hf_fc : nyquist * 0.99;
|
||||
pole_a_ = 1.0 - std::exp(-2.0 * 3.14159265358979323846 * fc * dt);
|
||||
}
|
||||
reset();
|
||||
}
|
||||
|
||||
void EventDetector::reset() noexcept {
|
||||
have_prev_ = false;
|
||||
prev_ = 0.0;
|
||||
in_pulse_ = false;
|
||||
pulse_up_ = false;
|
||||
pulse_start_s_ = 0.0;
|
||||
inside_ = false;
|
||||
last_cross_s_ = 0.0;
|
||||
dropped_ = false;
|
||||
pole_y_ = 0.0;
|
||||
pole_have_ = false;
|
||||
side_ = 0;
|
||||
}
|
||||
|
||||
double EventDetector::filter(double v) noexcept {
|
||||
// A NaN must stay a NaN and must not poison the state: a dropout is a real
|
||||
// reading here, not an error.
|
||||
if (!std::isfinite(v)) return v;
|
||||
if (!pole_have_) {
|
||||
pole_y_ = v;
|
||||
pole_have_ = true;
|
||||
return pole_y_;
|
||||
}
|
||||
pole_y_ += pole_a_ * (v - pole_y_);
|
||||
return pole_y_;
|
||||
}
|
||||
|
||||
void EventDetector::decide(double v, bool& up, bool& down) noexcept {
|
||||
up = false;
|
||||
down = false;
|
||||
if (cond_.noise_band <= 0.0) {
|
||||
up = crossed_up(v);
|
||||
down = crossed_down(v);
|
||||
return;
|
||||
}
|
||||
// A Schmitt trigger: a sample inside the band decides nothing, and a crossing
|
||||
// is a change between two *decided* sides. Seeding from the undecided side
|
||||
// would fire on a signal that starts inside the band and leaves it without
|
||||
// ever reaching `level`.
|
||||
const int was = side_;
|
||||
if (v >= cond_.level + cond_.noise_band) side_ = 1;
|
||||
else if (v <= cond_.level - cond_.noise_band) side_ = -1;
|
||||
up = was == -1 && side_ == 1;
|
||||
down = was == 1 && side_ == -1;
|
||||
}
|
||||
|
||||
double EventDetector::feed(double value, double t) noexcept {
|
||||
value = filter(value);
|
||||
if (!have_prev_) {
|
||||
prev_ = value;
|
||||
have_prev_ = true;
|
||||
// The window condition needs a side to change *from*, and the dropout a last-seen time
|
||||
// to count from; neither can be derived from a crossing that has not happened. Seeding
|
||||
// them here is what lets both handlers run without a "have we started" guard.
|
||||
const double lo = cond_.level < cond_.level2 ? cond_.level : cond_.level2;
|
||||
const double hi = cond_.level < cond_.level2 ? cond_.level2 : cond_.level;
|
||||
inside_ = value >= lo && value <= hi;
|
||||
last_cross_s_ = t;
|
||||
dropped_ = false;
|
||||
return kNoEvent;
|
||||
}
|
||||
double event = kNoEvent;
|
||||
switch (cond_.type) {
|
||||
case TriggerType::Edge:
|
||||
event = feed_edge(value, t);
|
||||
break;
|
||||
case TriggerType::PulseWidth:
|
||||
event = feed_pulse(value, t);
|
||||
break;
|
||||
case TriggerType::Window:
|
||||
event = feed_window(value, t);
|
||||
break;
|
||||
case TriggerType::Dropout:
|
||||
event = feed_dropout(value, t);
|
||||
break;
|
||||
}
|
||||
// After the handler: every crossing test above reads `prev_` as the previous sample.
|
||||
prev_ = value;
|
||||
return event;
|
||||
}
|
||||
|
||||
double EventDetector::feed_edge(double value, double t) noexcept {
|
||||
bool up = false;
|
||||
bool down = false;
|
||||
decide(value, up, down);
|
||||
const bool fired = (cond_.edge == TriggerEdge::Rising && up) ||
|
||||
(cond_.edge == TriggerEdge::Falling && down) ||
|
||||
(cond_.edge == TriggerEdge::Both && (up || down));
|
||||
return fired ? t : kNoEvent;
|
||||
}
|
||||
|
||||
bool EventDetector::qualifies(double width_s) const noexcept {
|
||||
const double a = cond_.width_s < cond_.width2_s ? cond_.width_s : cond_.width2_s;
|
||||
const double b = cond_.width_s < cond_.width2_s ? cond_.width2_s : cond_.width_s;
|
||||
switch (cond_.cmp) {
|
||||
case WidthCmp::Less:
|
||||
return width_s < cond_.width_s;
|
||||
case WidthCmp::Greater:
|
||||
return width_s > cond_.width_s;
|
||||
case WidthCmp::Within:
|
||||
return width_s >= a && width_s <= b;
|
||||
case WidthCmp::Outside:
|
||||
return width_s < a || width_s > b;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
double EventDetector::feed_pulse(double value, double t) noexcept {
|
||||
const bool up = crossed_up(value);
|
||||
const bool down = crossed_down(value);
|
||||
if (!in_pulse_) {
|
||||
// Which crossing opens a pulse is what `edge` selects: Rising measures the time spent
|
||||
// above the level, Falling the time below, Both every excursion either way.
|
||||
const bool opens = (cond_.edge == TriggerEdge::Rising && up) ||
|
||||
(cond_.edge == TriggerEdge::Falling && down) ||
|
||||
(cond_.edge == TriggerEdge::Both && (up || down));
|
||||
if (opens) {
|
||||
in_pulse_ = true;
|
||||
pulse_up_ = up;
|
||||
pulse_start_s_ = t;
|
||||
}
|
||||
return kNoEvent;
|
||||
}
|
||||
const bool closes = pulse_up_ ? down : up;
|
||||
if (!closes) return kNoEvent;
|
||||
const double width = t - pulse_start_s_;
|
||||
if (cond_.edge == TriggerEdge::Both) {
|
||||
// The closing edge of one excursion is the opening edge of the next, so a square wave
|
||||
// measures *every* half-period. Dropping the state here instead would measure every
|
||||
// other one, and the hits would come out half as dense as the waveform.
|
||||
pulse_up_ = !pulse_up_;
|
||||
pulse_start_s_ = t;
|
||||
} else {
|
||||
in_pulse_ = false;
|
||||
}
|
||||
return qualifies(width) ? t : kNoEvent;
|
||||
}
|
||||
|
||||
double EventDetector::feed_window(double value, double t) noexcept {
|
||||
const double lo = cond_.level < cond_.level2 ? cond_.level : cond_.level2;
|
||||
const double hi = cond_.level < cond_.level2 ? cond_.level2 : cond_.level;
|
||||
// The band is applied on the edge the signal must *leave*, and clamped to just
|
||||
// under half the window so `lo + band` can never pass `hi - band`.
|
||||
double band = cond_.noise_band > 0.0 ? cond_.noise_band : 0.0;
|
||||
const double room = 0.49 * (hi - lo);
|
||||
if (band > room) band = room;
|
||||
const bool inside = inside_ ? (value >= lo - band && value <= hi + band)
|
||||
: (value >= lo + band && value <= hi - band);
|
||||
if (inside == inside_) return kNoEvent;
|
||||
inside_ = inside;
|
||||
// The event is the *change of side*, so `Rising` reads as "into the band" and `Falling`
|
||||
// as "out of it". A band of zero width is not a band: `inside` reduces to an exact
|
||||
// float-equality test against the level, so the condition fires on none of an ordinary
|
||||
// signal's samples and on every transition of one that sits exactly at the level -- a
|
||||
// +/-1 square against a band of [1, 1] fired 19 times in a standalone check. Neither
|
||||
// answer is a band, so the panel refuses one.
|
||||
const bool fired = (cond_.edge == TriggerEdge::Rising && inside) ||
|
||||
(cond_.edge == TriggerEdge::Falling && !inside) ||
|
||||
cond_.edge == TriggerEdge::Both;
|
||||
return fired ? t : kNoEvent;
|
||||
}
|
||||
|
||||
double EventDetector::feed_dropout(double value, double t) noexcept {
|
||||
const bool up = crossed_up(value);
|
||||
const bool down = crossed_down(value);
|
||||
const bool crossed = (cond_.edge == TriggerEdge::Rising && up) ||
|
||||
(cond_.edge == TriggerEdge::Falling && down) ||
|
||||
(cond_.edge == TriggerEdge::Both && (up || down));
|
||||
if (crossed) {
|
||||
last_cross_s_ = t;
|
||||
dropped_ = false;
|
||||
return kNoEvent;
|
||||
}
|
||||
// One event per silence, not one per sample of it: without the latch a dead signal would
|
||||
// report an event on every sample after the timeout and fill the search's hit list with
|
||||
// one answer repeated.
|
||||
if (dropped_) return kNoEvent;
|
||||
if (t - last_cross_s_ <= cond_.timeout_s) return kNoEvent;
|
||||
dropped_ = true;
|
||||
return t;
|
||||
}
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -0,0 +1,125 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <limits>
|
||||
|
||||
namespace udpscope {
|
||||
|
||||
/// Which way through a level counts. Shared by every condition type: for `Window` it is
|
||||
/// the direction through the *band* (into it, out of it, or either), and for `Dropout` it
|
||||
/// selects which crossings count as the signal still being alive.
|
||||
///
|
||||
/// It lives here rather than in `trigger.hpp` because the buffer search needs the
|
||||
/// evaluator without the engine's arming state.
|
||||
enum class TriggerEdge { Rising, Falling, Both };
|
||||
|
||||
/// What shape in the signal counts as an event.
|
||||
enum class TriggerType { Edge, PulseWidth, Window, Dropout };
|
||||
|
||||
/// How a measured pulse width is compared with the condition's bounds. `Less` is the
|
||||
/// glitch trigger: "a pulse shorter than it should be".
|
||||
enum class WidthCmp { Less, Greater, Within, Outside };
|
||||
|
||||
/// Everything that decides whether a sample is an event. No arming, no capture window:
|
||||
/// those belong to `TriggerConfig`.
|
||||
struct EventCondition {
|
||||
TriggerType type = TriggerType::Edge;
|
||||
double level = 0.0;
|
||||
double level2 = 0.0; ///< Window: the band's other edge. Ignored otherwise.
|
||||
TriggerEdge edge = TriggerEdge::Rising;
|
||||
WidthCmp cmp = WidthCmp::Greater; ///< PulseWidth only.
|
||||
double width_s = 1.0e-3; ///< PulseWidth only.
|
||||
double width2_s = 2.0e-3; ///< PulseWidth, Within/Outside only.
|
||||
double timeout_s = 1.0e-3; ///< Dropout only.
|
||||
/// Half-width of the amplitude band around `level` a sample must leave before
|
||||
/// a crossing counts. 0 is the bare comparator. In signal units.
|
||||
double noise_band = 0.0;
|
||||
/// Corner of a one-pole low pass applied to the sample before any comparison.
|
||||
/// 0 is off. In Hz.
|
||||
double hf_fc = 0.0;
|
||||
|
||||
bool operator==(const EventCondition& o) const noexcept {
|
||||
return type == o.type && level == o.level && level2 == o.level2 && edge == o.edge &&
|
||||
cmp == o.cmp && width_s == o.width_s && width2_s == o.width2_s &&
|
||||
timeout_s == o.timeout_s && noise_band == o.noise_band && hf_fc == o.hf_fc;
|
||||
}
|
||||
bool operator!=(const EventCondition& o) const noexcept { return !(*this == o); }
|
||||
};
|
||||
|
||||
/// The event condition's state machine: one sample in, an event time or nothing out.
|
||||
///
|
||||
/// It holds no arming state and no capture state -- that is `TriggerEngine`'s job -- which
|
||||
/// is what lets the buffer search run the *same* evaluator over retained samples and get
|
||||
/// the answers the live trigger would have given.
|
||||
///
|
||||
/// Every condition but `Edge` is sequential: a pulse's width is only known at its closing
|
||||
/// edge, and a dropout is a measured absence. Callers must therefore feed it **every**
|
||||
/// sample in stream order, including samples they intend to ignore; a gap is a pulse it can
|
||||
/// never close.
|
||||
class EventDetector {
|
||||
public:
|
||||
/// What `feed()` returns when the sample is not an event.
|
||||
static constexpr double kNoEvent = std::numeric_limits<double>::quiet_NaN();
|
||||
|
||||
/// Re-applying the same condition is a no-op, deliberately: the producer calls this on
|
||||
/// every packet, and resetting there would restart the machine before any pulse could
|
||||
/// close. A genuinely different condition resets, because its state means nothing.
|
||||
///
|
||||
/// `dt` is the stored sample interval (`Partition::slot_dt()`), needed for the
|
||||
/// HF reject's pole. A no-op unless the condition or the interval changed.
|
||||
void configure(const EventCondition& c, double dt) noexcept;
|
||||
void reset() noexcept;
|
||||
|
||||
/// One sample, in stream order. Returns the event's own stream time, or `kNoEvent`.
|
||||
///
|
||||
/// There is no finite-value check: a NaN sample is benign here (every comparison against
|
||||
/// it is false, so it cannot fire), and rejecting one would leave `prev_` at the last good
|
||||
/// value and let the sample *after* the NaN fire a crossing that today cannot happen.
|
||||
double feed(double value, double t) noexcept;
|
||||
|
||||
double last_value() const noexcept { return prev_; }
|
||||
bool have_last() const noexcept { return have_prev_; }
|
||||
|
||||
private:
|
||||
bool crossed_up(double v) const noexcept {
|
||||
return prev_ < cond_.level && v >= cond_.level;
|
||||
}
|
||||
bool crossed_down(double v) const noexcept {
|
||||
return prev_ > cond_.level && v <= cond_.level;
|
||||
}
|
||||
/// The HF reject, applied to every sample before any comparison.
|
||||
double filter(double v) noexcept;
|
||||
/// `up`/`down` for the edge condition, through the band if there is one.
|
||||
void decide(double v, bool& up, bool& down) noexcept;
|
||||
double feed_edge(double value, double t) noexcept;
|
||||
double feed_pulse(double value, double t) noexcept;
|
||||
double feed_window(double value, double t) noexcept;
|
||||
double feed_dropout(double value, double t) noexcept;
|
||||
/// Whether a measured width satisfies `cond_.cmp`. `Within`/`Outside` take their two
|
||||
/// bounds in either order, so a panel cannot make an empty band by typing the larger
|
||||
/// figure into the first spin.
|
||||
bool qualifies(double width_s) const noexcept;
|
||||
|
||||
EventCondition cond_{};
|
||||
bool have_prev_ = false;
|
||||
double prev_ = 0.0;
|
||||
bool in_pulse_ = false; ///< PulseWidth: an opening edge has been seen.
|
||||
bool pulse_up_ = false; ///< PulseWidth: its polarity, so we know which edge closes it.
|
||||
double pulse_start_s_ = 0.0;
|
||||
bool inside_ = false; ///< Window: which side of the band the previous sample was.
|
||||
double last_cross_s_ = 0.0; ///< Dropout: when the signal was last seen crossing.
|
||||
bool dropped_ = false; ///< Dropout: the timeout has already been reported.
|
||||
/// One-pole prefilter. `pole_a_ == 1` passes the input through, so "off"
|
||||
/// costs no branch. `pole_y_` is the filtered value.
|
||||
double dt_ = 0.0;
|
||||
double pole_a_ = 1.0;
|
||||
double pole_y_ = 0.0;
|
||||
bool pole_have_ = false;
|
||||
/// Which side of the band the signal was last *decided* to be on: +1 above,
|
||||
/// -1 below, 0 undecided. A sample inside the band decides nothing.
|
||||
int side_ = 0;
|
||||
};
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -54,6 +54,14 @@ void print_usage(std::FILE* out, const char* argv0) {
|
||||
" --recv-buffer BYTES SO_RCVBUF for the receiver socket (default 8388608)\n"
|
||||
" --plots N stacked plots to start with, 1..8 (default 1); rows are\n"
|
||||
" added and removed from the layout editor\n"
|
||||
" --capture-packets N store the last N raw datagrams on disk for forensics, in a\n"
|
||||
" ring under the cache directory (1000 slots is ~62.6 MiB);\n"
|
||||
" off unless given, and toggleable from the status bar\n"
|
||||
" --relative-time take the plot's time from the packet counter and the\n"
|
||||
" declared rate, not the stream's time signal; absolute\n"
|
||||
" time is then kept for export only\n"
|
||||
" --open FILE open an exported waveform (.npy) instead of a live source:\n"
|
||||
" signals from its columns, no socket, no acquisition\n"
|
||||
" -h, --help this text\n",
|
||||
argv0);
|
||||
}
|
||||
@@ -79,6 +87,7 @@ Config parse_args(int argc, char** argv) {
|
||||
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"));
|
||||
c.buffer_mb_specified = true;
|
||||
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"));
|
||||
@@ -98,6 +107,15 @@ Config parse_args(int argc, char** argv) {
|
||||
fail("--plots must be 1.." + std::to_string(RowSet::kMaxRows) + ": " + v);
|
||||
}
|
||||
c.plots = static_cast<int>(n);
|
||||
} else if (a == "--capture-packets" || a.rfind("--capture-packets=", 0) == 0) {
|
||||
const long n = std::strtol(value_of(argc, argv, i, "--capture-packets").c_str(), nullptr, 10);
|
||||
if (n < 1 || n > 100000) fail("--capture-packets must be 1..100000");
|
||||
c.capture_packets = static_cast<std::uint32_t>(n);
|
||||
} else if (a == "--open" || a.rfind("--open=", 0) == 0) {
|
||||
c.open_waveform = value_of(argc, argv, i, "--open");
|
||||
} else if (a == "--relative-time") {
|
||||
c.relative_time = true;
|
||||
c.relative_time_specified = true;
|
||||
} else {
|
||||
fail("unknown argument: " + a);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ struct Config {
|
||||
/// 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;
|
||||
/// Whether `--buffer-mb` was given. A stored configuration's buffer size fills in only when the
|
||||
/// flag was *absent*: an explicit flag beats a saved setting, and "not asked" is not the same
|
||||
/// question as "asked for 200".
|
||||
bool buffer_mb_specified = false;
|
||||
|
||||
/// Largest time window the toolbar offers, in seconds. The buffer partition is
|
||||
/// sized to hold at least this much time (spec: decimation).
|
||||
@@ -35,6 +39,33 @@ struct Config {
|
||||
/// Number of stacked plots (rows) to start with, 1..`RowSet::kMaxRows`. Only the
|
||||
/// starting point: rows are added and removed from the layout editor.
|
||||
int plots = 1;
|
||||
|
||||
/// Take the plot's time from the packet counter and the declared sample rate rather than
|
||||
/// from the stream's own time signal, so arrival and producer-clock jitter cannot move a
|
||||
/// sample's x. Absolute stream time is still recorded, for export only (`todo.md` item 2).
|
||||
bool relative_time = false;
|
||||
|
||||
/// Whether relative time was *stated* rather than left at its default -- by `--relative-time`, or
|
||||
/// by the preferences, which the source chooser can reach before this run is planned. A source's
|
||||
/// stored configuration is a statement from an *older* session, so it only fills in the gap.
|
||||
bool relative_time_specified = false;
|
||||
|
||||
/// `--capture-packets N`: the ring size of the raw-datagram capture, 1..100000.
|
||||
/// 0 means the flag was absent: the capture is off, and the popover button then
|
||||
/// uses the 1000-slot default when the user toggles it.
|
||||
std::uint32_t capture_packets = 0;
|
||||
|
||||
/// `--open FILE`: a `.npy` an earlier run exported, opened for viewing and analysis instead of a
|
||||
/// live source. File mode and source mode are exclusive -- there is no acquisition behind a file,
|
||||
/// and the chooser's Waveforms page sets this rather than the connection fields.
|
||||
std::string open_waveform;
|
||||
|
||||
/// The source chooser's "Guided tour" entry. The tour is drawn on the application's own window,
|
||||
/// which does not exist while the chooser runs, so the request rides on the one value `main()`
|
||||
/// owns and hands to both the chooser and the front end (`open_waveform` and the preferences'
|
||||
/// relative time cross the same way) and the window starts the tour once it is up and laid out.
|
||||
/// It is *not* a preference: it describes this request and nothing else, so nothing persists it.
|
||||
bool tour_pending = false;
|
||||
};
|
||||
|
||||
/// Parses argv. On `--help` prints usage to stdout and exits(0); on a bad
|
||||
|
||||
@@ -32,6 +32,10 @@ struct EventSnapshot {
|
||||
/// (see `BufferStore::time()`), which also makes the cut in `publish_event()`/
|
||||
/// `publish_pause_view()` a straight copy.
|
||||
std::vector<f64> stamps;
|
||||
/// Absolute stream seconds on the same indices as `stamps`, or empty when the store had no
|
||||
/// absolute ring. **Not** rebased by `x_offset` or by the trigger edge: a snapshot outlives
|
||||
/// the epoch that produced it, so it stores the absolute value outright.
|
||||
std::vector<f64> abs_stamps;
|
||||
std::vector<std::vector<f32>> values; ///< one array per channel, same length
|
||||
/// The stream time of this snapshot's x = 0, so `x_offset + stamps[i]` is the clock the
|
||||
/// data arrived on: the edge for a trigger event, the newest sample at the moment of the
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include "event_store.hpp"
|
||||
|
||||
#include <dirent.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
|
||||
#include "layout_store.hpp"
|
||||
#include "waveform.hpp"
|
||||
|
||||
namespace udpscope {
|
||||
namespace {
|
||||
|
||||
/// One directory under the cache holds *every* source's events, and each source gets a folder inside
|
||||
/// it: `<cache>/udpscope/events/<source>/`. The other way round -- a folder per source with an
|
||||
/// `events` inside -- spreads one feature over as many directories as there are sources, and turns
|
||||
/// "where are my events" into a question about which host you last used.
|
||||
constexpr const char* kEventsSubdir = "/events";
|
||||
|
||||
/// `2026-10-09T11:32:00` in local time, plus the sequence number: two events inside one second are
|
||||
/// ordinary (a 5 Hz trigger latches five times a second), and a stem that collides would overwrite
|
||||
/// the event it collided with.
|
||||
std::string event_stem(double wall_time, std::uint64_t seq) {
|
||||
const std::time_t t = static_cast<std::time_t>(wall_time);
|
||||
std::tm tm{};
|
||||
localtime_r(&t, &tm);
|
||||
char buf[64];
|
||||
std::strftime(buf, sizeof(buf), "%Y%m%d-%H%M%S", &tm);
|
||||
char stem[96];
|
||||
std::snprintf(stem, sizeof(stem), "%s-%04llu", buf,
|
||||
static_cast<unsigned long long>(seq % 10000ull));
|
||||
return stem;
|
||||
}
|
||||
|
||||
/// `a.npy` -> `a`; anything else -> "".
|
||||
std::string stem_of(const std::string& file, const char* ext) {
|
||||
const std::size_t n = std::strlen(ext);
|
||||
if (file.size() <= n || file.compare(file.size() - n, n, ext) != 0) return {};
|
||||
return file.substr(0, file.size() - n);
|
||||
}
|
||||
|
||||
/// The event's picture, drawn from its samples: one horizontal strip per signal, min/max per pixel
|
||||
/// column, the trigger edge as a brighter line. A 24-bit BMP -- the header is nine words and the rest
|
||||
/// is the pixels, so it needs no encoder, and a person can open it with anything.
|
||||
///
|
||||
/// The palette is fixed rather than the plots': this is navigation, and what has to be told apart at
|
||||
/// a glance is one *event* from another, not one signal from another (for which the names are on the
|
||||
/// row above).
|
||||
///
|
||||
/// The height is **per strip**, not for the whole picture: with a fixed total, a six-signal event got
|
||||
/// 26 pixels a signal and an eight-signal one got 20, which is a smear and not a waveform -- the
|
||||
/// picture has to grow with the signal count, and it is the *viewer* that scales it down to a row.
|
||||
bool write_preview(const ExportTable& table, const std::string& path, int width = 480,
|
||||
int strip_h = 44) {
|
||||
if (table.empty() || table.columns.empty()) return false;
|
||||
const std::size_t rows = table.rows();
|
||||
const std::size_t strips = table.columns.size();
|
||||
const int total_h = strip_h * static_cast<int>(strips);
|
||||
static const std::uint32_t kPalette[] = {0x4FA3FFu, 0xFF7A4Fu, 0x6EE07Au, 0xE06ED0u,
|
||||
0xFFD24Fu, 0x6FD8E0u, 0xB9A0FFu, 0xE0E0E0u};
|
||||
|
||||
// One byte per pixel per channel, bottom-up and **blue first** -- both are BMP's own order, and
|
||||
// writing RGB here is what made every external viewer show the traces in the wrong colour.
|
||||
const int row_bytes = ((width * 3) + 3) & ~3;
|
||||
std::vector<unsigned char> pixels(std::size_t(row_bytes) * total_h, 0);
|
||||
const auto put = [&](int x, int y, std::uint32_t rgb, double alpha) {
|
||||
if (x < 0 || y < 0 || x >= width || y >= total_h) return;
|
||||
unsigned char* p = &pixels[std::size_t(total_h - 1 - y) * row_bytes + std::size_t(x) * 3];
|
||||
const double a = std::clamp(alpha, 0.0, 1.0);
|
||||
p[0] = static_cast<unsigned char>(p[0] * (1.0 - a) + (rgb & 0xFF) * a);
|
||||
p[1] = static_cast<unsigned char>(p[1] * (1.0 - a) + ((rgb >> 8) & 0xFF) * a);
|
||||
p[2] = static_cast<unsigned char>(p[2] * (1.0 - a) + ((rgb >> 16) & 0xFF) * a);
|
||||
};
|
||||
// The background: a dark field, so the traces read the way they do on screen.
|
||||
for (int y = 0; y < total_h; ++y) {
|
||||
for (int x = 0; x < width; ++x) put(x, y, 0x101418u, 1.0);
|
||||
}
|
||||
|
||||
for (std::size_t c = 0; c < strips; ++c) {
|
||||
const std::vector<f32>& values = table.columns[c].values;
|
||||
if (values.empty()) continue;
|
||||
double lo = values[0];
|
||||
double hi = values[0];
|
||||
for (f32 v : values) {
|
||||
if (!std::isfinite(v)) continue;
|
||||
lo = std::min(lo, double(v));
|
||||
hi = std::max(hi, double(v));
|
||||
}
|
||||
if (!(hi > lo)) {
|
||||
hi = lo + 1.0;
|
||||
}
|
||||
const int base = static_cast<int>(c) * strip_h;
|
||||
const std::uint32_t colour = kPalette[c % (sizeof(kPalette) / sizeof(kPalette[0]))];
|
||||
for (int x = 0; x < width; ++x) {
|
||||
const std::size_t from = rows * std::size_t(x) / std::size_t(width);
|
||||
const std::size_t to = std::max(from + 1, rows * std::size_t(x + 1) / std::size_t(width));
|
||||
double vmin = 1e300;
|
||||
double vmax = -1e300;
|
||||
for (std::size_t i = from; i < to && i < rows; ++i) {
|
||||
const double v = values[i];
|
||||
if (!std::isfinite(v)) continue;
|
||||
vmin = std::min(vmin, v);
|
||||
vmax = std::max(vmax, v);
|
||||
}
|
||||
if (vmin > vmax) continue;
|
||||
const auto to_y = [&](double v) {
|
||||
const double frac = (v - lo) / (hi - lo);
|
||||
return base + strip_h - 1 - static_cast<int>(frac * (strip_h - 1));
|
||||
};
|
||||
const int y0 = std::clamp(to_y(vmax), base, base + strip_h - 1);
|
||||
const int y1 = std::clamp(to_y(vmin), base, base + strip_h - 1);
|
||||
for (int y = y0; y <= y1; ++y) put(x, y, colour, 1.0);
|
||||
}
|
||||
// The strip's own baseline, so an empty stretch reads as a flat trace and not as a gap.
|
||||
for (int x = 0; x < width; ++x) {
|
||||
const int y = base + strip_h - 1;
|
||||
put(x, y, 0x303840u, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
// The trigger edge: the snapshot's x = 0, which is at the same fraction of every event's window.
|
||||
if (!table.time.empty()) {
|
||||
const double t0 = table.time.front();
|
||||
const double t1 = table.time.back();
|
||||
if (t1 > t0) {
|
||||
const int x = static_cast<int>((0.0 - t0) / (t1 - t0) * (width - 1));
|
||||
for (int y = 0; y < total_h; ++y) put(x, y, 0xFFE9A0u, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
std::FILE* f = std::fopen(path.c_str(), "wb");
|
||||
if (f == nullptr) return false;
|
||||
const std::uint32_t image_bytes = std::uint32_t(row_bytes) * std::uint32_t(total_h);
|
||||
const std::uint32_t file_bytes = 54u + image_bytes;
|
||||
unsigned char header[54] = {};
|
||||
header[0] = 'B';
|
||||
header[1] = 'M';
|
||||
const auto put32 = [&](int at, std::uint32_t v) {
|
||||
header[at] = static_cast<unsigned char>(v & 0xFF);
|
||||
header[at + 1] = static_cast<unsigned char>((v >> 8) & 0xFF);
|
||||
header[at + 2] = static_cast<unsigned char>((v >> 16) & 0xFF);
|
||||
header[at + 3] = static_cast<unsigned char>((v >> 24) & 0xFF);
|
||||
};
|
||||
put32(2, file_bytes);
|
||||
put32(10, 54u);
|
||||
put32(14, 40u);
|
||||
put32(18, std::uint32_t(width));
|
||||
put32(22, std::uint32_t(total_h));
|
||||
header[26] = 1; // planes
|
||||
header[28] = 24; // bits per pixel
|
||||
put32(34, image_bytes);
|
||||
put32(38, 2835u); // 72 dpi, in pixels per metre
|
||||
put32(42, 2835u);
|
||||
std::fwrite(header, 1, sizeof(header), f);
|
||||
std::fwrite(pixels.data(), 1, pixels.size(), f);
|
||||
std::fclose(f);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string event_meta_text(const EventRecord& r) {
|
||||
char buf[512];
|
||||
std::string out;
|
||||
std::snprintf(buf, sizeof(buf), "wall_time = %.6f\n", r.wall_time);
|
||||
out += buf;
|
||||
std::snprintf(buf, sizeof(buf), "duration_s = %.6f\n", r.duration_s);
|
||||
out += buf;
|
||||
std::snprintf(buf, sizeof(buf), "pre_s = %.6f\n", r.pre_s);
|
||||
out += buf;
|
||||
std::snprintf(buf, sizeof(buf), "post_s = %.6f\n", r.post_s);
|
||||
out += buf;
|
||||
std::snprintf(buf, sizeof(buf), "edge = %.6f\n", r.edge);
|
||||
out += buf;
|
||||
std::snprintf(buf, sizeof(buf), "rate_hz = %.6g\n", r.rate_hz);
|
||||
out += buf;
|
||||
std::snprintf(buf, sizeof(buf), "signals = %zu\n", r.signals);
|
||||
out += buf;
|
||||
std::snprintf(buf, sizeof(buf), "samples = %zu\n", r.samples);
|
||||
out += buf;
|
||||
std::snprintf(buf, sizeof(buf), "seq = %llu\n", static_cast<unsigned long long>(r.seq));
|
||||
out += buf;
|
||||
return out;
|
||||
}
|
||||
|
||||
bool event_meta_parse(const std::string& text, EventRecord* out) {
|
||||
if (out == nullptr) return false;
|
||||
bool any = false;
|
||||
std::size_t i = 0;
|
||||
while (i < text.size()) {
|
||||
const std::size_t end = text.find('\n', i);
|
||||
const std::string line = text.substr(i, end == std::string::npos ? std::string::npos : end - i);
|
||||
i = end == std::string::npos ? text.size() : end + 1;
|
||||
const std::size_t eq = line.find('=');
|
||||
if (eq == std::string::npos) continue;
|
||||
std::string key = line.substr(0, eq);
|
||||
std::string value = line.substr(eq + 1);
|
||||
while (!key.empty() && (key.back() == ' ' || key.back() == '\t')) key.pop_back();
|
||||
while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) value.erase(0, 1);
|
||||
const double number = std::atof(value.c_str());
|
||||
if (key == "wall_time") {
|
||||
out->wall_time = number;
|
||||
any = true;
|
||||
} else if (key == "duration_s") {
|
||||
out->duration_s = number;
|
||||
any = true;
|
||||
} else if (key == "pre_s") {
|
||||
out->pre_s = number;
|
||||
any = true;
|
||||
} else if (key == "post_s") {
|
||||
out->post_s = number;
|
||||
any = true;
|
||||
} else if (key == "edge") {
|
||||
out->edge = number;
|
||||
any = true;
|
||||
} else if (key == "rate_hz") {
|
||||
out->rate_hz = number;
|
||||
any = true;
|
||||
} else if (key == "signals") {
|
||||
out->signals = static_cast<std::size_t>(std::atoll(value.c_str()));
|
||||
any = true;
|
||||
} else if (key == "samples") {
|
||||
out->samples = static_cast<std::size_t>(std::atoll(value.c_str()));
|
||||
any = true;
|
||||
} else if (key == "seq") {
|
||||
out->seq = static_cast<std::uint64_t>(std::strtoull(value.c_str(), nullptr, 10));
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
return any;
|
||||
}
|
||||
|
||||
std::string default_events_dir() { return std::string(cache_dir()) + kEventsSubdir; }
|
||||
|
||||
EventStore::EventStore(std::string source, std::string base_dir) {
|
||||
if (source.empty()) return;
|
||||
std::string safe;
|
||||
for (char c : source) {
|
||||
const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
|
||||
c == '.' || c == '_' || c == '-';
|
||||
safe.push_back(ok ? c : '_');
|
||||
}
|
||||
dir_ = (base_dir.empty() ? default_events_dir() : base_dir) + "/" + safe;
|
||||
}
|
||||
|
||||
std::string EventStore::save(const ExportTable& table, const EventRecord& meta, std::size_t keep) {
|
||||
if (dir_.empty() || table.empty()) return {};
|
||||
mkdirs(dir_);
|
||||
const std::string stem = event_stem(meta.wall_time > 0.0 ? meta.wall_time : double(std::time(nullptr)),
|
||||
meta.seq);
|
||||
const std::string samples = dir_ + "/" + stem + ".npy";
|
||||
std::string error;
|
||||
if (!write_npy(samples, table, error)) {
|
||||
std::fprintf(stderr, "[udpscope] event cache: %s\n", error.c_str());
|
||||
std::fflush(stderr);
|
||||
return {};
|
||||
}
|
||||
EventRecord record = meta;
|
||||
record.stem = stem;
|
||||
std::string text = event_meta_text(record);
|
||||
if (std::FILE* f = std::fopen((dir_ + "/" + stem + ".txt").c_str(), "wb"); f != nullptr) {
|
||||
std::fwrite(text.data(), 1, text.size(), f);
|
||||
std::fclose(f);
|
||||
}
|
||||
write_preview(table, dir_ + "/" + stem + ".bmp");
|
||||
prune(keep);
|
||||
return samples;
|
||||
}
|
||||
|
||||
std::string EventStore::redraw_preview(const EventRecord& record) const {
|
||||
if (dir_.empty() || record.stem.empty() || record.file.empty()) return {};
|
||||
WaveformReader reader;
|
||||
std::string error;
|
||||
if (!reader.open(record.file, &error)) return {};
|
||||
// The whole event in one table, which an event can afford where an export cannot: it is bounded by
|
||||
// the trigger's own pre/post window, so this is seconds of samples and not a session's worth.
|
||||
ExportTable table;
|
||||
table.columns.resize(reader.names().size());
|
||||
for (std::size_t c = 0; c < table.columns.size(); ++c) {
|
||||
table.columns[c].name = reader.names()[c];
|
||||
table.columns[c].values.reserve(reader.rows());
|
||||
}
|
||||
table.time.reserve(reader.rows());
|
||||
std::vector<float> values(reader.names().size(), 0.0f);
|
||||
double t = 0.0;
|
||||
double abs_t = 0.0;
|
||||
while (reader.next(&t, &abs_t, values.data())) {
|
||||
table.time.push_back(t);
|
||||
for (std::size_t c = 0; c < table.columns.size(); ++c) {
|
||||
table.columns[c].values.push_back(values[c]);
|
||||
}
|
||||
}
|
||||
const std::string path = dir_ + "/" + record.stem + ".bmp";
|
||||
if (!write_preview(table, path)) return {};
|
||||
return path;
|
||||
}
|
||||
|
||||
std::vector<EventRecord> EventStore::list() const {
|
||||
std::vector<EventRecord> out;
|
||||
if (dir_.empty()) return out;
|
||||
DIR* d = ::opendir(dir_.c_str());
|
||||
if (d == nullptr) return out;
|
||||
while (const dirent* e = ::readdir(d)) {
|
||||
const std::string stem = stem_of(e->d_name, ".txt");
|
||||
if (stem.empty()) continue;
|
||||
std::FILE* f = std::fopen((dir_ + "/" + e->d_name).c_str(), "rb");
|
||||
if (f == nullptr) continue;
|
||||
std::string text;
|
||||
char buf[512];
|
||||
std::size_t n = 0;
|
||||
while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) text.append(buf, n);
|
||||
std::fclose(f);
|
||||
EventRecord r;
|
||||
if (!event_meta_parse(text, &r)) continue;
|
||||
r.stem = stem;
|
||||
r.file = dir_ + "/" + stem + ".npy";
|
||||
if (::access(r.file.c_str(), R_OK) != 0) continue; // a sidecar without its samples
|
||||
struct stat sb {};
|
||||
if (::stat((dir_ + "/" + stem + ".bmp").c_str(), &sb) == 0) {
|
||||
r.thumbnail = dir_ + "/" + stem + ".bmp";
|
||||
}
|
||||
out.push_back(std::move(r));
|
||||
}
|
||||
::closedir(d);
|
||||
// Newest first by the *record's* time, not the file's: a copy or a touch changes the mtime, and the
|
||||
// list is about when the event happened.
|
||||
std::stable_sort(out.begin(), out.end(), [](const EventRecord& a, const EventRecord& b) {
|
||||
if (a.wall_time != b.wall_time) return a.wall_time > b.wall_time;
|
||||
return a.seq > b.seq;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
bool EventStore::forget(const std::string& stem) {
|
||||
if (dir_.empty() || stem.empty()) return false;
|
||||
const std::string base = dir_ + "/" + stem;
|
||||
const bool gone = ::unlink((base + ".npy").c_str()) == 0;
|
||||
::unlink((base + ".txt").c_str());
|
||||
::unlink((base + ".bmp").c_str());
|
||||
return gone;
|
||||
}
|
||||
|
||||
void EventStore::prune(std::size_t keep) const {
|
||||
const std::vector<EventRecord> all = list();
|
||||
for (std::size_t i = keep; i < all.size(); ++i) {
|
||||
const std::string base = dir_ + "/" + all[i].stem;
|
||||
::unlink((base + ".npy").c_str());
|
||||
::unlink((base + ".txt").c_str());
|
||||
::unlink((base + ".bmp").c_str());
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -0,0 +1,100 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "export.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace udpscope {
|
||||
|
||||
/// What a cached event is, without loading its samples: the sidecar's fields, and what the pane
|
||||
/// shows in one row.
|
||||
struct EventRecord {
|
||||
std::string stem; ///< the file's name without the extension
|
||||
std::string file; ///< `<stem>.npy`, the event's table in the export format
|
||||
std::string thumbnail; ///< `<stem>.bmp`, the preview drawn from the samples; else empty
|
||||
double wall_time = 0.0; ///< CLOCK_REALTIME at the latch, for the row's label
|
||||
double duration_s = 0.0; ///< pre + post
|
||||
double pre_s = 0.0;
|
||||
double post_s = 0.0;
|
||||
double edge = 0.0; ///< the stream time of the event's x = 0
|
||||
double rate_hz = 0.0; ///< the stored sample rate at the latch
|
||||
std::size_t signals = 0; ///< columns in the file
|
||||
std::size_t samples = 0; ///< rows
|
||||
std::uint64_t seq = 0; ///< the run's own event counter, so two events in one second differ
|
||||
};
|
||||
|
||||
/// The cache of captured events: `<cache_dir>/events/<source>/`, one `.npy` per event written in
|
||||
/// the **export format** -- so `--open` and the chooser's Waveforms page read it back with the
|
||||
/// loader that already exists -- beside a `.txt` sidecar holding what the list shows without
|
||||
/// loading the samples, and whatever thumbnail the caller rendered.
|
||||
///
|
||||
/// No GTK: the caller renders the thumbnail and hands over a path, exactly as it hands over the
|
||||
/// table. The store owns naming, the sidecar, pruning and listing, which is what makes it testable
|
||||
/// without a window.
|
||||
/// The directory every source's event folder is created under when no preference says otherwise:
|
||||
/// `<cache>/udpscope/events`. Exposed because the preferences dialog shows it -- "the standard
|
||||
/// location" is not an answer a user can act on, and a second copy of the path computed in the dialog
|
||||
/// is a second thing to get wrong.
|
||||
std::string default_events_dir();
|
||||
|
||||
class EventStore {
|
||||
public:
|
||||
/// `source` is the `<host>-<port>` pair, the same key the packet capture uses.
|
||||
/// `base_dir` is the directory every source's own folder is created under; empty means
|
||||
/// `<cache>/udpscope/events`. It comes from the preferences (item 9), because a user with a small
|
||||
/// home partition and a second disk should not have to choose between events and free space.
|
||||
explicit EventStore(std::string source = {}, std::string base_dir = {});
|
||||
|
||||
/// How many events to keep, newest first. The oldest beyond it are deleted on every save; item 9's
|
||||
/// preferences are where this becomes the user's number.
|
||||
static constexpr std::size_t kDefaultKeep = 32;
|
||||
|
||||
const std::string& dir() const noexcept { return dir_; }
|
||||
bool empty_dir() const noexcept { return dir_.empty(); }
|
||||
|
||||
/// Writes `table` as one event, with `meta` in the sidecar and a preview drawn beside it. Returns
|
||||
/// the samples' path, or an empty string on failure -- a cache that cannot be written must not stop
|
||||
/// the scope.
|
||||
///
|
||||
/// The preview is drawn from the *data* -- min/max per pixel column, one strip per signal -- and not
|
||||
/// captured from the window: the plot is a `GtkGLArea`, and a GL area's ink is not in the widget's
|
||||
/// paint tree, so a snapshot of it is empty (`gtk_snapshot_free_to_node()` returns NULL). Drawing it
|
||||
/// here also keeps this class GTK-free, which is what makes it testable without a window.
|
||||
std::string save(const ExportTable& table, const EventRecord& meta,
|
||||
std::size_t keep = kDefaultKeep);
|
||||
|
||||
/// The cached events, newest first: the sidecars, never the sample files, so a listing is cheap
|
||||
/// however large the captures are.
|
||||
std::vector<EventRecord> list() const;
|
||||
|
||||
/// Draws `record`'s preview again from its samples and returns the thumbnail's path, or an empty
|
||||
/// string when the samples cannot be read.
|
||||
///
|
||||
/// This is the repair for a preview that is missing or will not decode -- a write that failed, a
|
||||
/// truncated file, a cache written by an older build. The `.npy` is the event, the `.bmp` is only a
|
||||
/// picture of it, so a lost picture is recoverable and must not be shown as a blank row. It reads
|
||||
/// the samples, so unlike `list()` it is **not** cheap: call it for the one event that needs it.
|
||||
std::string redraw_preview(const EventRecord& record) const;
|
||||
|
||||
/// Deletes one event's samples, sidecar and thumbnail.
|
||||
bool forget(const std::string& stem);
|
||||
|
||||
/// Deletes the oldest events beyond `keep`.
|
||||
void prune(std::size_t keep) const;
|
||||
|
||||
private:
|
||||
std::string dir_;
|
||||
};
|
||||
|
||||
/// The sidecar's text, and back. One `key = value` per line, the project's own TOML subset, so a
|
||||
/// human can read the cache with `cat` -- which is the point of a forensic directory.
|
||||
std::string event_meta_text(const EventRecord& record);
|
||||
bool event_meta_parse(const std::string& text, EventRecord* out);
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -6,8 +6,11 @@
|
||||
#include <algorithm>
|
||||
#include <bit>
|
||||
#include <cerrno>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <utility>
|
||||
|
||||
namespace udpscope {
|
||||
namespace {
|
||||
@@ -34,18 +37,29 @@ std::string sanitise(const std::string& name) {
|
||||
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", "abs_seconds"};
|
||||
/// `base` carrying a `_2`, `_3` … suffix for the `n`-th copy (`n == 1` is the name itself),
|
||||
/// cut to `max_len` bytes when one is given -- MDF's name field is 32 bytes wide and the file
|
||||
/// is re-read by a reader that keeps only the *first* occurrence of a name, so a cut that made
|
||||
/// two channels equal would silently lose one.
|
||||
std::string numbered(const std::string& base, int n, std::size_t max_len) {
|
||||
const std::string suffix = n == 1 ? std::string() : "_" + std::to_string(n);
|
||||
std::size_t room = base.size();
|
||||
if (max_len != 0) room = max_len > suffix.size() ? max_len - suffix.size() : 0;
|
||||
return base.substr(0, room) + suffix;
|
||||
}
|
||||
|
||||
/// The final labels: sanitised, and de-duplicated against each other *and* against
|
||||
/// `taken` -- the names the writer already spends -- 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,
|
||||
std::size_t max_len = 0) {
|
||||
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;
|
||||
std::string label = numbered(base, 1, max_len);
|
||||
for (int n = 2; std::find(taken.begin(), taken.end(), label) != taken.end(); ++n) {
|
||||
label = base + "_" + std::to_string(n);
|
||||
label = numbered(base, n, max_len);
|
||||
}
|
||||
taken.push_back(label);
|
||||
out.push_back(std::move(label));
|
||||
@@ -69,6 +83,74 @@ bool opened(const File& out, const std::string& path, std::string& error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// The block lengths of MDF 3.30, from the **reader's** own field lists
|
||||
/// (`asammdf/blocks/v2_v3_constants.py`, 8.8.27): every block is a two-letter id, a 16-bit
|
||||
/// length and then its own fields, and the reader walks the file by those lengths alone, so a
|
||||
/// block written one field too wide is a file whose reader lands in the middle of the next one.
|
||||
/// The widths here are the ones a `bytes(block)` of the reference file measures (the known-good
|
||||
/// 3.30 file `/tmp/ref.mdf` this writer was byte-diffed against).
|
||||
constexpr std::size_t kMdfIdBlock = 64; // "MDF 3.30" and the flags, no id/length
|
||||
constexpr std::size_t kMdfHeader = 208; // 164 common + 44 of the 3.2-era time fields
|
||||
constexpr std::size_t kMdfTxOverhead = 5; // "TX", the u16 length and the terminating NUL
|
||||
constexpr std::size_t kMdfDataGroup = 28; // 4 addresses, the group count, 4 reserved
|
||||
constexpr std::size_t kMdfConversion = 46; // a NONE conversion is the common fields alone
|
||||
constexpr std::size_t kMdfChannel = 228; // the display-name-length CNBLOCK
|
||||
constexpr std::size_t kMdfChannelGroup = 30; // 26 + the sample-reduction address of 3.30
|
||||
constexpr std::size_t kMdfNameBytes = 31; // a CNBLOCK's 32-byte name field, less its NUL
|
||||
|
||||
/// A byte sink for the MDF header blocks, which are a few hundred bytes each and have to be
|
||||
/// laid out field by field. The little-endian `raw()` writes are the same assumption the `.npy`
|
||||
/// writer states at the top of this file, and `static_assert`s there.
|
||||
struct Bytes {
|
||||
std::vector<unsigned char> v;
|
||||
|
||||
void raw(const void* p, std::size_t n) {
|
||||
const auto* b = static_cast<const unsigned char*>(p);
|
||||
v.insert(v.end(), b, b + n);
|
||||
}
|
||||
void u16(std::uint16_t x) {
|
||||
const unsigned char b[2] = {static_cast<unsigned char>(x & 0xFFu),
|
||||
static_cast<unsigned char>((x >> 8) & 0xFFu)};
|
||||
raw(b, sizeof(b));
|
||||
}
|
||||
void i16(std::int16_t x) { u16(static_cast<std::uint16_t>(x)); }
|
||||
void u32(std::uint32_t x) {
|
||||
const unsigned char b[4] = {static_cast<unsigned char>(x & 0xFFu),
|
||||
static_cast<unsigned char>((x >> 8) & 0xFFu),
|
||||
static_cast<unsigned char>((x >> 16) & 0xFFu),
|
||||
static_cast<unsigned char>((x >> 24) & 0xFFu)};
|
||||
raw(b, sizeof(b));
|
||||
}
|
||||
void u64(std::uint64_t x) {
|
||||
u32(static_cast<std::uint32_t>(x & 0xFFFFFFFFu));
|
||||
u32(static_cast<std::uint32_t>(x >> 32));
|
||||
}
|
||||
void f64(double x) { raw(&x, sizeof(x)); }
|
||||
void f32(float x) { raw(&x, sizeof(x)); }
|
||||
void zeros(std::size_t n) { v.insert(v.end(), n, 0); }
|
||||
|
||||
/// A fixed-width text field: the bytes, then NUL padding. MDF's names and units are
|
||||
/// NUL-terminated inside a field of a width the reader is told by the block size, so this is
|
||||
/// how every string in the format is spelled.
|
||||
void text(const std::string& s, std::size_t width) {
|
||||
const std::size_t n = std::min(s.size(), width);
|
||||
raw(s.data(), n);
|
||||
zeros(width - n);
|
||||
}
|
||||
|
||||
std::size_t size() const noexcept { return v.size(); }
|
||||
};
|
||||
|
||||
/// Checked rather than trusted: a field written at the wrong width would not fail here, it
|
||||
/// would produce a file whose reader walks into the middle of the next block. The widths are
|
||||
/// the asammdf 8.8.27 reader's own field lists (`blocks/v2_v3_constants.py`).
|
||||
bool mdf_block_size(const Bytes& block, std::size_t want, const char* what, std::string& error) {
|
||||
if (block.size() == want) return true;
|
||||
error = std::string("internal: the MDF ") + what + " block is " + std::to_string(block.size()) +
|
||||
" bytes, the layout wants " + std::to_string(want);
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
||||
@@ -97,6 +179,12 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
||||
return static_cast<std::size_t>(c) < infos.size() ? infos[static_cast<std::size_t>(c)].name
|
||||
: std::string("ch") + std::to_string(c);
|
||||
};
|
||||
// The unit travels with the column so the MDF writer can put it on the channel; the other
|
||||
// writers ignore it, as they always have.
|
||||
const auto unit_of = [&](int c) {
|
||||
return static_cast<std::size_t>(c) < infos.size() ? infos[static_cast<std::size_t>(c)].unit
|
||||
: std::string();
|
||||
};
|
||||
|
||||
// What the rows bind to is what gets exported, so the split is the same `DisplayMode`
|
||||
// `RowSet::bind()` switches on: the frozen event only while one is held, the live ring
|
||||
@@ -127,14 +215,17 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
||||
table.abs_seconds.resize(n);
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
table.time[i] = st[first + i];
|
||||
table.abs_seconds[i] = abs_base + table.time[i];
|
||||
table.abs_seconds[i] = snap->abs_stamps.empty()
|
||||
? abs_base + table.time[i]
|
||||
: snap->abs_stamps[first + 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),
|
||||
table.columns.push_back({name_of(c), unit_of(c),
|
||||
std::vector<f32>(src.begin() + static_cast<std::ptrdiff_t>(first),
|
||||
src.begin() + static_cast<std::ptrdiff_t>(first + n))});
|
||||
}
|
||||
return table;
|
||||
@@ -159,16 +250,39 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
||||
store.time().copy_from(first, n, stamps.data());
|
||||
table.time.resize(static_cast<std::size_t>(n));
|
||||
table.abs_seconds.resize(static_cast<std::size_t>(n));
|
||||
std::vector<f64> abs_stamps;
|
||||
if (const plot::data::RingBuffer<f64>* abs_ring = store.abs_time(); abs_ring != nullptr) {
|
||||
abs_stamps.resize(static_cast<std::size_t>(n));
|
||||
abs_ring->copy_from(first, n, abs_stamps.data());
|
||||
}
|
||||
for (std::size_t i = 0; i < stamps.size(); ++i) {
|
||||
table.time[i] = stamps[i] - s0;
|
||||
table.abs_seconds[i] = abs_base + table.time[i];
|
||||
table.abs_seconds[i] = abs_stamps.empty()
|
||||
? abs_base + table.time[i]
|
||||
: model.epoch() + abs_stamps[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)});
|
||||
table.columns.push_back({name_of(c), unit_of(c), std::move(values)});
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
ExportTable event_table(ScopeModel& model, const EventSnapshot& snapshot) {
|
||||
ExportTable table;
|
||||
table.time = snapshot.stamps;
|
||||
table.abs_seconds = snapshot.abs_stamps.empty() ? snapshot.stamps : snapshot.abs_stamps;
|
||||
std::lock_guard<std::mutex> lock(model.mutex());
|
||||
const std::vector<ChannelInfo>& channels = model.channels();
|
||||
for (std::size_t c = 0; c < snapshot.values.size() && c < channels.size(); ++c) {
|
||||
ExportTable::Column column;
|
||||
column.name = channels[c].name;
|
||||
column.unit = channels[c].unit;
|
||||
column.values = snapshot.values[c];
|
||||
table.columns.push_back(std::move(column));
|
||||
}
|
||||
return table;
|
||||
}
|
||||
@@ -178,7 +292,7 @@ bool write_npy(const std::string& path, const ExportTable& table, std::string& e
|
||||
error = "nothing to export: no signal is plotted in the visible range";
|
||||
return false;
|
||||
}
|
||||
const std::vector<std::string> labels = column_labels(table);
|
||||
const std::vector<std::string> labels = column_labels(table, {"time", "abs_seconds"});
|
||||
|
||||
// `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
|
||||
@@ -242,7 +356,7 @@ bool write_csv(const std::string& path, const ExportTable& table, std::string& e
|
||||
File out(path);
|
||||
if (!opened(out, path, error)) return false;
|
||||
|
||||
const std::vector<std::string> labels = column_labels(table);
|
||||
const std::vector<std::string> labels = column_labels(table, {"time", "abs_seconds"});
|
||||
std::string line = "time,abs_seconds";
|
||||
for (const std::string& label : labels) line += "," + label;
|
||||
line += "\n";
|
||||
@@ -278,4 +392,256 @@ bool write_csv(const std::string& path, const ExportTable& table, std::string& e
|
||||
return true;
|
||||
}
|
||||
|
||||
bool write_mdf(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::size_t rows = table.rows();
|
||||
const std::size_t cols = table.columns.size();
|
||||
|
||||
// **The master is the absolute clock, named `time`**, and the axis the plot showed goes out as
|
||||
// an ordinary channel, `time_relative`. The relative column is 0 at a different sample in each
|
||||
// display mode, so a master off it would put a live export and a held event's export on two
|
||||
// timelines -- the one thing the second column exists to avoid -- while `abs_seconds` is the
|
||||
// number the stream itself stamped the samples with. Both carry the unit `s`.
|
||||
const std::vector<std::string> labels =
|
||||
column_labels(table, {"time", "time_relative"}, kMdfNameBytes);
|
||||
const std::size_t channels = cols + 2;
|
||||
const std::size_t record = 2 * sizeof(f64) + cols * sizeof(f32);
|
||||
if (rows > 0xFFFFFFFFull) {
|
||||
error = "too many samples for an MDF 3 file: the channel group counts cycles in 32 bits";
|
||||
return false;
|
||||
}
|
||||
if (128 + (cols - 1) * 32 > 0xFFFFu) {
|
||||
error = "too many signals for an MDF 3 record: a channel's start offset is 16 bits";
|
||||
return false;
|
||||
}
|
||||
|
||||
// One channel per column, described once: what the CNBLOCK says about it (where in the record
|
||||
// its samples start, how wide they are, whether it is the master) and what the CCBLOCK says
|
||||
// (its unit). The range is measured from the samples rather than left at zero, so
|
||||
// `range_flag = 1` means what it says.
|
||||
struct Channel {
|
||||
std::string name;
|
||||
std::string unit;
|
||||
std::uint16_t bit_offset;
|
||||
std::uint16_t bit_count;
|
||||
std::uint16_t data_type; // MDF 3: 2 = float, 3 = double, both in the ID block's byte order
|
||||
std::uint16_t type; // 0 = value, 1 = master
|
||||
f64 lo, hi;
|
||||
};
|
||||
const auto range_of = [](const auto& values) {
|
||||
if (values.empty()) return std::pair<f64, f64>{0.0, 0.0};
|
||||
const auto mm = std::minmax_element(values.begin(), values.end());
|
||||
return std::pair<f64, f64>{static_cast<f64>(*mm.first), static_cast<f64>(*mm.second)};
|
||||
};
|
||||
std::vector<Channel> chans;
|
||||
chans.reserve(channels);
|
||||
const auto abs_range = range_of(table.abs_seconds);
|
||||
const auto rel_range = range_of(table.time);
|
||||
chans.push_back({"time", "s", 0, 64, 3, 1, abs_range.first, abs_range.second});
|
||||
chans.push_back({"time_relative", "s", 64, 64, 3, 0, rel_range.first, rel_range.second});
|
||||
for (std::size_t i = 0; i < cols; ++i) {
|
||||
const auto range = range_of(table.columns[i].values);
|
||||
chans.push_back({labels[i], table.columns[i].unit,
|
||||
static_cast<std::uint16_t>(128 + i * 32), 32, 2, 0, range.first, range.second});
|
||||
}
|
||||
|
||||
// Every address in the file, from the fixed block lengths above: the writer therefore knows
|
||||
// where it is going before it writes a byte, which is what lets each block carry its links.
|
||||
const std::string comment = "udpscope export: the visible window, " + std::to_string(rows) +
|
||||
" samples x " + std::to_string(cols) + " signals";
|
||||
const std::size_t hd_end = kMdfIdBlock + kMdfHeader;
|
||||
const std::size_t comment_addr = hd_end;
|
||||
const std::size_t data_addr = comment_addr + kMdfTxOverhead + comment.size();
|
||||
const std::size_t dg_addr = data_addr + rows * record;
|
||||
const std::size_t ch_addr = dg_addr + kMdfDataGroup;
|
||||
const std::size_t cg_addr = ch_addr + channels * (kMdfConversion + kMdfChannel);
|
||||
const std::uint64_t total = cg_addr + kMdfChannelGroup;
|
||||
if (total > 0xFFFFFFFFull) {
|
||||
error = "the export is larger than an MDF 3 file can address";
|
||||
return false;
|
||||
}
|
||||
const auto cc_addr = [&](std::size_t i) {
|
||||
return ch_addr + i * (kMdfConversion + kMdfChannel);
|
||||
};
|
||||
|
||||
// The wall clock the measurement file was written at. `abs_time` is the same instant in
|
||||
// nanoseconds since the epoch, which is what the 3.2-era header field asks for.
|
||||
const std::time_t now = std::time(nullptr);
|
||||
std::tm tm{};
|
||||
localtime_r(&now, &tm);
|
||||
char date[11] = {};
|
||||
char clock[9] = {};
|
||||
std::strftime(date, sizeof(date), "%d:%m:%Y", &tm);
|
||||
std::strftime(clock, sizeof(clock), "%H:%M:%S", &tm);
|
||||
|
||||
Bytes head;
|
||||
// Identification block: the version string, who wrote the file, and the byte order every
|
||||
// sample in it is in.
|
||||
head.text("MDF ", 8);
|
||||
head.text("3.30", 8);
|
||||
head.text("udpscope", 8);
|
||||
head.u16(0); // byte order: Intel, ahead of every value and the float format below
|
||||
head.u16(0); // float format: IEEE
|
||||
head.u16(330); // MDF version, digits without the separator
|
||||
head.u16(0); // code page: latin-1
|
||||
head.zeros(2); // reserved
|
||||
head.zeros(26); // reserved
|
||||
head.u16(0); // unfinalised standard flags
|
||||
head.u16(0); // unfinalised custom flags
|
||||
if (!mdf_block_size(head, kMdfIdBlock, "identification", error)) return false;
|
||||
|
||||
// Header block, which points at the data group and at the comment that follows it.
|
||||
head.raw("HD", 2);
|
||||
head.u16(static_cast<std::uint16_t>(kMdfHeader));
|
||||
head.u32(static_cast<std::uint32_t>(dg_addr)); // first data group
|
||||
head.u32(static_cast<std::uint32_t>(comment_addr));
|
||||
head.u32(0); // program block: nothing wrote this but us
|
||||
head.u16(1); // data groups
|
||||
head.text(date, 10);
|
||||
head.text(clock, 8);
|
||||
head.text("", 32); // author
|
||||
head.text("", 32); // department
|
||||
head.text("", 32); // project
|
||||
head.text("", 32); // subject
|
||||
head.u64(static_cast<std::uint64_t>(now) * 1000000000ull);
|
||||
head.i16(static_cast<std::int16_t>(tm.tm_gmtoff / 3600));
|
||||
head.u16(0); // time quality
|
||||
head.text("Local PC Reference Time", 32);
|
||||
if (!mdf_block_size(head, hd_end, "header", error)) return false;
|
||||
|
||||
// The comment, in the one text block the file carries: plain words rather than the XML a
|
||||
// writer that also *edits* files keeps its structured properties in.
|
||||
head.raw("TX", 2);
|
||||
head.u16(static_cast<std::uint16_t>(comment.size() + kMdfTxOverhead));
|
||||
head.raw(comment.data(), comment.size());
|
||||
head.zeros(1);
|
||||
|
||||
Bytes tail;
|
||||
// Data group: one channel group, whose channels are chained, and the address of the samples.
|
||||
tail.raw("DG", 2);
|
||||
tail.u16(static_cast<std::uint16_t>(kMdfDataGroup));
|
||||
tail.u32(0); // next data group
|
||||
tail.u32(static_cast<std::uint32_t>(cg_addr));
|
||||
tail.u32(0); // trigger block
|
||||
tail.u32(static_cast<std::uint32_t>(data_addr));
|
||||
tail.u16(1); // channel groups
|
||||
tail.u16(0); // record IDs: the group is sorted
|
||||
tail.zeros(4); // reserved
|
||||
if (!mdf_block_size(tail, kMdfDataGroup, "data group", error)) return false;
|
||||
|
||||
for (std::size_t i = 0; i < channels; ++i) {
|
||||
const Channel& c = chans[i];
|
||||
// Conversion block: the unit lives here and nowhere else in MDF 3, which is where ScryLab
|
||||
// reads a signal's unit from. NONE because the samples are already the physical values --
|
||||
// the table was collected as `f32`, so a conversion would only be a second copy of it.
|
||||
Bytes cc;
|
||||
cc.raw("CC", 2);
|
||||
cc.u16(static_cast<std::uint16_t>(kMdfConversion));
|
||||
cc.u16(1); // range_flag: the range below is measured, so it is valid
|
||||
cc.f64(c.lo);
|
||||
cc.f64(c.hi);
|
||||
cc.text(c.unit, 20);
|
||||
cc.u16(0xFFFFu);
|
||||
cc.u16(0); // referenced parameters
|
||||
if (!mdf_block_size(cc, kMdfConversion, "conversion", error)) return false;
|
||||
|
||||
// Channel block: the name ScryLab turns into the signal name, where this channel's samples
|
||||
// sit in a record, and the link to the next channel.
|
||||
Bytes cn;
|
||||
cn.raw("CN", 2);
|
||||
cn.u16(static_cast<std::uint16_t>(kMdfChannel));
|
||||
cn.u32(i + 1 < channels ? static_cast<std::uint32_t>(cc_addr(i + 1) + kMdfConversion) : 0);
|
||||
cn.u32(static_cast<std::uint32_t>(cc_addr(i)));
|
||||
cn.u32(0); // source: the stream does not say what produced a sample
|
||||
cn.u32(0); // dependency block
|
||||
cn.u32(0); // comment
|
||||
cn.u16(c.type);
|
||||
cn.text(c.name, 32);
|
||||
cn.zeros(128); // description
|
||||
cn.u16(c.bit_offset);
|
||||
cn.u16(c.bit_count);
|
||||
cn.u16(c.data_type);
|
||||
cn.u16(1); // range_flag
|
||||
cn.f64(c.lo);
|
||||
cn.f64(c.hi);
|
||||
cn.f64(0.0); // sampling rate: only a virtual time channel carries one
|
||||
cn.u32(0); // long name
|
||||
cn.u32(0); // display name
|
||||
cn.u16(0); // additional byte offset
|
||||
if (!mdf_block_size(cn, kMdfChannel, "channel", error)) return false;
|
||||
|
||||
tail.v.insert(tail.v.end(), cc.v.begin(), cc.v.end());
|
||||
tail.v.insert(tail.v.end(), cn.v.begin(), cn.v.end());
|
||||
}
|
||||
|
||||
// Channel group, last: it names the first channel, the record size and the cycle count from
|
||||
// which a reader derives the size of the sample block at `data_addr`.
|
||||
tail.raw("CG", 2);
|
||||
tail.u16(static_cast<std::uint16_t>(kMdfChannelGroup));
|
||||
tail.u32(0); // next channel group
|
||||
tail.u32(static_cast<std::uint32_t>(ch_addr + kMdfConversion));
|
||||
tail.u32(0); // comment
|
||||
tail.u16(1); // record ID
|
||||
tail.u16(static_cast<std::uint16_t>(channels));
|
||||
tail.u16(static_cast<std::uint16_t>(record));
|
||||
tail.u32(static_cast<std::uint32_t>(rows));
|
||||
tail.zeros(4); // sample reduction address
|
||||
if (!mdf_block_size(tail, kMdfDataGroup + channels * (kMdfConversion + kMdfChannel) +
|
||||
kMdfChannelGroup,
|
||||
"data group and channels", error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
File out(path);
|
||||
if (!opened(out, path, error)) return false;
|
||||
const auto put = [&](const Bytes& bytes) {
|
||||
return bytes.v.empty() || std::fwrite(bytes.v.data(), 1, bytes.v.size(), out.f) == bytes.v.size();
|
||||
};
|
||||
if (!put(head)) {
|
||||
error = "short write to '" + path + "': " + std::strerror(errno);
|
||||
return false;
|
||||
}
|
||||
// One record at a time, in the channel order the blocks above declare, so a full ring is not
|
||||
// held twice -- the same shape the `.npy` writer streams its records in.
|
||||
std::vector<unsigned char> rec(record);
|
||||
for (std::size_t i = 0; i < rows; ++i) {
|
||||
std::size_t at = 0;
|
||||
std::memcpy(rec.data() + at, &table.abs_seconds[i], sizeof(f64));
|
||||
at += sizeof(f64);
|
||||
std::memcpy(rec.data() + at, &table.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(rec.data() + at, &v, sizeof(f32));
|
||||
at += sizeof(f32);
|
||||
}
|
||||
if (std::fwrite(rec.data(), 1, rec.size(), out.f) != rec.size()) {
|
||||
error = "short write to '" + path + "': " + std::strerror(errno);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!put(tail)) {
|
||||
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_export(const std::string& path, const ExportTable& table, std::string& error) {
|
||||
const auto ends_with = [&path](const char* ext) {
|
||||
const std::size_t n = std::strlen(ext);
|
||||
return path.size() >= n && path.compare(path.size() - n, n, ext) == 0;
|
||||
};
|
||||
if (ends_with(".csv")) return write_csv(path, table, error);
|
||||
if (ends_with(".mf3") || ends_with(".mdf")) return write_mdf(path, table, error);
|
||||
return write_npy(path, table, error);
|
||||
}
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -21,6 +21,11 @@ namespace udpscope {
|
||||
struct ExportTable {
|
||||
struct Column {
|
||||
std::string name; ///< the legend name, sanitised for a column label
|
||||
/// The signal's unit exactly as the CONFIG carried it, the empty string when it
|
||||
/// carried none. The `.npy`/`.csv` writers do not spell it out (their readers get the
|
||||
/// names and the numbers); MDF does, because the unit *is* part of what a channel is
|
||||
/// there, and it is the only thing ScryLab reads a signal's unit from.
|
||||
std::string unit;
|
||||
std::vector<f32> values; ///< `rows()` long, the ring's own `f32`
|
||||
};
|
||||
|
||||
@@ -54,6 +59,12 @@ struct ExportTable {
|
||||
/// model access. In trigger mode nothing is locked: the `EventSnapshot` is immutable.
|
||||
ExportTable collect_export(ScopeModel& model, RowSet& rows);
|
||||
|
||||
/// The held event as a table, built from the **snapshot** rather than from the rows: a cache of the
|
||||
/// acquisition, every channel the event holds, independent of which traces a row happens to show
|
||||
/// when the latch lands. The channel names are the model's, in channel order -- the same order the
|
||||
/// columns come out in, so `--open` restores the same signals.
|
||||
ExportTable event_table(ScopeModel& model, const EventSnapshot& snapshot);
|
||||
|
||||
/// Writes a NumPy `.npy` file holding one structured array: `time` and `abs_seconds` as
|
||||
/// `<f8`, every signal as `<f4` -- the width the ring actually stores, so the file is
|
||||
/// about half the size of an all-`f8` one and invents no precision.
|
||||
@@ -65,4 +76,27 @@ bool write_npy(const std::string& path, const ExportTable& table, std::string& e
|
||||
/// 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);
|
||||
|
||||
/// Writes the same table as **MDF 3.30** (`.mf3`/`.mdf`), byte for byte by hand -- there is no
|
||||
/// writer library here and MF4 (HDF5) is the one format this app will not link.
|
||||
///
|
||||
/// One channel group, all channels record-interleaved, master first, which is the shape the
|
||||
/// [ScryLab data sources](https://docs.scrylab.de/docs/data-sources/) contract asks for: *every
|
||||
/// data channel becomes a signal* (the CNBLOCK's `short_name`), *the master channel of each
|
||||
/// group is the x axis* (our master is `abs_seconds`, named `time`), and *the unit is taken from
|
||||
/// the channel metadata* (MDF 3 keeps it in the channel's conversion block, the CCBLOCK). A
|
||||
/// second occurrence of a name is dropped by that reader, so the labels are de-duplicated and cut
|
||||
/// to the 31 bytes a CNBLOCK's name field holds.
|
||||
///
|
||||
/// The master is the **absolute** clock and the axis the plot showed is an ordinary channel,
|
||||
/// `time_relative`: the relative column is `0` at a different sample in every mode, so a master
|
||||
/// off it would put two exports from different modes on two timelines, while `abs_seconds` is the
|
||||
/// number the stream itself stamped the samples with. Both carry the unit `s`.
|
||||
bool write_mdf(const std::string& path, const ExportTable& table, std::string& error);
|
||||
|
||||
/// Writes `table` with the writer **`path`'s extension** names: `.csv` -> `write_csv`,
|
||||
/// `.mf3`/`.mdf` -> `write_mdf`, anything else -> `write_npy` (the default, as the save dialog's
|
||||
/// default filter is). The export dialog and the self-check's export probe both come through
|
||||
/// here, so the two cannot offer different formats.
|
||||
bool write_export(const std::string& path, const ExportTable& table, std::string& error);
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -1,78 +0,0 @@
|
||||
<b>What udpscope is</b>
|
||||
|
||||
udpscope plots a live UDPStreamer source. One window holds up to 8 rows of plots, each with up
|
||||
to 4 traces, and every row shares one time axis, so the whole figure scrolls together.
|
||||
Acquisition runs on its own thread and writes a ring buffer sized from <tt>--buffer-mb</tt>;
|
||||
the drawing thread reads whatever that ring holds when a frame starts, which is why a trace
|
||||
never makes the window stutter.
|
||||
|
||||
The stream is the clock. Every interval you read on screen is measured between timestamps the
|
||||
source sent, never your machine's, so a stalled source freezes the view instead of scrolling it
|
||||
-- and a source whose clock steps still gets the window you asked for.
|
||||
|
||||
The time axis is <i>relative to now</i>. While streaming it is a fixed <tt>[-window .. 0]</tt>
|
||||
with the newest sample at 0, so x reads as an age: <tt>-1.5s</tt> is a sample a second and a half
|
||||
old. In trigger mode 0 is the event's edge instead, and a paused view keeps the ages it was
|
||||
frozen with. The source's own clock is still there when you need it -- an export writes it as a
|
||||
second column.
|
||||
|
||||
<b>How to reach it</b>
|
||||
|
||||
Start the app with a source (<tt>udpscope --server 127.0.0.1 --port 8000</tt>), or start it with
|
||||
no <tt>--server</tt> and pick one from the chooser, which lists the sessions you have opened
|
||||
before. A unicast source serves one client at a time: if a second viewer connects, they take
|
||||
turns and the status bar says so. To watch one source from several instances, use
|
||||
<tt>--multicast</tt> with the group and <tt>--interface</tt> with the local address.
|
||||
|
||||
The signals arrive <i>disabled</i>. Nothing is acquired until you turn one on in the signal
|
||||
list, and enabling is what starts acquisition -- so a freshly opened scope with an idle side
|
||||
panel is working correctly, not failing to connect.
|
||||
|
||||
<b>The window</b>
|
||||
|
||||
• <b>The header bar</b> -- left to right: the time window (<tt>2 s</tt>), the trigger and the
|
||||
pause, then the pane switches -- signal list, cursors, measurements, math, side panel -- and
|
||||
the menu, which holds Fit to data, the layout editor, Export, Keyboard shortcuts, Help and
|
||||
About.
|
||||
|
||||
• <b>The side panel</b> -- the trigger, cursors, measurements and math panes at the top, then
|
||||
the signal list: one row per signal with its colour, an enable switch, its name and its unit.
|
||||
Signals are dragged from here onto a plot row.
|
||||
|
||||
• <b>The plot</b> -- up to 8 rows, each with its own y axis and up to 4 traces, all on one
|
||||
shared time axis. Right-click a row for its own menu (fit, add a signal, remove a trace).
|
||||
|
||||
• <b>The status bar</b>, directly above the plot's bottom edge -- the state word, the stored
|
||||
sample rate and the window, and the ⓘ beside it for packets, gaps and the buffer's own
|
||||
figures. The second screenshot above is what a held capture looks like: <tt>event held</tt>,
|
||||
the axis pinned to the event, and the trigger marker at the edge.
|
||||
|
||||
<b>The first five minutes</b>
|
||||
|
||||
1. <b>Connect.</b> With no <tt>--server</tt>, pick a stored session or type a host and port.
|
||||
2. <b>Enable a signal</b> in the signal list. The plot stays empty for the first fraction of a
|
||||
second while the first window fills -- the buffer holds only what has arrived.
|
||||
3. <b>Drag it onto a row.</b> Drop a second signal on another row, or the same signal again for
|
||||
a second view of it. The colour is per signal, so it is the same one wherever it appears.
|
||||
4. <b>Set the window</b> with the control in the header bar: type a number, pick a unit, or step
|
||||
a decade. The trigger's event window is a share of this span, so a window change moves it.
|
||||
5. <b>Pause</b> (<tt>space</tt>) to look at a still picture; the producer keeps filling the
|
||||
buffer behind it, and resuming shows what arrived meanwhile rather than a hole.
|
||||
6. <b>Arm the trigger</b> (<tt>a</tt>) and wait for a latch: the view freezes on one event, and
|
||||
you can zoom in on it. Disarming mid-capture keeps what the capture got.
|
||||
7. <b>Measure it</b> -- the measurements pane computes a statistic of the visible window, and
|
||||
<b>show on plot</b> marks it as a ruler or a pair of markers.
|
||||
8. <b>Save the data</b> with Export visible range, which writes the plotted signals over the
|
||||
window at the stored rate to <tt>.npy</tt> or <tt>.csv</tt>.
|
||||
|
||||
<b>Worth knowing</b>
|
||||
|
||||
• A signal nobody enables costs nothing -- no buffer, no socket work, no measurements.
|
||||
• The window remembers itself. Rows, traces, the time window, calibration, which signals were
|
||||
enabled, the math channels with their graphs, and the measurements are written per host and
|
||||
port, and reloaded the next time you open that source.
|
||||
• A very wide window on a fast stream is drawn from averaged groups of samples, so the rate in
|
||||
the status bar falls: that is the anti-alias filter, not a fault -- striding would fold a fast
|
||||
signal back as a slow one, which looks entirely plausible on screen.
|
||||
• Every instrument the app computed -- the trigger marker, the measurement marks -- is locked to
|
||||
the pointer's reach. The cursors are yours to drag; those are not.
|
||||
@@ -1,31 +0,0 @@
|
||||
<b>Choosing a source</b>
|
||||
|
||||
A source is a host and a port, optionally reached through a multicast group on a named
|
||||
interface. Pass it on the command line, or let udpscope ask.
|
||||
|
||||
<b>How to reach it</b>
|
||||
|
||||
Run with <tt>--server</tt> and the app connects straight away. Run <i>without</i>
|
||||
<tt>--server</tt> and a chooser appears first: the sessions udpscope has stored, newest
|
||||
first, plus a <b>New host…</b> form for one it has not seen. The chooser runs before any
|
||||
buffer is allocated and before any socket is opened, so declining it costs nothing.
|
||||
|
||||
<b>Worth knowing</b>
|
||||
|
||||
• The list is the sessions that <i>named a signal</i>. A session whose streamer was down
|
||||
stores empty slot names and is filtered out, which is what keeps the list the sources you
|
||||
actually configured rather than every port you have ever typed.
|
||||
• A stored row reconnects the way it was reached. The multicast group and the interface are
|
||||
written inside the session file, because the file's name (<tt>host_port.toml</tt>) cannot
|
||||
carry them.
|
||||
• <tt>--multicast</tt> still needs <tt>--server</tt>: the CONFIG message is fetched from the
|
||||
server, not from the group.
|
||||
• The <tt>×</tt> beside a row deletes that session file. Nothing else is removed.
|
||||
|
||||
<b>Example</b>
|
||||
|
||||
<tt>udpscope</tt> with two stored sessions lists
|
||||
<tt>127.0.0.1:8000 plots=1 signals=3</tt> and
|
||||
<tt>10.0.0.5:8001 plots=2 signals=4 multicast=239.1.2.3 interface=10.0.0.9</tt>.
|
||||
Activating the first reports <tt>[sources] picked 127.0.0.1:8000</tt> and continues into
|
||||
the scope with that session's layout already loaded.
|
||||
@@ -1,33 +0,0 @@
|
||||
<b>Signals and calibration</b>
|
||||
|
||||
The source's CONFIG message describes its signals: a name, a unit and a type. udpscope turns
|
||||
each enabled signal into a <i>channel</i> — a ring of values beside the shared time ring —
|
||||
and a channel is what a trace plots. Calibration is a scale and an offset applied at ingest.
|
||||
|
||||
<b>How to reach it</b>
|
||||
|
||||
The side panel (<tt>s</tt>) lists every signal with a switch. The <b>Signal list</b> item in
|
||||
the overflow menu opens the editor where the unit and the calibration live.
|
||||
|
||||
<b>Worth knowing</b>
|
||||
|
||||
• Signals start disabled, and enabling one is what starts acquiring it. A trigger on a signal
|
||||
nobody acquires can never fire, so arming a trigger enables its signal for you.
|
||||
• Calibration is applied <i>at ingest</i>, so changing it re-plans the buffer and drops the
|
||||
history. So does enabling or disabling a signal, and so does a new CONFIG. An empty plot
|
||||
for a second after one of those is the ring refilling, not a fault.
|
||||
• The stream's first signal is usually its <i>timestamp</i>, and that one is never acquired.
|
||||
It cannot be plotted, measured or used as a math source, and udpscope refuses to put it in
|
||||
a row rather than binding a trace that would read nothing for ever.
|
||||
• A signal's colour follows the <i>signal</i>, not the slot, so the same signal on two rows
|
||||
is one colour and the side panel's chip identifies it unambiguously. A signal on no row
|
||||
gets a grey chip rather than none, so the panel's rows keep their height.
|
||||
• <b>Click a coloured chip to change that signal's colour.</b> A grey chip names no trace, so
|
||||
it is not clickable. The colour is saved with the session; <b>Default colour</b> in the
|
||||
signal's ⋯ menu gives the palette entry back.
|
||||
|
||||
<b>Example</b>
|
||||
|
||||
A source declaring <tt>time</tt>, <tt>sine</tt>, <tt>ramp</tt> and <tt>noise</tt> yields three
|
||||
plottable signals. Enable all three and the exit dump reads <tt>3 signals, 3 channels</tt>;
|
||||
<tt>time</tt> is not among them.
|
||||
@@ -1,35 +0,0 @@
|
||||
<b>Rows, layout and drag & drop</b>
|
||||
|
||||
The figure is a stack of rows, 1 to 8 of them, each holding up to 4 traces. Rows share one
|
||||
time axis and one pair of side margins, so their y labels line up, their data areas start and
|
||||
end on the same two columns, and their windows cannot drift apart. The left margin holds the y
|
||||
labels; the right one is empty, and is there so the newest tick's label and the newest sample's
|
||||
ink have somewhere to land instead of sitting on the frame.
|
||||
|
||||
<b>How to reach it</b>
|
||||
|
||||
Drag a signal's chip out of the side panel (<tt>s</tt>) and drop it on a row to add a trace.
|
||||
Press <tt>l</tt>, or pick <b>Layout editor</b> from the overflow menu, to add and remove rows:
|
||||
a <tt>×</tt> appears at the top left of each row and a <tt>+</tt> on each boundary between
|
||||
rows, including above the first and below the last. The editor's own button in the top-right
|
||||
corner leaves it.
|
||||
|
||||
<b>Worth knowing</b>
|
||||
|
||||
• A drop <i>adds</i> a trace to the row's next free slot; it does not replace one. With four
|
||||
traces already on a row the drop is refused.
|
||||
• Inserting a row moves the rows below it <i>with their contents</i>. Inserting above row 1
|
||||
puts what row 1 plotted onto row 2 and leaves the new row empty — the rows are not
|
||||
renumbered under you.
|
||||
• A trace is remembered by the signal's <i>name</i>, not by its channel number, because a new
|
||||
CONFIG or an enable renumbers the channels and an index would silently re-point the trace.
|
||||
• Adding or removing a row rebuilds the figure, which is cheap, but it also re-deals every
|
||||
trace. A frozen trigger event survives the rebuild: the pinned window is restated rather
|
||||
than re-framed on the live edge.
|
||||
|
||||
<b>Example</b>
|
||||
|
||||
With three traces on row 1, pressing <tt>l</tt> shows 3 buttons (one <tt>×</tt>, two
|
||||
<tt>+</tt>). Clicking the <tt>+</tt> above row 1 gives 2 rows and 5 buttons, with the three
|
||||
traces now on row 2 — the layout probe reports exactly this as
|
||||
<tt>rows=2 buttons=5 traces=[0:-|1:sine,ramp,noise]</tt>.
|
||||
@@ -1,33 +0,0 @@
|
||||
<b>Time window</b>
|
||||
|
||||
The window is how much time the plot shows. Every row shares one window: the figure has a
|
||||
single x link group, so the rows cannot drift apart. The trigger's event span follows it, and
|
||||
the pre-trigger share is a share of <i>this</i> window.
|
||||
|
||||
<b>How to reach it</b>
|
||||
|
||||
The header bar's window button opens a popover: type a number, pick a unit under it, or step a
|
||||
decade with the arrows. The button's own label is a reading of the current window in a natural
|
||||
unit, width-locked so a change of window cannot widen the header bar.
|
||||
|
||||
<b>Worth knowing</b>
|
||||
|
||||
• A window too long to store at the wire rate is <i>decimated, not refused</i>. The shared clock
|
||||
takes a power-of-two stride, and each group of samples is <b>averaged</b> rather than strided,
|
||||
so a tone above the stored Nyquist is attenuated instead of aliasing to a false low frequency.
|
||||
• The stamp stored for a group is the group's <i>mean</i>, which is where the averaging puts the
|
||||
value. Stamping at the end of a group would shift every trace half a stored sample late.
|
||||
• Moving the window across a decimation boundary rebuilds the buffer and drops the history.
|
||||
Walking it inside one decade is free.
|
||||
• <b>This control is the only thing that changes the window.</b> A zoom, a pan or a fit moves the
|
||||
view <i>inside</i> it and leaves it alone — which is the point of the previous bullet: a wheel
|
||||
notch that redefined the window could re-plan the buffer and throw your samples away.
|
||||
• The trigger sees the <i>stored</i> (filtered) signal, not the wire signal, because an edge
|
||||
found on a sample that is not in the buffer could not be cut out of it.
|
||||
|
||||
<b>Example</b>
|
||||
|
||||
1 MS/s with 200 MB of buffer. A 2 s window stores every sample (<tt>time x1</tt>). Widen it to
|
||||
600 s and the stride becomes <tt>x16</tt>: 625 S/s stored, anti-aliased at 312.5 Hz. A 2 kHz
|
||||
sine seen through that window reads <tt>y=[-0.059, 0.079]</tt> — attenuated to about 6%, not
|
||||
folded down to a full-amplitude 125 Hz one.
|
||||
@@ -1,57 +0,0 @@
|
||||
<b>Navigating the plot</b>
|
||||
|
||||
The plot pans and zooms with the mouse and with the keyboard. Every gesture acts on the row the
|
||||
pointer is over, and because the rows share one time axis, an x change moves all of them.
|
||||
|
||||
<b>How to reach it</b>
|
||||
|
||||
<b>Wheel</b> zooms both axes · <b>Shift+wheel</b> zooms x only · <b>Ctrl+wheel</b> zooms y only.
|
||||
<b>Drag</b> pans · <b>Ctrl+drag</b> is a box zoom on x · <b>Alt+drag</b> a box zoom on y.
|
||||
<b>+</b> and <b>-</b> zoom both axes; <b>Ctrl</b> with them restricts to x, <b>Alt</b> to y.
|
||||
<b>Arrow keys</b> pan, <b>f</b> fits, <b>Escape</b> cancels a gesture in progress.
|
||||
The full table is in the shortcut overview (<tt>Ctrl+/</tt>).
|
||||
|
||||
<b>Worth knowing</b>
|
||||
|
||||
• The keyboard zoom's modifiers are <i>not</i> the wheel's, and that is deliberate: on common
|
||||
layouts <tt>+</tt> <i>is</i> shift+<tt>=</tt>, so a bare zoom-in already arrives with Shift
|
||||
set and Shift could not mean anything. Ctrl/Alt mirror the box-zoom drags instead.
|
||||
• A <i>pinned</i> view is navigable within its own data but cannot leave it. While an event is
|
||||
held, a capture is filling, or the view is paused, a zoom <i>in</i> works normally — "not
|
||||
outside the event" is not "not readable" — but a pan or a zoom out is clamped to the data.
|
||||
• Nothing you do on the plot changes the <i>time window</i> — only the header control does. A
|
||||
zoom or a pan moves the view inside the window; <b>f</b> (and Fit X axis) is the <i>undo</i> of a
|
||||
zoom, so a 2 s window zoomed into reads 2 s again. The window is more than the axis: it is also
|
||||
the trigger's event span and how the samples are stored, so a wheel notch that redefined it
|
||||
would re-plan the buffer and drop your history.
|
||||
• The axis does not scroll — the <i>data</i> does. A live axis is a fixed <tt>[-window .. 0]</tt>
|
||||
with the newest sample at 0, so the labels stand still and the trace rolls under them. The
|
||||
cursors stand still with the labels (they are positions on screen, so a cursor you placed stays
|
||||
where you put it, over newer data each frame), while a measurement's marks travel with the
|
||||
sample they measured and leave the view when it does.
|
||||
• A live view is clamped to the window you asked for, not to what the ring happens to hold.
|
||||
That is why a buffer that has only just started filling leaves its left-hand side empty
|
||||
instead of squeezing the window down to the first few samples.
|
||||
• Zooming hard into a held capture is exact. The renderer finds the visible samples by binary
|
||||
search over the real timestamps, never over a nominal sample grid, so a capture whose clock
|
||||
wandered does not drift away from its axis as you zoom.
|
||||
• A trace holding far more samples than the plot has pixels is drawn <i>one pixel column at a
|
||||
time</i>: each column is a single vertical line from the lowest to the highest value that falls
|
||||
in it. Drawn as a polyline instead, such a window puts three or four near-vertical lines in
|
||||
every column at slightly different sub-pixel positions, and how they overlap beats against the
|
||||
pixel grid — which reads as bands of light and dark that <i>crawl</i> as you pan. One line per
|
||||
column removes the beat, and it is also much faster: a 1 MS/s view costs a couple of thousand
|
||||
line segments a frame instead of a couple of hundred thousand.
|
||||
• Those columns are <i>graded by brightness</i>, the way a digital-phosphor scope reads: bright
|
||||
where the signal spent its time, faint where it only passed through. A column's highest and
|
||||
lowest value are the same whether the samples in it are spread evenly or piled at its two ends,
|
||||
so the grading is what tells the two apart — the flat parts of a square wave come out solid and
|
||||
the edges between them faint. It is read from a finer division of each column rather than from
|
||||
the line that is drawn, and it costs about a millisecond a frame.
|
||||
|
||||
<b>Example</b>
|
||||
|
||||
Pause a live view, then press <tt>Ctrl++</tt>: x narrows and y does not move. Press
|
||||
<tt>Alt++</tt>: y narrows and x does not. Eight doublings into a held 2 s capture, the trace
|
||||
still reaches both margins — the zoom probe reports <tt>gap_px</tt> and <tt>end_px</tt> within
|
||||
a pixel or two at every step.
|
||||
@@ -1,31 +0,0 @@
|
||||
<b>Pause and play</b>
|
||||
|
||||
Pause freezes the <i>display</i>, not the acquisition. The producer keeps filling the ring at
|
||||
full rate; the view binds an immutable snapshot of the window that was on screen, and the axis
|
||||
is pinned to it.
|
||||
|
||||
<b>How to reach it</b>
|
||||
|
||||
The pause button in the toolbar, or <tt>Space</tt>. Press it again to resume, which rebinds the
|
||||
live ring and puts the window back on the newest data.
|
||||
|
||||
<b>Worth knowing</b>
|
||||
|
||||
• Nothing is lost while paused. Because the producer never stops, resuming shows current data
|
||||
with no hole in the history behind it.
|
||||
• The axis is pinned to the <i>snapshot's own stamps</i>, not to the window that was requested.
|
||||
A window reaching back past the oldest retained sample yields a shorter snapshot, and the
|
||||
pinned axis is that shorter span — so you see a narrower view rather than an empty margin.
|
||||
• A paused view is pinned exactly as a held trigger event is: zoom in freely, but a pan or a
|
||||
zoom out is clamped to the snapshot's data.
|
||||
• Pausing is the easiest way to measure a layout or read a cursor precisely, because nothing
|
||||
under the pointer moves between frames.
|
||||
• The status bar says how long you have been paused — wall time, since the stream itself never
|
||||
stopped. It is there so a frozen picture cannot be mistaken for a live one that went quiet.
|
||||
|
||||
<b>Example</b>
|
||||
|
||||
Pause with a 2 s window over a ring holding 1.4 s: the frozen axis reads
|
||||
<tt>[-1.40 .. 0.00]</tt> rather than <tt>[-2.00 .. 0.00]</tt> with 0.6 s of blank. The pause
|
||||
probe reports <tt>ring_produced</tt> still growing across the pause and
|
||||
<tt>event_size</tt> non-zero exactly while paused.
|
||||
@@ -1,35 +0,0 @@
|
||||
<b>Trigger</b>
|
||||
|
||||
The trigger watches one signal for an edge through a level and captures a window around it.
|
||||
The captured window is frozen on screen until you re-arm.
|
||||
|
||||
<b>How to reach it</b>
|
||||
|
||||
The trigger button in the toolbar (<tt>t</tt>) reveals the trigger panel: the signal, the level,
|
||||
the edge (rising, falling, either), the mode (Normal or Single) and the pre-trigger share.
|
||||
<b>Arm</b> starts watching; <tt>a</tt> arms and disarms from the keyboard.
|
||||
|
||||
<b>Worth knowing</b>
|
||||
|
||||
• Arming <i>enables</i> the signal if it was off, because a trigger on a signal nobody acquires
|
||||
can never fire.
|
||||
• The pre-trigger share is a share of the <i>display window</i>, not of the ring. A 2 s window
|
||||
at 50% captures 1 s before the edge and 1 s after.
|
||||
• There are four display states, and the status bar names each: <tt>armed</tt> (watching,
|
||||
nothing drawn yet), <tt>capturing</tt> (the edge was accepted and the post window is filling
|
||||
<i>in view</i>, with the marker at x = 0), <tt>event held</tt> (the frozen capture) and
|
||||
<tt>disarmed</tt>. A held capture is never quietly replaced by live data.
|
||||
• The first edge after arming is only accepted once the history reaches back a full pre-trigger
|
||||
window, so a capture may legitimately be shorter than you asked for. The axis is pinned to
|
||||
what the capture actually got.
|
||||
• Disarming <i>during</i> a capture publishes what the capture got to, so the picture on screen
|
||||
at that moment stays on screen. No event fired, so the event count does not move.
|
||||
• The trigger reads the <b>stored</b> signal — decimated and averaged — not the wire signal.
|
||||
A spike narrower than one decimation group is filtered away rather than triggering on data
|
||||
the buffer does not contain.
|
||||
|
||||
<b>Example</b>
|
||||
|
||||
A 1 Hz sine, level 0, rising, 2 s window at 50%, Single. The capture spans
|
||||
<tt>[-1.000 .. 1.000]</tt> with <tt>n=20001</tt> samples at 10 kS/s — the span times the real
|
||||
rate, plus one — and the marker sits at x = 0 throughout.
|
||||
@@ -1,50 +0,0 @@
|
||||
<b>Cursors</b>
|
||||
|
||||
Cursors read values off the traces. There are two kinds: one <i>shared vertical pair</i> that
|
||||
every row carries at the same time, and <i>horizontal rulers</i>, one pair per row, that belong
|
||||
to that row alone.
|
||||
|
||||
<b>How to reach it</b>
|
||||
|
||||
The cursors button in the toolbar, or <tt>c</tt>. The pane below the plot shows the readings and
|
||||
owns a <b>Reset</b> button. Drag a marker to move it. The mode dropdown offers four modes:
|
||||
<tt>vertical</tt> (the shared time pair), <tt>horizontal</tt> (the per-row rulers),
|
||||
<tt>both</tt>, and <tt>mix</tt>.
|
||||
|
||||
<b>Worth knowing</b>
|
||||
|
||||
• The vertical pair is <i>one</i> time for the whole figure, so moving it on any row moves it on
|
||||
all of them. That is what makes <tt>dt</tt> meaningful across rows.
|
||||
• A ruler belongs to its row and nothing shares it, so two rows' rulers are independent.
|
||||
• <tt>mix</tt> is the reading mode: one line per <i>bound</i> trace, with the trace's own values
|
||||
at each marker and their difference. A slot with a name but no channel behind it is not a
|
||||
trace and gets no line.
|
||||
• A value is the <i>nearest stored sample's</i>, never a number between two samples. Zoom in past
|
||||
the sample spacing and the line between two samples is something the plot drew, not data —
|
||||
so a reading off it would be the drawing. The hover crosshair reads the same way.
|
||||
• Every reading carries a <i>metric prefix</i> so the digits sit in <tt>1..999</tt>:
|
||||
<tt>10.52 mV</tt>, not <tt>0.01 V</tt>. Two decimals is all an instrument landing on an
|
||||
<tt>f32</tt> sample can claim, so without the prefix a small value would read <tt>0.00</tt>
|
||||
and the reading would be gone. A stream that declares no unit gets the prefix anyway, written
|
||||
tight against the number (<tt>585.24m</tt>) — the space separates the number from its unit,
|
||||
and a spaced <tt>m</tt> with nothing after it would read as metres.
|
||||
• A marker past the newest sample reads <tt>--</tt>. With only one marker over data the row
|
||||
reads <tt>partial</tt>: a difference between a number and nothing is not a number.
|
||||
• In <tt>mix</tt> each marker also places a crossing annotation per trace, which is what lets
|
||||
you see on the plot which sample a number came from. It is <i>on that sample</i>, so when the
|
||||
marker falls between two it sits a little off its own line: the line is where you put the
|
||||
marker, the dot is the datum being quoted. The hover crosshair's mark snaps the same way, and
|
||||
is drawn larger than a trace's sample dots so it does not read as one more sample.
|
||||
• The time row exists only in the modes that show the vertical pair. <tt>horizontal</tt> has no
|
||||
time row, by design.
|
||||
• A cursor <i>stays where you put it</i> while a live view rolls under it, which is what a bench
|
||||
scope does: its <tt>T0</tt> is an age, so a marker left at <tt>-1.5s</tt> keeps reading the
|
||||
sample a second and a half old as the stream advances. A measurement's marks do the opposite —
|
||||
they travel with the sample they measured — because a cursor is a question you asked and a mark
|
||||
is an answer about one particular sample.
|
||||
|
||||
<b>Example</b>
|
||||
|
||||
Three traces in <tt>mix</tt> mode with both markers over data: 3 lines and 6 crossing marks,
|
||||
each line reading like <tt>sine 0.31 V -0.84 V -1.15 V</tt>. Move the second marker past the
|
||||
newest sample and the third column reads <tt>--</tt>.
|
||||
@@ -1,54 +0,0 @@
|
||||
<b>Measurements</b>
|
||||
|
||||
A measurement is a statistic of the <i>visible window</i> of one signal, recomputed as that
|
||||
window moves. The pane lists them with their values; each can also draw its result on the plot.
|
||||
|
||||
<b>How to reach it</b>
|
||||
|
||||
The measurements button in the toolbar, or <tt>m</tt>. The pane's <tt>+</tt> adds one; clicking
|
||||
a row opens the dialog where the signal, the type, the threshold share and the on-plot marks are
|
||||
set, with <b>Save</b> and <b>Delete</b>.
|
||||
|
||||
<b>Worth knowing</b>
|
||||
|
||||
• The types are average, top, bottom, min, max, peak to peak, amplitude, pulse duration, period
|
||||
and frequency. <i>top</i> and <i>bottom</i> are the means of the upper and lower halves, which
|
||||
is why a unit sine reads 0.637 (2/π) rather than 1.
|
||||
• The on-plot marks come in two kinds and the mix is the reading: a <i>level</i> mark is a ruler
|
||||
at a value, a <i>time</i> mark is a marker at a position. An average draws one level mark; a
|
||||
max draws a level mark <i>and</i> a time mark at the first sample that reached it.
|
||||
• Marks cannot be dragged. A measurement mark says what the measurement measured, so it is
|
||||
created unselectable — as is the trigger marker.
|
||||
• A pass is throttled to a cadence rather than run every frame, so a ruler over a live view is
|
||||
<i>stepped</i>. The number and the ruler come from the same pass, so they always agree.
|
||||
• An invalid pass keeps its <i>level</i> marks and drops its <i>time</i> marks. A level is a
|
||||
property of the signal and outlives the window; a position inside the window does not. That is
|
||||
what stops the rulers blinking off at a mode change.
|
||||
• A crossing is counted with <i>hysteresis</i>, not on a bare level: the thresholds are a tenth of
|
||||
the window's peak to peak either side of the share, and an edge is counted only once the signal
|
||||
has passed the far one. Period, frequency and pulse duration all depend on it. The mid level is
|
||||
exactly where a waveform moves fastest, so any interferer faster than the fundamental recrosses a
|
||||
single level several times per real crossing — a 5 Hz sine with a 500 Hz tone at a tenth of the
|
||||
amplitude reads 39 Hz without the band and 5 Hz with it. The band holds to an interferer a third
|
||||
of the waveform's own size.
|
||||
• The band is clamped to stay inside the data, which is what keeps a threshold set near an extreme
|
||||
usable: a fixed band there would sit above every sample and the measurement would read nothing.
|
||||
A share near an extreme still leaves the band little room, so it rejects less — which is one
|
||||
reason period and frequency offer no share to set.
|
||||
• A window with too few crossings reads <tt>invalid</tt> / <tt>--</tt> rather than a plausible
|
||||
invented number. Frequency over a window holding one edge is the usual case.
|
||||
• <tt>frequency</tt> is reported in Hz and <tt>period</tt>/<tt>pulse duration</tt> in seconds;
|
||||
every other type carries the channel's own unit. A value is written with a <i>metric prefix</i>
|
||||
so its digits sit in <tt>1..999</tt> — <tt>636.62 mV</tt>, not <tt>0.64 V</tt> — including when
|
||||
the stream declares no unit at all, which is always the case for a math channel: there the
|
||||
prefix goes tight against the number (<tt>636.62m</tt>), since two decimals of an unprefixed
|
||||
small value is <tt>0.00</tt>.
|
||||
• Cost is bounded by reading the summary pyramid the producer already maintains, not the raw
|
||||
window. The values are still exact: a bucket's sum is a sum.
|
||||
|
||||
<b>Example</b>
|
||||
|
||||
On a 1 Hz ±1 sine: <tt>average=0</tt>, <tt>top=0.636620</tt>, <tt>bottom=-0.636620</tt>,
|
||||
<tt>max=1</tt>, <tt>min=-1</tt>, <tt>peak to peak=2</tt>, <tt>amplitude=1</tt>,
|
||||
<tt>pulse duration=0.5 s</tt> (duty 0.5), <tt>period=1 s</tt>, <tt>frequency=1 Hz</tt>. Over a
|
||||
2 M-sample window those two passes read about 0.1% of the samples.
|
||||
@@ -1,61 +0,0 @@
|
||||
<b>Math channels</b>
|
||||
|
||||
A math channel is a signal computed from other signals. You build it as a small graph — source
|
||||
nodes, operations, one output — and udpscope then treats the result as an ordinary channel.
|
||||
|
||||
<b>How to reach it</b>
|
||||
|
||||
The math button in the toolbar, or <tt>g</tt>. The pane's <tt>+</tt> adds a channel, and opening
|
||||
one shows the graph editor: nodes as boxes, click a box to select it and edit its parameters beside
|
||||
the canvas. Inside the editor, <i>add node</i> is a menu button whose popover picks the op, so the
|
||||
node arrives as the operation you asked for.
|
||||
|
||||
<b>Graph gestures:</b> <b>drag</b> from an output port to an input port to wire them; drag a
|
||||
wired input port off its connection to detach it; click a wire and press <tt>Delete</tt> to remove
|
||||
it; drag a box to move it. <b>Wheel</b> zooms about the pointer. <b>Drag the background</b> to
|
||||
pan. <tt>f</tt> fits all nodes into view; <tt>Ctrl+0</tt> resets the view. <i>tidy</i>, the button
|
||||
beside <i>fit</i> in the editor, lays the graph out in topological columns and frames it. The orange
|
||||
dot marks the output node; click <i>set as output</i> to move it.
|
||||
|
||||
<b>Worth knowing</b>
|
||||
|
||||
• The graph is evaluated as an ordered list, so a node may only read ones <i>above</i> it. A
|
||||
wire drawn right to left re-sorts the list silently so the graph stays valid. A cycle is
|
||||
unrepresentable: the connection is refused rather than created, and the line beneath the form
|
||||
says why — a loop, or a node asked to read itself.
|
||||
• A derived channel <i>is</i> a channel. It appears in the signal panel, can be plotted,
|
||||
measured, exported, and used as a source for another math channel.
|
||||
• The math runs on the <b>stored</b> samples — decimated and averaged — so a cutoff you type in
|
||||
Hz means the rate the filter will actually run at. Change the window across a decimation
|
||||
boundary and the coefficients are rebuilt for the new rate.
|
||||
• A source may only name an <i>acquired</i> channel. The stream's timestamp signal is not one:
|
||||
a graph sourced from it validates and then reads NaN for ever, so the dropdown does not
|
||||
offer it.
|
||||
• The output defaults to the <i>last</i> node, and a node added to a linear graph becomes the
|
||||
output. Choosing an earlier node explicitly is respected.
|
||||
• Every numeric field starts at its own identity value — gain 1, offset 0 — not at the bottom
|
||||
of its range.
|
||||
|
||||
<b>Example</b>
|
||||
|
||||
<tt>[sine] → [gain x2]</tt> gives a channel reading <tt>y=[-2, 2]</tt>. A 100 Hz low pass on the
|
||||
same source passes 50 Hz at 0.97 and 2 kHz at 0.002; a 1 kHz band pass with Q=2 passes 200 Hz at
|
||||
0.05, 1 kHz at 0.95 and 4 kHz at 0.05. Each derived channel must hold exactly as many samples as
|
||||
the shared clock has time slots.
|
||||
|
||||
<b>Why an operation sometimes does nothing</b>. A graph's <i>output</i> is the node whose value
|
||||
becomes the channel, and it is drawn as the orange dot. A new graph is built source, then operation,
|
||||
and the last node added becomes the output -- but a graph whose output is left on the source is
|
||||
valid and simply passes its input through, which is the usual reason a gain or a filter appears to
|
||||
have no effect. <i>Set as output</i> moves the dot.
|
||||
|
||||
<b>Apply is insensitive while the graph is incomplete.</b> A line beneath the form says why:
|
||||
an input that is not connected, no output chosen, or a <i>channel</i> node naming a signal the
|
||||
scope is not acquiring. Only the channels being acquired are offered, because a source that is not
|
||||
acquired reads as no data for ever — and a graph that reads NaN is harder to diagnose than one that
|
||||
would not save.
|
||||
|
||||
<b>The filters are real filters.</b> A math channel's samples are produced as the stream arrives,
|
||||
once per stored slot, so a low pass carries the history a filter needs. A cutoff typed in hertz is
|
||||
against the <i>stored</i> rate, which is the wire rate until a wide window makes the buffer decimate
|
||||
-- the status line's rate is that number, and the filters are rebuilt whenever it changes.
|
||||
@@ -1,33 +0,0 @@
|
||||
<b>Saving and export</b>
|
||||
|
||||
Export writes the <i>visible range</i> of every plotted trace to a file: NumPy <tt>.npy</tt> or
|
||||
<tt>.csv</tt>, chosen by the extension you type.
|
||||
|
||||
<b>How to reach it</b>
|
||||
|
||||
<b>Export visible range…</b> in the overflow menu, or <tt>Ctrl+E</tt>. A file chooser asks where;
|
||||
the format follows the path's extension.
|
||||
|
||||
<b>Worth knowing</b>
|
||||
|
||||
• The table is collected at the moment you click, not when the chooser returns. A live view keeps
|
||||
scrolling while a dialog is up, so a late collection would write a window you never saw.
|
||||
• The rows are the <b>stored samples</b>, never the LOD buckets the plot draws from. A row count
|
||||
far below the span times the stored rate means something cut the table from the pyramid.
|
||||
• The stored rate is not always the wire rate. A window long enough to need decimation is stored
|
||||
averaged at a lower rate, and the export carries <i>that</i> — read the status bar's ⓘ popover
|
||||
for the figure before trusting a sample count.
|
||||
• Two time columns. <tt>time</tt> is the display timeline — negative ages ending at 0 while
|
||||
streaming, offsets from the trigger edge while an event is held — so it is the x you read a
|
||||
value at on screen. <tt>abs_seconds</tt> is the source's own clock for the same sample, which
|
||||
is what lets two exports taken in different modes line up. Both are strictly increasing and
|
||||
evenly spaced; duplicate or backwards stamps would mean the file held two clocks.
|
||||
• An export taken <i>mid-capture</i> writes the partly filled window that is on screen, which is
|
||||
shorter than the event span. Export outside a capture if you want the whole event.
|
||||
|
||||
<b>Example</b>
|
||||
|
||||
A 2 s window at 10 kS/s over 3 traces writes 20001 rows and 5 columns
|
||||
(<tt>time</tt>, <tt>abs_seconds</tt>, three signals). The <tt>.npy</tt> is
|
||||
<tt>64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)</tt> bytes. Read it back with
|
||||
<tt>pd.DataFrame(np.load(path))</tt>.
|
||||
@@ -1,34 +0,0 @@
|
||||
<b>Sessions and settings</b>
|
||||
|
||||
udpscope stores one session file per source, so reopening a source gives you the scope you
|
||||
closed. There is no global preferences file: the session <i>is</i> the settings.
|
||||
|
||||
<b>How to reach it</b>
|
||||
|
||||
Nothing to open — the file is written as you work. Its path is in the About dialog, and the
|
||||
directory is <tt>$XDG_CONFIG_HOME/udpscope</tt>, or <tt>~/.config/udpscope</tt> when that is
|
||||
unset. Each file is named <tt><host>_<port>.toml</tt>.
|
||||
|
||||
<b>Worth knowing</b>
|
||||
|
||||
• What a session persists: the time window, the rows and each row's slot signals, the
|
||||
calibration, which signals are enabled, the math channels with their graphs, and the
|
||||
measurements pane.
|
||||
• Signal identity is the <i>name</i> throughout, because a new CONFIG renumbers the channels.
|
||||
• A file written by a run against a source whose streamer was down would name no signals at all,
|
||||
so such a run does <b>not</b> write: a configured session is never overwritten by a run that
|
||||
configured nothing.
|
||||
• The multicast group and the interface live <i>inside</i> the file, because its name cannot
|
||||
carry them. A file from an older build has neither key and loads as unicast on the default
|
||||
route.
|
||||
• The startup chooser lists only files that name at least one signal, which is what keeps it the
|
||||
sources you configured rather than every port you have ever tested against.
|
||||
• Deleting a file is a supported way to start a source over; the chooser's <tt>×</tt> does
|
||||
exactly that.
|
||||
|
||||
<b>Example</b>
|
||||
|
||||
Connect to <tt>127.0.0.1:8000</tt>, drop three signals on row 1 and set a 2 s window.
|
||||
<tt>~/.config/udpscope/127.0.0.1_8000.toml</tt> now holds <tt>rows=1 window_s=2</tt> with
|
||||
<tt>signals = ["sine", "ramp", "noise", ""]</tt>, and the next run reports
|
||||
<tt>layout loaded from …/127.0.0.1_8000.toml</tt> with those signals already acquired.
|
||||
@@ -1,46 +0,0 @@
|
||||
<b>Status bar and diagnostics</b>
|
||||
|
||||
The status bar is two tiers. The bar itself is one glance line — the state, the rate and the
|
||||
window — and everything else is behind the ⓘ button at its right-hand end.
|
||||
|
||||
<b>How to reach it</b>
|
||||
|
||||
The bar is always visible under the plot. Click <b>ⓘ</b> for the detail popover.
|
||||
|
||||
<b>Worth knowing</b>
|
||||
|
||||
• The badge is one glyph in four colours: green <i>alternating</i> lit and dim means data is
|
||||
arriving, and a steady colour means it is not. A badge that never blinks is not a working badge.
|
||||
Of the two steady colours, <i>grey</i> is no stream at all or a pipeline you stopped (the
|
||||
tooltip says which), and <i>yellow</i> is a source that answered but has sent no configuration.
|
||||
• The state field is one word from a fixed set: <tt>connecting</tt>, <tt>live</tt>,
|
||||
<tt>paused</tt>, <tt>armed</tt>, <tt>capturing</tt>, <tt>event held</tt>, <tt>disarmed</tt>. Two
|
||||
states add one field each: in trigger mode, how long ago the last event was; while paused, how
|
||||
long you have been paused.
|
||||
• <tt>connecting</tt> means there is no <i>signal list</i> yet — either no session or a session
|
||||
with no configuration behind it. Every other word claims a signal list, so until one arrives
|
||||
there is nothing for them to describe: nothing can be enabled and no data is owed.
|
||||
• The paused figure is <i>wall</i> time, not stream time — the producer never stops while paused,
|
||||
so it answers "how long have I been looking at this picture".
|
||||
• Nothing on the bar may change width with the stream, because the bar sits directly above the
|
||||
plot and a wider label would relayout it. That is why the rate collapses to one figure or a
|
||||
range rather than listing per channel, and why an elapsed time steps to a coarser unit instead
|
||||
of growing digits.
|
||||
• <tt>gaps</tt> counts packets the receiver knows it missed. It is the number that matters: a
|
||||
non-zero count means samples are absent from the buffer, not merely late.
|
||||
• <tt>reconnects</tt> counts sessions re-established with the source. Over unicast a climbing
|
||||
count usually means a second client is taking the source's single session.
|
||||
• The starvation notice — <i>connected, but no data is arriving</i> — is that same situation seen
|
||||
from the other side: the signal list is here, so something took the stream away. A source that
|
||||
has sent <i>no</i> configuration gets a different notice, <i>trying to connect</i>, because
|
||||
nothing was taken away — pointing the app at a port where nothing answers is that case, and the
|
||||
usual cause is a streamer that is not running or is serving another port.
|
||||
• <tt>stored</tt> versus <tt>wire</tt> in the popover is the decimation. They differ exactly
|
||||
when the window is too long to store at full rate.
|
||||
|
||||
<b>Example</b>
|
||||
|
||||
The bar reads <tt>● live · 10 kS/s · 2 s window</tt>. Behind ⓘ:
|
||||
<tt>Stream connected, configured / packets 717 · frames 716 · gaps 0 / 3 signals · 3 channels</tt>,
|
||||
<tt>Buffer 200.0 MB · 1677 s span · time x1 / stored 10 kS/s (wire 10 kS/s)</tt>,
|
||||
<tt>Plot 1 3 traces · 195000 samples</tt>, <tt>Reconnects 0</tt>.
|
||||
@@ -1,34 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
SPDX-License-Identifier: MIT
|
||||
|
||||
The help pages, compiled into the binary for the same reason the icons are: udpscope is
|
||||
run straight out of the build tree and has no install step, so a search path would have
|
||||
to be located at runtime and the failure mode of not locating it is a help window full
|
||||
of missing pages.
|
||||
-->
|
||||
<gresources>
|
||||
<gresource prefix="/org/udpscope/help">
|
||||
<file>01-overview.txt</file>
|
||||
<file>02-source.txt</file>
|
||||
<file>03-signals.txt</file>
|
||||
<file>04-layout.txt</file>
|
||||
<file>05-window.txt</file>
|
||||
<file>06-navigate.txt</file>
|
||||
<file>07-pause.txt</file>
|
||||
<file>08-trigger.txt</file>
|
||||
<file>09-cursors.txt</file>
|
||||
<file>10-measurements.txt</file>
|
||||
<file>11-math.txt</file>
|
||||
<file>12-export.txt</file>
|
||||
<file>13-sessions.txt</file>
|
||||
<file>14-status.txt</file>
|
||||
|
||||
<!-- The screenshots live with the user documentation (docs/); the resource aliases
|
||||
them under `img/` so a help page can name one by resource path. The path is relative
|
||||
to this manifest's directory. -->
|
||||
<file alias="img/main-window.png">../../docs/img/main-window.png</file>
|
||||
<file alias="img/trigger-capture.png">../../docs/img/trigger-capture.png</file>
|
||||
<file alias="img/math-editor.png">../../docs/img/math-editor.png</file>
|
||||
</gresource>
|
||||
</gresources>
|
||||
@@ -3,25 +3,35 @@
|
||||
|
||||
#include <adwaita.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "build_info.hpp"
|
||||
#include "layout_store.hpp"
|
||||
#include "shortcuts.hpp"
|
||||
|
||||
// Both come from CMakeLists: the document identifier is derived from the desktop file's
|
||||
// basename (its desktop-file id), and the directory is where the Mallard set lives in the
|
||||
// source tree, which is what the self-check scans. Yelp resolves `help:<identifier>` to
|
||||
// `<datadir>/help/<lang>/<identifier>/` on its own (freedesktop Help System Specification),
|
||||
// so the identifier and the installed directory have to name the same set -- which is why
|
||||
// neither is written out again here.
|
||||
static_assert(sizeof(UDPSCOPE_HELP_NAME) > 1, "UDPSCOPE_HELP_NAME must come from the build");
|
||||
static_assert(sizeof(UDPSCOPE_HELP_DIR) > 1, "UDPSCOPE_HELP_DIR must come from the build");
|
||||
|
||||
namespace udpscope {
|
||||
namespace {
|
||||
|
||||
/// Nulled by each window's own close handler, so a second open cannot present a dead widget.
|
||||
/// One pointer per window, file-scope because there is one application.
|
||||
///
|
||||
/// The help window is a `GtkWindow`. The other two are `AdwDialog`s, which are **not**
|
||||
/// `GtkWindow`s: they have no `::close-request`, so they clear on `::closed` and close with
|
||||
/// `adw_dialog_close()`.
|
||||
GtkWidget* g_help = nullptr;
|
||||
/// Nulled by each dialog's own close handler, so a second open cannot present a dead widget.
|
||||
/// One pointer per dialog, file-scope because there is one application. The help manual is no
|
||||
/// longer one of these: it opens in a separate viewer process through the `help:` URI, and a
|
||||
/// process with no window of ours has nothing to null.
|
||||
AdwDialog* g_about = nullptr;
|
||||
AdwDialog* g_shortcuts = nullptr;
|
||||
/// The overview is built from a `.ui` file, so its builder is held too: the window belongs to
|
||||
/// the builder, and a *destroyed* `GtkWindow` cannot be presented again -- which is why the
|
||||
/// builder is replaced on the next opening rather than reused.
|
||||
GtkWidget* g_shortcuts = nullptr;
|
||||
GtkBuilder* g_shortcuts_ui = nullptr;
|
||||
|
||||
gboolean on_window_close(GtkWindow* window, gpointer slot) {
|
||||
(void)window;
|
||||
@@ -36,102 +46,58 @@ void on_dialog_closed(AdwDialog* dialog, gpointer slot) {
|
||||
*static_cast<AdwDialog**>(slot) = nullptr;
|
||||
}
|
||||
|
||||
struct HelpPage {
|
||||
const char* section; ///< the group heading in the sidebar
|
||||
const char* title; ///< the row's label and the page's heading
|
||||
const char* resource; ///< a path under /org/udpscope/help
|
||||
/// The screenshots shown above the text, as resource paths and a null terminator. A page
|
||||
/// with none is the common case and the reason this is a list rather than one field: the
|
||||
/// overview carries two, the window and a held capture.
|
||||
const char* const* images = nullptr;
|
||||
/// What an asynchronous launch needs to survive the call: the window it is attributed to (held,
|
||||
/// because the call outlives the stack frame that made it) and the caller's reporter, if it asked
|
||||
/// for one. One small struct rather than two `gpointer`s, because a callback that takes both through
|
||||
/// one `user_data` still has to get them there.
|
||||
struct HelpLaunch {
|
||||
GtkWindow* parent = nullptr;
|
||||
std::function<void(bool, const std::string&)> done;
|
||||
};
|
||||
|
||||
/// Adding a page is one .txt file, one <file> line in the manifest and one row here.
|
||||
/// The order here is the order the sidebar shows; `section` groups consecutive rows.
|
||||
/// How tall a page's screenshots are drawn. They are scaled to fit this *height* and centre
|
||||
/// themselves in it, so a 1500x950 capture is readable without a horizontal scrollbar.
|
||||
constexpr int kHelpShotHeight = 260;
|
||||
|
||||
/// The screenshots each page carries, in the order it shows them.
|
||||
constexpr const char* kOverviewShots[] = {"/org/udpscope/help/img/main-window.png",
|
||||
"/org/udpscope/help/img/trigger-capture.png",
|
||||
nullptr};
|
||||
constexpr const char* kTriggerShots[] = {"/org/udpscope/help/img/trigger-capture.png",
|
||||
nullptr};
|
||||
constexpr const char* kMathShots[] = {"/org/udpscope/help/img/math-editor.png", nullptr};
|
||||
|
||||
constexpr HelpPage kPages[] = {
|
||||
{"Getting started", "Overview", "/org/udpscope/help/01-overview.txt", kOverviewShots},
|
||||
{"Getting started", "Choosing a source", "/org/udpscope/help/02-source.txt"},
|
||||
{"Getting started", "Signals and calibration", "/org/udpscope/help/03-signals.txt"},
|
||||
{"Getting started", "Rows, layout and drag & drop", "/org/udpscope/help/04-layout.txt"},
|
||||
{"Viewing", "Time window", "/org/udpscope/help/05-window.txt"},
|
||||
{"Viewing", "Navigating the plot", "/org/udpscope/help/06-navigate.txt"},
|
||||
{"Viewing", "Pause and play", "/org/udpscope/help/07-pause.txt"},
|
||||
{"Viewing", "Trigger", "/org/udpscope/help/08-trigger.txt", kTriggerShots},
|
||||
{"Analysis", "Cursors", "/org/udpscope/help/09-cursors.txt"},
|
||||
{"Analysis", "Measurements", "/org/udpscope/help/10-measurements.txt"},
|
||||
{"Analysis", "Math channels", "/org/udpscope/help/11-math.txt", kMathShots},
|
||||
{"Output and state", "Saving and export", "/org/udpscope/help/12-export.txt"},
|
||||
{"Output and state", "Sessions and settings", "/org/udpscope/help/13-sessions.txt"},
|
||||
{"Output and state", "Status bar and diagnostics", "/org/udpscope/help/14-status.txt"},
|
||||
};
|
||||
constexpr int kPageCount = static_cast<int>(sizeof(kPages) / sizeof(kPages[0]));
|
||||
|
||||
/// The page's markup, or a one-line stand-in. `ok` reports whether the real body loaded
|
||||
/// *and* parsed: a missing resource and markup Pango rejects both render the stand-in,
|
||||
/// because a help window that fails to open is worse than one page that says so.
|
||||
std::string load_page(const HelpPage& page, bool* ok) {
|
||||
*ok = false;
|
||||
/// The launch failed. Report it rather than leaving a click that silently did nothing -- and say
|
||||
/// *which* thing is missing: `gtk_uri_launcher_launch_finish()` reports that the URI could not be
|
||||
/// handed over, which is the handler's absence, while a document that is not installed is a
|
||||
/// different failure the launcher cannot tell it from. `help_installed_path()` settles that from the
|
||||
/// file system, so a missing *document* is not reported as a missing *handler*: with the manual
|
||||
/// installed the message names the viewer, and without it the message names the document, its
|
||||
/// expected path and the install command -- and a viewer as well, because that second possibility
|
||||
/// cannot be ruled out from here.
|
||||
void on_help_launched(GObject* source, GAsyncResult* result, gpointer user) {
|
||||
auto* launch = static_cast<HelpLaunch*>(user);
|
||||
GError* error = nullptr;
|
||||
GBytes* bytes = g_resources_lookup_data(page.resource, G_RESOURCE_LOOKUP_FLAGS_NONE,
|
||||
&error);
|
||||
if (bytes == nullptr) {
|
||||
const bool ok = gtk_uri_launcher_launch_finish(GTK_URI_LAUNCHER(source), result, &error);
|
||||
if (!ok) {
|
||||
const std::string installed = help_installed_path();
|
||||
GtkAlertDialog* alert = gtk_alert_dialog_new("Could not open the help manual");
|
||||
std::string detail = error != nullptr ? error->message : std::string("the launch failed");
|
||||
detail += "\n\n";
|
||||
if (!installed.empty()) {
|
||||
detail += "The manual is installed at " + installed +
|
||||
", so what is missing is a viewer for the `help:` URI it is opened with: install "
|
||||
"one (for example `yelp`) or make it the desktop's default for that scheme.";
|
||||
} else {
|
||||
// The system-wide install is named *first* on purpose: a set under the user's data
|
||||
// directory is one a sandboxed viewer (Yelp) cannot read its figures from -- the pages
|
||||
// open without their images -- so the no-root route carries that caveat rather than
|
||||
// leading. The message is kept no longer than the one it replaces; do the same on edit.
|
||||
detail += "The manual does not look installed: nothing named \"help:" UDPSCOPE_HELP_NAME
|
||||
"\" was found where `help:` resolves. Install it system-wide:\n\n"
|
||||
" cmake -DCMAKE_INSTALL_PREFIX=/usr .. && sudo cmake --build build --target install-help\n\n"
|
||||
"That writes where the viewer looks. The no-root `install-help-user` writes under "
|
||||
"your data directory instead: a sandboxed viewer shows those pages without their "
|
||||
"figures. A `help:` viewer (for example `yelp`) is needed as well.";
|
||||
}
|
||||
gtk_alert_dialog_set_detail(alert, detail.c_str());
|
||||
gtk_alert_dialog_show(alert, launch->parent);
|
||||
g_object_unref(alert);
|
||||
}
|
||||
if (launch->done) {
|
||||
launch->done(ok, ok || error == nullptr ? std::string() : std::string(error->message));
|
||||
}
|
||||
if (launch->parent != nullptr) g_object_unref(launch->parent);
|
||||
delete launch;
|
||||
if (error != nullptr) g_error_free(error);
|
||||
return "<i>This page is missing from the build.</i>";
|
||||
}
|
||||
gsize size = 0;
|
||||
const char* data = static_cast<const char*>(g_bytes_get_data(bytes, &size));
|
||||
std::string body(data, size);
|
||||
g_bytes_unref(bytes);
|
||||
|
||||
if (!pango_parse_markup(body.c_str(), -1, 0, nullptr, nullptr, nullptr, &error)) {
|
||||
if (error != nullptr) g_error_free(error);
|
||||
return "<i>This page could not be displayed: its markup is malformed.</i>";
|
||||
}
|
||||
*ok = true;
|
||||
return body;
|
||||
}
|
||||
|
||||
void on_help_row_selected(GtkListBox*, GtkListBoxRow* row, gpointer body_label) {
|
||||
if (row == nullptr) return;
|
||||
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "help-page"));
|
||||
if (idx < 0 || idx >= kPageCount) return;
|
||||
bool ok = false;
|
||||
const std::string body = load_page(kPages[idx], &ok);
|
||||
gtk_label_set_markup(GTK_LABEL(body_label), body.c_str());
|
||||
// The screenshots above it, if this page has any. The column is the label's previous sibling
|
||||
// inside the page's box, which is what lets this handler stay a matter of the label alone --
|
||||
// and refilling it is a page *switch*, not a frame, so rebuilding it here is cheap and is
|
||||
// also the only way to be sure the pictures are this page's and not the last one's.
|
||||
GtkWidget* label = GTK_WIDGET(body_label);
|
||||
GtkWidget* parent = gtk_widget_get_parent(label);
|
||||
GtkWidget* shots = parent != nullptr ? gtk_widget_get_first_child(parent) : nullptr;
|
||||
if (shots != nullptr && GTK_IS_BOX(shots)) {
|
||||
while (GtkWidget* old = gtk_widget_get_first_child(shots)) {
|
||||
gtk_box_remove(GTK_BOX(shots), old);
|
||||
}
|
||||
for (const char* const* img = kPages[idx].images; img != nullptr && *img != nullptr; ++img) {
|
||||
GtkWidget* shot = gtk_picture_new_for_resource(*img);
|
||||
gtk_picture_set_can_shrink(GTK_PICTURE(shot), TRUE);
|
||||
// A height, and `CONTAIN` (the default) to fit the image inside it: a picture that may
|
||||
// shrink asks for no height at all in a box, so without this it is allocated nothing and
|
||||
// the page shows its text with a gap where the screenshots should be.
|
||||
gtk_widget_set_size_request(shot, -1, kHelpShotHeight);
|
||||
gtk_box_append(GTK_BOX(shots), shot);
|
||||
}
|
||||
gtk_widget_set_visible(shots, gtk_widget_get_first_child(shots) != nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -175,177 +141,114 @@ const char* about_icon_name() {
|
||||
return adw_about_dialog_get_application_icon(ADW_ABOUT_DIALOG(g_about));
|
||||
}
|
||||
|
||||
std::string help_installed_path() {
|
||||
// The freedesktop Help System Specification's own search order: `$XDG_DATA_HOME` first, then every
|
||||
// `$XDG_DATA_DIRS` entry in order. Yelp resolves `help:<id>` the same way, so this is a reading of
|
||||
// where the document *would* be found rather than a second, private convention.
|
||||
std::vector<std::string> roots;
|
||||
roots.emplace_back(g_get_user_data_dir() != nullptr ? g_get_user_data_dir() : "");
|
||||
const char* const* dirs = g_get_system_data_dirs();
|
||||
for (int i = 0; dirs != nullptr && dirs[i] != nullptr; ++i) roots.emplace_back(dirs[i]);
|
||||
for (const std::string& root : roots) {
|
||||
if (root.empty()) continue;
|
||||
const std::string base = root + "/help";
|
||||
GError* error = nullptr;
|
||||
GDir* dir = g_dir_open(base.c_str(), 0, &error);
|
||||
if (dir == nullptr) {
|
||||
if (error != nullptr) g_error_free(error);
|
||||
continue;
|
||||
}
|
||||
// `<lang>` is a directory of its own (`C`, `en`, `it`, ...), so the document is looked for one
|
||||
// level below the `help` root rather than straight under it.
|
||||
while (const char* lang = g_dir_read_name(dir)) {
|
||||
const std::string candidate = base + "/" + lang + "/" UDPSCOPE_HELP_NAME;
|
||||
if (g_file_test((candidate + "/index.page").c_str(), G_FILE_TEST_IS_REGULAR)) {
|
||||
g_dir_close(dir);
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
g_dir_close(dir);
|
||||
}
|
||||
return std::string();
|
||||
}
|
||||
|
||||
HelpReport help_self_check() {
|
||||
HelpReport report;
|
||||
report.pages = kPageCount;
|
||||
for (const HelpPage& page : kPages) {
|
||||
bool ok = false;
|
||||
const std::string body = load_page(page, &ok);
|
||||
if (ok && !body.empty()) ++report.parsed;
|
||||
if (report.shortest_title.empty() || body.size() < report.shortest_bytes) {
|
||||
report.shortest_title = page.title;
|
||||
report.shortest_bytes = body.size();
|
||||
|
||||
// The manual is a set of files on disk now, so the reading is a file-system one: a missing
|
||||
// directory leaves `topics=0 index=false`, which is a manual that is not where the build
|
||||
// says it is -- and Yelp would show nothing for `help:<identifier>`.
|
||||
GError* error = nullptr;
|
||||
GDir* dir = g_dir_open(UDPSCOPE_HELP_DIR, 0, &error);
|
||||
if (dir == nullptr) {
|
||||
if (error != nullptr) g_error_free(error);
|
||||
return report;
|
||||
}
|
||||
while (const char* name = g_dir_read_name(dir)) {
|
||||
if (!g_str_has_suffix(name, ".page")) continue;
|
||||
if (g_strcmp0(name, "index.page") == 0) report.index = true;
|
||||
else ++report.topics;
|
||||
}
|
||||
g_dir_close(dir);
|
||||
|
||||
// The figures the pages reference, counted for the same reason: an image that went missing
|
||||
// is a broken figure in the viewer rather than a failed build.
|
||||
const std::string figures = std::string(UDPSCOPE_HELP_DIR) + "/figures";
|
||||
if (GDir* figdir = g_dir_open(figures.c_str(), 0, &error)) {
|
||||
while (const char* name = g_dir_read_name(figdir)) {
|
||||
if (g_str_has_suffix(name, ".png") || g_str_has_suffix(name, ".jpg")) ++report.figures;
|
||||
}
|
||||
g_dir_close(figdir);
|
||||
} else if (error != nullptr) {
|
||||
g_error_free(error);
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
void show_help(GtkWindow* parent) {
|
||||
if (g_help != nullptr) {
|
||||
gtk_window_present(GTK_WINDOW(g_help));
|
||||
return;
|
||||
void show_help(GtkWindow* parent, std::function<void(bool, const std::string&)> done) {
|
||||
// The freedesktop `help:` URI, handed to whatever the desktop registered for it -- Yelp on
|
||||
// GNOME. The launch is asynchronous, so both the parent and the reporter are carried across it in
|
||||
// a heap `HelpLaunch` and released in the callback; `parent` may legitimately be nullptr (the
|
||||
// chooser runs before any window exists).
|
||||
auto* launch = new HelpLaunch;
|
||||
launch->parent = parent != nullptr ? GTK_WINDOW(g_object_ref(parent)) : nullptr;
|
||||
launch->done = std::move(done);
|
||||
GtkUriLauncher* launcher = gtk_uri_launcher_new("help:" UDPSCOPE_HELP_NAME);
|
||||
gtk_uri_launcher_launch(launcher, parent, nullptr, on_help_launched, launch);
|
||||
g_object_unref(launcher);
|
||||
}
|
||||
|
||||
GtkWidget* window = gtk_window_new();
|
||||
g_help = window;
|
||||
gtk_window_set_title(GTK_WINDOW(window), "udpscope help");
|
||||
gtk_window_set_default_size(GTK_WINDOW(window), 860, 640);
|
||||
gtk_window_set_transient_for(GTK_WINDOW(window), parent);
|
||||
|
||||
// One label, width-locked by the scrolled window, holding one page's markup. The
|
||||
// sidebar swaps the markup rather than rebuilding anything.
|
||||
GtkWidget* body = gtk_label_new(nullptr);
|
||||
gtk_label_set_use_markup(GTK_LABEL(body), TRUE);
|
||||
gtk_label_set_wrap(GTK_LABEL(body), TRUE);
|
||||
gtk_label_set_xalign(GTK_LABEL(body), 0.0f);
|
||||
gtk_label_set_yalign(GTK_LABEL(body), 0.0f);
|
||||
gtk_label_set_selectable(GTK_LABEL(body), TRUE);
|
||||
gtk_widget_set_margin_start(body, 18);
|
||||
gtk_widget_set_margin_end(body, 18);
|
||||
gtk_widget_set_margin_top(body, 14);
|
||||
gtk_widget_set_margin_bottom(body, 14);
|
||||
|
||||
GtkWidget* body_scroll = gtk_scrolled_window_new();
|
||||
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(body_scroll), GTK_POLICY_NEVER,
|
||||
GTK_POLICY_AUTOMATIC);
|
||||
// The page's own column: its screenshots above its text. Built once and *refilled* by the page
|
||||
// switch, which is why no picture is made here -- one `GtkPicture` per image of the page.
|
||||
GtkWidget* shot_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
|
||||
gtk_widget_set_margin_start(shot_box, 18);
|
||||
gtk_widget_set_margin_end(shot_box, 18);
|
||||
gtk_widget_set_margin_top(shot_box, 14);
|
||||
gtk_widget_set_visible(shot_box, false);
|
||||
GtkWidget* page_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||
gtk_box_append(GTK_BOX(page_box), shot_box);
|
||||
gtk_box_append(GTK_BOX(page_box), body);
|
||||
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(body_scroll), page_box);
|
||||
gtk_widget_set_hexpand(body_scroll, TRUE);
|
||||
|
||||
GtkWidget* list = gtk_list_box_new();
|
||||
gtk_list_box_set_selection_mode(GTK_LIST_BOX(list), GTK_SELECTION_SINGLE);
|
||||
const char* current_section = nullptr;
|
||||
for (int i = 0; i < kPageCount; ++i) {
|
||||
if (current_section == nullptr ||
|
||||
std::string(current_section) != kPages[i].section) {
|
||||
current_section = kPages[i].section;
|
||||
GtkWidget* heading = gtk_label_new(nullptr);
|
||||
char markup[128];
|
||||
std::snprintf(markup, sizeof(markup), "<b><small>%s</small></b>", current_section);
|
||||
gtk_label_set_markup(GTK_LABEL(heading), markup);
|
||||
gtk_label_set_xalign(GTK_LABEL(heading), 0.0f);
|
||||
gtk_widget_set_margin_start(heading, 10);
|
||||
gtk_widget_set_margin_top(heading, 10);
|
||||
gtk_widget_set_margin_bottom(heading, 2);
|
||||
GtkWidget* heading_row = gtk_list_box_row_new();
|
||||
gtk_list_box_row_set_selectable(GTK_LIST_BOX_ROW(heading_row), FALSE);
|
||||
gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(heading_row), FALSE);
|
||||
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(heading_row), heading);
|
||||
gtk_list_box_append(GTK_LIST_BOX(list), heading_row);
|
||||
}
|
||||
GtkWidget* label = gtk_label_new(kPages[i].title);
|
||||
gtk_label_set_xalign(GTK_LABEL(label), 0.0f);
|
||||
gtk_widget_set_margin_start(label, 18);
|
||||
gtk_widget_set_margin_end(label, 10);
|
||||
gtk_widget_set_margin_top(label, 4);
|
||||
gtk_widget_set_margin_bottom(label, 4);
|
||||
GtkWidget* row = gtk_list_box_row_new();
|
||||
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), label);
|
||||
g_object_set_data(G_OBJECT(row), "help-page", GINT_TO_POINTER(i));
|
||||
gtk_list_box_append(GTK_LIST_BOX(list), row);
|
||||
}
|
||||
g_signal_connect(list, "row-selected", G_CALLBACK(on_help_row_selected), body);
|
||||
|
||||
GtkWidget* list_scroll = gtk_scrolled_window_new();
|
||||
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(list_scroll), GTK_POLICY_NEVER,
|
||||
GTK_POLICY_AUTOMATIC);
|
||||
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(list_scroll), list);
|
||||
gtk_widget_set_size_request(list_scroll, 220, -1);
|
||||
|
||||
GtkWidget* split = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
|
||||
gtk_box_append(GTK_BOX(split), list_scroll);
|
||||
gtk_box_append(GTK_BOX(split), gtk_separator_new(GTK_ORIENTATION_VERTICAL));
|
||||
gtk_box_append(GTK_BOX(split), body_scroll);
|
||||
gtk_window_set_child(GTK_WINDOW(window), split);
|
||||
|
||||
// Select the first page so the window never opens blank.
|
||||
GtkListBoxRow* first = gtk_list_box_get_row_at_index(GTK_LIST_BOX(list), 1);
|
||||
if (first != nullptr) gtk_list_box_select_row(GTK_LIST_BOX(list), first);
|
||||
|
||||
g_signal_connect(window, "close-request", G_CALLBACK(on_window_close), &g_help);
|
||||
gtk_window_present(GTK_WINDOW(window));
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
/// Appends one table row to `dialog`, opening a new section whenever the row's `section`
|
||||
/// string differs from the last one. `*current` is the section being filled, `*title` the
|
||||
/// name it carries; both are owned by the caller so the two tables share one run.
|
||||
///
|
||||
/// `accel` is handed to `AdwShortcutsItem` as an accelerator when it is non-empty and
|
||||
/// `subtitle` is used when it is not, which is how a wheel or a drag gets a row: neither
|
||||
/// GTK's nor libadwaita's accelerator syntax can name one, and asking them to parse
|
||||
/// "Wheel" costs a parse warning and draws nothing.
|
||||
void append_shortcut(AdwShortcutsDialog* dialog, AdwShortcutsSection** current,
|
||||
const char** title, const char* section, const char* accel,
|
||||
const char* subtitle, const char* description) {
|
||||
if (*current == nullptr || std::string(*title) != section) {
|
||||
*current = adw_shortcuts_section_new(section);
|
||||
*title = section;
|
||||
adw_shortcuts_dialog_add(dialog, *current);
|
||||
}
|
||||
AdwShortcutsItem* item = adw_shortcuts_item_new(description, accel);
|
||||
if (subtitle != nullptr && *subtitle != '\0') {
|
||||
adw_shortcuts_item_set_subtitle(item, subtitle);
|
||||
}
|
||||
adw_shortcuts_section_add(*current, item);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void show_shortcuts(GtkWindow* parent) {
|
||||
if (g_shortcuts != nullptr) {
|
||||
adw_dialog_present(g_shortcuts, GTK_WIDGET(parent));
|
||||
gtk_window_present(GTK_WINDOW(g_shortcuts));
|
||||
return;
|
||||
}
|
||||
|
||||
AdwDialog* dialog = adw_shortcuts_dialog_new();
|
||||
g_shortcuts = dialog;
|
||||
adw_dialog_set_title(dialog, "Keyboard and mouse shortcuts");
|
||||
|
||||
AdwShortcutsSection* section = nullptr;
|
||||
const char* section_title = nullptr;
|
||||
int accel_count = 0;
|
||||
const Accel* accels = app_accels(&accel_count);
|
||||
for (int i = 0; i < accel_count; ++i) {
|
||||
append_shortcut(ADW_SHORTCUTS_DIALOG(dialog), §ion, §ion_title,
|
||||
accels[i].section, accels[i].accel, "", accels[i].description);
|
||||
}
|
||||
int gesture_count = 0;
|
||||
const Gesture* gestures = plot_gestures(&gesture_count);
|
||||
for (int i = 0; i < gesture_count; ++i) {
|
||||
append_shortcut(ADW_SHORTCUTS_DIALOG(dialog), §ion, §ion_title,
|
||||
gestures[i].section, gestures[i].accel, gestures[i].subtitle,
|
||||
gestures[i].description);
|
||||
}
|
||||
|
||||
g_signal_connect(dialog, "closed", G_CALLBACK(on_dialog_closed), &g_shortcuts);
|
||||
adw_dialog_present(dialog, GTK_WIDGET(parent));
|
||||
// A `GtkShortcutsWindow` from `src/ui/shortcuts.ui` -- gedit's arrangement, and GTK's own: one
|
||||
// section per page, with the sidebar and the search box drawn by the window itself. A UI file
|
||||
// rather than widget construction because of the mouse rows: an accelerator cannot spell "Ctrl
|
||||
// and drag", and libadwaita's `AdwShortcutsItem` -- the modern replacement -- has no icon
|
||||
// property, so its cell reads "No Shortcut". `GtkShortcutsShortcut`'s one generic gesture type
|
||||
// draws the `icon` the row names. (The widget is deprecated since 4.20, but this uses none of
|
||||
// its functions: the file is loaded as data and presented as a `GtkWindow`.)
|
||||
//
|
||||
// A builder per opening, not one reused: the window belongs to the builder and `close-request`
|
||||
// destroys the window, so the resource is parsed again rather than an already-dead window
|
||||
// fetched.
|
||||
g_clear_object(&g_shortcuts_ui);
|
||||
g_shortcuts_ui = gtk_builder_new_from_resource("/org/udpscope/ui/shortcuts.ui");
|
||||
if (g_shortcuts_ui == nullptr) return;
|
||||
GObject* overlay = gtk_builder_get_object(g_shortcuts_ui, "help_overlay");
|
||||
if (overlay == nullptr) return;
|
||||
g_shortcuts = GTK_WIDGET(overlay);
|
||||
gtk_window_set_transient_for(GTK_WINDOW(g_shortcuts), parent);
|
||||
g_signal_connect(g_shortcuts, "close-request", G_CALLBACK(on_window_close), &g_shortcuts);
|
||||
gtk_window_present(GTK_WINDOW(g_shortcuts));
|
||||
}
|
||||
|
||||
void close_help_windows() {
|
||||
if (g_help != nullptr) gtk_window_close(GTK_WINDOW(g_help));
|
||||
if (g_about != nullptr) adw_dialog_close(g_about);
|
||||
if (g_shortcuts != nullptr) adw_dialog_close(g_shortcuts);
|
||||
if (g_shortcuts != nullptr) gtk_window_close(GTK_WINDOW(g_shortcuts));
|
||||
}
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace udpscope {
|
||||
@@ -18,20 +19,39 @@ void show_about(GtkWindow* parent, const std::string& session_path);
|
||||
/// so the dialog is asserted to *show* the app icon rather than merely to open.
|
||||
const char* about_icon_name();
|
||||
|
||||
/// The paged help window. Modal on nothing — it is a reference, so it stays usable while
|
||||
/// the scope runs. Already open: presented.
|
||||
void show_help(GtkWindow* parent);
|
||||
/// Opens the installed Mallard manual in the desktop help viewer — Yelp, through the
|
||||
/// `help:` URI the freedesktop Help System Specification defines — rather than drawing a
|
||||
/// window of our own. `parent` is the window the launch is attributed to (and the one an
|
||||
/// error is reported on); the viewer is a separate process, so nothing here can be closed
|
||||
/// with `close_help_windows()`.
|
||||
///
|
||||
/// `done`, when the caller gives one, is called with the *launch*'s outcome: whether the
|
||||
/// `help:` URI was handed to a viewer, and the error text when it was not. It says nothing
|
||||
/// about the viewer itself — that process may fail for its own reasons and this side cannot
|
||||
/// see them — so it is the self-check's reading of the path and not a promise that a window
|
||||
/// appeared. The failure alert is shown either way; the callback is an extra, not instead.
|
||||
void show_help(GtkWindow* parent, std::function<void(bool ok, const std::string& error)> done = {});
|
||||
|
||||
/// What UDPSCOPE_PROBE_HELP_MS reports. `parsed` counts pages whose markup
|
||||
/// pango_parse_markup() accepted and whose body is non-empty; it must equal `pages`.
|
||||
/// Where `help:<id>` resolves to on this system: the first of `$XDG_DATA_HOME/help/<lang>/<id>/`
|
||||
/// and `$XDG_DATA_DIRS/help/<lang>/<id>/` that holds an `index.page`, or an empty string when no
|
||||
/// such directory does. This is the specification's own resolution, and the distinction a launch
|
||||
/// failure turns on: a missing *document* (install it) and a missing *handler* (install a viewer)
|
||||
/// are different problems with different fixes, and only the file system can tell them apart.
|
||||
std::string help_installed_path();
|
||||
|
||||
/// What UDPSCOPE_PROBE_HELP_MS reports about the manual *in the tree*. `topics` is the
|
||||
/// number of `<id>.page` files other than `index.page`, and `index` says whether
|
||||
/// `index.page` is present: the manual is now a set of files on disk (installed by the
|
||||
/// `install()` rule, and read by Yelp), not a GResource in the binary, so the self-check
|
||||
/// asserts the set is *there* and must be read as a file-system finding — `topics=0
|
||||
/// index=0` means the manual is missing from `UDPSCOPE_HELP_DIR`.
|
||||
struct HelpReport {
|
||||
int pages = 0;
|
||||
int parsed = 0;
|
||||
std::string shortest_title;
|
||||
std::size_t shortest_bytes = 0;
|
||||
int topics = 0;
|
||||
bool index = false;
|
||||
int figures = 0;
|
||||
};
|
||||
|
||||
/// Loads and validates every page without building a window. Safe to call headless.
|
||||
/// Scans the manual's directory without opening a viewer. Safe to call headless.
|
||||
HelpReport help_self_check();
|
||||
|
||||
/// The shortcut overview: the application accelerators and the plot's own interactions,
|
||||
@@ -39,11 +59,11 @@ HelpReport help_self_check();
|
||||
/// deprecated as of GTK 4.18 and this project builds warning-free.
|
||||
void show_shortcuts(GtkWindow* parent);
|
||||
|
||||
/// Closes whichever of the three windows are open. One of them is a GtkWindow -- the help
|
||||
/// window, closed with gtk_window_close(), which emits close-request so its handler runs. The
|
||||
/// other two are AdwDialogs, which are not GtkWindows: they close with adw_dialog_close(),
|
||||
/// which emits ::closed. Either way the file-scope pointer is nulled by the handler, which is
|
||||
/// what prevents a second open presenting a dead widget. Used by the probe.
|
||||
/// Closes whichever of the two dialogs are open. Both are `AdwDialog`s, which are not
|
||||
/// `GtkWindow`s: they close with `adw_dialog_close()`, which emits `::closed`, and the
|
||||
/// file-scope pointer is nulled by the handler that prevents a second open presenting a dead
|
||||
/// widget. There is no help window left to close -- the manual opens in a separate viewer
|
||||
/// process. Used by the probe.
|
||||
void close_help_windows();
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!-- The event history: a list of what was latched. A frame with three rows in it, the
|
||||
last one short, so it reads as a list that ends rather than one that continues. Every
|
||||
coordinate is a whole pixel of the 16px cell, and every subpath is a clockwise filled
|
||||
`path`: a stroke, a line or a polyline keeps the colour written here and stays dark on
|
||||
a dark theme, and the frame's hole needs `evenodd` because the rows sit inside it.
|
||||
(No double hyphen anywhere in a comment here: xmllint, which preprocess= runs it
|
||||
through, rejects one and fails the whole build.) -->
|
||||
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#2e3436" fill-rule="evenodd" d="M0 1 H16 V15 H0 Z
|
||||
M2 3 H14 V13 H2 Z"/>
|
||||
<path fill="#2e3436" d="M4 4 H12 V6 H4 Z
|
||||
M4 7 H12 V9 H4 Z
|
||||
M4 10 H9 V12 H4 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!-- A sigma: the one pane that computes rather than reads. Four filled subpaths, all
|
||||
wound clockwise so the default nonzero rule unions them where they overlap; the
|
||||
diagonals run from the bars' left ends to a vertex right of centre, which is the
|
||||
way the glyph actually points. -->
|
||||
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#2e3436" d="M3 2 H13 V4 H3 Z
|
||||
M3 12 H13 V14 H3 Z
|
||||
M4.64 2.23 L10.64 7.23 L9.36 8.77 L3.36 3.77 Z
|
||||
M10.64 8.77 L4.64 13.77 L3.36 12.23 L9.36 7.23 Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 762 B |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!-- A boundary that can be dragged: a vertical double-headed arrow. It says *which* edge the handle
|
||||
moves, which a plain grip does not. Every coordinate is a whole pixel of the 16px cell, every
|
||||
subpath is a filled `path` wound the same way (a stroke would keep this colour on a dark theme),
|
||||
and the two heads meet the shaft exactly, at x 5..11 and x 7..9. -->
|
||||
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#2e3436" d="M7 4 H9 V12 H7 Z
|
||||
M8 1 L11 5 H5 Z
|
||||
M8 15 L5 11 H11 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 734 B |
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!-- The Measure tab: two cursors beside the ruler they read against. Each cursor is a cap over two
|
||||
bold dashes — the mark the toolbar's cursors button carries, at a weight that survives this size.
|
||||
The ruler is two bands with three marks between them and *no end caps*: a closed frame reads as a
|
||||
rectangle, and it costs a fifth of the ink on two lines that say nothing. Both motifs are centred
|
||||
on the same line and share one weight, and everything is inset two pixels. -->
|
||||
<svg height="16px" width="32px" viewBox="0 0 32 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#2e3436" d="M2 2 H7 V5 H2 Z
|
||||
M3 6 H6 V10 H3 Z
|
||||
M3 11 H6 V14 H3 Z
|
||||
M9 2 H14 V5 H9 Z
|
||||
M10 6 H13 V10 H10 Z
|
||||
M10 11 H13 V14 H10 Z
|
||||
M17 4 H30 V6 H17 Z
|
||||
M17 10 H30 V12 H17 Z
|
||||
M18 6 H20 V10 H18 Z
|
||||
M22 6 H24 V10 H22 Z
|
||||
M26 6 H28 V10 H26 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!-- The Signals tab: the toolbar's trigger picture — a sine crossing its dashed level — beside the
|
||||
channel list. Sharing that picture is the point: the pill and the button that turns the trigger on
|
||||
should be recognisably about the same thing. A full period and not a hump: the crest and the trough
|
||||
are what make it read as a wave rather than as a hill, and the level's dashes survive between the
|
||||
crossings. Two channel rows on the right, drawn bold like the wave, and everything inset two
|
||||
pixels, because art flush with the cell edge is what a button or a crop clips. -->
|
||||
<svg height="16px" width="32px" viewBox="0 0 32 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#2e3436" d="M2 6 H3 V10 H2 Z
|
||||
M3 4 H4 V8 H3 Z
|
||||
M4 3 H5 V7 H4 Z
|
||||
M5 2 H6 V6 H5 Z
|
||||
M6 3 H7 V7 H6 Z
|
||||
M7 4 H8 V8 H7 Z
|
||||
M8 6 H9 V10 H8 Z
|
||||
M9 8 H10 V12 H9 Z
|
||||
M10 9 H11 V13 H10 Z
|
||||
M11 10 H12 V14 H11 Z
|
||||
M12 9 H13 V13 H12 Z
|
||||
M13 8 H14 V12 H13 Z
|
||||
M14 6 H15 V10 H14 Z
|
||||
M2 7 H5 V9 H2 Z
|
||||
M7 7 H10 V9 H7 Z
|
||||
M12 7 H15 V9 H12 Z
|
||||
M18 3 H23 V8 H18 Z
|
||||
M24 4 H30 V7 H24 Z
|
||||
M18 10 H23 V15 H18 Z
|
||||
M24 11 H30 V14 H24 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -1,16 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!-- A rising edge crossing a dashed trigger level: what the trigger *is*. The dash
|
||||
rhythm is 2 on, 1 off (tight enough to read as one line rather than as a row of
|
||||
dots), with the one wide gap at x 5..9 that the edge rises through. Every
|
||||
coordinate is a whole pixel of the 16px cell so nothing blurs at 1x. -->
|
||||
<!-- The trigger: a sine and the dashed level it is looked for at, which is what a trigger *is* on a
|
||||
waveform that is not a step. It used to be a rising edge, and one edge names only one of the four
|
||||
shape triggers the panel offers — a wave crossing a level names the trigger's job instead. One
|
||||
period, one-pixel stairs, the way the plot rasterises its own traces, with the level through the
|
||||
centre so the crest and the trough stand clear and the crossings fall on the steep parts. Inset
|
||||
two pixels, like the tabs' glyphs, so no art is flush with an edge a button might clip. -->
|
||||
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#2e3436" d="M0 11 H8 V13 H0 Z
|
||||
M6 3 H8 V13 H6 Z
|
||||
M6 3 H16 V5 H6 Z
|
||||
M0 7 H2 V9 H0 Z
|
||||
M3 7 H5 V9 H3 Z
|
||||
M9 7 H11 V9 H9 Z
|
||||
M12 7 H14 V9 H12 Z"/>
|
||||
<path fill="#2e3436" d="M2 7 H3 V10 H2 Z
|
||||
M3 5 H4 V8 H3 Z
|
||||
M4 3 H5 V6 H4 Z
|
||||
M5 3 H6 V6 H5 Z
|
||||
M6 4 H7 V7 H6 Z
|
||||
M7 6 H8 V9 H7 Z
|
||||
M8 8 H9 V11 H8 Z
|
||||
M9 10 H10 V13 H9 Z
|
||||
M10 11 H11 V14 H10 Z
|
||||
M11 11 H12 V14 H11 Z
|
||||
M12 9 H13 V12 H12 Z
|
||||
M13 7 H14 V10 H13 Z
|
||||
M2 7 H5 V9 H2 Z
|
||||
M7 7 H10 V9 H7 Z
|
||||
M11 7 H14 V9 H11 Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 857 B After Width: | Height: | Size: 1.5 KiB |
@@ -20,7 +20,10 @@
|
||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-trigger-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-cursors-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-measure-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-math-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-tab-measure-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-tab-signals-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-events-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-resize-rows-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app-symbolic.svg</file>
|
||||
</gresource>
|
||||
|
||||
@@ -31,6 +31,12 @@ std::string sanitize(const std::string& host) {
|
||||
return out.empty() ? "_" : out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
/// Creates `path` and every missing component, the last one included, like `mkdir -p`. Outside the
|
||||
/// anonymous namespace because the preferences file is written through it too: a bare `mkdir()` of
|
||||
/// `<config>/udpscope` fails when `<config>` does not exist yet, which is what a fresh install looks
|
||||
/// like -- and it fails *silently*, leaving the settings dialog reporting a write it did not make.
|
||||
void mkdirs(const std::string& path) {
|
||||
// **Every** component, the last one included. The condition used to be "the path has a trailing
|
||||
// slash", so the final directory was never created: `~/.config/udpscope` did not exist, the
|
||||
@@ -44,6 +50,8 @@ void mkdirs(const std::string& path) {
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
std::string toml_escape(const std::string& s) {
|
||||
std::string out = "\"";
|
||||
for (char c : s) {
|
||||
@@ -114,43 +122,204 @@ std::string config_dir() {
|
||||
return std::string(home) + "/.config/udpscope";
|
||||
}
|
||||
|
||||
std::string cache_dir() {
|
||||
const char* xdg = std::getenv("XDG_CACHE_HOME");
|
||||
if (xdg != nullptr && xdg[0] != '\0') return std::string(xdg) + "/udpscope";
|
||||
const char* home = std::getenv("HOME");
|
||||
if (home == nullptr || home[0] == '\0') return "/tmp/udpscope-cache";
|
||||
return std::string(home) + "/.cache/udpscope";
|
||||
}
|
||||
|
||||
std::string layout_path(const std::string& host, std::uint16_t port) {
|
||||
const std::string dir = config_dir();
|
||||
mkdirs(dir);
|
||||
return dir + "/" + sanitize(host) + "_" + std::to_string(port) + ".toml";
|
||||
}
|
||||
|
||||
std::vector<KnownSource> known_sources() {
|
||||
std::vector<KnownSource> out;
|
||||
const std::string dir = config_dir();
|
||||
std::string config_dir_for(const std::string& host, std::uint16_t port) {
|
||||
return config_dir() + "/" + sanitize(host) + "_" + std::to_string(port);
|
||||
}
|
||||
|
||||
std::string configuration_path(const std::string& host, std::uint16_t port,
|
||||
const std::string& name) {
|
||||
const std::string dir = config_dir_for(host, port);
|
||||
mkdirs(dir);
|
||||
return dir + "/" + sanitize(name) + ".toml";
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
bool file_exists(const std::string& path) {
|
||||
struct stat sb {};
|
||||
return ::stat(path.c_str(), &sb) == 0;
|
||||
}
|
||||
|
||||
/// A session written before configurations existed is the configuration named `default`: copied
|
||||
/// into the per-source directory, remembered as active, and the old file renamed out of the way
|
||||
/// rather than deleted, so the import can be undone by hand. Runs before every read and write of
|
||||
/// the per-source directory, so a machine that upgrades keeps its session.
|
||||
void import_legacy(const std::string& host, std::uint16_t port) {
|
||||
const std::string dir = config_dir_for(host, port);
|
||||
if (file_exists(dir)) return;
|
||||
const std::string legacy = layout_path(host, port);
|
||||
if (!file_exists(legacy)) return;
|
||||
mkdirs(dir);
|
||||
FILE* in = std::fopen(legacy.c_str(), "rb");
|
||||
if (in == nullptr) return;
|
||||
const std::string target = dir + "/default.toml";
|
||||
FILE* out = std::fopen(target.c_str(), "wb");
|
||||
if (out == nullptr) {
|
||||
std::fclose(in);
|
||||
return;
|
||||
}
|
||||
char buf[4096];
|
||||
std::size_t n = 0;
|
||||
while ((n = std::fread(buf, 1, sizeof(buf), in)) > 0) std::fwrite(buf, 1, n, out);
|
||||
std::fclose(in);
|
||||
std::fclose(out);
|
||||
FILE* active = std::fopen((dir + "/active").c_str(), "wb");
|
||||
if (active != nullptr) {
|
||||
std::fputs("default\n", active);
|
||||
std::fclose(active);
|
||||
}
|
||||
::rename(legacy.c_str(), (legacy + ".imported").c_str());
|
||||
}
|
||||
|
||||
/// `a.toml` -> `a`; anything else -> "".
|
||||
std::string toml_stem(const std::string& file) {
|
||||
if (file.size() <= 5 || file.compare(file.size() - 5, 5, ".toml") != 0) return {};
|
||||
return file.substr(0, file.size() - 5);
|
||||
}
|
||||
|
||||
/// `host_port` -> {host, port}, or false. The *last* '_' is the split, because `sanitize()`
|
||||
/// replaces anything unsafe in a host with one.
|
||||
bool split_stem(const std::string& stem, std::string* host, std::uint16_t* port) {
|
||||
const std::size_t cut = stem.rfind('_');
|
||||
if (cut == std::string::npos || cut + 1 == stem.size()) return false;
|
||||
char* end = nullptr;
|
||||
const long p = std::strtol(stem.c_str() + cut + 1, &end, 10);
|
||||
if (end == nullptr || *end != '\0' || p <= 0 || p > 65535) return false;
|
||||
*host = stem.substr(0, cut);
|
||||
*port = static_cast<std::uint16_t>(p);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The configurations a source directory holds, newest first, by file name.
|
||||
std::vector<std::string> names_in_dir(const std::string& dir) {
|
||||
std::vector<std::pair<std::int64_t, std::string>> found;
|
||||
DIR* d = ::opendir(dir.c_str());
|
||||
if (d == nullptr) return {};
|
||||
while (const dirent* e = ::readdir(d)) {
|
||||
const std::string stem = toml_stem(e->d_name);
|
||||
if (stem.empty()) continue;
|
||||
struct stat sb {};
|
||||
const std::string path = dir + "/" + e->d_name;
|
||||
std::int64_t mtime = 0;
|
||||
if (::stat(path.c_str(), &sb) == 0) mtime = static_cast<std::int64_t>(sb.st_mtime);
|
||||
found.emplace_back(mtime, stem);
|
||||
}
|
||||
::closedir(d);
|
||||
std::stable_sort(found.begin(), found.end(),
|
||||
[](const auto& a, const auto& b) { return a.first > b.first; });
|
||||
std::vector<std::string> out;
|
||||
out.reserve(found.size());
|
||||
for (auto& f : found) out.push_back(std::move(f.second));
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<std::string> configuration_names(const std::string& host, std::uint16_t port) {
|
||||
import_legacy(host, port);
|
||||
return names_in_dir(config_dir_for(host, port));
|
||||
}
|
||||
|
||||
std::string active_configuration(const std::string& host, std::uint16_t port) {
|
||||
const std::vector<std::string> names = configuration_names(host, port);
|
||||
if (names.empty()) return "default";
|
||||
FILE* f = std::fopen((config_dir_for(host, port) + "/active").c_str(), "rb");
|
||||
if (f != nullptr) {
|
||||
char buf[512];
|
||||
const std::size_t n = std::fread(buf, 1, sizeof(buf) - 1, f);
|
||||
std::fclose(f);
|
||||
buf[n] = '\0';
|
||||
const std::string want = trim(std::string(buf));
|
||||
if (std::find(names.begin(), names.end(), want) != names.end()) return want;
|
||||
}
|
||||
// Newest, which is the one just saved when nothing wrote an `active`.
|
||||
return names.front();
|
||||
}
|
||||
|
||||
bool set_active_configuration(const std::string& host, std::uint16_t port,
|
||||
const std::string& name) {
|
||||
const std::string dir = config_dir_for(host, port);
|
||||
mkdirs(dir);
|
||||
FILE* f = std::fopen((dir + "/active").c_str(), "wb");
|
||||
if (f == nullptr) return false;
|
||||
std::fputs(sanitize(name).c_str(), f);
|
||||
std::fputs("\n", f);
|
||||
std::fclose(f);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool forget_configuration(const std::string& host, std::uint16_t port, const std::string& name) {
|
||||
const std::string path = configuration_path(host, port, name);
|
||||
const bool gone = std::remove(path.c_str()) == 0;
|
||||
const std::string active = active_configuration(host, port);
|
||||
if (gone && sanitize(name) == sanitize(active)) {
|
||||
std::remove((config_dir_for(host, port) + "/active").c_str());
|
||||
}
|
||||
return gone;
|
||||
}
|
||||
|
||||
std::vector<KnownSource> known_sources() {
|
||||
const std::string root = config_dir();
|
||||
|
||||
// A legacy single-file session is imported first, so the listing below has one shape: the
|
||||
// per-source directory and its configuration files.
|
||||
if (DIR* d = ::opendir(root.c_str())) {
|
||||
while (const dirent* e = ::readdir(d)) {
|
||||
const std::string stem = toml_stem(e->d_name);
|
||||
if (stem.empty()) continue;
|
||||
std::string host;
|
||||
std::uint16_t port = 0;
|
||||
if (!split_stem(stem, &host, &port)) continue;
|
||||
// The scheme `layout_path()` writes, and nothing else: a stray `.toml` here is not ours.
|
||||
if (sanitize(host) + "_" + std::to_string(port) != stem) continue;
|
||||
import_legacy(host, port);
|
||||
}
|
||||
::closedir(d);
|
||||
}
|
||||
|
||||
std::vector<KnownSource> out;
|
||||
DIR* d = ::opendir(root.c_str());
|
||||
// No directory yet is an empty list, not an error: a first run has never saved anything.
|
||||
if (d == nullptr) return out;
|
||||
while (const dirent* e = ::readdir(d)) {
|
||||
const std::string name = e->d_name;
|
||||
if (name.size() <= 5 || name.compare(name.size() - 5, 5, ".toml") != 0) continue;
|
||||
const std::string stem = name.substr(0, name.size() - 5);
|
||||
// The *last* '_': `sanitize()` replaces anything unsafe in a host with one, so the host part
|
||||
// may hold them too and a first-match split would return a host cut in half.
|
||||
const std::size_t cut = stem.rfind('_');
|
||||
if (cut == std::string::npos || cut + 1 == stem.size()) continue;
|
||||
char* end = nullptr;
|
||||
const long port = std::strtol(stem.c_str() + cut + 1, &end, 10);
|
||||
if (end == nullptr || *end != '\0' || port <= 0 || port > 65535) continue;
|
||||
const std::string host = stem.substr(0, cut);
|
||||
// The name must be one `layout_path()` could have written, or the `×` -- which rebuilds it
|
||||
// through `layout_path()` -- would unlink a different session. `sanitize()` is idempotent on
|
||||
// any host that already came back out of a filename, so this is also true for hosts whose names
|
||||
// contain `_` from the sanitizer.
|
||||
if (sanitize(host) + "_" + std::to_string(port) != stem) continue;
|
||||
const std::string dir = root + "/" + e->d_name;
|
||||
struct stat sb {};
|
||||
if (::stat(dir.c_str(), &sb) != 0 || !S_ISDIR(sb.st_mode)) continue;
|
||||
std::string host;
|
||||
std::uint16_t port = 0;
|
||||
if (!split_stem(e->d_name, &host, &port)) continue;
|
||||
// The name must be one `config_dir_for()` could have written, or `forget_source()` would walk
|
||||
// out of the directory the row came from.
|
||||
if (sanitize(host) + "_" + std::to_string(port) != e->d_name) continue;
|
||||
|
||||
// One row per *configuration*: the same source may hold several named sessions, which is the
|
||||
// point of the feature, and the chooser is where they are told apart.
|
||||
for (const std::string& name : names_in_dir(dir)) {
|
||||
const std::string path = dir + "/" + name + ".toml";
|
||||
LayoutState st;
|
||||
const std::string path = dir + "/" + name;
|
||||
if (!load_layout(path, st)) continue;
|
||||
|
||||
KnownSource s;
|
||||
s.host = host;
|
||||
s.port = static_cast<std::uint16_t>(port);
|
||||
s.port = port;
|
||||
s.config = name;
|
||||
s.comment = st.comment;
|
||||
s.buffer_mb = st.buffer_mb;
|
||||
s.relative_time = st.relative_time;
|
||||
s.capture_packets = st.capture_packets;
|
||||
s.multicast = st.multicast;
|
||||
s.interface_addr = st.interface_addr;
|
||||
s.rows = static_cast<int>(st.rows.size());
|
||||
@@ -159,12 +328,18 @@ std::vector<KnownSource> known_sources() {
|
||||
if (!sig.empty()) ++s.signals;
|
||||
}
|
||||
}
|
||||
if (s.signals == 0) continue; // see the header: a session nobody configured
|
||||
|
||||
struct stat sb {};
|
||||
s.mtime = (::stat(path.c_str(), &sb) == 0) ? static_cast<std::int64_t>(sb.st_mtime) : 0;
|
||||
// A configuration nobody configured is not worth a row: a development machine accumulates one
|
||||
// session file per probe run and every one holds `signals = ["", "", "", ""]`. One that
|
||||
// carries a *setting* is worth a row even before it has ever streamed -- that is how a host is
|
||||
// set up from the chooser's form, and the settings are the whole point of editing one.
|
||||
const bool configured = s.signals > 0 || s.buffer_mb > 0.0 || !st.comment.empty() ||
|
||||
st.relative_time || st.capture_packets > 0;
|
||||
if (!configured) continue;
|
||||
struct stat fb {};
|
||||
s.mtime = (::stat(path.c_str(), &fb) == 0) ? static_cast<std::int64_t>(fb.st_mtime) : 0;
|
||||
out.push_back(std::move(s));
|
||||
}
|
||||
}
|
||||
::closedir(d);
|
||||
// Stable, so two files sharing an mtime keep the order the directory gave them rather than
|
||||
// swapping between runs.
|
||||
@@ -174,10 +349,10 @@ std::vector<KnownSource> known_sources() {
|
||||
}
|
||||
|
||||
bool forget_source(const KnownSource& src) {
|
||||
// Through `layout_path()` rather than by rebuilding the name here: one expression of the
|
||||
// Through `configuration_path()` rather than by rebuilding the name here: one expression of the
|
||||
// scheme. `src.host` came back out of a filename, so it is already sanitized, and `sanitize()`
|
||||
// keeps alphanumerics, '.', '-' and '_' -- it is idempotent, so the round trip is exact.
|
||||
return std::remove(layout_path(src.host, src.port).c_str()) == 0;
|
||||
return forget_configuration(src.host, src.port, src.config);
|
||||
}
|
||||
|
||||
std::string math_node_text(const MathNode& node) {
|
||||
@@ -191,7 +366,7 @@ std::string math_node_text(const MathNode& node) {
|
||||
std::ostringstream o;
|
||||
o << kind << '|' << node.source << '|' << node.in_a << '|' << node.in_b << '|' << node.gain << '|'
|
||||
<< node.offset << '|' << node.fc << '|' << node.q << '|' << node.lo << '|' << node.hi << '|'
|
||||
<< node.x << '|' << node.y;
|
||||
<< node.x << '|' << node.y << '|' << node.med_n;
|
||||
return o.str();
|
||||
}
|
||||
|
||||
@@ -207,7 +382,9 @@ bool math_node_from_text(const std::string& text, MathNode& out) {
|
||||
}
|
||||
}
|
||||
f.push_back(cur);
|
||||
if (f.size() != 12u) return false;
|
||||
// Twelve fields is a session file written before the de-noise node existed; its `med_n` stays at the
|
||||
// struct's default, which is what that file meant.
|
||||
if (f.size() != 12u && f.size() != 13u) return false;
|
||||
const auto number = [&](std::size_t i, double& v) {
|
||||
try {
|
||||
v = std::stod(f[i]);
|
||||
@@ -227,8 +404,16 @@ bool math_node_from_text(const std::string& text, MathNode& out) {
|
||||
out.source = f[1];
|
||||
out.in_a = static_cast<int>(in_a);
|
||||
out.in_b = static_cast<int>(in_b);
|
||||
return number(4, out.gain) && number(5, out.offset) && number(6, out.fc) && number(7, out.q) &&
|
||||
number(8, out.lo) && number(9, out.hi) && number(10, out.x) && number(11, out.y);
|
||||
if (!(number(4, out.gain) && number(5, out.offset) && number(6, out.fc) && number(7, out.q) &&
|
||||
number(8, out.lo) && number(9, out.hi) && number(10, out.x) && number(11, out.y))) {
|
||||
return false;
|
||||
}
|
||||
if (f.size() == 13u) {
|
||||
double med = 0.0;
|
||||
if (!number(12, med)) return false;
|
||||
out.med_n = static_cast<int>(med);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool load_layout(const std::string& path, LayoutState& out) {
|
||||
@@ -291,6 +476,48 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
||||
return v;
|
||||
};
|
||||
|
||||
/// One `trigger_*` key, by its name after the prefix. Text for the two enumerations and for the
|
||||
/// signal, numbers for the rest; `value` arrives unquoted.
|
||||
const auto trigger_key = [](LayoutState::Trigger& t, const std::string& k, const std::string& v) {
|
||||
const auto num = [&](double& out) {
|
||||
try {
|
||||
out = std::stod(v);
|
||||
} catch (...) {
|
||||
}
|
||||
};
|
||||
if (k == "enabled") {
|
||||
t.enabled = v == "true";
|
||||
} else if (k == "signal") {
|
||||
t.channel = v;
|
||||
} else if (k == "type") {
|
||||
t.type = v;
|
||||
} else if (k == "edge") {
|
||||
t.edge = v;
|
||||
} else if (k == "cmp") {
|
||||
t.cmp = v;
|
||||
} else if (k == "mode") {
|
||||
t.mode = v;
|
||||
} else if (k == "level") {
|
||||
num(t.level);
|
||||
t.level_given = true;
|
||||
} else if (k == "level2") {
|
||||
num(t.level2);
|
||||
} else if (k == "width") {
|
||||
num(t.width_s);
|
||||
} else if (k == "width2") {
|
||||
num(t.width2_s);
|
||||
} else if (k == "timeout") {
|
||||
num(t.timeout_s);
|
||||
} else if (k == "pre") {
|
||||
num(t.pretrigger_pct);
|
||||
} else if (k == "holdoff") {
|
||||
num(t.holdoff_s);
|
||||
} else if (k == "band") {
|
||||
num(t.noise_band);
|
||||
} else if (k == "hf") {
|
||||
num(t.hf_fc);
|
||||
}
|
||||
};
|
||||
if (section == Section::Top) {
|
||||
if (key == "window_s") {
|
||||
try {
|
||||
@@ -309,10 +536,30 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
||||
plot_count = std::max(0, std::stoi(value));
|
||||
} catch (...) {
|
||||
}
|
||||
} else if (key == "measure_stats") {
|
||||
st.measure_stats = value == "true";
|
||||
} else if (key == "side_tab") {
|
||||
st.side_tab = strip_quote(value);
|
||||
} else if (key.rfind("trigger_", 0) == 0) {
|
||||
trigger_key(st.trigger, key.substr(8), strip_quote(value));
|
||||
} else if (key == "multicast") {
|
||||
st.multicast = strip_quote(value);
|
||||
} else if (key == "interface") {
|
||||
st.interface_addr = strip_quote(value);
|
||||
} else if (key == "comment") {
|
||||
st.comment = strip_quote(value);
|
||||
} else if (key == "buffer_mb") {
|
||||
try {
|
||||
st.buffer_mb = std::stod(value);
|
||||
} catch (...) {
|
||||
}
|
||||
} else if (key == "relative_time") {
|
||||
st.relative_time = value == "true";
|
||||
} else if (key == "capture_packets") {
|
||||
try {
|
||||
st.capture_packets = static_cast<std::uint32_t>(std::max(0, std::stoi(value)));
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
} else if (section == Section::Plot && !st.rows.empty()) {
|
||||
if (key == "signals") {
|
||||
@@ -321,6 +568,8 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
||||
parse_string_array(value.substr(1, value.size() - 2), names);
|
||||
}
|
||||
st.rows.back().signals = names;
|
||||
} else if (key == "weight") {
|
||||
st.rows.back().weight = std::strtod(value.c_str(), nullptr);
|
||||
}
|
||||
} else if (section == Section::Math && !st.math.empty()) {
|
||||
MathChannel& m = st.math.back();
|
||||
@@ -363,6 +612,24 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
||||
m.options.threshold_pct = std::stod(value);
|
||||
} catch (...) {
|
||||
}
|
||||
} else if (key == "threshold2") {
|
||||
// The band's other edge. A file written before `Between` existed has no such key, and the
|
||||
// struct's own default is what such a measurement then reads -- which is the same value the
|
||||
// dialog offers a new one.
|
||||
try {
|
||||
m.options.threshold_pct2 = std::stod(value);
|
||||
} catch (...) {
|
||||
}
|
||||
} else if (key == "noise") {
|
||||
try {
|
||||
m.options.noise_pct = std::stod(value);
|
||||
} catch (...) {
|
||||
}
|
||||
} else if (key == "hf") {
|
||||
try {
|
||||
m.options.hf_fc = std::stod(value);
|
||||
} catch (...) {
|
||||
}
|
||||
} else if (key == "above") {
|
||||
m.options.above = value == "true";
|
||||
} else if (key == "show") {
|
||||
@@ -431,8 +698,32 @@ bool save_layout(const std::string& path, const LayoutState& state) {
|
||||
}
|
||||
o << "]\n";
|
||||
}
|
||||
o << "measure_stats = " << (state.measure_stats ? "true" : "false") << "\n";
|
||||
o << "side_tab = \"" << state.side_tab << "\"\n";
|
||||
// The trigger, settings and state both. Written even when it is off: that is the point of it.
|
||||
o << "trigger_enabled = " << (state.trigger.enabled ? "true" : "false") << "\n";
|
||||
if (!state.trigger.channel.empty()) {
|
||||
o << "trigger_signal = " << toml_escape(state.trigger.channel) << "\n";
|
||||
}
|
||||
o << "trigger_type = " << toml_escape(state.trigger.type) << "\n";
|
||||
o << "trigger_edge = " << toml_escape(state.trigger.edge) << "\n";
|
||||
o << "trigger_cmp = " << toml_escape(state.trigger.cmp) << "\n";
|
||||
o << "trigger_mode = " << toml_escape(state.trigger.mode) << "\n";
|
||||
o << "trigger_level = " << state.trigger.level << "\n";
|
||||
o << "trigger_level2 = " << state.trigger.level2 << "\n";
|
||||
o << "trigger_width = " << state.trigger.width_s << "\n";
|
||||
o << "trigger_width2 = " << state.trigger.width2_s << "\n";
|
||||
o << "trigger_timeout = " << state.trigger.timeout_s << "\n";
|
||||
o << "trigger_pre = " << state.trigger.pretrigger_pct << "\n";
|
||||
o << "trigger_holdoff = " << state.trigger.holdoff_s << "\n";
|
||||
o << "trigger_band = " << state.trigger.noise_band << "\n";
|
||||
o << "trigger_hf = " << state.trigger.hf_fc << "\n";
|
||||
if (!state.comment.empty()) o << "comment = " << toml_escape(state.comment) << "\n";
|
||||
if (state.buffer_mb > 0.0) o << "buffer_mb = " << state.buffer_mb << "\n";
|
||||
o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n";
|
||||
if (state.capture_packets > 0) o << "capture_packets = " << state.capture_packets << "\n";
|
||||
for (const auto& row : state.rows) {
|
||||
o << "\n[[plot]]\nsignals = [";
|
||||
o << "\n[[plot]]\nweight = " << row.weight << "\nsignals = [";
|
||||
for (std::size_t i = 0; i < row.signals.size(); ++i) {
|
||||
if (i != 0) o << ", ";
|
||||
o << toml_escape(row.signals[i]);
|
||||
@@ -469,8 +760,10 @@ bool save_layout(const std::string& path, const LayoutState& state) {
|
||||
}
|
||||
o << "\n[[measure]]\nsignal = " << toml_escape(m.signal) << "\ntype = " << kind
|
||||
<< "\nthreshold = " << m.options.threshold_pct
|
||||
<< "\nthreshold2 = " << m.options.threshold_pct2
|
||||
<< "\nabove = " << (m.options.above ? "true" : "false") << "\nshow = "
|
||||
<< (m.options.show_on_plot ? "true" : "false") << "\n";
|
||||
<< (m.options.show_on_plot ? "true" : "false") << "\nnoise = " << m.options.noise_pct
|
||||
<< "\nhf = " << m.options.hf_fc << "\n";
|
||||
}
|
||||
|
||||
// A sibling temp file + rename: a crash mid-write leaves the old layout, not a
|
||||
|
||||
@@ -20,6 +20,9 @@ struct LayoutState {
|
||||
struct Row {
|
||||
/// One entry per slot; an empty string is an empty slot.
|
||||
std::vector<std::string> signals;
|
||||
/// The row's share of the figure's height, 1 for equal rows. Part of the layout because a sizing a
|
||||
/// user dragged is an arrangement, and an arrangement that is not saved is one that is lost.
|
||||
double weight = 1.0;
|
||||
};
|
||||
|
||||
double window_s = 0.0; ///< 0 keeps the default window
|
||||
@@ -58,8 +61,61 @@ struct LayoutState {
|
||||
/// `math_node_text()` for the field order.
|
||||
std::vector<MathChannel> math;
|
||||
|
||||
/// Whether the measurements pane shows its statistics line. The only piece of the
|
||||
/// statistics that is persisted: the accumulated figures themselves describe a session's
|
||||
/// stream and mean nothing restored against another one.
|
||||
bool measure_stats = false;
|
||||
|
||||
/// Which sidebar tab was showing: "measure" or "signals". The one thing the panel persists --
|
||||
/// no revealer states remain, and the 320 px width is the `.ui`'s.
|
||||
std::string side_tab = "signals";
|
||||
|
||||
/// The measurements pane, by signal *name* and with the type and options a row was built with.
|
||||
std::vector<Measure> measures;
|
||||
|
||||
/// The trigger: its settings *and* whether trigger mode was on. Both, and the settings even while
|
||||
/// it is off, because a trigger set up once and switched off is still the trigger you come back to
|
||||
/// -- and the pane's locked view is built from exactly this.
|
||||
///
|
||||
/// The signal and the two enumerations are stored as *text*: the signal by name, because every
|
||||
/// rebuild renumbers the channels (the model's own rule), and the shapes by the words the panel's
|
||||
/// dropdowns show, because an index would tie this file to a declaration order -- which the math
|
||||
/// channel's op index already taught this store once.
|
||||
struct Trigger {
|
||||
bool enabled = false;
|
||||
std::string channel; ///< the acquired channel's name; empty is "none chosen"
|
||||
std::string type = "edge"; ///< edge | pulse width | window | dropout
|
||||
std::string edge = "rising"; ///< rising | falling | both
|
||||
std::string cmp = "greater"; ///< pulse width: `<` | `>` | within | outside
|
||||
std::string mode = "normal"; ///< normal | single
|
||||
double level = 0.0;
|
||||
double level2 = 0.0; ///< window: the band's other edge
|
||||
double width_s = 1.0e-3;
|
||||
double width2_s = 2.0e-3;
|
||||
double timeout_s = 1.0e-3;
|
||||
double pretrigger_pct = 50.0;
|
||||
double holdoff_s = 0.010;
|
||||
double noise_band = 0.0;
|
||||
double hf_fc = 0.0;
|
||||
/// Whether the file said anything about the level. A session that did keeps it; one that did not
|
||||
/// leaves the panel's own "follow the channel's range" default alone.
|
||||
bool level_given = false;
|
||||
};
|
||||
Trigger trigger;
|
||||
|
||||
/// Free text the user attached to this configuration: what it is for, what to look at first.
|
||||
/// A configuration is a *named* session, and the name alone rarely says what makes it different
|
||||
/// from its neighbour.
|
||||
std::string comment;
|
||||
|
||||
/// The startup settings the configuration carries, applied when the chooser opens it -- which is
|
||||
/// why they live here and not only on the command line: two sources on one machine want different
|
||||
/// buffers and different clocks, and retyping the flags to look at the second one is how a
|
||||
/// setting gets forgotten. A zero `buffer_mb` and an empty `capture_packets` mean "not stored":
|
||||
/// the command line's value stands, so an old file cannot silently shrink a buffer.
|
||||
double buffer_mb = 0.0;
|
||||
bool relative_time = false;
|
||||
std::uint32_t capture_packets = 0;
|
||||
};
|
||||
|
||||
/// A data source udpscope has a stored session for: one row of the startup chooser.
|
||||
@@ -71,13 +127,23 @@ struct KnownSource {
|
||||
int rows = 0; ///< plots the session had
|
||||
int signals = 0; ///< named slots across those plots
|
||||
std::int64_t mtime = 0; ///< orders the list; never shown
|
||||
/// Which *configuration* of that source this row is. A source may have several, each a named
|
||||
/// session of its own, so the pair (host, port, config) is what identifies a row.
|
||||
std::string config;
|
||||
std::string comment; ///< the configuration's note, shown beside its name
|
||||
/// The startup settings this configuration carries, so the chooser can apply them.
|
||||
double buffer_mb = 0.0;
|
||||
bool relative_time = false;
|
||||
std::uint32_t capture_packets = 0;
|
||||
};
|
||||
|
||||
/// Every session file that names at least one signal, newest first.
|
||||
/// Every configuration that names at least one signal *or* carries a setting, newest first.
|
||||
///
|
||||
/// The filter is what makes the list usable: a development machine accumulates one session file
|
||||
/// per probe run on a throwaway port -- 140 of them when this was written -- and every one holds
|
||||
/// `signals = ["", "", "", ""]`. A listed source is one somebody actually configured.
|
||||
/// `signals = ["", "", "", ""]`. A listed source is one somebody actually configured — and a
|
||||
/// buffer size, a note or a clock counts as configuring it, because that is how the chooser's form
|
||||
/// sets a host up before it has ever streamed.
|
||||
std::vector<KnownSource> known_sources();
|
||||
|
||||
/// Deletes one source's session file; the chooser's `x`. False means the file was already gone or
|
||||
@@ -93,11 +159,43 @@ bool math_node_from_text(const std::string& text, MathNode& out);
|
||||
/// The directory session files live in: $XDG_CONFIG_HOME/udpscope, else
|
||||
/// ~/.config/udpscope, else /tmp/udpscope-config. Not created by this call.
|
||||
std::string config_dir();
|
||||
/// `$XDG_CACHE_HOME/udpscope`, or `~/.cache/udpscope`: where bulky, disposable state
|
||||
/// lives (the packet captures; item 9's event cache). Created on demand by callers.
|
||||
std::string cache_dir();
|
||||
|
||||
/// `~/.config/udpscope/<host>_<port>.toml`, with the directory created.
|
||||
/// Characters unsafe in a filename are replaced in the host part.
|
||||
/// Creates `path` and every missing component, like `mkdir -p`. Public because the preferences are
|
||||
/// written under the config directory too.
|
||||
void mkdirs(const std::string& path);
|
||||
|
||||
/// The *legacy* single-file path, `~/.config/udpscope/<host>_<port>.toml`, with the directory
|
||||
/// created. Still the import source for a file written before configurations existed; nothing
|
||||
/// writes here any more.
|
||||
std::string layout_path(const std::string& host, std::uint16_t port);
|
||||
|
||||
/// The per-source directory a source's configurations live in:
|
||||
/// `~/.config/udpscope/<host>_<port>/`. Not created by this call.
|
||||
std::string config_dir_for(const std::string& host, std::uint16_t port);
|
||||
|
||||
/// One configuration's file, `<config_dir_for(host, port)>/<name>.toml`, with the directory
|
||||
/// created. The name is sanitized the way the host is, so a name the user typed with a slash in it
|
||||
/// cannot escape the directory.
|
||||
std::string configuration_path(const std::string& host, std::uint16_t port,
|
||||
const std::string& name);
|
||||
|
||||
/// The source's configurations, newest first. A legacy single-file session appears here as the
|
||||
/// configuration named `default`, and importing it is the first thing this does.
|
||||
std::vector<std::string> configuration_names(const std::string& host, std::uint16_t port);
|
||||
|
||||
/// The configuration the source was last left in: `<dir>/active`, or the newest that exists when
|
||||
/// nothing wrote one. Never empty for a source that has any configuration at all.
|
||||
std::string active_configuration(const std::string& host, std::uint16_t port);
|
||||
|
||||
/// Remembers which configuration this source is on. Called by every save.
|
||||
bool set_active_configuration(const std::string& host, std::uint16_t port, const std::string& name);
|
||||
|
||||
/// Deletes one configuration's file, and `active` if it named it.
|
||||
bool forget_configuration(const std::string& host, std::uint16_t port, const std::string& name);
|
||||
|
||||
/// Parses the file's TOML subset. Returns false when the file is missing or
|
||||
/// malformed; `out` is left usable either way (a malformed file loads as much as
|
||||
/// parsed, which beats refusing a hand-edited file over one stray character).
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
#include "acquisition.hpp"
|
||||
#include "config.hpp"
|
||||
#include "settings.hpp"
|
||||
#include "waveform.hpp"
|
||||
#include "model.hpp"
|
||||
#include "plot_host.hpp"
|
||||
#include "source_dialog.hpp"
|
||||
@@ -15,6 +17,10 @@ int main(int argc, char** argv) {
|
||||
udpscope::Config cfg;
|
||||
try {
|
||||
cfg = udpscope::parse_args(argc, argv);
|
||||
// The preferences fill in what the command line did not decide. `--relative-time` is an explicit
|
||||
// instruction for this run, so a preference can only turn the mode on, never off -- which is also
|
||||
// why this is read here, before the buffers are planned: the mode changes how they are filled.
|
||||
if (udpscope::load_settings().relative_time) cfg.relative_time = true;
|
||||
} catch (const std::exception& e) {
|
||||
std::fprintf(stderr, "udpscope: %s\n", e.what());
|
||||
return 2;
|
||||
@@ -22,18 +28,65 @@ int main(int argc, char** argv) {
|
||||
|
||||
// No `--server` means the user has not said which source to open, so ask. Before the model:
|
||||
// declining must cost no buffer and no socket, and nothing below has to cope with "no source".
|
||||
if (!cfg.server_specified && !udpscope::choose_source(cfg)) return 0;
|
||||
// `--open` is the other answer to that question, and the chooser knows it: its Waveforms page
|
||||
// fills `open_waveform` in instead of the connection fields.
|
||||
const bool asked = !cfg.server_specified && cfg.open_waveform.empty();
|
||||
if (asked && !udpscope::choose_source(cfg)) return 0;
|
||||
// A source named on the command line skips the chooser, but its *stored* configuration still
|
||||
// applies: the buffer size, the clock and the capture ring are decided here, before the model --
|
||||
// which is the only moment they can be -- and a saved host whose buffer was ignored because the
|
||||
// address came from the command line is an edit that looks like it never took effect.
|
||||
if (cfg.server_specified) udpscope::apply_stored_settings(cfg);
|
||||
|
||||
// Everything from here to `gtk_window_present()` inside `run_ui()` runs with no main loop: the
|
||||
// buffer is allocated, a recording is read, then the whole UI is built. The chooser's window is
|
||||
// already gone, so without this the screen is empty for all of it -- "the dialog disappeared and
|
||||
// nothing happened", which at a few gigabytes of buffer is ten seconds long. Only on the path
|
||||
// that *asked*: a source named with `--server` had no window to lose, and the status line after
|
||||
// the present is the right place to say what such a run is waiting for.
|
||||
if (asked) udpscope::loading_show(cfg);
|
||||
|
||||
udpscope::ScopeModel model(cfg);
|
||||
udpscope::RowSet rows;
|
||||
rows.set_row_count(cfg.plots);
|
||||
udpscope::Acquisition acquisition(model, cfg);
|
||||
if (cfg.open_waveform.empty()) {
|
||||
try {
|
||||
acquisition.start();
|
||||
} catch (const std::exception& e) {
|
||||
std::fprintf(stderr, "udpscope: %s\n", e.what());
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
// No socket and no thread: the file is the source, and there is nothing to arrive afterwards.
|
||||
// The acquisition object is passed to the UI all the same -- stopped, which is what every
|
||||
// "paused"/"capturing" question answers as false.
|
||||
udpscope::WaveformLoadReport report;
|
||||
std::string error;
|
||||
if (!udpscope::load_waveform_into_model(model, cfg.open_waveform, &report, &error)) {
|
||||
std::fprintf(stderr, "udpscope: %s\n", error.c_str());
|
||||
return 1;
|
||||
}
|
||||
// One row per column, so every trace opens where a user can see and rearrange it -- and sized
|
||||
// *here*, before the UI exists, exactly as `--plots` does: resizing the rows from inside the
|
||||
// activation leaves the panels built for the old count.
|
||||
rows.set_row_count(std::min<int>(std::max<int>(cfg.plots, static_cast<int>(report.signals)),
|
||||
udpscope::RowSet::kMaxRows));
|
||||
udpscope::remember_waveform(cfg.open_waveform);
|
||||
// The whole file framed from the start: the window is the recording's own span, and nothing
|
||||
// afterwards restates it -- a zoom is the user's.
|
||||
if (report.span_s > 0.0) rows.set_time_window(report.span_s);
|
||||
// `rows`/`decim` say whether what is on screen is the file sample for sample or a filtered
|
||||
// version of it: `samples` alone below `rows` is how a recording that did not fit its buffer at
|
||||
// its own rate used to read as a truncated one.
|
||||
std::fprintf(stderr,
|
||||
"[udpscope] waveform loaded: %s signals=%zu samples=%zu rows=%zu decim=%llu "
|
||||
"span=%.6fs rate=%.6g time=[%.6f..%.6f]\n",
|
||||
cfg.open_waveform.c_str(), report.signals, report.samples, report.rows,
|
||||
static_cast<unsigned long long>(report.decimation), report.span_s, report.rate_hz,
|
||||
report.first_time, report.last_time);
|
||||
std::fflush(stderr);
|
||||
}
|
||||
|
||||
const int result = udpscope::run_ui(argc, argv, cfg, model, acquisition, rows);
|
||||
acquisition.stop();
|
||||
|
||||
@@ -59,8 +59,37 @@ void biquad(MathOp op, double fc, double q, double dt, double& b0, double& b1, d
|
||||
|
||||
/// One biquad step, transposed direct form II -- the form whose state words are the two things
|
||||
/// that actually need carrying between samples.
|
||||
/// One pole's coefficient for a corner of `fc` Hz at `dt` seconds a sample: `a = 1 - exp(-2*pi*fc*dt)`,
|
||||
/// clamped just under Nyquist so `a` cannot exceed 1. `fc == 0` returns 1, which is a pass-through -- so
|
||||
/// "no pole" costs the step no branch.
|
||||
double pole_a(double fc, double dt) {
|
||||
if (!(fc > 0.0) || !(dt > 0.0)) return 1.0;
|
||||
const double nyquist = 0.5 / dt;
|
||||
const double use = fc < nyquist * 0.99 ? fc : nyquist * 0.99;
|
||||
return 1.0 - std::exp(-2.0 * kPi * use * dt);
|
||||
}
|
||||
|
||||
/// The median window's length: odd, and between 3 and 9.
|
||||
///
|
||||
/// Odd because an even window's median is the average of two samples, which is a value the signal never
|
||||
/// took -- and the whole point of a median here is that its output is a sample that was really there.
|
||||
/// Rounded *up*, so a request for 4 gets 5 rather than 3. The ceiling of 9 is what `math_step`'s fixed
|
||||
/// `double sorted[9]` is sized for.
|
||||
int median_n(int want) {
|
||||
int n = want < 3 ? 3 : (want > 9 ? 9 : want);
|
||||
if ((n & 1) == 0) ++n;
|
||||
return n;
|
||||
}
|
||||
|
||||
double biquad_step(double x, double b0, double b1, double b2, double a1, double a2, double& z1,
|
||||
double& z2) {
|
||||
// A reserved hole is NaN in the source, and a NaN integrated into the state words is
|
||||
// permanent: every later sample would come out NaN, so a filtered trace died at the first
|
||||
// gap and never came back -- which is what a dropped packet did to every low pass in
|
||||
// relative mode, where a counter gap reserves NaN slots by construction. A gap in the input
|
||||
// is a gap in the output (the hole stays visible), and the filter's history is the last
|
||||
// *real* samples it saw, so the state is left alone and the trace resumes after the hole.
|
||||
if (!std::isfinite(x)) return kNaN;
|
||||
const double y = b0 * x + z1;
|
||||
z1 = b1 * x - a1 * y + z2;
|
||||
z2 = b2 * x - a2 * y;
|
||||
@@ -83,11 +112,12 @@ const char* math_op_name(MathOp op) {
|
||||
case MathOp::Sub: return "difference";
|
||||
case MathOp::Mul: return "product";
|
||||
case MathOp::Div: return "ratio";
|
||||
case MathOp::DeNoise: return "de-noise";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
int math_op_count() { return 12; }
|
||||
int math_op_count() { return 13; }
|
||||
|
||||
MathOp math_op_at(int index) {
|
||||
switch (index) {
|
||||
@@ -102,7 +132,8 @@ MathOp math_op_at(int index) {
|
||||
case 8: return MathOp::Sum;
|
||||
case 9: return MathOp::Sub;
|
||||
case 10: return MathOp::Mul;
|
||||
default: return MathOp::Div;
|
||||
case 11: return MathOp::Div;
|
||||
default: return MathOp::DeNoise;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +160,8 @@ bool math_op_uses_filter(MathOp op) {
|
||||
return op == MathOp::LowPass || op == MathOp::HighPass || op == MathOp::BandPass;
|
||||
}
|
||||
bool math_op_uses_clamp(MathOp op) { return op == MathOp::Clamp; }
|
||||
bool math_op_uses_fc(MathOp op) { return math_op_uses_filter(op) || op == MathOp::DeNoise; }
|
||||
bool math_op_uses_median(MathOp op) { return op == MathOp::DeNoise; }
|
||||
|
||||
void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt,
|
||||
const std::function<int(const std::string&)>& index_of) {
|
||||
@@ -145,6 +178,17 @@ void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt,
|
||||
}
|
||||
rt.dt = dt;
|
||||
rt.ready = true;
|
||||
// The median windows, pooled: one pass to size the pool, then the node loop hands out the slices. A
|
||||
// window starts from rest after a re-prepare -- the same choice the biquads' `z2` makes -- while the
|
||||
// pole's `z1` is carried, so a decimation change does not make the trace jump.
|
||||
std::size_t med_total = 0;
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
if (channel.nodes[i].op == MathOp::DeNoise) {
|
||||
med_total += static_cast<std::size_t>(median_n(channel.nodes[i].med_n));
|
||||
}
|
||||
}
|
||||
rt.median.assign(med_total, 0.0);
|
||||
std::size_t med_next = 0;
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
const MathNode& node = channel.nodes[i];
|
||||
MathNodeRt& out = rt.node[i];
|
||||
@@ -152,6 +196,15 @@ void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt,
|
||||
out.src = index_of ? index_of(node.source) : -1;
|
||||
continue;
|
||||
}
|
||||
if (node.op == MathOp::DeNoise) {
|
||||
out.med_n = median_n(node.med_n);
|
||||
out.med_at = med_next;
|
||||
out.med_pos = 0;
|
||||
out.med_have = 0;
|
||||
med_next += static_cast<std::size_t>(out.med_n);
|
||||
out.b0 = pole_a(node.fc, dt);
|
||||
continue;
|
||||
}
|
||||
if (!math_op_uses_filter(node.op)) continue;
|
||||
biquad(node.op, node.fc, node.q, dt, out.b0, out.b1, out.b2, out.a1, out.a2);
|
||||
}
|
||||
@@ -214,6 +267,46 @@ double math_step(const MathChannel& channel, MathRuntime& rt, const float* value
|
||||
x = std::fabs(d) > 0.0 ? at(node.in_a) / d : 0.0;
|
||||
break;
|
||||
}
|
||||
case MathOp::DeNoise: {
|
||||
const double in = at(node.in_a);
|
||||
// A hole stays a hole, and the window is left alone: folding a NaN into the median would smear one
|
||||
// gap across `med_n` samples, and the whole trace after it in the pole. `med_n <= 0` is a node the
|
||||
// runtime has not prepared -- the window pointer below would be a read past the pool, so it is
|
||||
// checked rather than assumed.
|
||||
if (!std::isfinite(in) || self.med_n <= 0) {
|
||||
x = kNaN;
|
||||
break;
|
||||
}
|
||||
const std::size_t len = static_cast<std::size_t>(self.med_n);
|
||||
double* win = rt.median.data() + self.med_at;
|
||||
win[self.med_pos] = in;
|
||||
self.med_pos = self.med_pos + 1 == len ? 0 : self.med_pos + 1;
|
||||
if (self.med_have < len) ++self.med_have;
|
||||
// An insertion sort of at most nine doubles into a local. The window is tiny and fixed, so this
|
||||
// beats anything that allocates, and it leaves the ring itself in arrival order -- which is what
|
||||
// lets the oldest sample be overwritten in place. Before the window has filled, the median is taken
|
||||
// over what there is: a shorter window, deliberately, rather than NaN. A derived trace that reads
|
||||
// "no data" for its first few samples after every rebuild looks like a broken graph.
|
||||
double sorted[9];
|
||||
const std::size_t m = self.med_have;
|
||||
for (std::size_t j = 0; j < m; ++j) {
|
||||
const double v = win[j];
|
||||
std::size_t p = j;
|
||||
while (p > 0 && sorted[p - 1] > v) {
|
||||
sorted[p] = sorted[p - 1];
|
||||
--p;
|
||||
}
|
||||
sorted[p] = v;
|
||||
}
|
||||
const double med = sorted[m / 2];
|
||||
// Median *then* pole, and the order is the design. The median kills an impulse outright; the pole
|
||||
// then takes the broadband hash it leaves behind. The other way round feeds the pole the impulse,
|
||||
// which smears it over the pole's whole settling time and leaves the median a wide bump it cannot
|
||||
// reject.
|
||||
self.z1 += self.b0 * (med - self.z1);
|
||||
x = self.z1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.value = x;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ enum class MathOp {
|
||||
Sub, ///< a - b
|
||||
Mul, ///< a * b
|
||||
Div, ///< a / b, zero when b is 0 (a scope trace must not go to inf/NAN)
|
||||
/// A running median of `med_n` samples, then a one-pole at `fc` Hz. For impulsive interference -- an EM
|
||||
/// peak a few samples wide -- which a linear filter can only smear over its own settling time. `fc = 0`
|
||||
/// is the median alone.
|
||||
DeNoise,
|
||||
};
|
||||
|
||||
const char* math_op_name(MathOp op);
|
||||
@@ -41,6 +45,11 @@ int math_op_arity(MathOp op);
|
||||
bool math_op_uses_gain(MathOp op);
|
||||
bool math_op_uses_filter(MathOp op);
|
||||
bool math_op_uses_clamp(MathOp op);
|
||||
/// Whether an op reads `fc`. Wider than `math_op_uses_filter()`, which keeps its present meaning of *a
|
||||
/// biquad*: `DeNoise` reads `fc` for a one-pole and has no `q`.
|
||||
bool math_op_uses_fc(MathOp op);
|
||||
/// Whether an op reads `med_n`.
|
||||
bool math_op_uses_median(MathOp op);
|
||||
|
||||
/// One node of a math channel's graph. Node indices are positions in `MathChannel::nodes`, and a
|
||||
/// node may only read ones before it: the evaluator walks the list in order, which is what makes a
|
||||
@@ -56,8 +65,23 @@ struct MathNode {
|
||||
double q = 0.707; ///< BP quality factor.
|
||||
double lo = -1.0; ///< `Clamp` bounds.
|
||||
double hi = 1.0;
|
||||
/// `DeNoise`: the median window, in samples. Odd and between 3 and 9 -- `median_n()` in the
|
||||
/// implementation is what enforces that, so a session file holding anything else still loads. An impulse
|
||||
/// up to `med_n / 2` samples wide is rejected outright.
|
||||
int med_n = 5;
|
||||
double x = 0.0; ///< the editor's box position, logical pixels.
|
||||
double y = 0.0;
|
||||
|
||||
/// Whether two nodes are the same node for the *engine's* purposes.
|
||||
///
|
||||
/// `x`/`y` are the canvas's own and no op reads them, so a graph that only moved is not a change:
|
||||
/// rebuilding for it replaces the buffer and drops its history -- and in a file view that means
|
||||
/// re-reading the file -- to store two numbers nothing but the editor draws with.
|
||||
bool operator==(const MathNode& other) const {
|
||||
return op == other.op && source == other.source && in_a == other.in_a && in_b == other.in_b &&
|
||||
gain == other.gain && offset == other.offset && fc == other.fc && q == other.q &&
|
||||
lo == other.lo && hi == other.hi && med_n == other.med_n;
|
||||
}
|
||||
};
|
||||
|
||||
/// A derived channel: a graph of nodes and which node is the answer.
|
||||
@@ -67,6 +91,11 @@ struct MathChannel {
|
||||
bool enabled = false;
|
||||
std::vector<MathNode> nodes;
|
||||
int output = -1; ///< the node whose value is the channel's.
|
||||
|
||||
bool operator==(const MathChannel& other) const {
|
||||
return name == other.name && unit == other.unit && enabled == other.enabled &&
|
||||
output == other.output && nodes == other.nodes;
|
||||
}
|
||||
};
|
||||
|
||||
/// The per-channel runtime: the filter memory and the per-node scratch. Kept apart from the
|
||||
@@ -94,10 +123,25 @@ struct MathNodeRt {
|
||||
/// per source node, per sample, behind a type-erased call. Names cannot change without a
|
||||
/// rebuild, and every rebuild re-prepares.
|
||||
int src = -1;
|
||||
/// `DeNoise`: where this node's median window starts in `MathRuntime::median`, how long it is, the ring's
|
||||
/// write position, and how many samples it holds. The window is *pooled* rather than held here because a
|
||||
/// node's state belongs on one cache line, and nine doubles would push this struct across two for every
|
||||
/// node in the graph, filter or not.
|
||||
///
|
||||
/// The one-pole reuses `b0` for its coefficient and `z1` for its value, which is not a shortcut:
|
||||
/// `math_prepare()` deliberately carries `z1` across a re-prepare, so the pole keeps its history through
|
||||
/// a decimation change exactly as the biquads keep theirs.
|
||||
std::size_t med_at = 0;
|
||||
int med_n = 0;
|
||||
std::size_t med_pos = 0;
|
||||
std::size_t med_have = 0;
|
||||
};
|
||||
|
||||
struct MathRuntime {
|
||||
std::vector<MathNodeRt> node;
|
||||
/// Every `DeNoise` node's median window, end to end: one allocation for the channel, sized by
|
||||
/// `math_prepare()`, a node's slice starting at its own `med_at`.
|
||||
std::vector<double> median;
|
||||
/// The rate the coefficients were built for; a change rebuilds them.
|
||||
double dt = 0.0;
|
||||
bool ready = false;
|
||||
|
||||
@@ -28,16 +28,30 @@ void enter_side(MeasureLevel& acc, std::size_t e, double t) {
|
||||
acc.open[e] = true;
|
||||
}
|
||||
|
||||
/// The hysteresis band's half-width, as a share of the window's peak-to-peak. Measured rather than
|
||||
/// chosen: 5 % reads the mock's 0.1 interferer correctly and fails at 0.2 (`period=0.075` for a
|
||||
/// true 0.2), while 10 % holds to an interferer amplitude of 0.3 -- a third of the waveform -- and
|
||||
/// to white noise at sigma=0.05. Above that the band starts eating a real waveform's own extremes
|
||||
/// on anything less square than a sine.
|
||||
constexpr double kHysteresisFrac = 0.10;
|
||||
// The hysteresis band and its rationale moved to `MeasureOptions::noise_pct`: it is a per-measurement
|
||||
// setting now, and a second copy of the number here would be a second thing to keep in step.
|
||||
constexpr double kPi = 3.14159265358979323846;
|
||||
|
||||
/// The flat level's band, as a half-width in shares of the window's peak-to-peak. 5 % is *under*
|
||||
/// the overshoot a band-limited edge rings to -- Gibbs, about 9 % of the swing at the harmonic
|
||||
/// counts the mock's `square` uses -- so the ringing's excursion falls outside the densest band
|
||||
/// while a plateau's own ripple, a few percent, stays inside it. Measured on the mock's 30-harmonic
|
||||
/// square at 1.3 MS/s: the flat top reads 1.000000 with this band, 1.003573 at 3 %, 1.003495 at
|
||||
/// 10 % -- the wider bands reach the ringing again. A share and not an absolute width: the band is
|
||||
/// the same quantity `threshold_pct` and the hysteresis are measured in.
|
||||
constexpr double kFlatBandShare = 0.05;
|
||||
|
||||
/// Which bin of a side is the level: the *outermost* one holding at least this share of the
|
||||
/// densest bin's buckets. One rule for both kinds of waveform. Where a plateau exists it is by
|
||||
/// far the densest band and the rule cannot reach past it. Where none exists -- a sine, a ramp --
|
||||
/// the signal dwells in every band it visits, the whole side qualifies, and the outermost is the
|
||||
/// honest answer: the level is then the top of what the window reaches, near the peak, rather
|
||||
/// than a bin of an arbitrary mid-range density that a shifted window would move.
|
||||
constexpr double kFlatTieShare = 0.5;
|
||||
|
||||
} // namespace
|
||||
|
||||
void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct) {
|
||||
void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct, double noise_frac) {
|
||||
const double span = s.hi - s.lo;
|
||||
acc.level = s.lo + pct * span;
|
||||
// Half the room to the nearer extreme is the clamp. A threshold at 90 % has a tenth of the span
|
||||
@@ -46,10 +60,21 @@ void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct) {
|
||||
// a noisy reading. Measured on a 3.3 V logic signal with ringing at thr=90 %: `enters=0` with a
|
||||
// fixed band, `enters=4 period=0.200000` with the clamp.
|
||||
const double room = std::min(s.hi - acc.level, acc.level - s.lo);
|
||||
acc.hysteresis = std::max(0.0, std::min(kHysteresisFrac * span, 0.5 * room));
|
||||
const double want = noise_frac > 0.0 ? noise_frac * span : 0.0;
|
||||
acc.hysteresis = std::max(0.0, std::min(want, 0.5 * room));
|
||||
}
|
||||
|
||||
void measure_scan(const MeasureChunk& chunk, MeasureStats& s) {
|
||||
void measure_filter_set(MeasureFilter& f, double fc, double dt) {
|
||||
f.reset();
|
||||
f.a = 1.0;
|
||||
if (fc > 0.0 && dt > 0.0) {
|
||||
const double nyquist = 0.5 / dt;
|
||||
const double use = fc < nyquist * 0.99 ? fc : nyquist * 0.99;
|
||||
f.a = 1.0 - std::exp(-2.0 * kPi * use * dt);
|
||||
}
|
||||
}
|
||||
|
||||
void measure_scan(const MeasureChunk& chunk, MeasureStats& s, MeasureFilter& filter) {
|
||||
// The chunk's own extremes first, in a loop that touches nothing but the values: *where* an
|
||||
// extreme is costs an index, and the caller turns the two that survive into stamps once at the
|
||||
// end. The sum is accumulated per chunk and folded in once, which is also kinder numerically
|
||||
@@ -62,7 +87,7 @@ void measure_scan(const MeasureChunk& chunk, MeasureStats& s) {
|
||||
std::size_t at_hi = 0;
|
||||
bool any = false;
|
||||
for (std::size_t i = 0; i < chunk.count; ++i) {
|
||||
const double v = static_cast<double>(chunk.value[i]);
|
||||
const double v = filter.step(static_cast<double>(chunk.value[i]));
|
||||
if (!std::isfinite(v)) continue;
|
||||
if (!any) {
|
||||
lo = hi = v;
|
||||
@@ -108,14 +133,29 @@ void measure_scan(const MeasureChunk& chunk, MeasureStats& s) {
|
||||
}
|
||||
}
|
||||
|
||||
void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, MeasureStats& s) {
|
||||
void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, MeasureStats& s,
|
||||
bool flat_hist) {
|
||||
double sum = 0.0;
|
||||
std::size_t finite = 0;
|
||||
// The *buckets'* own range, which is not the window's: the window's partial edge buckets are
|
||||
// read raw and are not in this list. Only the non-empty buckets: an all-non-finite bucket is
|
||||
// stored as an empty aggregate and its `lo`/`hi` say nothing.
|
||||
double blo = 0.0;
|
||||
double bhi = 0.0;
|
||||
bool any = false;
|
||||
for (std::size_t b = 0; b < count; ++b) {
|
||||
const MeasureBucket& k = buckets[b];
|
||||
// A bucket of nothing (every sample non-finite) is stored as an empty aggregate, and the raw
|
||||
// walk skips exactly those samples -- so it contributes nothing here either.
|
||||
if (k.count == 0) continue;
|
||||
if (!any) {
|
||||
blo = k.lo;
|
||||
bhi = k.hi;
|
||||
any = true;
|
||||
} else {
|
||||
if (k.lo < blo) blo = k.lo;
|
||||
if (k.hi > bhi) bhi = k.hi;
|
||||
}
|
||||
sum += k.sum;
|
||||
finite += k.count;
|
||||
if (!s.valid) {
|
||||
@@ -139,16 +179,41 @@ void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, Measu
|
||||
}
|
||||
s.sum += sum;
|
||||
s.count += finite;
|
||||
if (!flat_hist || !any || !(bhi > blo)) return;
|
||||
// One observation per bucket, at its midpoint, carrying that bucket's *exact* sum and finite
|
||||
// count -- so the level the caller finally reports is a mean of real samples and not a bin
|
||||
// centre. Weighting by the bucket (and not by its 64 samples) is what keeps the flat regions
|
||||
// and the ringing on the same footing: the question is how much of the *waveform* dwells in a
|
||||
// band, and a bucket is a span of it. A range that is degenerate (every bucket the same value)
|
||||
// has no band to pick and leaves `folded` at 0, which is the caller's "no histogram".
|
||||
s.flat.lo = blo;
|
||||
s.flat.hi = bhi;
|
||||
const double scale = static_cast<double>(MeasureFlatHist::kBins) / (bhi - blo);
|
||||
for (std::size_t b = 0; b < count; ++b) {
|
||||
const MeasureBucket& k = buckets[b];
|
||||
if (k.count == 0) continue;
|
||||
const double mid = 0.5 * (k.lo + k.hi);
|
||||
std::size_t bin = mid <= blo ? 0u : static_cast<std::size_t>((mid - blo) * scale);
|
||||
if (bin >= MeasureFlatHist::kBins) bin = MeasureFlatHist::kBins - 1;
|
||||
s.flat.sum[bin] += k.sum;
|
||||
s.flat.count[bin] += k.count;
|
||||
++s.flat.buckets[bin];
|
||||
++s.flat.folded;
|
||||
}
|
||||
}
|
||||
|
||||
void measure_accumulate(const MeasureChunk& chunk,
|
||||
const std::function<double(std::size_t)>& time_of, MeasureLevel* levels,
|
||||
std::size_t level_count) {
|
||||
std::size_t level_count, MeasureFilter& filter) {
|
||||
// Samples outer, levels inner -- the reverse of what reads more naturally, and deliberately. The filter
|
||||
// holds one state for the whole walk, so a levels-outer loop would step it once per level per sample and
|
||||
// every level after the first would read a signal filtered `k` times over. Each level's own state is
|
||||
// independent of the others, so the swap costs nothing else.
|
||||
for (std::size_t i = 0; i < chunk.count; ++i) {
|
||||
const double v = filter.step(static_cast<double>(chunk.value[i]));
|
||||
if (!std::isfinite(v)) continue;
|
||||
for (std::size_t k = 0; k < level_count; ++k) {
|
||||
MeasureLevel& acc = levels[k];
|
||||
for (std::size_t i = 0; i < chunk.count; ++i) {
|
||||
const double v = static_cast<double>(chunk.value[i]);
|
||||
if (!std::isfinite(v)) continue;
|
||||
// The *sums* split at the bare level: `Top` and `Bottom` are defined as the mean above and
|
||||
// below the threshold, and a band here would move a documented exact reading.
|
||||
const int s = v > acc.level ? 1 : 0;
|
||||
@@ -207,7 +272,9 @@ bool measure_accumulate_bucket(const MeasureBucket& k,
|
||||
const bool fully_outside = above ? (k.lo > top) : (k.hi < bot);
|
||||
// Nothing may be folded before this point: a refusal hands the bucket to `measure_accumulate()`,
|
||||
// which adds the same samples again. Sums added above the refusal double-counted every opened
|
||||
// bucket, which read as `top` 0.614304 on a unit sine against the 2/pi it must be.
|
||||
// bucket -- which read as `top` 0.614304 on a unit sine against the 2/pi a mean over a half then
|
||||
// gave, and which is now the sums `Top`/`Bottom` fall back to on a window with no histogram, so
|
||||
// a wrong one is a wrong answer waiting for that window.
|
||||
if (change && !fully_outside) return false;
|
||||
acc.sum[e] += k.sum;
|
||||
acc.count[e] += k.count;
|
||||
@@ -219,21 +286,92 @@ bool measure_accumulate_bucket(const MeasureBucket& k,
|
||||
return true;
|
||||
}
|
||||
|
||||
bool measure_level_pct(MeasureType type, const MeasureOptions& options, double& pct) {
|
||||
MeasureFlatLevels measure_flat_levels(const MeasureStats& b, double pct) {
|
||||
MeasureFlatLevels out;
|
||||
const MeasureFlatHist& h = b.flat;
|
||||
const double span = b.hi - b.lo;
|
||||
if (h.folded == 0 || !(h.hi > h.lo) || !(span > 0.0)) return out;
|
||||
// The split, sized exactly as `measure_set_level()` sizes the level the crossing types read, so
|
||||
// "the top" and "the level a period is counted about" are placed by one rule.
|
||||
const double level = b.lo + std::clamp(pct, 0.0, 1.0) * span;
|
||||
const double width = (h.hi - h.lo) / static_cast<double>(MeasureFlatHist::kBins);
|
||||
const double band = kFlatBandShare * span;
|
||||
for (int upper = 1; upper >= 0; --upper) {
|
||||
const auto in_side = [&](std::size_t i) {
|
||||
const double centre = h.lo + (static_cast<double>(i) + 0.5) * width;
|
||||
return (centre > level) == (upper != 0);
|
||||
};
|
||||
// One scan for the densest bin of this side, one for the outermost of the equally dense
|
||||
// ones, one to sum the band about it: 192 iterations of an array of 64, per flat level.
|
||||
std::size_t densest = 0;
|
||||
for (std::size_t i = 0; i < MeasureFlatHist::kBins; ++i) {
|
||||
if (in_side(i) && h.buckets[i] > densest) densest = h.buckets[i];
|
||||
}
|
||||
// A side of the split with no bucket in it has no level: `top` on a window that never reaches
|
||||
// above the split reads `--`, which is exactly what it read before.
|
||||
if (densest == 0) continue;
|
||||
const double floor_buckets = kFlatTieShare * static_cast<double>(densest);
|
||||
std::size_t anchor = 0;
|
||||
bool have_anchor = false;
|
||||
for (std::size_t i = 0; i < MeasureFlatHist::kBins; ++i) {
|
||||
if (!in_side(i) || static_cast<double>(h.buckets[i]) < floor_buckets) continue;
|
||||
// The upper side wants the *last* qualifier and the lower one the first, so a bottom-up scan
|
||||
// is "always overwrite" for one and "only ever write once" for the other.
|
||||
if (upper != 0 || !have_anchor) anchor = i;
|
||||
have_anchor = true;
|
||||
}
|
||||
if (!have_anchor) continue;
|
||||
const double centre = h.lo + (static_cast<double>(anchor) + 0.5) * width;
|
||||
double sum = 0.0;
|
||||
std::size_t count = 0;
|
||||
for (std::size_t i = 0; i < MeasureFlatHist::kBins; ++i) {
|
||||
const double offset = h.lo + (static_cast<double>(i) + 0.5) * width - centre;
|
||||
if (offset < -band || offset > band) continue;
|
||||
sum += h.sum[i];
|
||||
count += h.count[i];
|
||||
}
|
||||
if (count == 0) continue;
|
||||
if (upper != 0) {
|
||||
out.top = sum / static_cast<double>(count);
|
||||
out.top_ok = true;
|
||||
} else {
|
||||
out.bottom = sum / static_cast<double>(count);
|
||||
out.bottom_ok = true;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
int measure_level_pcts(MeasureType type, const MeasureOptions& options, double pct[2]) {
|
||||
const auto share = [](double v) { return std::clamp(v, 0.0, 100.0) / 100.0; };
|
||||
switch (type) {
|
||||
case MeasureType::Top:
|
||||
case MeasureType::Bottom:
|
||||
pct = std::clamp(options.threshold_pct, 0.0, 100.0) / 100.0;
|
||||
return true;
|
||||
case MeasureType::Above:
|
||||
case MeasureType::Below:
|
||||
pct[0] = share(options.threshold_pct);
|
||||
return 1;
|
||||
case MeasureType::Between: {
|
||||
// The band's two edges, clamped and *ordered*: the shares name a lower and an upper edge, so
|
||||
// "between 80 % and 20 %" is the band between 20 % and 80 %. Normalizing rather than refusing
|
||||
// is the honest reading of a band -- the set of samples does not depend on which edge the user
|
||||
// typed first -- and the two accumulators the pair is answered from are keyed on these ordered
|
||||
// values, so the same band is one walk however it was written.
|
||||
const double a = share(options.threshold_pct);
|
||||
const double b = share(options.threshold_pct2);
|
||||
pct[0] = std::min(a, b);
|
||||
pct[1] = std::max(a, b);
|
||||
return 2;
|
||||
}
|
||||
case MeasureType::PulseDuration:
|
||||
case MeasureType::Period:
|
||||
case MeasureType::Frequency:
|
||||
// The *mid* level, for all three. No option here on purpose: a period is the spacing of
|
||||
// one crossing per cycle, and a threshold near either extreme counts noise as crossings.
|
||||
pct = 0.5;
|
||||
return true;
|
||||
pct[0] = 0.5;
|
||||
return 1;
|
||||
default:
|
||||
return false;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,11 +388,14 @@ const char* measure_type_name(MeasureType type) {
|
||||
case MeasureType::PulseDuration: return "pulse duration";
|
||||
case MeasureType::Period: return "period";
|
||||
case MeasureType::Frequency: return "frequency";
|
||||
case MeasureType::Above: return "above";
|
||||
case MeasureType::Below: return "below";
|
||||
case MeasureType::Between: return "between";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
int measure_type_count() { return 10; }
|
||||
int measure_type_count() { return 13; }
|
||||
|
||||
MeasureType measure_type_at(int index) {
|
||||
switch (index) {
|
||||
@@ -267,21 +408,35 @@ MeasureType measure_type_at(int index) {
|
||||
case 6: return MeasureType::Amplitude;
|
||||
case 7: return MeasureType::PulseDuration;
|
||||
case 8: return MeasureType::Frequency;
|
||||
default: return MeasureType::Period;
|
||||
case 9: return MeasureType::Period;
|
||||
// Appended rather than filed beside `Top`/`Bottom`, where they read best in the dropdown:
|
||||
// `layout_store` writes *this* index into the session file, so renumbering would turn a saved
|
||||
// `max` into an `above` on the next start -- silently, and only for users who had one.
|
||||
case 10: return MeasureType::Above;
|
||||
case 11: return MeasureType::Below;
|
||||
default: return MeasureType::Between;
|
||||
}
|
||||
}
|
||||
|
||||
bool measure_uses_threshold(MeasureType type) {
|
||||
return type == MeasureType::Top || type == MeasureType::Bottom ||
|
||||
type == MeasureType::PulseDuration;
|
||||
type == MeasureType::PulseDuration || type == MeasureType::Above ||
|
||||
type == MeasureType::Below || type == MeasureType::Between;
|
||||
}
|
||||
|
||||
bool measure_uses_band(MeasureType type) { return type == MeasureType::Between; }
|
||||
|
||||
bool measure_uses_side(MeasureType type) { return type == MeasureType::PulseDuration; }
|
||||
|
||||
bool measure_uses_noise(MeasureType type) {
|
||||
return type == MeasureType::PulseDuration || type == MeasureType::Period ||
|
||||
type == MeasureType::Frequency;
|
||||
}
|
||||
|
||||
bool measure_can_show_on_plot(MeasureType) { return true; }
|
||||
|
||||
MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level, MeasureType type,
|
||||
const MeasureOptions& options) {
|
||||
MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* const levels[2],
|
||||
MeasureType type, const MeasureOptions& options) {
|
||||
MeasureResult out;
|
||||
if (!b.valid) return out;
|
||||
|
||||
@@ -306,11 +461,11 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level,
|
||||
out.valid = true;
|
||||
return out;
|
||||
}
|
||||
case MeasureType::PeakToPeak:
|
||||
case MeasureType::Amplitude: {
|
||||
// Both come straight out of the window's own extremes, which every type already needs:
|
||||
// the two rulers are the same pair, and the numbers differ by a factor of two.
|
||||
out.primary = type == MeasureType::PeakToPeak ? b.hi - b.lo : 0.5 * (b.hi - b.lo);
|
||||
case MeasureType::PeakToPeak: {
|
||||
// Straight out of the window's own extremes, which every type already needs: this one *is*
|
||||
// the extremes, ripple and overshoot included -- that is the difference between it and
|
||||
// `Amplitude`, which reads the two levels the signal dwells at instead.
|
||||
out.primary = b.hi - b.lo;
|
||||
// A ruler at each extreme: the *span* is the measurement, and two levels are how a
|
||||
// plot states a span without a band between them.
|
||||
out.marks.push_back({true, b.lo});
|
||||
@@ -318,15 +473,88 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level,
|
||||
out.valid = true;
|
||||
return out;
|
||||
}
|
||||
case MeasureType::Amplitude: {
|
||||
// The delta between the two flat levels, so a ringing edge's overshoot is not part of it.
|
||||
// The split is the mid level, fixed rather than an option -- the dialog offers no threshold
|
||||
// for this type -- and the reading is insensitive to it anyway: the band a level is read from
|
||||
// is a *value* band, so any split between the two plateaus picks the same two.
|
||||
const MeasureFlatLevels flat = measure_flat_levels(b, 0.5);
|
||||
if (flat.top_ok && flat.bottom_ok) {
|
||||
out.primary = flat.top - flat.bottom;
|
||||
out.marks.push_back({true, flat.top});
|
||||
out.marks.push_back({true, flat.bottom});
|
||||
out.valid = true;
|
||||
return out;
|
||||
}
|
||||
// No density estimate to separate a plateau from its ringing: a window shorter than a
|
||||
// bucket, or an HF-rejected walk, which cannot use the pyramid at all. The delta then
|
||||
// degenerates to the window's own peak to peak -- the same number when there is nothing to
|
||||
// exclude, and the only honest one when the ripple cannot be told from the level.
|
||||
out.primary = b.hi - b.lo;
|
||||
out.marks.push_back({true, b.lo});
|
||||
out.marks.push_back({true, b.hi});
|
||||
out.valid = true;
|
||||
return out;
|
||||
}
|
||||
case MeasureType::Top:
|
||||
case MeasureType::Bottom: {
|
||||
// The mean of one side of the level, straight out of the second pass' running sums. A flat
|
||||
// window has no range at all, so every sample lands on the "below" side -- which is why
|
||||
// `top` on a signal that never moves reads `--` rather than that one value.
|
||||
if (level == nullptr) return out;
|
||||
const std::size_t s = type == MeasureType::Top ? 1 : 0;
|
||||
if (level->count[s] == 0) return out;
|
||||
out.primary = level->sum[s] / static_cast<double>(level->count[s]);
|
||||
// The flat level of one side of the split: the densest band there, whose samples' exact mean
|
||||
// is the value. The band is a *value* band, so the reading does not follow the window's
|
||||
// alignment, and it excludes both a plateau's ripple and an edge's overshoot -- which is the
|
||||
// whole point, because those are exactly what a mean over a side reports instead.
|
||||
double pct[2] = {0.0, 0.0};
|
||||
if (measure_level_pcts(type, options, pct) == 0) return out;
|
||||
const bool want_top = type == MeasureType::Top;
|
||||
const MeasureFlatLevels flat = measure_flat_levels(b, pct[0]);
|
||||
if (want_top ? flat.top_ok : flat.bottom_ok) {
|
||||
out.primary = want_top ? flat.top : flat.bottom;
|
||||
out.marks.push_back({true, out.primary});
|
||||
out.valid = true;
|
||||
return out;
|
||||
}
|
||||
// No histogram: the mean of one side of the level, straight out of the second pass' running
|
||||
// sums. A window the pyramid cannot summarize is one bucket or two of a zoomed-in view, or
|
||||
// an HF-rejected walk, and on those the split's own means are the honest reading -- and they
|
||||
// are what `Above`/`Below` report on *every* window.
|
||||
const MeasureLevel* lv = levels != nullptr ? levels[0] : nullptr;
|
||||
const std::size_t s = want_top ? 1 : 0;
|
||||
if (lv == nullptr || lv->count[s] == 0) return out;
|
||||
out.primary = lv->sum[s] / static_cast<double>(lv->count[s]);
|
||||
out.marks.push_back({true, out.primary});
|
||||
out.valid = true;
|
||||
return out;
|
||||
}
|
||||
case MeasureType::Above:
|
||||
case MeasureType::Below: {
|
||||
// The plain mean of the samples on one side of the share -- what `Top`/`Bottom` reported
|
||||
// before they became flat levels, and a different question from them: a mean over a side
|
||||
// carries the ripple and the edge's overshoot (0.986496 on the rippled square where the
|
||||
// plateau is 1), which is exactly why the flat-level types exist beside these.
|
||||
const MeasureLevel* lv = levels != nullptr ? levels[0] : nullptr;
|
||||
const std::size_t s = type == MeasureType::Above ? 1 : 0;
|
||||
if (lv == nullptr || lv->count[s] == 0) return out;
|
||||
out.primary = lv->sum[s] / static_cast<double>(lv->count[s]);
|
||||
out.marks.push_back({true, out.primary});
|
||||
out.valid = true;
|
||||
return out;
|
||||
}
|
||||
case MeasureType::Between: {
|
||||
// The band's interior: what lies above the lower edge and *not* above the upper one. Both
|
||||
// accumulators split the same samples the same way -- pass two walks one bucket list and one
|
||||
// filter for the whole window -- so the band is the *difference* of the two upper sides, and
|
||||
// it is exact rather than approximate. It needs no third walk of its own, and the bucket
|
||||
// fast path serves it unchanged: a bucket wholly inside the band is strictly above the lower
|
||||
// level and strictly below the upper, so both accumulators fold it whole.
|
||||
const MeasureLevel* lower = levels != nullptr ? levels[0] : nullptr;
|
||||
const MeasureLevel* upper = levels != nullptr ? levels[1] : nullptr;
|
||||
if (lower == nullptr || upper == nullptr) return out;
|
||||
// Equal counts mean an empty interior: nothing lies in the band, so there is nothing to
|
||||
// average -- the pane reads `--`, as it does for any other type with no samples on the side
|
||||
// it was asked about. `lower` may not count *fewer*, which would say the two accumulators
|
||||
// saw different samples.
|
||||
if (lower->count[1] <= upper->count[1]) return out;
|
||||
out.primary = (lower->sum[1] - upper->sum[1]) /
|
||||
static_cast<double>(lower->count[1] - upper->count[1]);
|
||||
out.marks.push_back({true, out.primary});
|
||||
out.valid = true;
|
||||
return out;
|
||||
@@ -334,18 +562,19 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level,
|
||||
case MeasureType::PulseDuration:
|
||||
case MeasureType::Period:
|
||||
case MeasureType::Frequency: {
|
||||
if (level == nullptr) return out;
|
||||
if (levels == nullptr || levels[0] == nullptr) return out;
|
||||
const MeasureLevel& level = *levels[0];
|
||||
// The pulse is one side of the mid level: its widths run from the edge that enters it to
|
||||
// the edge that leaves it, and a period is the spacing of the edges that *enter* it -- one
|
||||
// per cycle, whatever else the waveform does in between.
|
||||
const std::size_t s = options.above ? 1 : 0;
|
||||
const double enter_span = level->last_enter[s] - level->first_enter[s];
|
||||
const double enter_period = level->enters[s] >= 2
|
||||
? enter_span / static_cast<double>(level->enters[s] - 1)
|
||||
const double enter_span = level.last_enter[s] - level.first_enter[s];
|
||||
const double enter_period = level.enters[s] >= 2
|
||||
? enter_span / static_cast<double>(level.enters[s] - 1)
|
||||
: 0.0;
|
||||
if (type == MeasureType::PulseDuration) {
|
||||
if (level->widths[s] == 0) return out;
|
||||
out.primary = level->width_sum[s] / static_cast<double>(level->widths[s]);
|
||||
if (level.widths[s] == 0) return out;
|
||||
out.primary = level.width_sum[s] / static_cast<double>(level.widths[s]);
|
||||
// Duty as width/period, not as pulse-time over the window: a window that starts or
|
||||
// ends mid-pulse holds partial cycles, and counting only the complete ones against
|
||||
// the whole span would report a duty that depends on where the window happened to
|
||||
@@ -353,15 +582,15 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level,
|
||||
// them -- a window holding one pulse reports the width alone.
|
||||
out.secondary = enter_period > 0.0 ? out.primary / enter_period : 0.0;
|
||||
out.has_secondary = enter_period > 0.0;
|
||||
out.marks.push_back({false, level->first_enter[s]});
|
||||
out.marks.push_back({false, level->first_enter[s] + out.primary});
|
||||
out.marks.push_back({false, level.first_enter[s]});
|
||||
out.marks.push_back({false, level.first_enter[s] + out.primary});
|
||||
out.valid = true;
|
||||
return out;
|
||||
}
|
||||
if (!(enter_period > 0.0)) return out;
|
||||
out.primary = type == MeasureType::Period ? enter_period : 1.0 / enter_period;
|
||||
out.marks.push_back({false, level->first_enter[s]});
|
||||
out.marks.push_back({false, level->first_enter[s] + enter_period});
|
||||
out.marks.push_back({false, level.first_enter[s]});
|
||||
out.marks.push_back({false, level.first_enter[s] + enter_period});
|
||||
out.valid = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
#pragma once
|
||||
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -15,28 +17,58 @@ enum class MeasureType {
|
||||
Min, ///< the smallest visible value
|
||||
Max, ///< the largest visible value
|
||||
/// The largest swing in the window: `max - min`, the two extremes the eye would measure
|
||||
/// with a ruler across the waveform.
|
||||
/// with a ruler across the waveform -- ripple and overshoot included, which is what makes it
|
||||
/// the *extremes* and not a level.
|
||||
PeakToPeak,
|
||||
/// Half the peak-to-peak swing, `(max - min) / 2` -- the amplitude of a symmetric waveform
|
||||
/// about its own mean, which is the figure a datasheet quotes and a signal generator is set
|
||||
/// to. Deliberately not `max(|value|)`: that is a *distance from zero*, which for a signal
|
||||
/// riding on an offset is a different and less useful number.
|
||||
/// The delta between the two **flat** levels, `top - bottom` (a bench scope's `Vamplitude`,
|
||||
/// which is `Vtop - Vbase`). Deliberately *not* the peak-to-peak swing: a band-limited edge
|
||||
/// overshoots and rings, so the extremes of a nominal 2 V square read about 2.36 V, while the
|
||||
/// levels the signal actually dwells at are ±1 and their delta exactly 2. Where the window
|
||||
/// has no density estimate to separate a plateau from its ripple (shorter than one bucket, or
|
||||
/// an HF-rejected walk) it degenerates to the window's own peak to peak, which is the same
|
||||
/// number on a waveform with nothing to exclude. Never `max(|value|)`: that is a *distance
|
||||
/// from zero*, which for a signal riding on an offset is a different and less useful number.
|
||||
Amplitude,
|
||||
Top, ///< the signal's high level (the mean of the samples above the threshold)
|
||||
Bottom, ///< the signal's low level (the mean of the samples below it)
|
||||
/// The signal's high level: the densest band of the window's upper side, whose samples' exact
|
||||
/// mean is the value -- the *flat top*, so a plateau's ripple and an edge's overshoot neither
|
||||
/// define it nor enter it.
|
||||
Top,
|
||||
/// The signal's low level, the same way from the window's lower side.
|
||||
Bottom,
|
||||
PulseDuration, ///< how long the signal spends on one side of the threshold
|
||||
Period, ///< the time between two successive crossings of the threshold
|
||||
Frequency, ///< 1 / period
|
||||
/// The mean of the samples **above** `threshold_pct` -- the split's own upper mean, which is
|
||||
/// what `Top` reported before a flat level replaced it. `Top`/`Bottom` are the level the signal
|
||||
/// *dwells at* (a density question, so a ringing edge does not drag them); these two are the
|
||||
/// plain mean of the samples on one side of the share, ripple and overshoot included, which is
|
||||
/// a *different* question about the same split and the reason the two coexist: on a unit sine
|
||||
/// this reads `0.636620` (2/pi) where `Top` reads `0.957988`, and on the mock's band-limited
|
||||
/// square `0.986496` where its plateau is 1.
|
||||
Above,
|
||||
/// The same from the split's lower side.
|
||||
Below,
|
||||
/// The mean of the samples **between** `threshold_pct` and `threshold_pct2`: the band's
|
||||
/// interior, which neither of the two means above can answer (one of them gives the two sides
|
||||
/// and the other the whole window, because the band's two edges are not one split).
|
||||
Between,
|
||||
};
|
||||
|
||||
/// Type-specific behaviour. Every field is meaningful only for the types named below; the
|
||||
/// others ignore it (an option that does nothing for a type is left out of the dialog
|
||||
/// rather than stored and ignored).
|
||||
struct MeasureOptions {
|
||||
/// `Top`/`Bottom`/`PulseDuration`/`Period`/`Frequency`: where the split is, as a share of
|
||||
/// `Top`/`Bottom`/`PulseDuration`/`Above`/`Below`/`Between`: where the split is, as a share of
|
||||
/// the window's own min..max. The middle by default, which is what a symmetric waveform
|
||||
/// wants; a 3.3 V logic signal with ringing wants it nearer the top.
|
||||
/// wants; a 3.3 V logic signal with ringing wants it nearer the top. For `Above`/`Below` it is
|
||||
/// the level the mean is taken on one side of; for `Between` it is the band's *lower* edge.
|
||||
double threshold_pct = 50.0;
|
||||
/// `Between`: the band's other edge, the same unit. The two are read through
|
||||
/// `measure_level_pcts()`, which clamps both and puts the pair in order, so the band between
|
||||
/// 80 % and 20 % is the band between 20 % and 80 % -- the shares name two *edges* and the order
|
||||
/// they were typed in carries no meaning, which is why a reversed pair is normalized rather
|
||||
/// than refused.
|
||||
double threshold_pct2 = 75.0;
|
||||
/// `PulseDuration`: which side of the threshold is "the pulse". `Period`/`Frequency`
|
||||
/// use it as the edge direction, so one cycle is counted once.
|
||||
bool above = true;
|
||||
@@ -44,6 +76,23 @@ struct MeasureOptions {
|
||||
/// extremes (min/max), the pulse's two edges as vertical markers (pulse duration). The
|
||||
/// markers are readouts -- they follow the window, and the user cannot drag them.
|
||||
bool show_on_plot = false;
|
||||
/// `PulseDuration`/`Period`/`Frequency`: the half-width of the hysteresis band about the threshold, as
|
||||
/// a percentage of the window's own peak-to-peak. A crossing counts only once the signal has passed the
|
||||
/// band, so an edge count is a count of the *waveform's* cycles rather than of its noise.
|
||||
///
|
||||
/// The default is measured rather than chosen: 5 % reads the mock's 0.1 interferer correctly and fails
|
||||
/// at 0.2 (`period=0.075` for a true 0.2), while 10 % holds to an interferer amplitude of 0.3 -- a third
|
||||
/// of the waveform -- and to white noise at sigma=0.05. Above that the band starts eating a real
|
||||
/// waveform's own extremes on anything less square than a sine. 0 reduces the walk exactly to a single
|
||||
/// level.
|
||||
double noise_pct = 10.0;
|
||||
/// Every type: the corner of a one-pole low pass applied to each sample before *either* walk reads it,
|
||||
/// so the extremes, the mean and the crossings all describe the filtered signal. 0 is off. In Hz.
|
||||
///
|
||||
/// It rejects what a band cannot: an impulse large enough to cross the band on its own. It is not free
|
||||
/// -- a filtered window cannot use the LOD pyramid, because a bucket aggregates unfiltered samples --
|
||||
/// so a non-zero corner costs a full raw walk.
|
||||
double hf_fc = 0.0;
|
||||
};
|
||||
|
||||
/// One entry in the measurements pane, and everything needed to recompute it: the signal by
|
||||
@@ -73,6 +122,35 @@ struct MeasureResult {
|
||||
bool valid = false;
|
||||
};
|
||||
|
||||
/// The density estimate behind `Top`/`Bottom`/`Amplitude`: one bin per band of the window's
|
||||
/// value range, over the whole LOD buckets pass one walked.
|
||||
///
|
||||
/// A flat level is a *density* question, not an extreme one: on a band-limited square the
|
||||
/// plateau holds most of the samples while the edge's ringing holds a few percent, so the
|
||||
/// densest band is the level the signal dwells at and the ringing is what a mean over a side
|
||||
/// reports instead. One bucket is one observation at its midpoint -- a bucket is 64 samples
|
||||
/// held as one aggregate, so counting its samples would weight a flat region by nothing at all
|
||||
/// and a ringing one by 64 -- and each bin also carries the exact sum and count of the samples
|
||||
/// its buckets hold, which is what makes the reported level an exact mean rather than a bin
|
||||
/// centre. The bucket list is the one pass one already fetches; no pass reads raw samples for
|
||||
/// this, which is the whole reason it is built from buckets and not from a sample histogram
|
||||
/// (that would be a second O(window) walk).
|
||||
struct MeasureFlatHist {
|
||||
/// Bins across the buckets' own range. The band the level is read from is picked at this
|
||||
/// resolution and is a share of the *window's* peak-to-peak, so the bin count only sets how
|
||||
/// finely the densest band can be located.
|
||||
static constexpr std::size_t kBins = 64;
|
||||
double lo = 0.0; ///< the bin range: the whole buckets' own smallest `lo`..
|
||||
double hi = 0.0; ///< ..and largest `hi`. Empty when nothing was folded.
|
||||
double sum[kBins] = {};
|
||||
std::size_t count[kBins] = {};
|
||||
std::size_t buckets[kBins] = {};
|
||||
/// Whole buckets folded in: 0 means this window has no histogram at all (a window shorter
|
||||
/// than a bucket, or an HF-rejected walk, which cannot use the pyramid), and the flat-level
|
||||
/// types then fall back to their split-relative readings.
|
||||
std::size_t folded = 0;
|
||||
};
|
||||
|
||||
/// The window's own extremes, which every threshold-relative type scales from and which
|
||||
/// several types report directly. Public so a caller holding *several* measurements on one
|
||||
/// signal can pay for the scan once: it is O(visible samples) and a live view at 1 MS/s makes
|
||||
@@ -100,9 +178,44 @@ struct MeasureStats {
|
||||
/// `compute_measure()` reads these, so a caller that skips the resolve reports t = 0.
|
||||
double at_lo = 0.0;
|
||||
double at_hi = 0.0;
|
||||
/// The flat levels' density estimate, folded from the same whole buckets pass one walked.
|
||||
/// The *values* are resolved by `measure_flat_levels()`, which needs the split and the
|
||||
/// window's span and so cannot be done until the walk is over.
|
||||
MeasureFlatHist flat;
|
||||
bool valid = false;
|
||||
};
|
||||
|
||||
/// The HF reject, as a walk carries it: one pole, one state word.
|
||||
///
|
||||
/// `a == 1` is a pass-through and is the default, so an unfiltered walk pays one multiply and no branch,
|
||||
/// and every caller can step it unconditionally. A non-finite sample passes through unchanged and leaves
|
||||
/// the state alone -- a dropout is a real reading, and letting it poison `y` would make every sample
|
||||
/// after it non-finite too.
|
||||
struct MeasureFilter {
|
||||
double a = 1.0;
|
||||
double y = 0.0;
|
||||
bool have = false;
|
||||
|
||||
double step(double v) noexcept {
|
||||
if (!std::isfinite(v)) return v;
|
||||
if (!have) {
|
||||
y = v;
|
||||
have = true;
|
||||
return y;
|
||||
}
|
||||
y += a * (v - y);
|
||||
return y;
|
||||
}
|
||||
void reset() noexcept {
|
||||
y = 0.0;
|
||||
have = false;
|
||||
}
|
||||
};
|
||||
|
||||
/// `a = 1 - exp(-2*pi*fc*dt)`, clamped just under Nyquist so `a` cannot exceed 1. `fc == 0` or `dt == 0`
|
||||
/// leaves it at 1, which is the pass-through. Resets the state.
|
||||
void measure_filter_set(MeasureFilter& f, double fc, double dt);
|
||||
|
||||
/// One chunk of a window's values, in time order, exactly as the rings hold them.
|
||||
///
|
||||
/// The engine is *fed* the window this way rather than handed a materialized copy. A visible
|
||||
@@ -124,6 +237,9 @@ struct MeasureChunk {
|
||||
/// as it arrives, at amortised O(1), so a bucket is 64 samples the measurement walks never have
|
||||
/// to read. A 1.7 M-sample window is 26 k buckets, and only the handful that *straddle* a level
|
||||
/// are opened.
|
||||
///
|
||||
/// A bucket aggregates **unfiltered** samples, so a walk with an HF reject must not use these at all --
|
||||
/// that is what makes a non-zero `hf_fc` cost a full raw walk.
|
||||
struct MeasureBucket {
|
||||
double lo = 0.0;
|
||||
double hi = 0.0;
|
||||
@@ -137,12 +253,19 @@ struct MeasureBucket {
|
||||
/// and the sum and count of the finite samples. Feed the chunks in window order into a zeroed
|
||||
/// `stats` -- the first sample to reach an extreme is the one the mark names, so a later chunk
|
||||
/// that merely ties it must not move it.
|
||||
void measure_scan(const MeasureChunk& chunk, MeasureStats& stats);
|
||||
void measure_scan(const MeasureChunk& chunk, MeasureStats& stats, MeasureFilter& filter);
|
||||
|
||||
/// Pass one from whole buckets: the same figures, exactly, for 1/64 of the reads. The extreme's
|
||||
/// position comes out as a *bucket* (`lo_search`/`hi_search` non-zero), which one raw
|
||||
/// `measure_scan()` of that bucket then pins. Interleave with `measure_scan()` in window order.
|
||||
void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, MeasureStats& stats);
|
||||
///
|
||||
/// `flat_hist` folds the same list into `stats.flat` for the flat-level types, in a second tight
|
||||
/// loop over the array (the bin range is the buckets' own, which the first loop is what
|
||||
/// discovers). That loop is O(buckets) and reads no sample, so the `measure cost:` line's `raw`
|
||||
/// is unchanged by it -- the cost is buckets, which is the budget the pane is on. It is the
|
||||
/// caller's because only the caller knows whether any measurement reads a flat level.
|
||||
void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, MeasureStats& stats,
|
||||
bool flat_hist);
|
||||
|
||||
/// Everything the threshold-relative types need from a second walk, at one level: the sum and
|
||||
/// count of the samples on either side of it, and its crossings reduced to running figures -- the
|
||||
@@ -162,12 +285,19 @@ struct MeasureLevel {
|
||||
/// `period=0.025622 freq=39.029` for a true 0.2 s / 5 Hz. With the band, `period=0.200188`.
|
||||
///
|
||||
/// It moves the *edge decision* only. The `sum`/`count` split below stays at the bare `level`,
|
||||
/// because that is what `Top`/`Bottom` are -- the mean of the samples above/below the threshold --
|
||||
/// and a band there would change a documented exact reading (`top` on a unit sine is 2/pi).
|
||||
/// `measure_set_level()` is what sizes it; zero reduces the walk exactly to a single level.
|
||||
/// so a band can never change it -- the split is the split, and a sample's side of it is a
|
||||
/// fact rather than a threshold decision. `measure_set_level()` is what sizes it; zero reduces
|
||||
/// the walk exactly to a single level.
|
||||
double hysteresis = 0.0;
|
||||
/// Index 0 is the side below the level (a sample exactly *at* it counts here, which is what
|
||||
/// `bottom` has always reported), index 1 the side above it.
|
||||
/// The samples either side of `level`, exactly: the reading of `Above`/`Below` is one of these
|
||||
/// two means, `Between`'s is the difference of two *levels'* upper sides, and `Top`/`Bottom` fall
|
||||
/// back to them on a window with no bucket histogram to read a flat level from (shorter than one
|
||||
/// bucket, or an HF-rejected walk). They are folded on every window -- pass two walks it for the
|
||||
/// crossing types anyway -- but a window that has a histogram is read from that instead, because
|
||||
/// a mean over a side reports the ripple and the overshoot rather than the level the signal
|
||||
/// dwells at.
|
||||
/// Index 0 is the side below the level (a sample exactly *at* it counts here), index 1 the
|
||||
/// side above it.
|
||||
double sum[2] = {0.0, 0.0};
|
||||
std::size_t count[2] = {0, 0};
|
||||
double first_enter[2] = {0.0, 0.0};
|
||||
@@ -190,14 +320,17 @@ struct MeasureLevel {
|
||||
/// nearer extreme. The clamp is what keeps a threshold near an extreme usable -- `PulseDuration`
|
||||
/// lets the user put one at 90 %, and a fixed band there puts the upper threshold above every
|
||||
/// sample in the window, which reads as nothing measured at all rather than as a noisy reading.
|
||||
void measure_set_level(MeasureLevel& level, const MeasureStats& stats, double pct);
|
||||
/// `noise_frac` is `MeasureOptions::noise_pct / 100`. It is the caller's because the band is a
|
||||
/// per-measurement setting and this is called once per *level*, which several measurements may share; the
|
||||
/// clamp to half the room to the nearer extreme stays here, so every caller gets it.
|
||||
void measure_set_level(MeasureLevel& level, const MeasureStats& stats, double pct, double noise_frac);
|
||||
|
||||
/// Pass two: one chunk into every level at once. The usual case is a single level -- every
|
||||
/// crossing type reads the mid level and `Top`/`Bottom` default to it too -- so a pane full of
|
||||
/// measurements on one signal costs two walks, not two per measurement.
|
||||
void measure_accumulate(const MeasureChunk& chunk,
|
||||
const std::function<double(std::size_t)>& time_of, MeasureLevel* levels,
|
||||
std::size_t level_count);
|
||||
std::size_t level_count, MeasureFilter& filter);
|
||||
|
||||
/// Pass two from one whole bucket, into one level. True when the bucket answered it: a bucket
|
||||
/// every one of whose samples is on the same side of the level holds no crossing, so its sums
|
||||
@@ -216,16 +349,42 @@ bool measure_accumulate_bucket(const MeasureBucket& bucket,
|
||||
const std::function<double(std::size_t)>& time_of,
|
||||
MeasureLevel& level);
|
||||
|
||||
/// The level a type reads, as a share of the window's own min..max, or false when it reads none
|
||||
/// (the pure statistics, answered from `MeasureStats` alone). `Top`/`Bottom` read the
|
||||
/// measurement's own threshold; the crossing types read the mid level, deliberately -- see
|
||||
/// `measure_uses_threshold()`.
|
||||
bool measure_level_pct(MeasureType type, const MeasureOptions& options, double& pct);
|
||||
/// The levels a type reads, as shares of the window's own min..max, **lower first**, or 0 when it
|
||||
/// reads none (the pure statistics, answered from `MeasureStats` alone). `pct` must hold two: a
|
||||
/// level type fills `pct[0]` and answers 1, `Between` fills both and answers 2.
|
||||
///
|
||||
/// `Top`/`Bottom`/`Above`/`Below` read the measurement's own threshold; the crossing types read the
|
||||
/// mid level, deliberately -- see `measure_uses_threshold()`. `Amplitude` reads no level either: its
|
||||
/// value is the delta between the two flat levels, which the histogram answers, and the split's two
|
||||
/// means are only a fallback it takes from the window's own extremes -- see `compute_measure()`.
|
||||
///
|
||||
/// This is the one place `Between`'s pair is clamped and ordered, so the pane's dedup key, the
|
||||
/// engine's two accumulators and the dialog all describe the same band whatever was typed.
|
||||
int measure_level_pcts(MeasureType type, const MeasureOptions& options, double pct[2]);
|
||||
|
||||
/// The engine: one measurement's answer, O(1), from the two passes' results. `level` is the
|
||||
/// accumulator for `measure_level_pct(type, options)` and may be null for the types that read
|
||||
/// none. The function reads nothing else, which is what keeps the answer independent of the plot.
|
||||
MeasureResult compute_measure(const MeasureStats& stats, const MeasureLevel* level,
|
||||
/// The window's two flat levels, from the bucket histogram in `stats.flat`. `top` is the densest
|
||||
/// band of the bins *above* `lo + pct * (hi - lo)`, `bottom` the same below it -- for a
|
||||
/// crossing type's mid level that is the two plateaus of a pulse train, and for `Top`/`Bottom`'s
|
||||
/// own threshold it is "which side is the level I want". Each side is false when it holds no
|
||||
/// bucket, and both are false when the window has no histogram at all.
|
||||
///
|
||||
/// Two things this does not do. It does not read a sample: every figure comes from buckets pass
|
||||
/// one already fetched. And it does not read the window raw, which is why the answer is
|
||||
/// independent of how the window happens to be aligned -- the band is a *value* band, so the
|
||||
/// level it reports does not move when the window does.
|
||||
struct MeasureFlatLevels {
|
||||
double top = 0.0;
|
||||
bool top_ok = false;
|
||||
double bottom = 0.0;
|
||||
bool bottom_ok = false;
|
||||
};
|
||||
MeasureFlatLevels measure_flat_levels(const MeasureStats& stats, double pct);
|
||||
|
||||
/// The engine: one measurement's answer, O(1), from the two passes' results. `levels` is the
|
||||
/// accumulators `measure_level_pcts(type, options)` asked for, in the same order -- `nullptr` for
|
||||
/// the types that read none, and either entry may be null when the caller could not resolve it.
|
||||
/// The function reads nothing else, which is what keeps the answer independent of the plot.
|
||||
MeasureResult compute_measure(const MeasureStats& stats, const MeasureLevel* const levels[2],
|
||||
MeasureType type, const MeasureOptions& options);
|
||||
|
||||
/// A measurement type's name, as the pane's row and the dialog's dropdown spell it.
|
||||
@@ -238,10 +397,59 @@ MeasureType measure_type_at(int index);
|
||||
/// spacing of the crossings of the *mid* level, one rising edge per cycle, and offering a
|
||||
/// threshold there only invites a setting that makes the count wrong.
|
||||
bool measure_uses_threshold(MeasureType type);
|
||||
/// Whether a type reads a *pair* of shares, so the dialog shows its second share only then: the
|
||||
/// two edges of a band are one measurement's two parameters, and one field of the two is a
|
||||
/// measurement of a different band than the user asked for.
|
||||
bool measure_uses_band(MeasureType type);
|
||||
/// Whether a type uses the `above` side (only `PulseDuration`, where it says which half of
|
||||
/// the waveform is the pulse).
|
||||
bool measure_uses_side(MeasureType type);
|
||||
/// Whether a type reads the hysteresis band, so the dialog can show the field only then. The band moves
|
||||
/// the *edge* decision and nothing else, so it is exactly the crossing types: `Top`/`Bottom` read a flat
|
||||
/// level out of the bucket histogram, which no band touches.
|
||||
bool measure_uses_noise(MeasureType type);
|
||||
/// Whether a type draws its result on the plot when asked.
|
||||
bool measure_can_show_on_plot(MeasureType type);
|
||||
|
||||
/// Running statistics over a measurement's successive results.
|
||||
///
|
||||
/// One observation per *recompute* of the measurements pane, never one per sample: a
|
||||
/// measurement is a figure derived from a whole window, and counting its samples would
|
||||
/// describe the window rather than the measurement's stability. `n` is therefore a count of
|
||||
/// passes, which is what makes the readout comparable with a bench scope's.
|
||||
///
|
||||
/// The mean and spread are Welford's recurrence rather than running sums of `v` and `v*v`:
|
||||
/// the results are absolute signal values, so a channel sitting at a large offset with a
|
||||
/// small ripple -- the case the readout is *for* -- cancels to nothing in a naive
|
||||
/// sum-of-squares long before the ripple is visible.
|
||||
struct MeasureStatsAccum {
|
||||
std::uint64_t n = 0;
|
||||
double mean = 0.0; ///< Welford's running mean.
|
||||
double m2 = 0.0; ///< Welford's sum of squared deviations from the running mean.
|
||||
double lo = 0.0;
|
||||
double hi = 0.0;
|
||||
|
||||
void add(double v) noexcept {
|
||||
if (n == 0) {
|
||||
lo = v;
|
||||
hi = v;
|
||||
} else {
|
||||
if (v < lo) lo = v;
|
||||
if (v > hi) hi = v;
|
||||
}
|
||||
++n;
|
||||
const double delta = v - mean;
|
||||
mean += delta / static_cast<double>(n);
|
||||
m2 += delta * (v - mean);
|
||||
}
|
||||
|
||||
void reset() noexcept { *this = MeasureStatsAccum{}; }
|
||||
|
||||
/// The sample standard deviation (n-1), and 0 rather than NaN for fewer than two
|
||||
/// observations: the readout is a figure in a label, and a single pass has no spread.
|
||||
double sd() const noexcept {
|
||||
return n < 2 ? 0.0 : std::sqrt(m2 / static_cast<double>(n - 1));
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -43,7 +43,7 @@ constexpr std::uint64_t kMathCostSample = 1024;
|
||||
} // namespace
|
||||
|
||||
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
|
||||
double target_span_s) {
|
||||
double target_span_s, bool with_abs_time) {
|
||||
Partition p;
|
||||
const std::size_t n = channel_dt.size();
|
||||
p.stride.assign(n, 1);
|
||||
@@ -69,12 +69,14 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
|
||||
// affects every channel equally and goes through the ingest filter.
|
||||
for (std::size_t i = 0; i < n; ++i) p.stride[i] = 1;
|
||||
|
||||
p.abs_time = with_abs_time;
|
||||
|
||||
const u64 budget_bytes = static_cast<u64>(std::max(1.0, buffer_bytes));
|
||||
// Weighted in *bytes*, not in slots: the shared clock is f64 and the values are f32
|
||||
// (see `BufferStore::time()`), so a time slot costs twice a value sample and the two
|
||||
// slices are not interchangeable. Counting both as one f32 is what would make
|
||||
// `--buffer-mb` quietly understate the time ring by its own size.
|
||||
double weight = static_cast<double>(sizeof(f64)); // the time slice itself
|
||||
double weight = static_cast<double>(sizeof(f64)) * (with_abs_time ? 2.0 : 1.0); // the time slice(s)
|
||||
for (u64 s : p.stride) weight += static_cast<double>(sizeof(f32)) / static_cast<double>(s);
|
||||
|
||||
const u64 slots = static_cast<u64>(static_cast<double>(budget_bytes) / weight);
|
||||
@@ -99,7 +101,7 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
|
||||
return std::max<u64>(pow2_floor(ticks / stride), 1);
|
||||
};
|
||||
const auto bytes_for = [&](u64 ticks) {
|
||||
u64 total = ticks * sizeof(f64);
|
||||
u64 total = ticks * sizeof(f64) * (with_abs_time ? 2u : 1u);
|
||||
for (u64 s : p.stride) total += capacity_for(ticks, s) * sizeof(f32);
|
||||
return total;
|
||||
};
|
||||
@@ -116,8 +118,11 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
|
||||
// Only now, with the ring as long as the budget can buy, decimate the shared clock
|
||||
// enough for the requested window to fit in it: deciding this earlier would over-
|
||||
// decimate by whatever factor the growth loop was about to add. A power of two keeps
|
||||
// the ring capacities (themselves powers of two) in step and keeps `tick % period`
|
||||
// cheap. The cap stops a nonsense request -- a window far past `--max-window` -- from
|
||||
// the ring capacities (themselves powers of two) in step and lets the producer's group
|
||||
// test be a mask (`Acquisition::acc_mask_`) rather than a modulo -- a modulo on a stride
|
||||
// this function chose at runtime is a real 64-bit division, which the test *was* paying
|
||||
// once per channel per wire sample.
|
||||
// The cap stops a nonsense request -- a window far past `--max-window` -- from
|
||||
// spinning here; at 2^20 the stored rate is six decades below the wire rate and the
|
||||
// picture is averages of averages anyway.
|
||||
u64 m = 1;
|
||||
@@ -139,6 +144,11 @@ BufferStore::BufferStore(const Partition& part, f64 span_s)
|
||||
: part_(part),
|
||||
time_(std::make_shared<plot::data::RingBuffer<f64>>(part.time_capacity)) {
|
||||
(void)span_s;
|
||||
// Same capacity as the clock it parallels: the two are written on the same slots, so a
|
||||
// smaller ring would make an export's stamps run out before its values do.
|
||||
if (part.abs_time) {
|
||||
abs_time_ = std::make_shared<plot::data::RingBuffer<f64>>(part.time_capacity);
|
||||
}
|
||||
const std::size_t n = part.value_capacity.size();
|
||||
values_.reserve(n);
|
||||
channels_.reserve(n);
|
||||
@@ -153,6 +163,16 @@ BufferStore::BufferStore(const Partition& part, f64 span_s)
|
||||
}
|
||||
}
|
||||
|
||||
void BufferStore::reset() noexcept {
|
||||
time_->clear();
|
||||
if (abs_time_) abs_time_->clear();
|
||||
for (auto& v : values_) v->clear();
|
||||
for (auto& c : channels_) {
|
||||
c->invalidate();
|
||||
c->set_epoch(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
void BufferStore::acquire_all() {
|
||||
for (auto& c : channels_) c->acquire();
|
||||
}
|
||||
@@ -163,6 +183,35 @@ ScopeModel::ScopeModel(const Config& cfg) : cfg_(cfg) {
|
||||
trigger_.holdoff_s = cfg.trigger_holdoff_s;
|
||||
}
|
||||
|
||||
namespace {
|
||||
/// True when the incoming CONFIG names exactly the stream the model already has: same
|
||||
/// signals, same shapes, rates, ranges and time modes. A reconnect re-sends the CONFIG,
|
||||
/// and EMI makes that happen at the worst possible moment; the buffer, its history, the
|
||||
/// holes, the trigger's held event and every measurement are all still valid and must
|
||||
/// survive the reconnect untouched. Only a *different* stream is worth a re-plan, and a
|
||||
/// re-plan is what drops history (see `rebuild_locked`).
|
||||
bool same_stream_signals(const std::vector<SignalInfo>& incoming,
|
||||
const std::vector<SignalInfo>& current) {
|
||||
// `current` is the stream's signals with the math channels' synthetic entries appended,
|
||||
// so compare the incoming list against current's prefix, skipping nothing on the left.
|
||||
std::size_t n_stream = 0;
|
||||
while (n_stream < current.size() && !current[n_stream].math) ++n_stream;
|
||||
if (incoming.size() != n_stream) return false;
|
||||
for (std::size_t i = 0; i < incoming.size(); ++i) {
|
||||
const SignalInfo& a = incoming[i];
|
||||
const SignalInfo& b = current[i];
|
||||
if (a.name != b.name || a.unit != b.unit || a.type_code != b.type_code ||
|
||||
a.quant_type != b.quant_type || a.rows != b.rows || a.cols != b.cols ||
|
||||
a.range_min != b.range_min || a.range_max != b.range_max ||
|
||||
a.time_mode != b.time_mode || a.sampling_rate != b.sampling_rate ||
|
||||
a.time_signal_idx != b.time_signal_idx) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
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. The math
|
||||
@@ -177,6 +226,14 @@ void ScopeModel::set_signals(std::vector<SignalInfo> signals) {
|
||||
signals.erase(std::remove_if(signals.begin(), signals.end(),
|
||||
[](const SignalInfo& s) { return s.math; }),
|
||||
signals.end());
|
||||
if (!signals_.empty() && same_stream_signals(signals, signals_)) {
|
||||
// A reconnect's identical CONFIG: the honest recovery is *nothing* -- the ring keeps
|
||||
// its history, the trigger keeps its state and its held event, and the outage shows
|
||||
// as the gap in the data that it was.
|
||||
std::fprintf(stderr, "[udpscope] CONFIG unchanged: keeping the buffer and its history\n");
|
||||
std::fflush(stderr);
|
||||
return;
|
||||
}
|
||||
signals_ = std::move(signals);
|
||||
enabled_.assign(signals_.size(), false);
|
||||
for (std::size_t i = 0; i < signals_.size(); ++i) {
|
||||
@@ -213,6 +270,65 @@ void ScopeModel::append_math_signals_locked() {
|
||||
|
||||
void ScopeModel::set_math_channels(std::vector<MathChannel> channels) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
// An identical list is not a change. The rebuild below replaces the buffer and drops its history --
|
||||
// which a file view then has to re-read from disk -- and `apply_math_channels()` runs this on every
|
||||
// session start, where a session with no graphs hands over an empty list against a model that has
|
||||
// none either. Comparing the graphs is what makes that call free.
|
||||
if (channels == math_) return;
|
||||
// An edit of an existing graph -- the same channels, names and enables -- does not move the *plan*, which
|
||||
// is a partition over the channels. Nothing has to be rebuilt, and every other channel keeps its history:
|
||||
// only the edited graphs, and the channels that read them, are recomputed.
|
||||
bool same_set = channels.size() == math_.size();
|
||||
for (std::size_t i = 0; same_set && i < channels.size(); ++i) {
|
||||
same_set = channels[i].name == math_[i].name && channels[i].enabled == math_[i].enabled;
|
||||
}
|
||||
if (same_set) {
|
||||
const auto math_index_of = [this](const std::string& name) -> int {
|
||||
for (std::size_t k = 0; k < math_.size(); ++k) {
|
||||
if (math_[k].name == name) return static_cast<int>(k);
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
std::vector<bool> affected(math_.size(), false);
|
||||
std::vector<bool> edited(math_.size(), false);
|
||||
for (std::size_t i = 0; i < channels.size(); ++i) {
|
||||
if (!(channels[i] == math_[i])) {
|
||||
affected[i] = true;
|
||||
edited[i] = true;
|
||||
}
|
||||
}
|
||||
// A channel's output is another's *input*, so whatever names a changed channel is recomputed too -- and
|
||||
// nothing else is touched at all.
|
||||
for (bool moved = true; moved;) {
|
||||
moved = false;
|
||||
for (std::size_t i = 0; i < channels.size(); ++i) {
|
||||
if (!affected[i]) continue;
|
||||
for (const MathNode& node : channels[i].nodes) {
|
||||
if (node.op != MathOp::Source) continue;
|
||||
const int k = math_index_of(node.source);
|
||||
if (k >= 0 && !affected[static_cast<std::size_t>(k)]) {
|
||||
affected[static_cast<std::size_t>(k)] = true;
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
math_ = std::move(channels);
|
||||
const auto index_of = [this](const std::string& name) -> int {
|
||||
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
||||
if (channels_[c].name == name) return static_cast<int>(c);
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
// Every affected channel is prepared again: a runtime carried over from the old graph is history for a
|
||||
// filter nobody asked for, and one whose *inputs* changed is history for inputs it no longer has.
|
||||
for (std::size_t i = 0; i < math_.size(); ++i) {
|
||||
if (affected[i]) math_prepare(math_[i], math_rt_[i], part_.slot_dt(), index_of);
|
||||
}
|
||||
recompute_math_locked(affected);
|
||||
generation_.fetch_add(1, std::memory_order_release);
|
||||
return;
|
||||
}
|
||||
math_ = std::move(channels);
|
||||
// Lift the previous synthetic entries out of the signal list; `append_math_signals_locked()`
|
||||
// puts the current ones back, so an edit is the same kind of structural rebuild as a CONFIG.
|
||||
@@ -250,7 +366,38 @@ void ScopeModel::prepare_math_locked() {
|
||||
}
|
||||
}
|
||||
|
||||
void ScopeModel::push_loaded_slot(const std::vector<f32>& values, f64 t, f64 abs_t) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!store_) return;
|
||||
BufferStore& store = *store_;
|
||||
const std::size_t channels = channels_.size();
|
||||
std::vector<f32> slot(channels, 0.0f);
|
||||
for (std::size_t i = 0; i < channels; ++i) {
|
||||
// A derived channel has no column of its own: `push_math_sample()` below fills it, and pushing the
|
||||
// file's value here as well would put *two* samples in its ring per slot -- a trace at double rate.
|
||||
// The same rule the packet path states in `ChannelInfo::math`: the ingest must skip it.
|
||||
if (channels_[i].math) continue;
|
||||
const f32 v = i < values.size() ? values[i] : 0.0f;
|
||||
store.values(i).push(v);
|
||||
slot[i] = v;
|
||||
}
|
||||
store.time().push(t);
|
||||
if (auto* abs = store.abs_time()) abs->push(abs_t);
|
||||
push_math_sample(slot);
|
||||
}
|
||||
|
||||
std::uint64_t ScopeModel::stored_slots() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return store_ ? store_->time().size() : 0;
|
||||
}
|
||||
|
||||
void ScopeModel::push_math_sample(const std::vector<f32>& sample) {
|
||||
// The producer's entry: every derived channel, and timed (see the cost sampling below).
|
||||
math_apply_locked(sample, nullptr, true);
|
||||
}
|
||||
|
||||
void ScopeModel::math_apply_locked(const std::vector<f32>& sample, const std::vector<bool>* write_mask,
|
||||
bool timed) {
|
||||
// The common case is no derived channels at all, and this runs once per *stored slot* -- so the
|
||||
// early-out is what keeps a stream with no math from paying for the feature.
|
||||
if (math_.empty()) return;
|
||||
@@ -259,9 +406,14 @@ void ScopeModel::push_math_sample(const std::vector<f32>& sample) {
|
||||
// slot is itself able to drop packets, and timing one slot at a time charges it a whole clock
|
||||
// read. A run amortises both: one pair of reads per 64 measured slots, one pair per 65536 slots
|
||||
// overall, and the figure converges over the millions this counter sees.
|
||||
const std::uint64_t slot = math_slot_count_++;
|
||||
// The sampling counts *producer* slots: a recompute must not inflate `math_slots_`, which the dump
|
||||
// reports as the run's own cadence.
|
||||
std::uint64_t slot = 0;
|
||||
if (timed) {
|
||||
slot = math_slot_count_++;
|
||||
math_slots_.store(math_slot_count_, std::memory_order_relaxed);
|
||||
const bool timed = slot % kMathCostSample == 0;
|
||||
timed = slot % kMathCostSample == 0;
|
||||
}
|
||||
if (timed) math_cost_t0_ = now_ns();
|
||||
// Values for this slot, extended as the derived ones are computed: a graph may name another math
|
||||
// channel, and nodes are evaluated in list order, so by the time one is asked for it is there.
|
||||
@@ -274,7 +426,11 @@ void ScopeModel::push_math_sample(const std::vector<f32>& sample) {
|
||||
if (channel < 0 || m >= math_rt_.size()) continue;
|
||||
const double out =
|
||||
math_step(math_[m], math_rt_[m], math_values_.data(), math_values_.size());
|
||||
// A recompute computes every channel -- the graphs are a list walked in order, so a channel's inputs
|
||||
// have to be there -- but only the affected ones take the sample.
|
||||
if (write_mask == nullptr || (*write_mask)[m]) {
|
||||
live().values(static_cast<std::size_t>(channel)).push(static_cast<f32>(out));
|
||||
}
|
||||
if (static_cast<std::size_t>(channel) < math_values_.size()) {
|
||||
math_values_[static_cast<std::size_t>(channel)] = static_cast<f32>(out);
|
||||
}
|
||||
@@ -287,6 +443,48 @@ void ScopeModel::push_math_sample(const std::vector<f32>& sample) {
|
||||
}
|
||||
}
|
||||
|
||||
void ScopeModel::recompute_math_locked(const std::vector<bool>& affected) {
|
||||
if (!store_ || math_.empty()) return;
|
||||
BufferStore& store = *store_;
|
||||
const std::size_t slots = static_cast<std::size_t>(store.time().size());
|
||||
if (slots == 0) return;
|
||||
|
||||
std::vector<bool> derived(channels_.size(), false);
|
||||
for (std::size_t m = 0; m < math_.size(); ++m) {
|
||||
const int channel = m < math_channel_.size() ? math_channel_[m] : -1;
|
||||
if (channel < 0) continue;
|
||||
derived[static_cast<std::size_t>(channel)] = true;
|
||||
// Only the affected channels start again. The rest keep their samples *and* their runtimes: their graphs
|
||||
// are untouched, so the state they hold is the answer to the inputs they have always had.
|
||||
if (affected[m]) store.values(static_cast<std::size_t>(channel)).clear();
|
||||
}
|
||||
|
||||
// One bulk copy per channel per chunk, through the ring's own reader. A derived channel is fed a zero:
|
||||
// `math_apply_locked()` overwrites its value as it walks the list in order, and reading its half-rebuilt
|
||||
// ring here would read out of range.
|
||||
constexpr std::size_t kChunk = 1u << 14;
|
||||
const std::size_t base = static_cast<std::size_t>(store.time().base());
|
||||
std::vector<std::vector<f32>> inputs(channels_.size());
|
||||
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
||||
if (!derived[c]) inputs[c].resize(kChunk);
|
||||
}
|
||||
std::vector<f32> slot(channels_.size(), 0.0f);
|
||||
for (std::size_t start = 0; start < slots; start += kChunk) {
|
||||
const std::size_t n = std::min(kChunk, slots - start);
|
||||
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
||||
if (derived[c]) continue;
|
||||
store.values(c).copy_from(base + start, n, inputs[c].data());
|
||||
}
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
||||
slot[c] = derived[c] ? 0.0f : inputs[c][i];
|
||||
}
|
||||
math_apply_locked(slot, &affected, /*timed=*/false);
|
||||
}
|
||||
}
|
||||
// The pyramid is the consumer's and the tick publishes it; the samples are written here.
|
||||
}
|
||||
|
||||
void ScopeModel::rebuild_channels() {
|
||||
channels_.clear();
|
||||
// Which channel each math channel ended up with, by name: a disabled one has none, and the
|
||||
@@ -329,6 +527,15 @@ void ScopeModel::rebuild_channels() {
|
||||
}
|
||||
|
||||
void ScopeModel::rebuild_locked() {
|
||||
// The store is replaced below, so every hole's `slot_begin`/`tick_begin`/`x_begin` describes a
|
||||
// buffer that no longer exists: the new store's base is 0, so `forget_holes_before()` cannot
|
||||
// retire them and they would shade a band over the wrong x until the table pushed them out.
|
||||
// Dropping them is the honest answer -- the samples they reported absent are gone with the
|
||||
// history. `++plan_generation_` is what tells the producer the same thing about `tick_`, whose
|
||||
// relation to `produced()` is what addresses a backfill (see `Acquisition::on_data`).
|
||||
holes_.clear();
|
||||
++plan_generation_;
|
||||
|
||||
std::vector<double> dts;
|
||||
dts.reserve(channels_.size());
|
||||
for (const ChannelInfo& c : channels_) dts.push_back(c.dt);
|
||||
@@ -339,9 +546,32 @@ void ScopeModel::rebuild_locked() {
|
||||
return;
|
||||
}
|
||||
|
||||
part_ = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0,
|
||||
target_span_.load(std::memory_order_relaxed));
|
||||
const Partition want = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0,
|
||||
target_span_.load(std::memory_order_relaxed),
|
||||
cfg_.relative_time);
|
||||
// A plan that describes the storage already allocated is cleared, not rebuilt. Allocating
|
||||
// a store is what costs -- its rings are value-initialised, so it touches the whole
|
||||
// `--buffer-mb` -- and most structural changes do not move the plan at all: a calibration
|
||||
// keeps the channel set, and so does leaving an event for the live view. Startup asked for
|
||||
// sixteen of these and eight of them planned the same buffer twice. Clearing has the same
|
||||
// meaning (`BufferStore::reset()`): the history is gone either way, which is why
|
||||
// `plan_generation_` bumps above regardless.
|
||||
if (store_ && part_.same_storage_as(want)) {
|
||||
part_ = want;
|
||||
store_->reset();
|
||||
} else {
|
||||
part_ = want;
|
||||
const std::uint64_t t0 = now_ns();
|
||||
store_ = std::make_unique<BufferStore>(part_, part_.span_s);
|
||||
// Named, with what it cost: this is the one operation in the application whose duration a
|
||||
// user can see with no instrument, because it blocks the main thread for the time it takes
|
||||
// to touch the whole buffer -- about 300 ms per GB. A startup or a mode change that reads
|
||||
// more than one of these lines is a re-plan that should have been coalesced, and that is
|
||||
// the defect the line exists to name (it was fifteen of them at startup).
|
||||
std::fprintf(stderr, "[udpscope] buffer allocated: %zu channels, %.3g s span, %.0f ms\n",
|
||||
channels_.size(), part_.span_s, double(now_ns() - t0) / 1.0e6);
|
||||
std::fflush(stderr);
|
||||
}
|
||||
|
||||
if (trigger_.channel >= static_cast<int>(channels_.size())) trigger_.channel = -1;
|
||||
prepare_math_locked();
|
||||
@@ -358,7 +588,7 @@ bool ScopeModel::set_target_span(double seconds) {
|
||||
std::vector<double> dts;
|
||||
dts.reserve(channels_.size());
|
||||
for (const ChannelInfo& c : channels_) dts.push_back(c.dt);
|
||||
const Partition want = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0, seconds);
|
||||
const Partition want = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0, seconds, cfg_.relative_time);
|
||||
if (want.time_stride == part_.time_stride) return false;
|
||||
std::fprintf(stderr,
|
||||
"[udpscope] window %.4f s -> %.4f s: decimation %llu -> %llu, stored rate "
|
||||
@@ -393,9 +623,8 @@ 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;
|
||||
bool ScopeModel::enable_locked(std::size_t signal, bool on) {
|
||||
if (signal >= enabled_.size() || enabled_[signal] == on) return false;
|
||||
enabled_[signal] = on;
|
||||
// A math channel's synthetic signal is enabled *here* -- it is the panel's switch -- while
|
||||
// `append_math_signals_locked()` re-states `enabled_` from `math_` on the next edit. The two have
|
||||
@@ -406,9 +635,14 @@ void ScopeModel::set_signal_enabled(std::size_t signal, bool on) {
|
||||
if (m.name == signals_[signal].name) m.enabled = on;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void ScopeModel::rebuild_enabled_locked() {
|
||||
// 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).
|
||||
// then waits, and comes back when the signal is re-enabled). Read before
|
||||
// `rebuild_channels()`, which is what renumbers.
|
||||
const int old_trigger = trigger_.channel;
|
||||
const std::string trigger_name =
|
||||
(old_trigger >= 0 && old_trigger < static_cast<int>(channels_.size()))
|
||||
@@ -425,6 +659,31 @@ void ScopeModel::set_signal_enabled(std::size_t signal, bool on) {
|
||||
generation_.fetch_add(1, std::memory_order_release);
|
||||
}
|
||||
|
||||
void ScopeModel::set_signal_enabled(std::size_t signal, bool on) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!enable_locked(signal, on)) return;
|
||||
rebuild_enabled_locked();
|
||||
}
|
||||
|
||||
void ScopeModel::set_signals_enabled(const std::vector<std::size_t>& signals, bool on) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
bool any = false;
|
||||
for (const std::size_t s : signals) {
|
||||
if (enable_locked(s, on)) any = true;
|
||||
}
|
||||
// Nothing moved: no re-plan, and in particular no `generation_` bump -- a restored session
|
||||
// re-enables what is already on, and a bump there would make the UI rebind for nothing.
|
||||
if (!any) return;
|
||||
rebuild_enabled_locked();
|
||||
}
|
||||
|
||||
std::pair<double, double> ScopeModel::calibration(std::size_t signal) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (signal >= signals_.size()) return {1.0, 0.0};
|
||||
const auto it = calibration_.find(signals_[signal].name);
|
||||
return it != calibration_.end() ? it->second : std::pair<double, double>{1.0, 0.0};
|
||||
}
|
||||
|
||||
void ScopeModel::set_calibration(std::size_t signal, double gain, double offset) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (signal < signals_.size()) calibration_[signals_[signal].name] = {gain, offset};
|
||||
@@ -450,6 +709,13 @@ double ScopeModel::channel_dt(std::size_t channel) const {
|
||||
BufferStore& ScopeModel::live() { return *store_; }
|
||||
|
||||
void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& last) {
|
||||
// There may be **no store at all**: `rebuild_locked()` resets it for a stream with no channels, which
|
||||
// is what the model is in until a CONFIG arrives. `live()` dereferences the pointer, so every path
|
||||
// that can be reached without a stream has to ask first -- pausing a scope that is not connected did
|
||||
// not, and walked into this function through `publish_pause_view()`, a null dereference.
|
||||
first = 0;
|
||||
last = 0;
|
||||
if (store_ == nullptr) return;
|
||||
BufferStore& src = live();
|
||||
plot::data::RingBuffer<f64>& tsrc = src.time();
|
||||
|
||||
@@ -501,21 +767,11 @@ void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& l
|
||||
if (last < first) last = first;
|
||||
}
|
||||
|
||||
void ScopeModel::reset_live_history() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
reset_live_history_locked();
|
||||
}
|
||||
|
||||
void ScopeModel::reset_live_history_locked() {
|
||||
BufferStore& src = live();
|
||||
src.time().clear();
|
||||
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
||||
src.values(c).clear();
|
||||
src.channel(c).invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
void ScopeModel::publish_pause_view(double lo_s, double hi_s, double x_offset) {
|
||||
// Nothing has arrived yet, so there is nothing to freeze: pausing a scope that is not connected is a
|
||||
// legitimate gesture (the button works), and it must not take a window from a store that does not
|
||||
// exist.
|
||||
if (store_ == nullptr) return;
|
||||
// Timed from before the lock: the wait for it is part of what freezing a view costs the thread
|
||||
// doing it, and this one is the UI's.
|
||||
const std::uint64_t t0 = now_ns();
|
||||
@@ -538,6 +794,12 @@ void ScopeModel::publish_pause_view(double lo_s, double hi_s, double x_offset) {
|
||||
src.time().copy_from(first, n, snap->stamps.data());
|
||||
for (f64& t : snap->stamps) t -= x_offset;
|
||||
}
|
||||
if (const plot::data::RingBuffer<f64>* abs = src.abs_time(); abs != nullptr) {
|
||||
snap->abs_stamps.resize(static_cast<std::size_t>(n));
|
||||
abs->copy_from(first, n, snap->abs_stamps.data());
|
||||
// Absolute seconds outright -- deliberately not rebased like `stamps`.
|
||||
for (f64& t : snap->abs_stamps) t += epoch_;
|
||||
}
|
||||
snap->values.resize(channels_.size());
|
||||
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0});
|
||||
snap->pyramids.resize(channels_.size());
|
||||
@@ -602,6 +864,12 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
|
||||
// disagree with.
|
||||
for (auto& t : snap->stamps) t -= edge;
|
||||
}
|
||||
if (const plot::data::RingBuffer<f64>* abs = src.abs_time(); abs != nullptr) {
|
||||
snap->abs_stamps.resize(static_cast<std::size_t>(n));
|
||||
abs->copy_from(first, n, snap->abs_stamps.data());
|
||||
// Absolute seconds outright -- deliberately not rebased like `stamps`.
|
||||
for (f64& t : snap->abs_stamps) t += epoch_;
|
||||
}
|
||||
|
||||
snap->values.resize(channels_.size());
|
||||
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0});
|
||||
@@ -649,7 +917,25 @@ void ScopeModel::set_display_epoch(f64 e) noexcept {
|
||||
}
|
||||
|
||||
void ScopeModel::acquire_all() {
|
||||
if (store_) store_->acquire_all();
|
||||
if (store_) {
|
||||
store_->acquire_all();
|
||||
// Drop holes whose slots the time ring has already overwritten: their slot addresses
|
||||
// would alias live slots once the ring wraps past them, and they can no longer be
|
||||
// backfilled or drawn. `base()` is the absolute index of the oldest slot the ring
|
||||
// still retains -- anything earlier has already been discarded.
|
||||
forget_holes_before(store_->time().base());
|
||||
}
|
||||
}
|
||||
|
||||
void ScopeModel::note_hole(const Hole& h) {
|
||||
if (holes_.size() >= kMaxHoles) holes_.erase(holes_.begin());
|
||||
holes_.push_back(h);
|
||||
}
|
||||
|
||||
void ScopeModel::forget_holes_before(u64 slot) {
|
||||
// A hole whose last slot the ring has dropped can no longer be backfilled or drawn, and
|
||||
// its address would alias a live slot once the ring wraps past it.
|
||||
std::erase_if(holes_, [slot](const Hole& h) { return h.slot_begin + h.slot_count <= slot; });
|
||||
}
|
||||
|
||||
void ScopeModel::set_trigger_enabled(bool on) {
|
||||
|
||||