trigger: a phosphor persistence, driven by the plotter's density image
The trigger pane gains the display the user asked for and the library now provides, and the
persistence it already had is renamed to what it is.
**Persistence is one image; the last-N-traces display is "multiple events".** Both live in a new
`Advanced` expander in the trigger panel, together with the settings that are set up once and left
alone. `persistence` (a switch) turns on the plotter's `Plot::set_density()` for every row:
every triggered acquisition is accumulated into a screen-sized intensity grid drawn behind the
traces, brightness being how many acquisitions covered a cell -- no depth to choose, no memory
that grows with the acquisitions (2.2 MB over four 573x412 rows in the probe's run). `fades by`
picks the fade the user asked for: **acquisitions** (each keeps a factor of what was before it,
0.5 by default -- the phosphor afterglow) or **time** (a time constant in stream seconds, so the
display fades while you watch whatever the trigger rate).
- **Trigger mode only, as asked.** `apply_density()` (the tick) states the display on for `Held`
and `Capturing` and off for `Live`, `Waiting`, `Paused` and a file, and turning it off drops the
grid at once rather than at an acquisition a quiet trigger never sends. It is unpickable and
reserves no layout space, exactly as the invalid-span bands are: a density can never be in the
way of a cursor or a drop, and it cannot move the framing.
- **One deposit per acquisition, one frame after the frame that frames it.** `Plot::accumulate_density()`
deposits what the plot holds through the transforms of its *last* layout -- the frame's own is
computed inside the paint that follows the tick -- so `density_pending` is set beside
`record_pending` and consumed by the next tick. In the same tick it was right on a Held-to-Held
transition and wrong on the first latch, whose axis was still the live ring's. Switching the
display on under an acquisition already on screen deposits it too, so the switch does not appear
to do nothing until the next latch (`single` mode may never send one).
- **The session carries it**: `trigger_density`, `trigger_density_decay` ("acquisitions"/"time",
the words the panel shows), `trigger_density_tau` and `trigger_density_factor`, written whether
or not it is on -- and `trigger_persistence` still means the multiple-event depth, so an older
file is unchanged.
- **The library changed twice under this**: the deposit now counts a cell **once per trace per
sweep** (counting the hits put one dense sweep at the ceiling of the brightness scale and hid the
sweeps behind it -- the probe's `on` leg read `peak=18` for a single sweep and reads `peak=1`
now), and a frame's upload is every row's rather than the last row's. Both are in the plotter's
own commit.
`UDPSCOPE_PROBE_DENSITY_MS` reads it all back off the plots -- the plotter's own `ink()`/`peak()` --
with the panel's own widgets driving the switch and the fade, in four legs: **off** (`rows=0
ink=0.0 peak=0.0000`), **on** (`rows=4`, `peak=1.0000`), **step-acquisitions** (`peak=1.0000 ->
1.5000`, the factor then the new sweep) and **step-time** (`ratio=0.367879`, `1/e` for `dt = tau`).
It also reads the frame cost as an A/B **inside one run** (the accumulators at the switch against
the frames after it): `tick 0.74 -> 9.99 ms`, `draw 2.09 -> 20.91 ms` at four rows and 2.2 M cells
on llvmpipe.
Acceptance: clean build 0 errors and exactly the three documented warnings (12352-byte GResource
blob, two `MathNode::source`), `test-ui` 13/13, `test-help` green, a plain run with **0
Gtk-CRITICAL / Gtk-WARNING** and `gtk warnings: 0`, and the panel's own frame reading
`boxes=16 controls_inside=15/15 advanced_widgets=5/5 advanced_inside=5/5` (the block's controls
counted from the expander's child, which is not in the widget-parent chain -- and the search
block's walk now names *its* expander instead of "the first one", which the second expander would
have stolen).
Docs: the trigger/pinning invariants carry the rule and its five consequences, `probe-readings.md`
the four legs and the dump line, `verification.md` the probe's sequence and assertions, and the
manual's trigger page explains both displays to a user.
This commit is contained in:
1 parent
e14e1ee069
commit
974d6aa2d9
15 files changed
+993
-41
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;
|
||||
}
|
||||
|
||||
+95
-7
@@ -209,15 +209,103 @@
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="persistence_row">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">persistence</property>
|
||||
<property name="halign">start</property>
|
||||
<!-- 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">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>
|
||||
|
||||
@@ -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