fix: persistence is the trace's own phosphor, not a density behind a stroke

The plotter now deposits every acquisition -- the newest included -- into one grid per trace and
draws no solid trace over it, each grid in that trace's colour and with a one-sweep ceiling (its own
commit, **1b8b55d**). This side needed almost no change of its own, because the colour was already
the trace's and the trace's colour is `RowSet::signal_color()` -- the invariants' one route -- but the
figures it reads back are now per trace:

- **The ceiling is left at the library's default of one sweep**, no longer derived from the fade.
  Deriving it from the steady state is the gain offset the user reported between the latest
  acquisition and the persistent image; a signal crossing the same place every acquisition now settles
  at the trace's colour, at full strength, and the newest sweep reads as the trace it is a picture of.
- **`cells`/`grid_bytes` are the sum over the row's traces** -- one grid each, a trace with no series
  holding none -- and both the probe and the exit dump carry `traces=` beside `rows=`. That is what
  makes the per-signal memory figure read: **556 537 cells and 2.2 MB per signal**, at 8904600 bytes
  over the four one-signal rows of the probe's run.

The probes, re-read on this build (the `verification.md` command, `--rate 500000 --batch 1000`):

    [density-probe] off enabled=0 ... rows=0 traces=0 cells=0 ink=0.0 peak=0.0000
    [density-probe] on enabled=1 ... rows=4 traces=4 cells=2226150 ink=226786.0 peak=1.0000
    [density-probe] step-acquisitions ... peak=1.5000
    [density-probe] time-before ... peak=2.1359
    [density-probe] step-time ... ratio=0.367879
    [density-probe] frame cost: tick 0.62 -> 6.96 ms (184/23 frames), draw 2.02 -> 6.16 ms (177/18)
    [udpscope]   density: frames=748 rows=4 traces=4 cells=2226150 grid_bytes=8904600 ink=489400.2 peak=2.2 upload_bytes=2226150

`on` `peak=1.0000`, `step-acquisitions` `peak=1.5000` and `step-time` `ratio=0.367879` are the legs
that were expected, unchanged; the *draw* mean is now **below** the run before the split (2.02 -> 6.16
against 2.09 -> 20.91), because the solid traces are no longer drawn over the image.

Docs updated with it: the trigger manual page (`persistence` is the trace, one image per signal, the
newest included), the README's persistence section, `invariants-trigger-and-pinning.md` (five rules,
the ceiling and the no-stroke rule among them), `verification.md` and `probe-readings.md` (the lines
above), the trigger panel's comment and tooltips, and the plotter submodule.

`cmake --build build --clean-first -j4`: **0 errors, the documented three warnings** (two
`MathNode::source`, one `-Woverlength-strings` at 12352); `make -C build test-ui` **13 ok**;
`make -C build test-help` **green** (15 pages); the self-check **0 `Gtk-WARNING`/`Gtk-CRITICAL`** and
`gtk warnings: 0`, two `buffer allocated:` lines.
This commit is contained in:
Martino Ferrari committed 2026-10-16 20:21:57 +02:00
1 parent 5e32199597
commit da826f2cb5
11 files changed
+181 -130

No files matched your search

