trigger: a phosphor persistence, driven by the plotter's density image

The trigger pane gains the display the user asked for and the library now provides, and the
persistence it already had is renamed to what it is.

**Persistence is one image; the last-N-traces display is "multiple events".** Both live in a new
`Advanced` expander in the trigger panel, together with the settings that are set up once and left
alone. `persistence` (a switch) turns on the plotter's `Plot::set_density()` for every row:
every triggered acquisition is accumulated into a screen-sized intensity grid drawn behind the
traces, brightness being how many acquisitions covered a cell -- no depth to choose, no memory
that grows with the acquisitions (2.2 MB over four 573x412 rows in the probe's run). `fades by`
picks the fade the user asked for: **acquisitions** (each keeps a factor of what was before it,
0.5 by default -- the phosphor afterglow) or **time** (a time constant in stream seconds, so the
display fades while you watch whatever the trigger rate).

- **Trigger mode only, as asked.** `apply_density()` (the tick) states the display on for `Held`
  and `Capturing` and off for `Live`, `Waiting`, `Paused` and a file, and turning it off drops the
  grid at once rather than at an acquisition a quiet trigger never sends. It is unpickable and
  reserves no layout space, exactly as the invalid-span bands are: a density can never be in the
  way of a cursor or a drop, and it cannot move the framing.
- **One deposit per acquisition, one frame after the frame that frames it.** `Plot::accumulate_density()`
  deposits what the plot holds through the transforms of its *last* layout -- the frame's own is
  computed inside the paint that follows the tick -- so `density_pending` is set beside
  `record_pending` and consumed by the next tick. In the same tick it was right on a Held-to-Held
  transition and wrong on the first latch, whose axis was still the live ring's. Switching the
  display on under an acquisition already on screen deposits it too, so the switch does not appear
  to do nothing until the next latch (`single` mode may never send one).
- **The session carries it**: `trigger_density`, `trigger_density_decay` ("acquisitions"/"time",
  the words the panel shows), `trigger_density_tau` and `trigger_density_factor`, written whether
  or not it is on -- and `trigger_persistence` still means the multiple-event depth, so an older
  file is unchanged.
- **The library changed twice under this**: the deposit now counts a cell **once per trace per
  sweep** (counting the hits put one dense sweep at the ceiling of the brightness scale and hid the
  sweeps behind it -- the probe's `on` leg read `peak=18` for a single sweep and reads `peak=1`
  now), and a frame's upload is every row's rather than the last row's. Both are in the plotter's
  own commit.

`UDPSCOPE_PROBE_DENSITY_MS` reads it all back off the plots -- the plotter's own `ink()`/`peak()` --
with the panel's own widgets driving the switch and the fade, in four legs: **off** (`rows=0
ink=0.0 peak=0.0000`), **on** (`rows=4`, `peak=1.0000`), **step-acquisitions** (`peak=1.0000 ->
1.5000`, the factor then the new sweep) and **step-time** (`ratio=0.367879`, `1/e` for `dt = tau`).
It also reads the frame cost as an A/B **inside one run** (the accumulators at the switch against
the frames after it): `tick 0.74 -> 9.99 ms`, `draw 2.09 -> 20.91 ms` at four rows and 2.2 M cells
on llvmpipe.

Acceptance: clean build 0 errors and exactly the three documented warnings (12352-byte GResource
blob, two `MathNode::source`), `test-ui` 13/13, `test-help` green, a plain run with **0
Gtk-CRITICAL / Gtk-WARNING** and `gtk warnings: 0`, and the panel's own frame reading
`boxes=16 controls_inside=15/15 advanced_widgets=5/5 advanced_inside=5/5` (the block's controls
counted from the expander's child, which is not in the widget-parent chain -- and the search
block's walk now names *its* expander instead of "the first one", which the second expander would
have stolen).

Docs: the trigger/pinning invariants carry the rule and its five consequences, `probe-readings.md`
the four legs and the dump line, `verification.md` the probe's sequence and assertions, and the
manual's trigger page explains both displays to a user.
This commit is contained in:
Martino Ferrari committed 2026-10-16 17:28:54 +02:00
1 parent e14e1ee069
commit 974d6aa2d9
15 files changed
+993 -41

No files matched your search

+142 -11
View File
@@ -3344,6 +3344,91 @@ void update_capture_row(App& app) {
// --- the frame tick ---------------------------------------------------------
/// The phosphor density's one step for a frame. See the declaration for the rule; what is here is
/// the two things easy to get wrong.
///
/// **The deposit is one frame behind the bind**, and that is deliberate. `Plot::accumulate_density()`
/// deposits the traces the plot *currently* holds through the transforms of its *last* layout --
/// which is the only layout there is, since the frame's own is computed inside the paint that
/// follows this tick. Running the deposit in the same tick that frames an acquisition would
/// therefore deposit the new snapshot through the *previous* window's transform: right on the
/// common Held-to-Held transition (both are edge-relative over the same span) and wrong on the
/// first latch, whose axis was the live ring's. Waiting one frame costs a frame of latency on an
/// image whose whole subject is the last few seconds.
///
/// **The fade's clock is the frame's own wall `dt`.** The plot states the time constant in seconds
/// and the app hands it the seconds this frame covered: at a steady stream that is the same clock
/// the display window slides on, and it is what makes the fade "while you watch" rather than "per
/// acquisition". A stall (a rebuild, a modal dialog, a paused process) is capped at a quarter of a
/// second, so a step the user did not watch cannot take the whole image out in one go.
void apply_density(App& app, DisplayMode display, bool live_display) {
ScopeModel& model = *app.model;
TriggerConfig tc;
{
std::lock_guard<std::mutex> lock(model.mutex());
tc = model.trigger();
}
// On while an acquisition is on screen; a *file* is not a stream of acquisitions at all, and a
// live or blank display has nothing for the image to be an image *of*.
const bool on =
!live_display && (display == DisplayMode::Held || display == DisplayMode::Capturing) &&
tc.density;
const gint64 now_us = g_get_monotonic_time();
double dt = app.density_last_us != 0 ? double(now_us - app.density_last_us) / 1.0e6 : 0.0;
app.density_last_us = now_us;
dt = std::clamp(dt, 0.0, 0.25);
// Switching it on under an acquisition already on screen deposits that one: the alternative is a
// switch that appears to do nothing until the next latch, which in `single` mode is for ever.
const bool fresh = on && !app.density_was_on;
const bool deposit = app.density_pending || fresh;
app.density_was_on = on;
int rows_on = 0;
unsigned long long cells = 0;
double ink = 0.0;
double peak = 0.0;
for (int i = 0; i < app.rows->row_count(); ++i) {
plot::Plot* p = app.rows->row(i).plot;
if (p == nullptr) continue;
plot::DensityConfig cfg;
cfg.enabled = on;
cfg.decay = tc.density_decay == PersistDecay::Time ? plot::DensityDecay::TimeConstant
: plot::DensityDecay::PerAcquisition;
cfg.time_constant_s = static_cast<float>(tc.density_tau_s);
cfg.per_acquisition = static_cast<float>(tc.density_factor);
// The plot drops what it held when this turns the display off, so the step below only ever
// speaks about an image that is on screen.
p->set_density(cfg);
if (!on) continue;
++rows_on;
// Time first, then the deposit: under `PerAcquisition` the decay is the accumulate path's own,
// so this is a no-op there; under `TimeConstant` it is this frame's fade and the deposit lands
// on top of it at full ink.
p->decay_density(dt);
if (deposit) p->accumulate_density();
cells += static_cast<unsigned long long>(p->density_grid().cell_count());
ink += p->density_grid().ink();
peak = std::max(peak, static_cast<double>(p->density_grid().peak()));
}
app.density_pending = false;
if (rows_on == 0) return; // nothing was on this frame: the dump's figures stay the last real ones
app.dbg_density_rows = rows_on;
app.dbg_density_cells = cells;
app.dbg_density_grid_bytes = cells * sizeof(plot::f32);
app.dbg_density_ink = ink;
app.dbg_density_peak = peak;
++app.dbg_density_frames;
if (app.area != nullptr) {
if (const plot::Renderer* r = app.area->renderer()) {
// The **most** any one frame uploaded, not the last one's: the exit probe changes the display
// before the dump, so the last painted frame is not a frame of this display -- and "the grid
// is one byte a cell" is the claim, which a maximum states and a last value does not.
const long long uploaded = static_cast<long long>(r->stats().density_upload_bytes);
app.dbg_density_upload_bytes = std::max(app.dbg_density_upload_bytes, uploaded);
}
}
}
gboolean tick(gpointer user) {
App* app = static_cast<App*>(user);
if (app->window == nullptr) return G_SOURCE_REMOVE;
@@ -3518,6 +3603,10 @@ gboolean tick(gpointer user) {
// Declared outside the lock block because the notice below is a widget write, and the tick's
// widget work happens outside the model's lock.
const bool live_display = display == DisplayMode::Live || !app->waveform_name.empty();
// The phosphor density's step for this frame, *before* the bind below: what it deposits is the
// acquisition the last frame bound and laid out, which is what the plot's transforms still
// describe (see `apply_density()`).
apply_density(*app, display, live_display);
int persist_depth = 1;
int persist_want = 0;
@@ -3869,6 +3958,9 @@ gboolean tick(gpointer user) {
// Keep it, at the end of this tick: the record's thumbnail is a picture of the plot, and the plot
// is bound to the event later in this same frame.
app->record_pending = true;
// ... and the frame after this one deposits it into every row's density grid, through the
// transforms the pin below is about to state (`apply_density()`).
app->density_pending = true;
// The window is pinned to exactly what the snapshot captured, read off the
// snapshot itself. Not off `event_span`/`pretrigger_pct`: those say what was
// *asked for*, in stream seconds, while how much the capture actually covers
@@ -5953,6 +6045,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_PERSIST_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), persist_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_DENSITY_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), density_probe, &app);
}
// Our own options were consumed by parse_args(); GTK would reject them, so only
// the program name is handed to g_application_run().
@@ -6369,23 +6464,46 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
// different mistakes, and only one of them shows as a missing button.
// The expander's child is **not** in the widget-parent chain: `gtk_widget_get_parent()` on it stays
// NULL even after the window is presented, while `gtk_expander_get_child()` returns it (measured with
// a throwaway program, all three ways of setting it). So the walk the search widgets are read with has
// to start at the expander's child and not at the panel -- walking to the panel reads 0/4 on a panel
// a throwaway program, all three ways of setting it). So the walk a block's widgets are read with has
// to start at that block's own child and not at the panel -- walking to the panel reads 0 on a panel
// that is entirely correct, which is what this line did first.
//
// Two expanders now: the search block and the Advanced one. The root is taken from **the expander
// itself** (`App::search_exp`/`App::trigger_advanced`) rather than from "the first expander in the
// panel", which is what this loop used to do -- and which stopped naming the search block the moment
// a second one was added before it.
GtkWidget* search_root = app.trigger_body;
for (GtkWidget* c = gtk_widget_get_first_child(app.trigger_body); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
if (GTK_IS_EXPANDER(c)) {
if (GtkWidget* inner = gtk_expander_get_child(GTK_EXPANDER(c))) search_root = inner;
break;
if (app.search_exp != nullptr) {
if (GtkWidget* inner = gtk_expander_get_child(GTK_EXPANDER(app.search_exp))) {
search_root = inner;
}
}
GtkWidget* advanced_root = app.trigger_body;
if (app.trigger_advanced != nullptr) {
if (GtkWidget* inner = gtk_expander_get_child(GTK_EXPANDER(app.trigger_advanced))) {
advanced_root = inner;
}
}
const int search_present =
(app.search_run_button != nullptr) + (app.search_cancel_button != nullptr) +
(app.search_prev_button != nullptr) + (app.search_next_button != nullptr);
// The Advanced block's own two readings, for the search block's two reasons: the ids resolving and
// the controls being *in* the block. Its controls are the five the density added, and they must be
// inside `advanced_root` and not merely non-null -- an id that drifted leaves a control built and
// nowhere, which is the failure this pair exists to catch.
const int advanced_present =
(app.trigger_persist_spin != nullptr) + (app.trigger_density_switch != nullptr) +
(app.trigger_density_decay_drop != nullptr) + (app.trigger_density_tau_spin != nullptr) +
(app.trigger_density_factor_spin != nullptr);
const int advanced_inside =
inside(app.trigger_persist_spin, advanced_root) +
inside(app.trigger_density_switch, advanced_root) +
inside(app.trigger_density_decay_drop, advanced_root) +
inside(app.trigger_density_tau_spin, advanced_root) +
inside(app.trigger_density_factor_spin, advanced_root);
std::printf(
"[udpscope] trigger panel: boxes=%d controls_inside=%d/16 search_widgets=%d/4 "
"search_inside=%d/4\n",
"[udpscope] trigger panel: boxes=%d controls_inside=%d/15 search_widgets=%d/4 "
"search_inside=%d/4 advanced_widgets=%d/5 advanced_inside=%d/5\n",
boxes,
inside(app.trigger_signal_drop, app.trigger_body) +
inside(app.trigger_type_drop, app.trigger_body) +
@@ -6401,11 +6519,11 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
inside(app.trigger_holdoff_spin, app.trigger_body) +
inside(app.trigger_band_spin, app.trigger_body) +
inside(app.trigger_hf_spin, app.trigger_body) +
inside(app.trigger_persist_spin, app.trigger_body) +
inside(app.events_record_switch, app.trigger_body),
search_present,
inside(app.search_run_button, search_root) + inside(app.search_cancel_button, search_root) +
inside(app.search_prev_button, search_root) + inside(app.search_next_button, search_root));
inside(app.search_prev_button, search_root) + inside(app.search_next_button, search_root),
advanced_present, advanced_inside);
}
{
plot::Plot& p = *rows.row(0).plot;
@@ -6671,6 +6789,19 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
}
std::printf("]\n");
}
{
// The phosphor density, as of the last tick it was **on** (the exit probe switches the
// trigger off before this runs, so the plots hold nothing by now -- see `App::dbg_density_*`).
// `frames` is how many ticks drew an image, `cells`/`grid_bytes` its resolution and memory
// over the rows it was on, `ink`/`peak` the numbers the plot's own readers report, and
// `upload_bytes` the most any one frame uploaded -- one byte a cell, on the frames whose grid
// moved; a frame whose grid did not move uploads nothing at all.
std::printf("[udpscope] density: frames=%d rows=%d cells=%llu grid_bytes=%llu ink=%.1f "
"peak=%.1f upload_bytes=%lld\n",
app.dbg_density_frames, app.dbg_density_rows, app.dbg_density_cells,
app.dbg_density_grid_bytes, app.dbg_density_ink, app.dbg_density_peak,
app.dbg_density_upload_bytes);
}
}
return result;
}