Feature/source plugins #1
No files matched your search
@@ -1257,7 +1257,7 @@ snapshot's own stamps. That re-basing machinery already existed (it was written
|
||||
frozen view and then never needed, since snapshots carry edge-relative stamps); this is
|
||||
what finally uses it.
|
||||
|
||||
**Persistence: the last N acquisitions, dimmer with age.** Trigger mode can keep the last
|
||||
**Multiple events: the last N acquisitions, dimmer with age.** Trigger mode can keep the last
|
||||
`N` acquisitions on screen at once, each older one drawn at half the brightness of the one before
|
||||
it (1, 0.5, 0.25, 0.125, …), which is what a phosphor display does with successive sweeps: they
|
||||
are aligned on their own trigger edge, so the shape of the event repeats and whatever varies
|
||||
@@ -1269,6 +1269,24 @@ pinned: the axis, the framing and every measurement still describe the event on
|
||||
`N = 1` retains nothing at all — no copy, no layer — and lowering the depth frees the retained
|
||||
snapshots there and then, because a trigger that has stopped firing never reaches another publish.
|
||||
|
||||
**Persistence: one image, every acquisition.** The trigger panel's `Advanced` block carries the
|
||||
*other* display of the same acquisitions, and it is not a count at all: `persistence` turns on a
|
||||
**phosphor density** — the plotter's screen-sized intensity image (`Plot::set_density()`, one `f32`
|
||||
cell per device pixel of the axes rect) into which every triggered acquisition is deposited and
|
||||
which is drawn behind the traces. A cell's brightness is how many acquisitions passed through it,
|
||||
so the sweep on screen is the brightest, the older ones add on top **with no depth to choose and no
|
||||
memory that grows with them** — the image is 2.2 MB over four 573x412 rows in the probe's run, and
|
||||
a thousand acquisitions cost the same. `fades by` picks how it decays: `acquisitions` (each
|
||||
acquisition keeps the factor of what was before it, 0.5 by default — the phosphor afterglow
|
||||
schedule) or `time` (a time constant in stream seconds, so the display fades while you watch
|
||||
whatever the trigger rate). It applies in **trigger mode only** — while a captured acquisition is
|
||||
on screen — and it goes away with the trigger, on a pause and on an opened file, because the image
|
||||
is an image *of* a stream of acquisitions. Like the invalid-span bands it reserves no layout space
|
||||
and nothing on it can be picked. The `[density-probe]` run that measured it is in
|
||||
`docs/claude/probe-readings.md`; what it costs is in the same place (`[density-probe] frame cost:`:
|
||||
tick 0.74 → 9.99 ms and draw 2.09 → 20.91 ms over the same run, at four rows and 2.2 M cells a
|
||||
frame, on llvmpipe).
|
||||
|
||||
**And it says what it costs.** A retained acquisition is a whole window copied out of the ring, so
|
||||
the retention is bounded by two things: the depth, and **`--buffer-mb`** — persistence may hold at
|
||||
most as much again as the buffer does, which is the bound a long window on a fast stream reaches
|
||||
|
||||
Vendored
+1
-1
Submodule deps/plotter updated: 3ad02e5120...6a6915e64e.
@@ -376,6 +376,44 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
trigger that has stopped firing never reaches another publish. A depth change is therefore also
|
||||
how the memory comes back.
|
||||
|
||||
- **The phosphor density is one *image*, and that is a different display from the layers above, not a
|
||||
mode of them.** The trigger panel's `Advanced` block offers both, and the words matter: *multiple
|
||||
events* is `persist_depth` (N retained acquisitions, N traces, dimmer with age) while *persistence*
|
||||
is `TriggerConfig::density` -- the plotter's `Plot::set_density()`, a screen-sized intensity grid
|
||||
every acquisition is deposited into and which fades either by a time constant or by a
|
||||
per-acquisition factor. There is **no depth** on the density and no per-acquisition memory: the
|
||||
image is `columns * rows * 4` bytes, one cell per device pixel of the row's axes (2.2 MB over the
|
||||
four rows of the probe's run), so a thousand acquisitions cost what one costs. A cell's value is a
|
||||
number of *sweeps*: the library deposits a cell once per trace per sweep however many of that
|
||||
trace's own samples crossed it (`[density-probe] on` reads `peak=1.0000` for one sweep; a per-hit
|
||||
deposit read 18) -- dwell is `Style::intensity_grading`'s question, not this one's.
|
||||
Four things hold it together, each of which is a rule:
|
||||
* **It is a trigger-mode display.** `apply_density()` (the tick) states `Held` and `Capturing` as
|
||||
the states it is on in; `Live`, `Waiting`, `Paused` and a file are not, and turning it off drops
|
||||
the grid **at once** rather than at an acquisition a quiet trigger never sends (the rule
|
||||
`set_persist_depth()` follows). So it goes away with the trigger, and a re-enable starts from an
|
||||
empty image rather than a stale one at another scale.
|
||||
* **One deposit per acquisition, one frame *after* the frame that frames it.** `Plot::accumulate_density()`
|
||||
deposits what the plot currently holds through the transforms of its **last** layout, because the
|
||||
frame's own layout is computed inside the paint that follows the tick: `density_pending` is set
|
||||
where a snapshot is *framed* -- beside `record_pending` -- and consumed by the next tick, so the
|
||||
deposit is of the new acquisition drawn at the axis that acquisition is pinned to. Doing it in the
|
||||
same tick was right on the common Held-to-Held transition and wrong on the first latch, whose axis
|
||||
was still the live ring's.
|
||||
* **The switch deposits what is already on screen.** `on && !density_was_on` sets the same pending
|
||||
flag, so a user who turns persistence on while an event is held sees it instead of waiting for a
|
||||
latch that `single` mode may never send.
|
||||
* **It is the plot's, so a figure rebuild resets it** -- the grid lives on each `Plot`, and
|
||||
`rebuild_all()` builds new ones. That is the one state a structural change (a row added, a signal
|
||||
switched off, a trace recoloured) does not carry: it costs the accumulated image and nothing else,
|
||||
and the alternative -- keeping the grid in the `RowSet` -- is what would make it not a plot
|
||||
feature. The layout is unaffected either way: the density reserves no space and nothing on it can
|
||||
be picked, exactly as `set_invalid_spans()` does.
|
||||
* **The session carries it.** `trigger_density`, `trigger_density_decay`, `trigger_density_tau` and
|
||||
`trigger_density_factor` in the layout file, written whether or not it is on -- a display set up
|
||||
once and switched off is still the display you come back to. The decay is the *word* the panel's
|
||||
dropdown shows ("acquisitions"/"time"), for the reason the condition's shapes are text.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -1899,27 +1899,31 @@ through one row type, for the reason in `invariants-gtk-and-chrome.md`. The form
|
||||
## `trigger panel:`
|
||||
|
||||
```
|
||||
[udpscope] trigger panel: boxes=17 controls_inside=16/16 search_widgets=4/4 search_inside=4/4
|
||||
[udpscope] trigger panel: boxes=16 controls_inside=15/15 search_widgets=4/4 search_inside=4/4 advanced_widgets=5/5 advanced_inside=5/5
|
||||
```
|
||||
|
||||
The trigger panel's frame, read from the window: **the `.ui`'s own panel** (`trigger_body`), not the box
|
||||
this app wraps it in — the heading row and the locked summary are the code's arrangement, and reading the
|
||||
*wrapper* made this line report `boxes=2 … search_inside=0/4` the moment the heading moved out of the file.
|
||||
That is the failure this line is for, so it is worth knowing it can happen to itself. `boxes` is the row
|
||||
boxes the `.ui` holds, `controls_inside`
|
||||
boxes the `.ui` holds and `controls_inside`
|
||||
counts the sixteen controls the code built and then appended into those rows -- the last three being the
|
||||
noise band and the HF reject, whose rows arrived with the noise-rejection work, and the persistence depth
|
||||
whose row arrived with the persistence display (the Arm/Force pair is a row of its own too, so `boxes` counts
|
||||
it while `controls_inside` does not: the pair is a *button* row, not a value) -- `search_widgets` is the four
|
||||
search buttons having resolved by id, and `search_inside` is those four being inside the block the expander
|
||||
opens onto. A control built and never placed -- an id that drifted between the file and the code -- reads as
|
||||
`controls_inside` below 16 while every other line still looks right, which is the panel that has a field
|
||||
missing.
|
||||
counts the fifteen controls the code built and then appended into the panel's own rows -- the noise band and
|
||||
the HF reject, the persistence-depth spin and the record switch among them, and the Arm/Force *pair* not,
|
||||
because that row holds buttons and a `boxes` count reads the rows either way.
|
||||
|
||||
The search walk starts at `gtk_expander_get_child()`, not at the panel: an expander's child is not in the
|
||||
widget-parent chain at all, so walking up from a search button reaches no ancestor of the panel (see
|
||||
`invariants-gtk-and-chrome.md`). `[defaults] trigger:` prints the same panel's ten spins in tree order, so
|
||||
the two lines together read what the file gave and what the code put in it.
|
||||
`boxes` is **16** since the trigger's display settings moved into the `Advanced` expander: the multiple-event
|
||||
depth and the four density controls left the panel's own child list, and the expander that holds them is not a
|
||||
box. Those five are what `advanced_widgets`/`advanced_inside` count, for the same two reasons the search pair
|
||||
exists -- the ids resolving, and the controls being *in* the block -- and an expander's child is **not** in the
|
||||
widget-parent chain, so a control there is invisible to `controls_inside` by construction (see
|
||||
`invariants-gtk-and-chrome.md`). The walk for a block therefore starts at **its own** expander,
|
||||
`gtk_expander_get_child(GTK_EXPANDER(app.search_exp))`: the first version of this line took "the first
|
||||
expander in the panel", which stopped naming the search block the moment a second expander was added *before*
|
||||
it -- and would have read `search_inside=0/4` on a panel that was entirely correct. `[defaults] trigger:`
|
||||
prints the same panel's spins in tree order, so the two lines together read what the file gave and what the
|
||||
code put in it.
|
||||
|
||||
## `[persist-probe]` and `persistence:`
|
||||
|
||||
@@ -1972,6 +1976,58 @@ which are bound event series on the same rows — moved neither the axis nor the
|
||||
waits for one), because a latch landing mid-frame leaves the mode `Held` while the rows still hold the live
|
||||
ring's bindings, and a reading taken there says nothing about either.
|
||||
|
||||
## `[density-probe]` and `density:`
|
||||
|
||||
```
|
||||
[density-probe] arming on 'square', window 0.5 s, display off
|
||||
[density-probe] off enabled=0 fade=acquisitions tau=1.000 factor=0.500 rows=0 cells=0 ink=0.0 peak=0.0000 ratio=1.000000
|
||||
[density-probe] on enabled=1 fade=acquisitions tau=1.000 factor=0.500 rows=4 cells=2226150 ink=226596.0 peak=1.0000 ratio=1.000000
|
||||
[density-probe] step-acquisitions enabled=1 fade=acquisitions tau=1.000 factor=0.500 rows=4 cells=2226150 ink=262722.0 peak=1.5000 ratio=1.159429
|
||||
[density-probe] time-before enabled=1 fade=time tau=1.000 factor=0.500 rows=4 cells=2226150 ink=213362.2 peak=1.2182 ratio=1.000000
|
||||
[density-probe] step-time enabled=1 fade=time tau=1.000 factor=0.500 rows=4 cells=2226150 ink=78491.6 peak=0.4481 ratio=0.367879
|
||||
[density-probe] frame cost: tick before=0.74 ms (163 frames) after=9.99 ms (12 frames) draw before=2.09 ms (157) after=20.91 ms (7)
|
||||
[udpscope] density: frames=504 rows=4 cells=2226150 grid_bytes=8904600 ink=632845.4 peak=2.8 upload_bytes=1081350
|
||||
```
|
||||
|
||||
The **phosphor density** (`Plot::set_density()`, the trigger pane's `persistence` switch): one
|
||||
screen-sized intensity image per row, every acquisition accumulated into it, drawn behind the traces and
|
||||
fading by a count or by the clock. The numbers are the plotter's own -- `DensityGrid::ink()`/`peak()` summed
|
||||
and maxed over the rows the display is on, which is the same source the library's tests read -- and the
|
||||
probe drives the panel's **own widgets** for the switch and the fade, so what it measures is this app's
|
||||
wiring.
|
||||
|
||||
- **`off` is the display without it**: `rows=0`, so there is no grid at all and both numbers are zero.
|
||||
That is also the run the frame cost is read against, and it is a plain run -- the density is off by
|
||||
default and nothing draws it.
|
||||
- **`on` is one sweep**: `peak=1.0000` exactly, and `ink=226596` is the cells that sweep covered over
|
||||
four rows. A cell takes **1.0 once per trace per sweep** however many of the trace's own samples crossed
|
||||
it, which is what makes a sweep's peak *one* rather than the eighteen a per-hit deposit read on the same
|
||||
run -- and what makes the sweeps behind it visible at all.
|
||||
- **`step-acquisitions`** deposits one more sweep: `peak=1.0000 -> 1.5000`, which is `1 + 0.5 x 1` -- the
|
||||
panel's factor 0.5 applied to what was there, then the new sweep's own pass on top. `ink` rises from
|
||||
`226596` to `262722` for the same reason: the old image halved and one sweep landed on it. The ratio is
|
||||
**not** a constant to expect -- the acquisition on screen can move between the two reads, so what it says
|
||||
is "the old image halved and a pass landed on it"; the exact relation is the *library's* case
|
||||
(`a_density_halves_per_acquisition_and_keeps_the_newest_brightest`), where the same frame is deposited
|
||||
twice.
|
||||
- **`step-time` is `1/e` by construction**: one step of `dt = tau` through the plot's own `decay_density()`,
|
||||
so `ratio=0.367879` is `exp(-1)`. The leg before it (`time-before`) reads the same image under the time
|
||||
fade, and the pair is the reading that says the *panel's* constant reaches the grid.
|
||||
- **`frame cost:` is the A/B inside one run**: the accumulators as of the tick the display was switched on
|
||||
against the frames after it, so both means are the same stream, the same layout and the same mock. At four
|
||||
rows of 573x412 cells it reads **tick 0.74 -> 9.99 ms** and **draw 2.09 -> 20.91 ms**: the per-frame
|
||||
conversion, upload and deposit. It is llvmpipe, and the arithmetic is the grid's -- 2.2 M cells, one byte
|
||||
each, every frame the time fade moves it.
|
||||
- **The dump's `density:` line** is captured on the last tick the display was **on**, not read at exit: the
|
||||
exit probe switches the trigger off before it dumps, which takes the display with it, so a dump that asked
|
||||
the plots would say `rows=0` about a run whose tail had the image up. `upload_bytes` is the **most** any
|
||||
one frame uploaded -- one byte a cell, over the rows that moved -- and not the last frame's, for the same
|
||||
reason.
|
||||
|
||||
**Read `peak` as the display's ceiling**: with the default `full_scale` of 1.0 a cell at 1.0 is full ink, so
|
||||
a steady display under the acquisition fade sits between 1 and 2, and a `peak` well above that (the
|
||||
per-hit deposit read 189 after two sweeps) is the defect this probe's `on` leg now catches.
|
||||
|
||||
**And `N = 1` is the display without the feature**, which is the same command at
|
||||
`UDPSCOPE_PROBE_PERSIST_DEPTH=1`:
|
||||
|
||||
|
||||
@@ -114,6 +114,34 @@ precedent, not an exception.
|
||||
the axis, the framing and the measurements still describe the current event. `alphas=` is the decay
|
||||
schedule in use, oldest first — halves, and `1/2^age` at that. Both lines and the two exit-dump
|
||||
lines are in [probe-readings.md](probe-readings.md).
|
||||
- `UDPSCOPE_PROBE_DENSITY_MS` (+ `UDPSCOPE_PROBE_DENSITY_TAU`, default 1.0 s) — the **phosphor
|
||||
density**: one screen-sized intensity image every triggered acquisition is accumulated into, drawn
|
||||
behind the traces, fading by a per-acquisition factor or by a time constant. It states the display
|
||||
**off** through the panel's own switch first, so the `off` leg is the run without it, then arms the
|
||||
trigger on the mock's `square` with one signal per row — the arrangement the persistence probe uses
|
||||
— and only then switches it on, so the acquisition already on screen is the first image. Four legs,
|
||||
each a separate `g_timeout`, so whole frames happen between them:
|
||||
|
||||
```sh
|
||||
python3 tools/mock_udpstreamer.py --port 9551 --rate 500000 --batch 1000 &
|
||||
PY=$!; sleep 0.5
|
||||
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=14000 \
|
||||
UDPSCOPE_PROBE_DENSITY_MS=2500 ./build/udpscope --server 127.0.0.1 --port 9551 2>&1 | \
|
||||
grep -E 'density-probe|density:'
|
||||
kill $PY
|
||||
```
|
||||
|
||||
**The assertions, in order:** `off` reads `rows=0 cells=0 ink=0.0 peak=0.0000` (there is no grid at
|
||||
all); `on` reads `rows=4` — every row has its own image — and **`peak=1.0000`**, one sweep's ink
|
||||
being one per cell it covers; `step-acquisitions` reads **`peak=1.5000`**, which is the panel's
|
||||
0.5 applied to the old image plus the new sweep's own pass; and `step-time` reads
|
||||
**`ratio=0.367879`**, one step of `dt = tau` through the plot's own `decay_density()`. A `peak` in
|
||||
the tens for the `on` leg is the *per-hit* deposit -- one sweep counted once per sample that
|
||||
crossed a cell -- which saturates a single acquisition and hides the sweeps behind it. The line
|
||||
also carries the app's own A/B of the frame cost (`tick`/`draw` before and after the switch, in
|
||||
one run), and the exit dump's `density:` line says what the last frame it was on held. Both lines
|
||||
and the dump line are in [probe-readings.md](probe-readings.md).
|
||||
|
||||
- `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
|
||||
|
||||
@@ -93,9 +93,9 @@
|
||||
offers a pulse width to a window trigger.</p>
|
||||
</section>
|
||||
|
||||
<section id="persistence">
|
||||
<title>Persistence: keeping the last few acquisitions</title>
|
||||
<p><gui>Persistence</gui> decides how many of the most recent triggered acquisitions stay on
|
||||
<section id="multiple-events">
|
||||
<title>Multiple events: keeping the last few acquisitions</title>
|
||||
<p><gui>multiple events</gui> decides how many of the most recent triggered acquisitions stay on
|
||||
screen at once. It is <em>1</em> by default, which is the event alone — the display you get
|
||||
when you ask for no persistence — and it can be raised to 8. Each older acquisition is drawn
|
||||
at half the brightness of the one before it (1, 0.5, 0.25, 0.125, …), and they are all aligned
|
||||
@@ -107,7 +107,7 @@
|
||||
<p>The older acquisitions are drawn <em>behind</em> the current one and nothing else changes
|
||||
because of them: the axis still frames the event on screen, the trigger marker still marks
|
||||
<em>its</em> edge, and the measurements still describe the current frame. Lowering
|
||||
<gui>Persistence</gui> back to 1 remembers nothing and frees the retained acquisitions
|
||||
<gui>multiple events</gui> back to 1 remembers nothing and frees the retained acquisitions
|
||||
immediately.</p>
|
||||
|
||||
<p>Two things bound how many are drawn, and the panel tells you when one of them bites.</p>
|
||||
@@ -120,6 +120,40 @@
|
||||
so persistence may hold at most as much again as the buffer (<gui>--buffer-mb</gui>) does.
|
||||
On a long window at a high sample rate that bound is reached before the eighth layer.</p></item>
|
||||
</list>
|
||||
<section id="persistence">
|
||||
<title>Persistence: how often the trace has been here</title>
|
||||
<p><gui>persistence</gui> — the switch in the trigger panel's <gui>Advanced</gui> block — keeps
|
||||
one <em>image</em> of the last acquisitions instead of their traces. Every acquisition is
|
||||
accumulated into it, so a cell's brightness means how many of them passed through it: the
|
||||
sweep on screen is the brightest, the ones behind it add on top, and where several agree the
|
||||
ink is brighter still. It is off by default.</p>
|
||||
|
||||
<p>Two things make it different from <gui>multiple events</gui> beside it, and the panel offers
|
||||
both because they answer different questions. <em>It never runs out of room</em>: the image is
|
||||
the size of the plot on screen, so a display of a thousand acquisitions costs what a display of
|
||||
one costs, and there is no count to choose. <em>And it fades</em>, in one of two ways —
|
||||
<gui>fades by</gui> picks them:</p>
|
||||
<list>
|
||||
<item><p><gui>acquisitions</gui>: every acquisition keeps
|
||||
<gui>per acquisition</gui> of what was there before it (0.5, the default, is the schedule a
|
||||
phosphor's afterglow has: 1, 0.5, 0.25, …). The display then shows the last few sweeps
|
||||
whatever the trigger rate.</p></item>
|
||||
<item><p><gui>time</gui>: the image fades by <gui>time constant s</gui> as the display runs —
|
||||
one constant is the time for it to fall to a third of its ink — so a slow trigger leaves
|
||||
faint sweeps and a fast one leaves bright ones, and the picture is the same whichever the
|
||||
stream is doing.</p></item>
|
||||
</list>
|
||||
|
||||
<p>Persistence is a <em>trigger mode</em> display, like the trigger itself: it is drawn while a
|
||||
captured acquisition is on screen and goes away with the trigger, on a pause, and when you open
|
||||
a stored waveform. Nothing about it moves the framing — the axis is still the current event's,
|
||||
the older sweeps only add ink — and it reserves no space in the plot and cannot be clicked, so
|
||||
it is never in the way of a cursor or a drop.</p>
|
||||
|
||||
<p>Both displays live in the trigger panel's <gui>Advanced</gui> block, together with the
|
||||
trigger's other settings that are set up once and left alone.</p>
|
||||
</section>
|
||||
|
||||
|
||||
<p>If a depth cannot be shown in full, a notice says how many layers fit, and the panel's
|
||||
tooltip carries the same rule. Persistence is a display of what has already been captured: it
|
||||
|
||||
@@ -520,6 +520,14 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
||||
double n = 0.0;
|
||||
num(n);
|
||||
t.persistence = static_cast<int>(n);
|
||||
} else if (k == "density") {
|
||||
t.density = v == "true";
|
||||
} else if (k == "density_decay") {
|
||||
t.density_decay = v == "time" ? "time" : "acquisitions";
|
||||
} else if (k == "density_tau") {
|
||||
num(t.density_tau_s);
|
||||
} else if (k == "density_factor") {
|
||||
num(t.density_factor);
|
||||
}
|
||||
};
|
||||
if (section == Section::Top) {
|
||||
@@ -723,6 +731,13 @@ bool save_layout(const std::string& path, const LayoutState& state) {
|
||||
o << "trigger_band = " << state.trigger.noise_band << "\n";
|
||||
o << "trigger_hf = " << state.trigger.hf_fc << "\n";
|
||||
o << "trigger_persistence = " << state.trigger.persistence << "\n";
|
||||
// The phosphor density, the display that is an image rather than a set of traces. Its four fields
|
||||
// are written whether or not it is on, like every other trigger setting: a display set up once and
|
||||
// switched off is still the display you come back to.
|
||||
o << "trigger_density = " << (state.trigger.density ? "true" : "false") << "\n";
|
||||
o << "trigger_density_decay = " << toml_escape(state.trigger.density_decay) << "\n";
|
||||
o << "trigger_density_tau = " << state.trigger.density_tau_s << "\n";
|
||||
o << "trigger_density_factor = " << state.trigger.density_factor << "\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";
|
||||
|
||||
@@ -100,7 +100,23 @@ struct LayoutState {
|
||||
/// How many of the most recent acquisitions the trigger mode keeps on screen (1 = the event
|
||||
/// alone). Part of the trigger's own setup, like the window it captures and the shape it waits
|
||||
/// for: it is what the display does with the acquisitions, and there is no second switch.
|
||||
///
|
||||
/// This is the **multiple event** display -- N retained acquisitions, drawn as N traces. The
|
||||
/// phosphor density beside it is a different display with a different cost (one screen-sized
|
||||
/// image against N window copies), which is why it has its own four fields below and why this
|
||||
/// one keeps the key it has always had: an older file's `trigger_persistence` is still the
|
||||
/// multiple-event depth.
|
||||
int persistence = 1;
|
||||
/// The **phosphor density** (`TriggerConfig::density` and the three beside it): whether the
|
||||
/// trigger-mode display accumulates every acquisition into one image, what it fades by, and the
|
||||
/// constant or factor that fade uses. Persisted because it is a display the user set up and
|
||||
/// comes back to -- the trigger settings around it already are.
|
||||
bool density = false;
|
||||
/// "acquisitions" | "time": the same two words the panel's dropdown shows, for the reason the
|
||||
/// shapes above are stored as text -- an index would tie the file to a declaration order.
|
||||
std::string density_decay = "acquisitions";
|
||||
double density_tau_s = 1.0;
|
||||
double density_factor = 0.5;
|
||||
/// 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;
|
||||
|
||||
@@ -13,6 +13,19 @@ namespace udpscope {
|
||||
/// How an armed trigger re-arms after it fired.
|
||||
enum class TriggerMode { Normal, Single };
|
||||
|
||||
/// How a trigger-mode **phosphor density** fades (the plotter's `plot::DensityDecay`, restated here
|
||||
/// so the panel's dropdown, the session file and the model's copy of the setting all name one
|
||||
/// thing). The two answer different questions and the user asked for both: see the longer note on
|
||||
/// `TriggerConfig::density_decay`.
|
||||
enum class PersistDecay {
|
||||
/// Every acquisition multiplies the image by `density_factor` before depositing its own sweep,
|
||||
/// so the N-th acquisition back is the factor to the N. Scales with the trigger rate.
|
||||
Acquisitions,
|
||||
/// Every frame fades the image by `exp(-dt / density_tau_s)` of stream time, so the display
|
||||
/// fades while you watch whatever the trigger rate.
|
||||
Time,
|
||||
};
|
||||
|
||||
/// The trigger panel's configuration (spec item 2.2).
|
||||
///
|
||||
/// The trigger names a *signal* (an index into `ScopeModel::signals()`), not a
|
||||
@@ -34,8 +47,34 @@ struct TriggerConfig {
|
||||
/// dimmer (see `RowSet::persist_alpha()`): 1 -- the default -- keeps nothing but the event on
|
||||
/// screen, which is the display without persistence, and there is deliberately no second switch
|
||||
/// for it.
|
||||
///
|
||||
/// This is the **multiple event** display (the trigger pane's own words for it): a *set of
|
||||
/// retained acquisitions*, one trace per (signal, age). It is not the phosphor density below,
|
||||
/// which is one image and no count -- the two are different displays and the panel offers both.
|
||||
int persist_depth = 1;
|
||||
|
||||
/// The **phosphor density** (the plotter's `Plot::set_density()`): off by default, and trigger
|
||||
/// mode only. Where `persist_depth` keeps N whole acquisitions as N traces, this accumulates
|
||||
/// every acquisition into one screen-sized intensity image -- brightness is how often a trace
|
||||
/// passed a cell, so the cost of a display of a thousand acquisitions is the cost of one, and
|
||||
/// there is no depth to get wrong. The image is drawn behind the traces, it is unpickable and it
|
||||
/// reserves no layout space, exactly as the invalid-span bands do.
|
||||
///
|
||||
/// It applies while an acquisition is on screen -- `Held`, and `Capturing` as it fills -- and
|
||||
/// not on a live view, which is what the user asked for: a live view is the stream, and a
|
||||
/// display of the last few sweeps of it is a *trigger* display. Turning the trigger off (or
|
||||
/// pausing, or opening a file) takes the display away, and a fresh presentation starts from an
|
||||
/// empty image rather than a stale one drawn at another scale.
|
||||
bool density = false;
|
||||
/// Which way it fades (see `PersistDecay`) -- the choice the user asked for, and the panel's
|
||||
/// dropdown. Deliberately one field and not two switches: a density fades one way or the other.
|
||||
PersistDecay density_decay = PersistDecay::Acquisitions;
|
||||
/// `PersistDecay::Time`'s constant, in stream seconds. The step the panel offers is 0.5 s.
|
||||
double density_tau_s = 1.0;
|
||||
/// `PersistDecay::Acquisitions`'s factor. 0.5 -- half per acquisition -- is the schedule a
|
||||
/// phosphor's afterglow has, and the panel does not offer another.
|
||||
double density_factor = 0.5;
|
||||
|
||||
/// Seconds of post-trigger data to collect before latching the event, given
|
||||
/// the buffer's span.
|
||||
double post_span_s(double buffer_span_s) const;
|
||||
|
||||
+142
-11
@@ -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;
|
||||
}
|
||||
|
||||
+90
-2
@@ -209,17 +209,105 @@
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="persistence_row">
|
||||
<!-- The trigger's *display* settings: what the mode does with the acquisitions it takes, as
|
||||
against the condition that decides when it takes them. Collapsed by default; a trigger
|
||||
is set up once and run for hours, and these are what you come back to when the display
|
||||
is what you are looking at. The rows are the file's, as everywhere in this panel; the
|
||||
controls are appended into them by `build_trigger_panel()`.
|
||||
|
||||
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. -->
|
||||
<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>
|
||||
<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
|
||||
off screen while the block is collapsed (see
|
||||
docs/claude/invariants-gtk-and-chrome.md). -->
|
||||
<object class="GtkBox" id="advanced_head">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">persistence</property>
|
||||
<property name="label" translatable="yes">Advanced</property>
|
||||
<property name="halign">start</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<property name="child">
|
||||
<object class="GtkBox" id="advanced_box">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">6</property>
|
||||
<property name="margin-top">4</property>
|
||||
<child>
|
||||
<object class="GtkBox" id="persistence_row">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel" id="multiple_event_label">
|
||||
<property name="label" translatable="yes">multiple events</property>
|
||||
<property name="halign">start</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="density_row">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel" id="density_label">
|
||||
<property name="label" translatable="yes">persistence</property>
|
||||
<property name="halign">start</property>
|
||||
<property name="hexpand">1</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="density_decay_row">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">fades by</property>
|
||||
<property name="halign">start</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="density_tau_row">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">time constant s</property>
|
||||
<property name="halign">start</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="density_factor_row">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">per acquisition</property>
|
||||
<property name="halign">start</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="arm_row">
|
||||
<property name="orientation">horizontal</property>
|
||||
|
||||
@@ -256,6 +256,66 @@ struct App {
|
||||
std::size_t dbg_persist_last_kept = 0;
|
||||
int dbg_persist_stable_polls = 0;
|
||||
bool dbg_persist_settled = false;
|
||||
/// The phosphor density's controls -- the trigger pane's *Advanced* block: the switch, the
|
||||
/// dropdown that chooses what the image fades by, and the two parameter spins, only one of which
|
||||
/// is on screen at a time (`sync_trigger_rows()` states that, as it does for the condition rows).
|
||||
/// The rows and the expander are held too, so the probe can read what the `.ui` holds and the dump
|
||||
/// can walk into the block -- an expander's child is *not* in the widget-parent chain.
|
||||
GtkWidget* trigger_density_switch = nullptr;
|
||||
GtkWidget* trigger_density_decay_drop = nullptr;
|
||||
GtkWidget* trigger_density_tau_spin = nullptr;
|
||||
GtkWidget* trigger_density_factor_spin = nullptr;
|
||||
GtkWidget* trigger_density_row = nullptr;
|
||||
GtkWidget* trigger_density_decay_row = nullptr;
|
||||
GtkWidget* trigger_density_tau_row = nullptr;
|
||||
GtkWidget* trigger_density_factor_row = nullptr;
|
||||
GtkWidget* trigger_advanced = nullptr;
|
||||
/// A new acquisition has reached the screen and has not been deposited into the density grid yet.
|
||||
/// Set where a snapshot is *framed* -- the held-event branch of the tick -- and when the display
|
||||
/// is switched on under an acquisition already on screen, and consumed by the next tick's
|
||||
/// `apply_density()`: the deposit needs the plot laid out for the acquisition it deposits, and
|
||||
/// that layout is the frame's, one frame after the bind.
|
||||
bool density_pending = false;
|
||||
/// The previous tick's clock (`g_get_monotonic_time()`), for the density's per-frame fade; 0
|
||||
/// before the first frame, which is what makes the first frame's `dt` zero rather than since-boot.
|
||||
gint64 density_last_us = 0;
|
||||
/// Whether the density was on the last time the tick looked, so switching it on under an
|
||||
/// acquisition already on screen deposits that one at once instead of waiting for a latch that
|
||||
/// `single` mode may never send.
|
||||
bool density_was_on = false;
|
||||
/// What the last tick left on screen, for the dump: the rows the density was on, the total ink
|
||||
/// and the peak over them, and the bytes the last *frame* uploaded. Captured on the tick because
|
||||
/// the dump runs after the window is gone.
|
||||
/// The **last tick the density was on**, captured there and not read back from the plots at
|
||||
/// exit: the dump runs after the window is gone and after the exit probe has switched the trigger
|
||||
/// 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;
|
||||
unsigned long long dbg_density_cells = 0;
|
||||
unsigned long long dbg_density_grid_bytes = 0;
|
||||
double dbg_density_ink = 0.0;
|
||||
double dbg_density_peak = 0.0;
|
||||
/// The most any one frame **uploaded** of the grid: one byte a cell, so this is the resolution
|
||||
/// read back as the frame's own cost. A maximum rather than the last value, because the exit
|
||||
/// probe changes the display before the dump runs and the last painted frame is then not a frame
|
||||
/// of this display.
|
||||
long long dbg_density_upload_bytes = 0;
|
||||
/// How many ticks it was on for: 0 is "no image was ever drawn", which is what separates a real
|
||||
/// reading from a default.
|
||||
int dbg_density_frames = 0;
|
||||
/// The frame-cost accumulators as of the tick the probe switched the display on, so the cost can
|
||||
/// be read **before and after** it inside one run -- the same stream, the same layout, the same
|
||||
/// mock. Two runs would differ in everything but the density.
|
||||
double dbg_density_tick_sum0 = 0.0;
|
||||
int dbg_density_tick_n0 = 0;
|
||||
double dbg_density_draw_sum0 = 0.0;
|
||||
int dbg_density_draw_n0 = 0;
|
||||
/// The density probe's own state: the time constant its second half uses, and how many polls it
|
||||
/// has spent waiting for an event to be on screen.
|
||||
double dbg_density_tau = 1.0;
|
||||
int dbg_density_polls = 0;
|
||||
/// The density probe's step, so its sequence is a state machine rather than nested timers.
|
||||
int dbg_density_stage = 0;
|
||||
GtkWidget* signal_list = nullptr;
|
||||
GtkWidget* pause_button = nullptr;
|
||||
/// When the pause began, on the **monotonic wall clock**, or 0 while running. The elapsed time
|
||||
@@ -1817,6 +1877,26 @@ void persist_probe_report(App& a, const char* what);
|
||||
void persist_probe_set_depth(App& a, int depth);
|
||||
gboolean persist_probe_wait(gpointer user);
|
||||
gboolean persist_probe(gpointer user);
|
||||
|
||||
/// The phosphor density's one step for a frame: state every row's `plot::Plot` with the
|
||||
/// configuration the trigger holds, advance its fade by the frame's own `dt`, and deposit the
|
||||
/// acquisition on screen if one has arrived since the last step. `display` and `live_display` are
|
||||
/// passed in rather than re-read, so the step, the bind and the status line all describe one frame.
|
||||
///
|
||||
/// The density is on while an acquisition is on screen -- `Held`, and `Capturing` as it fills --
|
||||
/// and off on a live view, a paused one, or a file: the display the user asked for is a *trigger*
|
||||
/// display. Off is not "paused": switching it off (and leaving trigger mode, and opening a file)
|
||||
/// drops the image at once, so a fresh presentation starts empty rather than showing a stale image
|
||||
/// at another scale.
|
||||
void apply_density(App& a, DisplayMode display, bool live_display);
|
||||
|
||||
/// The density probe (`UDPSCOPE_PROBE_DENSITY_MS`): the display's own numbers read back off the
|
||||
/// plots -- total ink and peak with it off, on, and after one decay step of each kind.
|
||||
void density_probe_report(App& a, const char* what);
|
||||
/// States the switch and/or the fade through the panel's own widgets, the way a user would.
|
||||
void density_probe_set(App& a, bool on, bool by_time);
|
||||
gboolean density_probe(gpointer user);
|
||||
gboolean density_probe_step(gpointer user);
|
||||
void force_probe_report(App& a, const char* leg);
|
||||
gboolean force_probe_press(gpointer user);
|
||||
gboolean force_probe_poll(gpointer user);
|
||||
|
||||
@@ -88,6 +88,10 @@ LayoutState collect_layout(App& app) {
|
||||
st.trigger.noise_band = tc.condition.noise_band;
|
||||
st.trigger.hf_fc = tc.condition.hf_fc;
|
||||
st.trigger.persistence = tc.persist_depth;
|
||||
st.trigger.density = tc.density;
|
||||
st.trigger.density_decay = tc.density_decay == PersistDecay::Time ? "time" : "acquisitions";
|
||||
st.trigger.density_tau_s = tc.density_tau_s;
|
||||
st.trigger.density_factor = tc.density_factor;
|
||||
st.trigger.level_given = true; // written means given, so a load keeps it
|
||||
}
|
||||
// The connection is the session's too: the file's name carries only host and port.
|
||||
@@ -341,6 +345,14 @@ void apply_trigger_layout(App& app, const LayoutState::Trigger& t) {
|
||||
tc.condition.hf_fc = t.hf_fc;
|
||||
tc.pretrigger_pct = t.pretrigger_pct;
|
||||
tc.holdoff_s = t.holdoff_s;
|
||||
// The phosphor density: a plain field write, unlike the multiple-event depth below -- there is
|
||||
// nothing to prune, because the image is not retained data. Its own clamp is the plotter's
|
||||
// (`full_scale`/`gamma` are the plot's), and the two numbers a file may carry anywhere are
|
||||
// already inside the floats the plot takes.
|
||||
tc.density = t.density;
|
||||
tc.density_decay = t.density_decay == "time" ? PersistDecay::Time : PersistDecay::Acquisitions;
|
||||
tc.density_tau_s = t.density_tau_s;
|
||||
tc.density_factor = t.density_factor;
|
||||
}
|
||||
// The persistence depth is not a field write like the rest: it *prunes* the retained
|
||||
// acquisitions to what the file asks for, so it goes through the one setter (which takes the lock
|
||||
|
||||
@@ -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
|
||||
+129
-5
@@ -248,6 +248,20 @@ void write_trigger_widgets(App& app) {
|
||||
static_cast<double>(
|
||||
std::clamp(tc.persist_depth, 1, kMaxPersistDepth)));
|
||||
}
|
||||
if (app.trigger_density_switch != nullptr) {
|
||||
gtk_switch_set_active(GTK_SWITCH(app.trigger_density_switch), tc.density);
|
||||
}
|
||||
if (app.trigger_density_decay_drop != nullptr) {
|
||||
gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_density_decay_drop),
|
||||
tc.density_decay == PersistDecay::Time ? 1u : 0u);
|
||||
}
|
||||
if (app.trigger_density_tau_spin != nullptr) {
|
||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_density_tau_spin), tc.density_tau_s);
|
||||
}
|
||||
if (app.trigger_density_factor_spin != nullptr) {
|
||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_density_factor_spin),
|
||||
tc.density_factor);
|
||||
}
|
||||
gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_type_drop),
|
||||
dropdown_index_of(app.trigger_type_drop,
|
||||
trigger_type_text(tc.condition.type)));
|
||||
@@ -393,6 +407,23 @@ void publish_trigger_fields(App* app) {
|
||||
const double hf = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_hf_spin));
|
||||
const int persist =
|
||||
static_cast<int>(std::lround(gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_persist_spin))));
|
||||
// The density's four controls. Guarded on null, unlike the ones above: they are built by this
|
||||
// panel and a `.ui` that lost one leaves the field at its default rather than crashing the
|
||||
// publish -- and the switch's own state is what a session file and the tick then agree on.
|
||||
const bool density = app->trigger_density_switch != nullptr &&
|
||||
gtk_switch_get_active(GTK_SWITCH(app->trigger_density_switch)) != FALSE;
|
||||
const guint density_decay =
|
||||
app->trigger_density_decay_drop != nullptr
|
||||
? gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_density_decay_drop))
|
||||
: 0;
|
||||
const double density_tau =
|
||||
app->trigger_density_tau_spin != nullptr
|
||||
? gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_density_tau_spin))
|
||||
: 1.0;
|
||||
const double density_factor =
|
||||
app->trigger_density_factor_spin != nullptr
|
||||
? gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_density_factor_spin))
|
||||
: 0.5;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(app->model->mutex());
|
||||
TriggerConfig& tc = app->model->trigger();
|
||||
@@ -411,6 +442,10 @@ void publish_trigger_fields(App* app) {
|
||||
tc.condition.timeout_s = timeout;
|
||||
tc.condition.noise_band = band;
|
||||
tc.condition.hf_fc = hf;
|
||||
tc.density = density;
|
||||
tc.density_decay = density_decay == 1u ? PersistDecay::Time : PersistDecay::Acquisitions;
|
||||
tc.density_tau_s = density_tau;
|
||||
tc.density_factor = density_factor;
|
||||
}
|
||||
// The persistence depth is applied through the one setter rather than written into the config
|
||||
// here: it is clamped to the cap *and* it prunes the retained acquisitions at once, so lowering
|
||||
@@ -460,6 +495,37 @@ void sync_trigger_rows(App& app) {
|
||||
gtk_spin_button_set_value(l2, other < level ? level - step : level + step);
|
||||
}
|
||||
}
|
||||
// The density's own rows follow the *display*, not the condition: the two fades take different
|
||||
// numbers, so only the one in use is on screen -- a greyed spin for the other is a field the user
|
||||
// cannot tell from a broken one -- and the whole choice is hidden while the display is off, since
|
||||
// a decay kind for an image that is not being drawn has no subject. The switch itself stays: it is
|
||||
// the one control that turns the display on.
|
||||
const bool density_on = app.trigger_density_switch != nullptr &&
|
||||
gtk_switch_get_active(GTK_SWITCH(app.trigger_density_switch)) != FALSE;
|
||||
const bool density_by_time =
|
||||
app.trigger_density_decay_drop != nullptr &&
|
||||
gtk_drop_down_get_selected(GTK_DROP_DOWN(app.trigger_density_decay_drop)) == 1u;
|
||||
show(app.trigger_density_decay_row, density_on);
|
||||
show(app.trigger_density_tau_row, density_on && density_by_time);
|
||||
show(app.trigger_density_factor_row, density_on && !density_by_time);
|
||||
}
|
||||
|
||||
/// The **density switch**. It publishes the field and nothing else: whether the image is on is
|
||||
/// read every frame from the model, so there is no widget state to keep in step, and the tick
|
||||
/// notices the transition itself -- which is what deposits the acquisition already on screen
|
||||
/// instead of waiting for the next latch (in `single` mode there may never be one).
|
||||
void on_density_toggled(GObject* o, GParamSpec*, gpointer user) {
|
||||
(void)o;
|
||||
publish_trigger_fields(static_cast<App*>(user));
|
||||
}
|
||||
|
||||
/// The **fade** dropdown. Publishes, then re-states the block: which of the two parameter rows is
|
||||
/// on screen is this dropdown's business and `sync_trigger_rows()` is the one place that reads it.
|
||||
void on_density_decay_changed(GObject* o, GParamSpec*, gpointer user) {
|
||||
(void)o;
|
||||
App* app = static_cast<App*>(user);
|
||||
publish_trigger_fields(app);
|
||||
sync_trigger_rows(*app);
|
||||
}
|
||||
|
||||
void on_trigger_field_changed(GtkWidget*, gpointer user) {
|
||||
@@ -684,12 +750,65 @@ GtkWidget* build_trigger_panel(App& app) {
|
||||
static_cast<double>(persist_seed));
|
||||
gtk_widget_set_tooltip_text(
|
||||
app.trigger_persist_spin,
|
||||
"how many of the most recent triggered acquisitions stay on screen, each older one at half "
|
||||
"the previous one's brightness; 1 is the event alone. A layer needs a free trace slot in the "
|
||||
"row that plots its signal, and the retained acquisitions are also bounded by the buffer's "
|
||||
"own size");
|
||||
"multiple events: how many of the most recent triggered acquisitions stay on screen as "
|
||||
"separate traces, each older one at half the previous one's brightness; 1 is the event "
|
||||
"alone. A layer needs a free trace slot in the row that plots its signal, and the retained "
|
||||
"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.
|
||||
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);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(app.model->mutex());
|
||||
gtk_switch_set_active(GTK_SWITCH(app.trigger_density_switch),
|
||||
app.model->trigger().density);
|
||||
}
|
||||
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");
|
||||
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
|
||||
// slider rather than as a switch -- the record row's rule, and the label carries the `hexpand`.
|
||||
if (GtkWidget* density_row = GTK_WIDGET(gtk_builder_get_object(ui, "density_row"))) {
|
||||
gtk_box_append(GTK_BOX(density_row), app.trigger_density_switch);
|
||||
app.trigger_density_row = density_row;
|
||||
}
|
||||
|
||||
const char* density_decays[] = {"acquisitions", "time", nullptr};
|
||||
app.trigger_density_decay_drop = gtk_drop_down_new_from_strings(density_decays);
|
||||
gtk_widget_set_tooltip_text(
|
||||
app.trigger_density_decay_drop,
|
||||
"'acquisitions': every acquisition multiplies the image by the factor below, so the N-th "
|
||||
"acquisition back is the factor to the N -- it scales with the trigger rate. 'time': the "
|
||||
"image fades by exp(-dt / the time constant) as the display runs, whatever the rate");
|
||||
app.trigger_density_decay_row =
|
||||
row_for(ui, "density_decay_row", app.trigger_density_decay_drop);
|
||||
g_signal_connect(app.trigger_density_decay_drop, "notify::selected",
|
||||
G_CALLBACK(on_density_decay_changed), &app);
|
||||
|
||||
app.trigger_density_tau_spin = spin(0.01, 3600.0, 0.1, 3, 1.0);
|
||||
gtk_widget_set_tooltip_text(app.trigger_density_tau_spin,
|
||||
"seconds for the image to fall to 1/e of its ink: the trace is still "
|
||||
"visible a second afterwards, faintly, which is what a phosphor does");
|
||||
app.trigger_density_tau_row = row_for(ui, "density_tau_row", app.trigger_density_tau_spin);
|
||||
|
||||
app.trigger_density_factor_spin = spin(0.0, 1.0, 0.05, 2, 0.5);
|
||||
gtk_widget_set_tooltip_text(app.trigger_density_factor_spin,
|
||||
"what each acquisition keeps of the ones before it: 0.5 is the "
|
||||
"schedule a phosphor's afterglow has -- 1, 0.5, 0.25, ... -- and 0 "
|
||||
"shows only the newest acquisition");
|
||||
app.trigger_density_factor_row =
|
||||
row_for(ui, "density_factor_row", app.trigger_density_factor_spin);
|
||||
|
||||
app.arm_button = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_arm"));
|
||||
if (app.arm_button != nullptr) {
|
||||
g_signal_connect(app.arm_button, "clicked", G_CALLBACK(on_arm_clicked), &app);
|
||||
@@ -708,9 +827,14 @@ GtkWidget* build_trigger_panel(App& app) {
|
||||
for (GtkWidget* w : {app.trigger_level_spin, app.trigger_pre_spin, app.trigger_holdoff_spin,
|
||||
app.trigger_width_spin, app.trigger_width2_spin,
|
||||
app.trigger_level2_spin, app.trigger_timeout_spin, app.trigger_band_spin,
|
||||
app.trigger_hf_spin, app.trigger_persist_spin}) {
|
||||
app.trigger_hf_spin, app.trigger_persist_spin,
|
||||
app.trigger_density_tau_spin, app.trigger_density_factor_spin}) {
|
||||
g_signal_connect(w, "value-changed", G_CALLBACK(on_trigger_field_changed), &app);
|
||||
}
|
||||
// The advanced block itself, so the probe can read it back and the dump can walk into it: an
|
||||
// expander's child is *not* in the widget-parent chain (see docs/claude/invariants-gtk-and-chrome.md),
|
||||
// so "is this control in the panel" has to start from the child the expander returns.
|
||||
app.trigger_advanced = GTK_WIDGET(gtk_builder_get_object(ui, "advanced_exp"));
|
||||
g_signal_connect(app.trigger_edge_drop, "notify::selected",
|
||||
G_CALLBACK(on_trigger_drop_changed), &app);
|
||||
g_signal_connect(app.trigger_mode_drop, "notify::selected",
|
||||
|
||||
Reference in new issue
Block a user