+33 -25
View File
@@ -3384,6 +3384,7 @@ void apply_density(App& app, DisplayMode display, bool live_display) {
app.density_was_on = on;
int rows_on = 0;
int traces_on = 0;
unsigned long long cells = 0;
double ink = 0.0;
double peak = 0.0;
@@ -3396,18 +3397,14 @@ void apply_density(App& app, DisplayMode display, bool live_display) {
: plot::DensityDecay::PerAcquisition;
cfg.time_constant_s = static_cast<float>(tc.density_tau_s);
cfg.per_acquisition = static_cast<float>(tc.density_factor);
// The brightness ceiling: the ink this fade settles at, so the newest acquisition is a
// *fraction* of full ink and the display reaches white only where the acquisitions agree. For a
// per-acquisition fade that is the geometric sum `1/(1-f)` exactly. A time fade has no fixed
// one -- it settles where the trigger's rate and the constant meet, which is the point of it --
// so it keeps the library's default of two sweeps, and a fast trigger then reads brighter than
// a slow one the way a phosphor's does. The gamma is left at the library's 1.0: the cell is a
// count of sweeps, so half as many sweeps must read as half the ink. (With the ceiling at one
// sweep -- the library's first default -- one sweep was already full white and the fade was
// invisible, which is exactly what a user reported seeing.)
cfg.full_scale = tc.density_decay == PersistDecay::Time
? 2.0f
: static_cast<float>(1.0 / std::max(1.0 - tc.density_factor, 0.05));
// The brightness ceiling is left at the library's default of **one sweep** -- the trace's own
// brightness -- so the acquisition just deposited reads as the trace it is a picture of and a
// signal crossing the same place every acquisition settles at the trace's colour rather than
// brighter. The fade's steady state is deliberately *not* the ceiling: it put a gain offset
// between the newest sweep and the image behind it (the newest at a fraction of the settled
// brightness), which is what a user reported as a gain offset between the latest acquisition
// and the persistent image, and the "dimmer by design" headroom that made the ramp visible is
// the wrong call at this brightness scale -- reversed here.
// 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);
@@ -3415,16 +3412,26 @@ void apply_density(App& app, DisplayMode display, bool live_display) {
++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.
// on top of it at full ink. Either way the *newest* acquisition is inside the image, so it
// fades with the rest -- the renderer draws no solid trace over the density.
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()));
// One grid per trace, each in its own signal's colour, so the row's figures are the **sum over
// its traces** and `cells` scales with the signal count as well as the row count. `peak` is the
// brightest of them, which is the reading that says whether any one trace is saturating.
for (plot::u32 t = 0; t < p->trace_count(); ++t) {
const plot::DensityGrid& g = p->density_grid(t);
if (g.cell_count() == 0u) continue; // an unbound slot holds no grid
++traces_on;
cells += static_cast<unsigned long long>(g.cell_count());
ink += g.ink();
peak = std::max(peak, static_cast<double>(g.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_traces = traces_on;
app.dbg_density_cells = cells;
app.dbg_density_grid_bytes = cells * sizeof(plot::f32);
app.dbg_density_ink = ink;
@@ -6804,15 +6811,16 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
{
// 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);
// `frames` is how many ticks drew an image; `rows`/`traces` how many plots and how many of
// their traces held a grid (one per trace, in the trace's own colour); `cells`/`grid_bytes`
// its resolution and memory over them, `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 grids moved; a frame whose grids did not move uploads nothing at all.
std::printf("[udpscope] density: frames=%d rows=%d traces=%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_traces,
app.dbg_density_cells, app.dbg_density_grid_bytes, app.dbg_density_ink,
app.dbg_density_peak, app.dbg_density_upload_bytes);
}
}
return result;
+4 -4
View File
@@ -217,11 +217,11 @@
Both displays live here and they are two different things: *multiple events* keeps the
last N acquisitions as N traces, dimmer with age, while *persistence* accumulates every
acquisition into one screen-sized image (the plotter's `Plot::set_density()`). The
first is a count and the second is a decay, which is why neither is a mode of the
other. -->
acquisition into an image (one screen-sized grid per trace, in that trace's own colour,
the plotter's `Plot::set_density()`), drawn in place of the traces. The first is a
count and the second is a decay, which is why neither is a mode of the other. -->
<object class="GtkExpander" id="advanced_exp">
<property name="tooltip-text" translatable="yes">what the trigger mode does with the acquisitions it takes: the last few kept as dimmer traces, and a persistence image every acquisition is accumulated into</property>
<property name="tooltip-text" translatable="yes">what the trigger mode does with the acquisitions it takes: the last few kept as dimmer traces, and a persistence image (one per signal, in its own colour) every acquisition is accumulated into</property>
<child type="label">
<!-- A box rather than a plain label, for the search block's reason: the contextual
help's "(i)" joins its last child and a hint hung on the expander's own *child* is
+1
View File
@@ -291,6 +291,7 @@ struct App {
/// off, which turns the display off with it -- a dump that asked the plots would say `rows=0`
/// about a run whose whole tail had the image up.
int dbg_density_rows = 0;
int dbg_density_traces = 0;
unsigned long long dbg_density_cells = 0;
unsigned long long dbg_density_grid_bytes = 0;
double dbg_density_ink = 0.0;
+30 -21
View File
@@ -6102,10 +6102,12 @@ double density_probe_tau() {
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.
/// What the density holds over every row it is on: the rows, the traces (one grid each), 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;
int traces = 0;
unsigned long long cells = 0;
double ink = 0.0;
double peak = 0.0;
@@ -6116,11 +6118,17 @@ DensityReading density_read(const App& a) {
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()));
// **One grid per trace**, each in its trace's own colour, so the row's cells/ink are the sum
// over its traces and the peak the brightest of them.
for (plot::u32 t = 0; t < row.plot->trace_count(); ++t) {
const plot::DensityGrid& g = row.plot->density_grid(t);
if (g.cell_count() == 0u) continue; // an unbound slot holds no grid
++r.traces;
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;
}
@@ -6129,12 +6137,18 @@ DensityReading density_read(const App& a) {
/// 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();
}
// The ink summed over every trace's grid (one grid per trace): the same total `density_read()`
// reports, so a step's ratio is off one reading.
const auto ink = [&]() {
double total = 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;
for (plot::u32 t = 0; t < row.plot->trace_count(); ++t) total += row.plot->density_grid(t).ink();
}
return total;
};
const double before = 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;
@@ -6144,12 +6158,7 @@ double density_step(App& a, bool by_time) {
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();
}
const double after = ink();
return before > 0.0 ? after / before : 0.0;
}
@@ -6180,11 +6189,11 @@ void density_probe_report(App& a, const char* what, double ratio) {
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",
"[density-probe] %s enabled=%d fade=%s tau=%.3f factor=%.3f rows=%d traces=%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);
tc.density_factor, r.rows, r.traces, r.cells, r.ink, r.peak, ratio);
std::fflush(stderr);
}
+10 -8
View File
@@ -756,11 +756,12 @@ GtkWidget* build_trigger_panel(App& app) {
"acquisitions are also bounded by the buffer's own size");
app.trigger_persist_row = row_for(ui, "persistence_row", app.trigger_persist_spin);
// The phosphor *density*: one screen-sized image every acquisition is accumulated into, drawn
// behind the traces and fading by a time constant or by a per-acquisition factor -- the other
// display of the same acquisitions, and nothing like the multiple-event one above (N traces
// against one image, and no count to choose). It applies in trigger mode only, which the body
// already is: the whole block goes insensitive with the trigger off, the panel's own rule.
// The phosphor *density*: one screen-sized image per trace, in that trace's own colour, every
// acquisition accumulated into it, drawn *in place of* the traces and fading by a time constant
// or by a per-acquisition factor -- the other display of the same acquisitions, and nothing like
// the multiple-event one above (an image per signal against N retained traces, and no count to
// choose). It applies in trigger mode only, which the body already is: the whole block goes
// insensitive with the trigger off, the panel's own rule.
app.trigger_density_switch = gtk_switch_new();
gtk_widget_set_valign(app.trigger_density_switch, GTK_ALIGN_CENTER);
gtk_widget_set_halign(app.trigger_density_switch, GTK_ALIGN_END);
@@ -771,9 +772,10 @@ GtkWidget* build_trigger_panel(App& app) {
}
gtk_widget_set_tooltip_text(
app.trigger_density_switch,
"accumulate every triggered acquisition into one screen-sized image, drawn behind the "
"traces: brightness is how often a trace passed, so an image of a thousand acquisitions "
"costs what one costs. Trigger mode only, and cleared whenever the display leaves it");
"accumulate every triggered acquisition into a screen-sized image per signal, in that "
"signal's own colour, drawn in place of the traces: brightness is how often a trace passed, "
"so an image of a thousand acquisitions costs what one costs. Trigger mode only, and cleared "
"whenever the display leaves it");
g_signal_connect(app.trigger_density_switch, "notify::active",
G_CALLBACK(on_density_toggled), &app);
// Not `row_for()`: it expands its child, and an expanded switch is a wide pill that reads as a