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

+273
View File
@@ -6061,5 +6061,278 @@ gboolean persist_probe_wait(gpointer user) {
return G_SOURCE_CONTINUE;
}
// --- phosphor density (UDPSCOPE_PROBE_DENSITY_MS) -----------------------------
//
// The density is the *plotter's* image, so every number below is read off the plots -- the same
// `DensityGrid::ink()`/`peak()` the library's own tests read -- summed and maxed over the rows the
// display is on. The probe drives the panel's own widgets for the switch and the fade (the way a
// click publishes them) and calls the plot's own `accumulate_density()`/`decay_density()` for the
// two decay steps, so a step's ratio is arithmetic rather than a race against the frame clock: both
// reads and the step happen inside one `g_timeout` callback, with no tick in between.
//
// The legs, in order:
// * **off** -- the switch as a session that never turned it on leaves it, which is also the run
// the exit dump's `frame cost:` line is read against (a plain run, no probe: the density is off
// by default and nothing draws it);
// * **on** -- one deposit, the acquisition already on screen (switching the display on under an
// event deposits it at once rather than waiting for a latch `single` mode may never send);
// * **step, acquisitions** -- one more accumulation: every cell keeps the factor of what it held
// and the trace on screen adds its own pass. With the same frame in both reads the relation is
// exact (`ink' = f*ink + ink`, the second `ink` being one pass), which is what the *library's*
// own case asserts; **in the app the acquisition may move between the two reads**, so what the
// line's ratio says is "the old image halved and one more pass landed on top" rather than a
// number to expect -- and `ink` growing while the ratio stays above `f` is that reading. 0.5 is
// the panel's default factor;
// * **step, time** -- `exp(-dt/tau)` off the ink, with `dt = tau` stated outright, so the ratio
// the line prints must be `1/e`.
//
// It is left **on**, with the fade it last set, so the tail of the run is the display with the
// density: that is what makes the exit dump's `frame cost:` a mean over frames that uploaded an
// image, to be compared with the same command without the probe.
//
// `UDPSCOPE_PROBE_DENSITY_TAU` states the time constant the time leg uses (default 1.0 s, clamped to
// the panel's own range): `dt = tau` is one step whatever it is, so the ratio does not depend on it
// -- it is there so a run can fade faster than a second and still be read.
/// The time constant the probe's time leg sets, from `UDPSCOPE_PROBE_DENSITY_TAU` (default 1.0 s).
double density_probe_tau() {
const char* env = g_getenv("UDPSCOPE_PROBE_DENSITY_TAU");
const double s = env != nullptr ? std::atof(env) : 1.0;
return s > 0.0 ? std::min(s, 3600.0) : 1.0;
}
/// What the density holds over every row it is on: the rows, the cells, and the two numbers the
/// plot reports. A row with the display off contributes nothing -- it has no grid at all.
struct DensityReading {
int rows = 0;
unsigned long long cells = 0;
double ink = 0.0;
double peak = 0.0;
};
DensityReading density_read(const App& a) {
DensityReading r;
for (int i = 0; i < a.rows->row_count(); ++i) {
const RowSet::Row& row = a.rows->row(i);
if (row.plot == nullptr || !row.plot->density().enabled) continue;
const plot::DensityGrid& g = row.plot->density_grid();
++r.rows;
r.cells += static_cast<unsigned long long>(g.cell_count());
r.ink += g.ink();
r.peak = std::max(r.peak, static_cast<double>(g.peak()));
}
return r;
}
/// One decay step of each kind, on every row the display is on. The acquisitions step is the
/// plotter's own accumulation (its per-acquisition fade happens inside it, before the deposit), and
/// the time step is `decay_density(tau)` -- the same call the frame tick makes with a frame's `dt`.
double density_step(App& a, bool by_time) {
double before = 0.0;
for (int i = 0; i < a.rows->row_count(); ++i) {
const RowSet::Row& row = a.rows->row(i);
if (row.plot == nullptr || !row.plot->density().enabled) continue;
before += row.plot->density_grid().ink();
}
for (int i = 0; i < a.rows->row_count(); ++i) {
plot::Plot* p = a.rows->row(i).plot;
if (p == nullptr || !p->density().enabled) continue;
if (by_time) {
p->decay_density(a.dbg_density_tau);
} else {
p->accumulate_density();
}
}
double after = 0.0;
for (int i = 0; i < a.rows->row_count(); ++i) {
const RowSet::Row& row = a.rows->row(i);
if (row.plot == nullptr || !row.plot->density().enabled) continue;
after += row.plot->density_grid().ink();
}
return before > 0.0 ? after / before : 0.0;
}
/// States the display through the panel's own widgets and publishes it: the *widget* is the path a
/// user takes, so what is measured is the app's wiring rather than a second way in.
void density_probe_set(App& a, bool on, bool by_time) {
if (a.trigger_density_switch != nullptr) {
gtk_switch_set_active(GTK_SWITCH(a.trigger_density_switch), on);
}
if (a.trigger_density_decay_drop != nullptr) {
gtk_drop_down_set_selected(GTK_DROP_DOWN(a.trigger_density_decay_drop), by_time ? 1u : 0u);
}
publish_trigger_fields(&a);
}
/// One leg's line: the numbers, the fade in force, and the ratio a decay step produced (1.0 where no
/// step was taken).
void density_probe_report(App& a, const char* what, double ratio) {
const DensityReading r = density_read(a);
// The configuration is read from the **model**, not off the widgets: the switch and the dropdown
// are what the probe writes, so reading them back would only say what it just set. The model is
// what the tick reads, so `fade=`/`tau=` beside `rows=` is the wiring's own answer -- and a
// spread between the two is the defect the line exists for (a switch that published nothing, a
// tau the plot never saw).
TriggerConfig tc;
{
std::lock_guard<std::mutex> lock(a.model->mutex());
tc = a.model->trigger();
}
std::fprintf(stderr,
"[density-probe] %s enabled=%d fade=%s tau=%.3f factor=%.3f rows=%d cells=%llu "
"ink=%.1f peak=%.4f ratio=%.6f\n",
what, tc.density ? 1 : 0,
tc.density_decay == PersistDecay::Time ? "time" : "acquisitions", tc.density_tau_s,
tc.density_factor, r.rows, r.cells, r.ink, r.peak, ratio);
std::fflush(stderr);
}
/// Step 1: the display **off** through the panel's switch (a session file remembers the trigger, so
/// the off state is stated rather than assumed), then the trigger armed on the mock's `square` the
/// way the persistence probe arms its own -- several crossings a second, so an acquisition lands
/// within a couple of seconds.
gboolean density_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
density_probe_set(*a, /*on=*/false, /*by_time=*/false);
{
const std::vector<SignalInfo>& sigs = a->model->signals();
std::vector<std::size_t> all(sigs.size());
for (std::size_t i = 0; i < all.size(); ++i) all[i] = i;
if (!all.empty()) a->model->set_signals_enabled(all, true);
}
// One signal per row, the arrangement the app gives an opened file and the persistence probe
// uses -- and, for this probe, the one that puts an image in *every* row (a reading that summed
// over rows would be one row's if only one row plotted anything).
const std::vector<ChannelInfo>& setup_channels = a->model->channels();
const std::vector<SignalInfo>& setup_signals = a->model->signals();
const int setup_rows =
std::min<int>(RowSet::kMaxRows, static_cast<int>(setup_channels.size()));
if (setup_rows > 0) {
a->rows->set_row_count(setup_rows);
for (int r = 0; r < setup_rows; ++r) {
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) a->rows->assign(r, s, "", "", 0);
const std::size_t ci = static_cast<std::size_t>(r);
a->rows->assign(r, 0, setup_channels[ci].name, setup_channels[ci].name,
signal_color_index(setup_signals, setup_channels[ci].signal));
}
a->relayout_pending = true;
}
// The time constant the time leg will state, through the panel's own spin -- so the model's tau,
// the plot's tau and the step's `dt` are one number rather than three that must agree.
a->dbg_density_tau = density_probe_tau();
if (a->trigger_density_tau_spin != nullptr) {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_density_tau_spin), a->dbg_density_tau);
}
if (a->trigger_density_factor_spin != nullptr) {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_density_factor_spin), 0.5);
}
int sel = -1;
const std::vector<ChannelInfo>& channels = a->model->channels();
for (std::size_t i = 0; i < channels.size(); ++i) {
if (channels[i].name == "square") sel = static_cast<int>(i);
}
if (sel >= 0) {
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_signal_drop), static_cast<guint>(sel));
}
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_type_drop), 0);
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_edge_drop), 0);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_level_spin), 0.0);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_holdoff_spin), 0.0);
apply_time_window(*a, 0.5);
publish_trigger_fields(a);
std::fprintf(stderr, "[density-probe] arming on '%s', window 0.5 s, display off\n",
sel >= 0 ? channels[static_cast<std::size_t>(sel)].name.c_str() : "(none)");
std::fflush(stderr);
if (a->trigger_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
}
a->dbg_density_stage = 1;
a->dbg_density_polls = 0;
g_timeout_add(200, density_probe_step, a);
return G_SOURCE_REMOVE;
}
/// The sequence. Each step is a separate `g_timeout`, so whole frames -- ticks, binds, paints --
/// happen between two of them: a reading taken in the same callback that set a widget would describe
/// the frame *before* the change, which is the trap the compact-arm probe records.
gboolean density_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
++a->dbg_density_polls;
const bool held = a->model->display_mode() == DisplayMode::Held;
double lo = 0.0;
double hi = 0.0;
switch (a->dbg_density_stage) {
case 1:
// Wait for an acquisition to be on screen. The trigger's cadence is the stream's, so a fixed
// delay would report whatever happened to have latched; the cap reports anyway (and says so
// through `rows=0`).
if (!held || !a->rows->event_data_window(lo, hi)) {
if (a->dbg_density_polls > 100) {
density_probe_report(*a, "off", 1.0);
std::fprintf(stderr, "[density-probe] no event appeared; stopping\n");
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
return G_SOURCE_CONTINUE;
}
density_probe_report(*a, "off", 1.0);
// The cost *before*: the accumulators as of this tick, so the "after" mean is over the frames
// that follow and the two are read off one run -- same stream, same layout, same mock. Two
// runs would differ in everything but the density, which is exactly what a frame-cost A/B
// cannot afford.
a->dbg_density_tick_sum0 = a->dbg_tick_ms_sum;
a->dbg_density_tick_n0 = a->dbg_tick_n;
a->dbg_density_draw_sum0 = a->dbg_draw_ms_sum;
a->dbg_density_draw_n0 = a->dbg_paint_n;
density_probe_set(*a, /*on=*/true, /*by_time=*/false);
a->dbg_density_stage = 2;
return G_SOURCE_CONTINUE;
case 2: {
// The frame that switched the display on deposited the acquisition already on screen; the
// numbers are the one-pass ones.
density_probe_report(*a, "on", 1.0);
const double ratio = density_step(*a, /*by_time=*/false);
density_probe_report(*a, "step-acquisitions", ratio);
density_probe_set(*a, /*on=*/true, /*by_time=*/true);
a->dbg_density_stage = 3;
return G_SOURCE_CONTINUE;
}
case 3: {
density_probe_report(*a, "time-before", 1.0);
// One step of `dt = tau`, which is 1/e by the plot's own rule -- and the one reading that says
// the time fade is wired at all: the panel's constant reaches the grid through the tick.
const double ratio = density_step(*a, /*by_time=*/true);
density_probe_report(*a, "step-time", ratio);
// The cost *after*: the same two means over the frames since the switch, which is the
// difference this display costs -- the per-frame upload and deposit against the frame without
// them. `upload_bytes` in the exit dump beside it is the one frame's share of it.
const int tick_n = a->dbg_tick_n - a->dbg_density_tick_n0;
const int draw_n = a->dbg_paint_n - a->dbg_density_draw_n0;
std::fprintf(stderr,
"[density-probe] frame cost: tick before=%.2f ms (%d frames) after=%.2f ms "
"(%d frames) draw before=%.2f ms (%d) after=%.2f ms (%d)\n",
a->dbg_density_tick_n0 > 0 ? (a->dbg_density_tick_sum0 / a->dbg_density_tick_n0)
: 0.0,
a->dbg_density_tick_n0,
tick_n > 0 ? (a->dbg_tick_ms_sum - a->dbg_density_tick_sum0) / tick_n : 0.0,
tick_n,
a->dbg_density_draw_n0 > 0 ? (a->dbg_density_draw_sum0 / a->dbg_density_draw_n0)
: 0.0,
a->dbg_density_draw_n0,
draw_n > 0 ? (a->dbg_draw_ms_sum - a->dbg_density_draw_sum0) / draw_n : 0.0,
draw_n);
std::fprintf(stderr, "[density-probe] done: the display is left on, fading by time\n");
std::fflush(stderr);
a->dbg_density_stage = 0;
return G_SOURCE_REMOVE;
}
default:
return G_SOURCE_REMOVE;
}
}
} // namespace ui_impl
} // namespace udpscope