Feature/source plugins #1

Merged
martino merged 130 commits from feature/source-plugins into main 2026-11-04 07:35:21 +01:00
22 changed files with 1650 additions and 24 deletions
Showing only changes of commit e14e1ee069 - Show all commits

No files matched your search

+48
View File
@@ -1214,6 +1214,30 @@ back to the *previous* event, or to blank if this was the first. No event fired,
latch counter does not move; a separate `ScopeModel::event_seq()` counts published latch counter does not move; a separate `ScopeModel::event_seq()` counts published
snapshots, and that is what the frame tick keys its framing on. snapshots, and that is what the frame tick keys its framing on.
**Force takes one acquisition now.** The trigger panel's **Force** button, beside
Arm/Disarm, is the scope's "capture this instant": for the condition that is not coming,
it opens a capture at the present moment with the configured pre/post window rather than
waiting for an edge. It is deliberately *not* a second publisher — `TriggerEngine::force()`
only puts the engine into the pending state an edge leaves it in, with the newest stored
stamp as the event's own time, and the ordinary latch does everything else: the cut, the
publish, the latch count, the marker at x = 0 and the framing. A forced acquisition is
therefore framed, pinned and recorded exactly like a latched one, down to the axis being
pinned from the snapshot's own stamps rather than from what was asked for (a request the
ring cannot reach back to — a 100 s window at 99 % pre — comes out seconds wide, which is
what the probe's second leg reads).
Two choices are worth naming. The engine's pre-window gate is **skipped**: the history
behind "now" is whatever the ring holds, and refusing to capture it because the ring is
young would refuse the one thing the button is for. And a capture already filling is **not**
restarted — it is the acquisition being asked for, and restarting it would throw away the
post window it has collected; the click is ignored (and said so on stderr) in that state
only. What the trigger *is* afterwards is still the mode's decision, made where it always
was: Normal leaves it armed for the next edge, Single disarms, because one acquisition is
what the mode means whether an edge produced it or the button. The button lives in the body
of the pane rather than in the locked pane's heading beside Arm/Disarm: the heading's one
control is the runtime *state* a locked pane exists to change, while a force is an action
whose subject — the condition, the level, the pre/post split and the mode — is in the body.
The frame that **frames** a snapshot is the one that consumes its sequence number, not The frame that **frames** a snapshot is the one that consumes its sequence number, not
merely the first frame to see it. The tick reads `display_mode()` before it takes the merely the first frame to see it. The tick reads `display_mode()` before it takes the
model's mutex, while the producer publishes the snapshot inside that mutex and only lowers model's mutex, while the producer publishes the snapshot inside that mutex and only lowers
@@ -1233,6 +1257,30 @@ 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 frozen view and then never needed, since snapshots carry edge-relative stamps); this is
what finally uses it. what finally uses it.
**Persistence: 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
between acquisitions — jitter, ringing, an occasional glitch — reads as a faint ghost behind the
current trace. <gui>Persistence</gui> in the trigger panel is the knob; `1`, the default, is
exactly the display without it, and the range stops at 8, where the eighth layer would be 0.8 %
ink that nothing can see. The older acquisitions are drawn *behind* the current one and are never
pinned: the axis, the framing and every measurement still describe the event on screen.
`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.
**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
long before the eighth layer. The other bound is the plot's own: a layer takes a *free trace slot
in the row that plots its signal*, layer by layer, so a row of four signals — the layout the app
starts with — has room for one older acquisition of each, while a row per signal has room for all
seven. A depth that the room or the budget cuts short is counted and said in a notice, rather than
quietly drawing fewer layers than were asked for. The layers are *not* signals: they hold no
channel, so the signal list, the colour chips, the calibration table, the layout file and every
measurement see one signal where the plot draws several traces of it — the same rule the event
*pane*'s accumulate view follows.
**The whole trigger path measures the window in stream seconds, and never in **The whole trigger path measures the window in stream seconds, and never in
samples.** `TriggerEngine` is fed each sample's timestamp and latches once that clock samples.** `TriggerEngine` is fed each sample's timestamp and latches once that clock
has advanced past the post window; `publish_event()` then finds the slice covering has advanced past the post window; `publish_event()` then finds the slice covering
+135 -1
View File
@@ -27,6 +27,16 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
already says "stream (trigger off)" and there is no engine to arm, and with the pane open the body's already says "stream (trigger off)" and there is no engine to arm, and with the pane open the body's
button is the one on screen — **two arm buttons at once would be two controls for one state**, which button is the one on screen — **two arm buttons at once would be two controls for one state**, which
is why the probe reads the body's allocation as `-1` while the compact one is up. is why the probe reads the body's allocation as `-1` while the compact one is up.
**The same function states the Force button beside the arm one, and the Force button is in the body
only.** Its sensitivity is the same `enabled` (a trigger that is off has no engine to force) and it
is stated in `sync_arm_buttons()` for the same reason the pair above is stated there — one place, so
nothing can drift — but it is *not* armed-gated: in Single mode the previous acquisition leaves the
engine disarmed, which is exactly when a user reaches for Force again, so a button that greyed out
with the engine's armed state would be unavailable for the one thing it is for. It stays in the body
because the heading's single control is the runtime *state* a locked pane exists to change, while a
force is an action whose subject — the condition, the level, the pre/post split and the mode that
decides whether one acquisition is all of it — is in the body; a second Force in the heading would
be a second control for one action whose settings are off screen.
- **A widget's allocation is not readable in the tick it is first shown.** The compact arm control is - **A widget's allocation is not readable in the tick it is first shown.** The compact arm control is
created hidden and shown for the first time inside a signal handler, so `gtk_widget_get_width()` in created hidden and shown for the first time inside a signal handler, so `gtk_widget_get_width()` in
that same tick reads `0` while `gtk_widget_is_visible()` already says `1` — and its sibling's stale that same tick reads `0` while `gtk_widget_is_visible()` already says `1` — and its sibling's stale
@@ -36,7 +46,15 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
has a `compact_arm_step()` of its own; and the control to compare against must be measured with has a `compact_arm_step()` of its own; and the control to compare against must be measured with
`is_visible()` or not at all. `is_visible()` or not at all.
- **Signals start disabled** — enabling is what starts acquisition. A trigger on a - **Signals start disabled** — enabling is what starts acquisition. A trigger on a
signal nobody acquires can never fire, so selecting/arming enables it. signal nobody acquires can never fire, so selecting/arming enables it. **And the signal is resolved
*through* the channel, never taken as one.** `TriggerConfig::channel` indexes `channels()` (the
acquired ones) while `ScopeModel::set_signal_enabled()` indexes the stream's `signals()`, and the
two are not the same index: with one signal acquired, channel 0 *is* that signal while signal 0 is
the stream's own clock. Passing one for the other switched on the time base, left the signal the
trigger watches exactly as it was — and, because a signal the panel was not showing *is* a change,
rebuilt the buffer under the click and dropped its history, which is precisely the pre-trigger
window an arming, and now a force, was about to capture. `trigger_signal_index()` is the one
resolution and both buttons go through it.
- **The trigger's event window is separate from the display window**, and - **The trigger's event window is separate from the display window**, and
`pretrigger_pct` is a share of the *event* span, not of the ring. `pretrigger_pct` is a share of the *event* span, not of the ring.
- **The event window is counted in stream seconds end to end; nothing converts it to - **The event window is counted in stream seconds end to end; nothing converts it to
@@ -201,6 +219,42 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
export.** Both need the same absolute index range over the time ring and every value export.** Both need the same absolute index range over the time ring and every value
ring, or an export and a capture of the same window would hold different samples. Call ring, or an export and a capture of the same window would hold different samples. Call
it under `mutex()`. it under `mutex()`.
- **Force is a synthetic edge, and the latch is still the only publisher.** The trigger panel's
Force button opens one capture at the *present* moment through `TriggerEngine::force()`: the
newest stored stamp becomes the event's own time and the engine enters the same pending state an
edge leaves it in, so the ordinary latch cuts, publishes, counts and frames the acquisition. A
forced acquisition therefore obeys every rule a latched one does — the axis pinned from the
snapshot's own stamps, a `pre_s` shorter than the request when the ring does not reach back that
far, and the frame that frames it being the one that consumes its `event_seq()` — and the button
owns no window, no publish and no framing of its own: **the event it publishes is cut by the same
code as a latched one.** Two things it deliberately does *not* do, both of them choices:
- **It skips the pre-window gate.** `force()` does not test `event_t - oldest_s_ >= pre_seconds_`:
"capture now" is a request for the history that *exists*, and refusing it because the ring is
young would refuse the one thing the button is for. The snapshot reports its own `pre_s`, and the
pin follows it — which is also how the pin gets *read*: with a request the ring cannot satisfy (a
100 s window at 99 % pre) the axis must come out a few seconds wide, where a pin stated from the
request would come out a hundred.
- **It does not restart a capture already filling.** That capture *is* the acquisition being asked
for: the display is already showing it fill (`live(capturing)`), and restarting it would throw
away the post window it has collected and move the marker to the press. The click is refused, and
named on stderr, rather than silently reinterpreted.
What the trigger *is* afterwards is still the mode's decision, and it is made where it always was —
in the latch: Normal leaves the engine armed with the latch's hold-off, Single disarms it. One
acquisition, whether an edge produced it or the button.
- **The Force request is written under the model lock and consumed at the end of a packet.** It is
one atomic flag on `Acquisition` (`request_force()`), read with `exchange` after the channel loop
and before the `trigger_pending()`/`capture_edge()` publish — after the loop because the cut
`publish_event()` is about to make clamps to every value ring's `produced()`, so the newest sample
of every channel has to be in by then, and before the publish so the capture the click opened is
reported by the same packet that opened it. The *write* belongs under `ScopeModel::mutex()` and in
the same critical section as the `armed` flag the click also sets: the producer holds that lock for
a whole packet, reading the panel's `armed` at the top of one and the request at the bottom, so a
request written in between would be read by a packet whose engine was still disarmed and then
dropped — and dropped is right, because a momentary request must not be replayed later on a trigger
the user has stopped watching. With **no channel resolved** (no signal acquired) the click is
refused in the handler and not left to the producer: `on_data()` returns at once for a model with
no *streamed* channel, so a request parked behind that return would fire on the first signal
switched on afterwards.
- **The trigger engine is not fed a reserved hole's samples.** A NaN comparison is false anyway, - **The trigger engine is not fed a reserved hole's samples.** A NaN comparison is false anyway,
so the engine would not latch; but the real reason the samples are withheld is the other one: so the engine would not latch; but the real reason the samples are withheld is the other one:
feeding them would advance the hold-off clock across time nothing was sampled in. The probe feeding them would advance the hold-off clock across time nothing was sampled in. The probe
@@ -242,6 +296,86 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
moved under the stream; a frame accepted into a model with no streamed channel) and for why the moved under the stream; a frame accepted into a model with no streamed channel) and for why the
relative clock is accumulated in seconds. relative clock is accumulated in seconds.
- **Persistence is the older acquisitions drawn *over* the current one, and "a layer is not a
signal" is what keeps them from becoming one.** Trigger mode may keep the last `N`
acquisitions -- `TriggerConfig::persist_depth`, the panel's *persistence* spin, 1 = the event
on screen alone -- and draw the older ones dimmer, which is the whole feature. The machinery is
the layer machinery the accumulate path already has: one **layer** per (signal, age), in the
same trace slot the accumulate path puts its own extra events in, named with the same suffix
vocabulary (` (A)`, ` (B)`, ` #4`), so `layer_base_name()` gives the base signal back and the
signal list, the colour resolver, the measurements and the layout writer all keep seeing **one**
signal where the plot draws several traces of it. Four consequences, each of which is a rule:
* **A layer slot holds no channel.** `RowSet::bind()` resolves a layer's *base* signal (it must
still be acquired -- switching a signal off takes its traces off every plot, layers included)
and then finds the channel **by name inside the snapshot** (`EventSnapshot::channel_names`),
because the channel list is renumbered by any structural change since that acquisition was
cut -- the rule `TriggerEngine` already follows, for the same reason. It leaves
`Row::channel[]` at `-1`, which is what makes every reader that asks "is this slot a trace of
a signal" skip it: the trace counts, the cursors pane's `mix` mask, the measurement marks, the
dump's per-slot lines, `collect_layout()`. A persistence layer is therefore invisible to the
signal list, the calibration table and the measurements by construction and not by a check;
the *measurements* go on describing the current frame because they resolve a channel from the
signal name the user configured, and `sine (A)` is not a signal anybody can configure.
* **Its brightness is a property of the slot, and it is applied where the trace is created.**
`Row::slot_alpha` is `RowSet::persist_alpha(age)` -- **half per acquisition**, so the schedule
is 1, 0.5, 0.25, 0.125, ... and the seventh layer is 0.8 %, i.e. gone; it does *not* depend on
the depth, so raising N adds dimmer acquisitions instead of restating the ones already on
screen. `install()` mixes `slot_alpha * trace_alpha_` into the trace's colour, because a
`Plot` fixes a trace's colour **at `add_trace()`** and a brightness nobody can set later is a
brightness that has to be a rebuild. Two things follow: a per-layer alpha is impossible
without a per-slot one, and **`set_trace_alpha()` is finally read** -- it was set by the
accumulate path and read by nothing, so an accumulation's "translucent, so agreement is
brightness" was opaque in fact while the docs claimed otherwise.
* **The dimmest trace is created first.** A `Plot` draws its traces in the order they were
created and cannot reorder them, so `install()` creates them sorted by brightness and the
*newest* acquisition is the ink on top. A layer drawn over the event would take its colour out
of the event's own trace wherever the two agree -- the opposite of what a persistence display
shows.
* **Every slot the plan does not fill is freed, and the plan does not depend on the data.**
`RowSet::sync_layers()` (called from the tick, before the frame's `bind()`) re-plans one layer
per (signal, age) into the row's free slots, *layer by layer* -- age 1 for every signal first,
then age 2, as the accumulate path does -- so a row that runs out of room draws the sharper
layer for each of its signals and stops. The plan is a function of the rows, the depth and
nothing else, so a **latch costs no rebuild**: the slot names and ages stay put and only the
snapshot behind them changes.
- **An older acquisition is *drawn*, and it may not move anything.** The current event is the one
the axis is pinned to, and persistence must not disturb that, so `RowSet::event_data_window()`
-- the union the pin is re-stated from every frame while an event is held -- **skips layer
slots**: a wider older acquisition would otherwise re-pin the axis, and with it the window a
pinned measurement reads, which is a *measurement* moving because a display option was turned
up. The layers are bound only in `Held` and `Capturing` (a live view has no current event to be
the newest of them, a `Waiting` display is blank by design, and a pause view's stamps are on the
live window's clock, not on an edge): in `Waiting` and `Live` the layer slots are planned but
bound to nothing, and a trace with no series is not drawn and not listed in the legend. The one
thing they do share with the signal is the **row's y axis**, so the y auto-fit sees them -- a
y axis that ignored the traces it draws would clip the excursion the display exists to show.
- **A retained acquisition is a whole window copy, so the depth is capped by bytes and by what a
row can draw.** `publish_event()` moves the outgoing `event()` into the retention before it
publishes the new one: `ScopeModel::persist_event(age)` is the event `age` latches back, one
atomic `shared_ptr` slot per age, read by the tick without a lock exactly as `event()` is.
`snapshot_bytes()` is the honest cost of one -- both stamp arrays, every channel's `f32` samples
and the LOD pyramids built for them, i.e. ~4.5 bytes per sample per channel plus 8 per sample of
clock -- and `prune_persist_locked()` cuts the set to **both** bounds: the depth, and
`persist_budget_bytes()`, which is the ring's own `--buffer-mb`, because persistence is a copy
*of* what the rings hold and holding as much again is the honest ceiling for a display of it.
`kMaxPersistDepth` is 8 for the different reason the panel needs one: the layer there is already
invisible (0.8 %) and seven is all the free slots a one-signal row has. The depth is not the only
thing that limits what is *drawn* -- a row of `S` signals with 8 slots shows
`floor((8 - S) / S)` ages of each, so the default four-signal row shows one older acquisition
and a row per signal shows all seven. The two shortfalls are **separate readings**, and a caller
that conflated them would report a shrunken display as complete: a *slot* shortfall is
`placed < want` (what `RowSet::sync_layers()` returns against what the depth asked for), while a
*budget* shortfall is a `kept` below the depth plus the count `persist_dropped()` carries -- and in
that case the layer slots **exist and draw nothing at all**, so `layers=`/`placed=` alone say
nothing is wrong. The panel's notice therefore names both numbers -- `D of W layers fit, K of A
older acquisitions retained` -- because a user who asked for eight layers and sees two has no other
way to tell "the display is deliberately shallower" from "the feature is broken".
- **Depth 1 is not a mode, it is the *absence* of one.** `persist_depth = 1` retains nothing at
all -- no copy, no layer, no rebuild -- and `set_persist_depth()` prunes the set **at once**
rather than at the next latch, because lowering the depth is a user asking to stop paying, and a
trigger that has stopped firing never reaches another publish. A depth change is therefore also
how the memory comes back.
## A latch is also a file ## 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 The pane that lists the cache is **on demand**: the toolbar's event-history button and the pane are
+140 -8
View File
@@ -94,6 +94,50 @@ Two lines of the self-check output are assertions in disguise:
`events` does not move**: no event fired, so the partial publish deliberately leaves `events` does not move**: no event fired, so the partial publish deliberately leaves
`trigger_latches()` alone — which is also what keeps the `trigger events: N total, M at `trigger_latches()` alone — which is also what keeps the `trigger events: N total, M at
the disarm` line in the dump meaning what it says. the disarm` line in the dump meaning what it says.
- `[force-probe] button off: sensitive=0 visible=1 w=X | on: sensitive=1 w=X | arm w=Y |
label_found=1` (stderr, `UDPSCOPE_PROBE_FORCE_MS`) — the Force button's stated state, read on both
sides of the trigger toggle. **`sensitive=0` with the trigger off and `1` with it on** is the whole
rule (`sync_arm_buttons()` states it from the toggle, and a trigger that is off has no engine to
force); `visible=1` in both says it is a *body* control and not a compact-heading one, which is why
the off reading must not be `visible=0`; `w` is its allocation a frame after the state it belongs
to, and `arm w=Y` the button beside it, so a `w=0` next to a non-zero `Y` is the "width read in the
tick the control was stated" trap rather than a collapsed button. `label_found=1` is the button the
window shows under the word **Force** *being* the panel's own widget.
- `[force-probe] leg A|B want=[-a..b] during=[-c..d] snap=[-e..f] n=N axis=[..] latches=P->Q
display=.. matches_request=D pin_is_snapshot=P` (stderr, `UDPSCOPE_PROBE_FORCE_MS`) — one line per
leg, taken after the latch the press produced. **`latches` must move by exactly one and `display`
must read `event(frozen)`**: the probe sets the condition to a level the mock never reaches (9 V
against ±1 V signals), so the engine cannot latch on its own and a forced acquisition is the only
thing that *can* move the count — that is also why this probe must run alone, since any other probe
that arms a reachable condition would add latches of its own. `want` is the pre/post split the
panel was set to at the press, taken from the producer's own `post_span_s()`; `snap` is what the
snapshot holds (`[-pre_s..+post_s]`); `during` is the axis while the forced capture was *filling*,
which is the *requested* window — a capture has no snapshot to frame from yet. The two legs differ
in which of those can agree:
- **Leg A (2 s window at 50 % pre, on a ring that has filled)**: `matches_request=1`, i.e.
`snap ≈ want` to within a stored interval, and `n` the span times the stream's real rate plus
one (`20001` at 10 kS/s for `[-1..+1]`, exactly as the latch line demands).
- **Leg B (100 s window at 99 % pre, which no run this short can reach back to)**: the request is
99 s of history and the ring holds seconds, so `matches_request=0` **on purpose** and
`snap ≈ [-history..+1]`. That is the leg that makes **`pin_is_snapshot=1`** a reading rather than
a coincidence: `axis` must equal `[-snap.pre..+snap.post]` (a few seconds wide) while `during`
still reads the requested `[-99..+1]`. A pin stated from the *request* — the
`hold_pinned_window()`/`pin_event_window()` mistake this probe exists for — reads
`axis=[-99.0000..1.0000]` here, i.e. `pin_is_snapshot=0` with the frozen picture a fraction of
its own axis.
A forced acquisition is an ordinary latch in every other respect, so the `trigger: edge at X ...
want [-a..b] got [-c..d]` line appears beside each leg with the *force's* edge as its `X`, and that
line's own rule (`got == want`, `n` exact) applies to it unchanged. The line
`[udpscope] trigger: forced acquisition, edge at E -- want [-a..b] s, H s of history behind it,
mode=M` is the producer accepting the request: `H` is the ring's retained span at the press, which
is what caps the pre side in leg B, and `mode=single (disarms at the latch)` says the one
acquisition the mode means, so an armed state that reads `0` afterwards is right and not a lost
click. `force ignored (enabled=.. armed=.. ch=.. pending=..)` is the refusal — a trigger that is
off, a channel that is still `-1`, or **a capture already filling**, which is deliberately *not*
restarted (it is the acquisition being asked for); in the `ch=-1` case the click is refused in the
handler instead (`force refused -- no signal is being acquired to trigger on`), because a request
left to the producer would sit behind `on_data()`'s no-streamed-channel early return and fire on
the first signal switched on afterwards.
- `[trigrenum-probe] window=W event_span=S pre_pct=P post=O holdoff=H level=L edge=E mode=M - `[trigrenum-probe] window=W event_span=S pre_pct=P post=O holdoff=H level=L edge=E mode=M
enabled=1 armed=1 ch=C channels=N`, then `capture open: t0=T ch=C -- enabling signal 1 now`, enabled=1 armed=1 ch=C channels=N`, then `capture open: t0=T ch=C -- enabling signal 1 now`,
`after enable: trigger ch=C' channels=N'`, and `latched t0=T' opened on T t0_held=D` (stderr, `after enable: trigger ch=C' channels=N'`, and `latched t0=T' opened on T t0_held=D` (stderr,
@@ -598,7 +642,12 @@ Two lines of the self-check output are assertions in disguise:
must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the
first run's values, and the signals it names must come back *acquired* (a `row 0 slot 0 first run's values, and the signals it names must come back *acquired* (a `row 0 slot 0
-> channel ... samples=N` line). Delete the file before a run whose layout you did not -> channel ... samples=N` line). Delete the file before a run whose layout you did not
mean to inherit (`127.0.0.1_9540.toml` and friends accumulate otherwise). mean to inherit (`127.0.0.1_9540.toml` and friends accumulate otherwise). A **persistence
layer** would appear in that line as a suffixed name (`sine (A)`, `sine #4`) *while trigger mode
is on* — it is not the user's arrangement and is never saved (`collect_layout()` writes a layer
slot as empty) — and it is absent from the dump, which is always taken after the exit probe's
go-live has cleared the layers. `[persist-probe]` is therefore the only run-in-progress reading of
them.
- `[udpscope] starved: connected (config=1), reconnects=N, no new frames in the last - `[udpscope] starved: connected (config=1), reconnects=N, no new frames in the last
second ...` (stderr, once per second while the state holds, plus a status-line notice) — second ...` (stderr, once per second while the state holds, plus a status-line notice) —
"connected but nothing is arriving after a reconnect", which over unicast is the second "connected but nothing is arriving after a reconnect", which over unicast is the second
@@ -1323,7 +1372,7 @@ popover row reads `failed: <strerror>` — one stderr line, no retry storm again
## `[defaults]` and `[configs]` ## `[defaults]` and `[configs]`
``` ```
[defaults] calibrate: 1 0 model=1 0 | trigger: 0.001 0.002 0 0.001 0 50 0.01 [defaults] calibrate: 1 0 model=1 0 | trigger: 0.001 0.002 0 0.001 0 50 0.01 0 0 1
[defaults] calibrated: 2.5 -1.25 model=2.5 -1.25 [defaults] calibrated: 2.5 -1.25 model=2.5 -1.25
[configs] before=default active=default [configs] before=default active=default
[configs] save-as=1 names=probe,default active=probe [configs] save-as=1 names=probe,default active=probe
@@ -1339,7 +1388,7 @@ sets them to **7.5 and 250** before saving, so a key that never reached the file
| --- | --- | --- | | --- | --- | --- |
| `[defaults] calibrate:` | `1 0 model=1 0` | an unseeded spin button: `gtk_spin_button_new_with_range()` leaves the value at the range minimum, so the offset opened at -1e12 and Apply wrote it | | `[defaults] calibrate:` | `1 0 model=1 0` | an unseeded spin button: `gtk_spin_button_new_with_range()` leaves the value at the range minimum, so the offset opened at -1e12 and Apply wrote it |
| `[defaults] calibrated:` | `2.5 -1.25 model=2.5 -1.25` — the dialog and the model agreeing after a calibration was set | a dialog that opens at the identity while the trace is drawn through a gain: pressing Apply "confirms" the calibration away. The two halves are printed so the assertion is that they *match*, not that either is a particular number | | `[defaults] calibrated:` | `2.5 -1.25 model=2.5 -1.25` — the dialog and the model agreeing after a calibration was set | a dialog that opens at the identity while the trace is drawn through a gain: pressing Apply "confirms" the calibration away. The two halves are printed so the assertion is that they *match*, not that either is a particular number |
| `[defaults] trigger:` | `0.001 0.002 0 0.001 0 50 0.01` | the same, per trigger field; a level of `-1e12` is a number nobody chose | | `[defaults] trigger:` | `0.001 0.002 0 0.001 0 50 0.01 0 0 1` | the same, per trigger field — the ten spins in tree order: width, width2, level2, timeout, level, pre-trigger, hold-off, noise band, HF reject, persistence; a level of `-1e12` is a number nobody chose, and the trailing `1` is the persistence depth, whose default *is* the feature off |
| `[configs] names` | gains `probe`, and loses it after `forget` | a save that writes somewhere the listing does not read, or a delete that leaves the file | | `[configs] names` | gains `probe`, and loses it after `forget` | a save that writes somewhere the listing does not read, or a delete that leaves the file |
| `[configs] active` | `probe` after the save-as | the `active` file not written, so the next start opens the wrong configuration | | `[configs] active` | `probe` after the save-as | the `active` file not written, so the next start opens the wrong configuration |
| `[configs] round trip` | `read=1`, the note verbatim, `buffer_mb` non-zero | the note or the startup settings not persisted — a configuration that carries a layout but not the buffer size it was tuned with | | `[configs] round trip` | `read=1`, the note verbatim, `buffer_mb` non-zero | the note or the startup settings not persisted — a configuration that carries a layout but not the buffer size it was tuned with |
@@ -1850,7 +1899,7 @@ through one row type, for the reason in `invariants-gtk-and-chrome.md`. The form
## `trigger panel:` ## `trigger panel:`
``` ```
[udpscope] trigger panel: boxes=15 controls_inside=15/15 search_widgets=4/4 search_inside=4/4 [udpscope] trigger panel: boxes=17 controls_inside=16/16 search_widgets=4/4 search_inside=4/4
``` ```
The trigger panel's frame, read from the window: **the `.ui`'s own panel** (`trigger_body`), not the box The trigger panel's frame, read from the window: **the `.ui`'s own panel** (`trigger_body`), not the box
@@ -1858,18 +1907,101 @@ this app wraps it in — the heading row and the locked summary are the code's a
*wrapper* made this line report `boxes=2 … search_inside=0/4` the moment the heading moved out of the file. *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 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, `controls_inside`
counts the fifteen controls the code built and then appended into those rows -- the last two being the 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, `search_widgets` is the four 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 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 opens onto. A control built and never placed -- an id that drifted between the file and the code -- reads as
`controls_inside` below 15 while every other line still looks right, which is the panel that has a field `controls_inside` below 16 while every other line still looks right, which is the panel that has a field
missing. missing.
The search walk starts at `gtk_expander_get_child()`, not at the panel: an expander's child is not in the 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 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 seven spins in tree order, so `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. the two lines together read what the file gave and what the code put in it.
## `[persist-probe]` and `persistence:`
```
[persist-probe] held depth=4 kept=3 bytes=20998152 budget=209715200 dropped=0 signals=5 channels=4 layers=12 want=12 placed=12 alphas=[0.1250,0.2500,0.5000] ages=[3,2,1] row0=[1:'sine (A)'@0.5000 2:'sine (B)'@0.2500 3:'sine #4'@0.1250] drawn=12
[persist-probe] held axis=[-0.2500..0.2500] pin=[-0.2500..0.2500] snap=[-0.2500..0.2500] n=250001 ev=[-0.2500..0.2500] ev_is_snap=1 pin_is_snap=1
[udpscope] persistence: depth=4 kept=3 bytes=20998152 budget=209715200 dropped=0 layers=0 want=0 placed=0 schedule=[0.1250,0.2500,0.5000] alphas=[]
```
The persistence display: the last `depth` triggered acquisitions drawn behind the current one, each older one
at half the previous one's brightness. Run with one signal per row and a 0.5 s window against the mock at
500 kS/s, which is what the two lines above are.
**`alphas=` is the assertion, oldest first**: `0.5000,0.2500,0.1250` — halving, with `ages=[3,2,1]` matching
one for one. A flat, ascending, or age-gapped list is the decay not being applied or applied backwards (the
rule is in [invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)). `row0=` is the same
schedule read per slot, with each layer's name — `sine (A)` one acquisition back, `sine (B)` two, `sine #4`
three, the accumulate path's own suffix vocabulary. `layers=` is the layer slots that exist,
`want=` the depth's whole demand across the rows, `placed=` what fitted: **the three agreeing is a display
that shows every age it was asked for**, and `placed < want` is a layout with no free trace slot left (a row
of `S` signals shows `floor((8 - S) / S)` ages of each, so the default four-signal row shows one).
**`kept`/`bytes`/`dropped` are the *retention*, and they bound what those slots can draw.** `kept=3` of the
3 asked at `bytes=20998152` (21 MB: three acquisitions of a 0.5 s window at 500 kS/s over four channels,
~7 MB each) against `budget=209715200`, the ring's own `--buffer-mb`. `drawn=` is how many layer slots are
actually *bound* to a retained acquisition, and it is a different number from `placed=` exactly when the
budget bit — which is the run to take next, on the fast mock with a long window:
```
[persist-probe] settled: kept=1 of 7 after 270 polls
[persist-probe] held depth=8 kept=1 bytes=72798888 budget=209715200 dropped=0 signals=5 channels=4 layers=28 want=28 placed=28 alphas=[0.0078,0.0156,0.0312,0.0625,0.1250,0.2500,0.5000] ages=[7,6,5,4,3,2,1] row0=[1:'sine (A)'@0.5000 … 7:'sine #8'@0.0078] drawn=4
[persist-probe] held axis=[-1.0000..1.0000] pin=[-1.0000..1.0000] snap=[-1.0000..1.0000] n=2600000 ev=[-1.0000..1.0000] ev_is_snap=1 pin_is_snap=1
[udpscope] persistence: depth=8 kept=2 bytes=145598128 budget=209715200 dropped=2 layers=0 want=0 placed=0 schedule=[0.0078,0.0156,0.0312,0.0625,0.1250,0.2500,0.5000] alphas=[]
```
`UDPSCOPE_PROBE_PERSIST_DEPTH=8 UDPSCOPE_PROBE_PERSIST_WINDOW=2` against the mock at 1.3 MS/s: one retained
acquisition is 72.8 MB, so the 200 MB budget keeps two and drops two — `kept` below the depth, `dropped`
non-zero, and **`drawn=4` against `placed=28`**: the layer slots exist and the oldest seven ages per row draw
nothing at all. That is the memory bound as a reading, and `settled:` is the wait saying so (the retention
can never reach the depth, so the probe reports what stopped growing instead of waiting out its cap — its
`kept` can therefore be mid-fill, which is why the *dump's* line below is the settled one). The
in-doubling of `alphas=` there is the same rule at full depth: 1/128 at the eighth, which is why the depth
stops at 8. Do not read this run's `frame cost:` as persistence's: a 2 s window at 1.3 MS/s costs ~90 ms a
latch in `publish cost:` on its own, with or without persistence.
**`ev_is_snap` and `pin_is_snap` must be 1**, and that is the rule about the past: the pin the held event's
axis is re-stated from (`event_data_window()`) is the *snapshot's* own window, so the older acquisitions —
which are bound event series on the same rows — moved neither the axis nor the framing; both runs above read
`axis = pin = snap = [-0.2500..0.2500]` with `n=250001`. They are read on a frame that is *bound* (the probe
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.
**And `N = 1` is the display without the feature**, which is the same command at
`UDPSCOPE_PROBE_PERSIST_DEPTH=1`:
```
[persist-probe] held depth=1 kept=0 bytes=0 budget=209715200 dropped=0 signals=5 channels=4 layers=0 want=0 placed=0 alphas=[] ages=[] row0=[] drawn=0
[persist-probe] held axis=[-0.2500..0.2500] pin=[-0.2500..0.2500] snap=[-0.2500..0.2500] n=250001 ev=[-0.2500..0.2500] ev_is_snap=1 pin_is_snap=1
```
Nothing retained, nothing planned, nothing bound, no bytes — and the framing line identical field for field,
which is what "the same display as before the feature existed" means as a reading. **That run is also the
baseline for the exit dump's `frame cost:` line**, because the tick mean is a mean over the whole run and the
depth is raised at `UDPSCOPE_PROBE_PERSIST_MS` (2.5 s of 16 s), so the difference between the pair is a
*lower bound* on what the layers cost. At the scale above (0.5 s window, 500 kS/s, four rows, one signal
each):
| run | tick mean | draw mean | paint mean | frames |
|---|---|---|---|---|
| `..._PERSIST_DEPTH=1` | `0.82 ms` | `11.10 ms` | `15.96 ms` | 936 |
| `..._PERSIST_DEPTH=4` | `1.18 ms` | `28.92 ms` | `34.60 ms` | 470 |
Thirteen extra traces across the four rows (four signals plus nine layers, the twelfth layer's signal having
no free slot in its row) for **+17.8 ms of draw a frame**, i.e. ~2.6× the ink of the same plot without
persistence; `tick` moves with it by a few tenths, mostly because there are half as many frames to average.
`[udpscope] persistence:` is the same state at the end of the run, and the one line the *standard* self-check
prints without a probe: `depth`, the retention (`kept`/`bytes`/`budget`/`dropped`), the plan
(`layers`/`want`/`placed`, 0 here because the exit probe's go-live has already cleared the layers —
`layers=0` in that line is correct, not a defect), `schedule=` the alphas the depth asks for oldest first
(the rule; the probe's `alphas=` is what the rows were built with), and `alphas=` for row 0.
## `signals panel:` ## `signals panel:`
``` ```
+57
View File
@@ -57,6 +57,63 @@ precedent, not an exception.
`UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes. `UDPSCOPE_PROBE_DISARM_MS` only `UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes. `UDPSCOPE_PROBE_DISARM_MS` only
*starts* its sequence: it switches the trigger on and then polls for a capture to open, *starts* its sequence: it switches the trigger on and then polls for a capture to open,
so the Disarm lands ~300 ms into one rather than wherever a fixed delay happened to fall. so the Disarm lands ~300 ms into one rather than wherever a fixed delay happened to fall.
- `UDPSCOPE_PROBE_FORCE_MS` — the trigger panel's **Force** button: one acquisition at the present
moment, with the pre/post window the panel is set to, without waiting for the condition. It is a
chain, not an instant — `MS` is when it *starts*: it forces trigger mode **off** through the toggle
(a session file remembers the trigger, so the off state is stated rather than assumed) and reads the
button's stated state 300 ms later; it then sets a level no mock signal reaches (9 V), a 2 s window
at 50 % pre, acquires the mock's `sine` and turns the trigger on; it presses Force 2.5 s after that
(the window write re-plans the buffer and clears the history, so the ring needs the time to fill
past the 1 s leg A asks for); it presses a second time on a 100 s window at 99 % pre — a request no
run this short can satisfy — 400 ms after the first latch. **Run it alone, against the plain mock**,
with time for both legs:
```sh
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 &
sleep 1.5
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=14000 UDPSCOPE_PROBE_FORCE_MS=3000 \
./build/udpscope --server 127.0.0.1 --port 8114 2>&1 | \
grep -E 'force-probe|trigger:'
```
Being alone is load-bearing twice: the level is chosen so the engine *cannot* latch on its own, so
a latch count that moves is the force's and nothing else's — any other probe that arms a reachable
condition adds latches of its own — and `UDPSCOPE_ENABLE_ALL_MS` before it would acquire four
signals, which changes the level's default range and the plan. Two lines per pass are in
[probe-readings.md](probe-readings.md); **`latches=P->Q` with `Q == P + 1`, `display=event(frozen)`,
`matches_request=1` in leg A, `pin_is_snapshot=1` in both** are the assertions, and leg B's
`matches_request=0` is *expected* — a ring that cannot reach back 99 s is the only state where
"pinned to the snapshot, not to the request" is a reading rather than a coincidence.
- `UDPSCOPE_PROBE_PERSIST_MS` (+ `UDPSCOPE_PROBE_PERSIST_DEPTH`, default 4, 1..8) — the **persistence
display**: trigger mode keeps the last N acquisitions on screen, each older one drawn at half the
previous one's brightness. It enables the signals itself (one call, as a restored session does) and
arranges one signal per row — the arrangement the app gives an opened file, and the one with room
for every age — sets the depth **through the panel's own spin**, arms the trigger on the mock's
`square` and polls until the retention is full: an event on screen and as many older ones kept as
the depth asks for. The depth is *left* where it put it, so the run's own tail is the display at
that depth:
```sh
python3 tools/mock_udpstreamer.py --port 9541 --rate 500000 --batch 1000 &
PY=$!; sleep 0.5
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=16000 \
UDPSCOPE_PROBE_PERSIST_MS=2500 UDPSCOPE_PROBE_PERSIST_DEPTH=4 \
./build/udpscope --server 127.0.0.1 --port 9541 2>&1 | grep -E 'persist-probe|persistence:|layout:'
kill $PY
```
**Not with `UDPSCOPE_ENABLE_ALL_MS`**: that probe ends by switching a signal off two seconds later,
which takes the row that plotted it — and its layers with it — out of the middle of the measurement.
**Read it as a pair of runs**: the same command at `..._PERSIST_DEPTH=1` is the display *without*
the feature, so its `kept=0`, `layers=0`, `bytes=0` and its `layout:` line holding only the signals
is the "N = 1 is exactly the old behaviour" reading, and it is also the run the exit dump's
`frame cost:` line is compared against — the tick/draw means are means over the whole run, and the
depth is raised at `MS`, so the difference is a *lower bound* on what the layers cost. **`ev_is_snap`
and `pin_is_snap` must be 1 in both runs**: the older acquisitions are drawn and never pinned, so
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_TRIGRENUM_MS` — the trigger's capture must survive a **renumbering of its own - `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, 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 hold-off from `--holdoff`) on the mock's *square* alone, polls for a capture to open, and then
+50 -2
View File
@@ -5,7 +5,7 @@
<info> <info>
<link type="guide" xref="index#viewing"/> <link type="guide" xref="index#viewing"/>
<link type="seealso" xref="pause"/> <link type="seealso" xref="pause"/>
<desc>The trigger's four shapes, arming and hold-off, the event cache with its replay, <desc>The trigger's four shapes, arming, forcing an acquisition and hold-off, the event cache with its replay,
compare and accumulate, and searching the buffer for a condition.</desc> compare and accumulate, and searching the buffer for a condition.</desc>
<credit type="author copyright"> <credit type="author copyright">
<name>Martino Ferrari</name> <name>Martino Ferrari</name>
@@ -35,6 +35,17 @@
offered only while trigger mode is on, because a trigger that is off has no engine to offered only while trigger mode is on, because a trigger that is off has no engine to
arm.</p> arm.</p>
</item> </item>
<item>
<p><gui>Force</gui> takes one acquisition <em>now</em>, with the event window above,
without waiting for the condition to hold — for when the event you are waiting for is not
coming and you want to see what the signal is doing this instant. The capture runs out its
post-trigger share and then freezes, exactly like a latch: the marker sits at the instant
you pressed. In <gui>Normal</gui> mode the trigger goes back to waiting afterwards; in
<gui>Single</gui> mode it disarms, because one acquisition is the whole of what that mode
means either way. It needs a signal to be acquired — the trigger cannot watch one that is
switched off — and pressing it while a capture is already filling leaves that capture
alone, since it is the acquisition you asked for.</p>
</item>
<item> <item>
<p><gui>Search the buffer</gui> is at the foot of the same panel.</p> <p><gui>Search the buffer</gui> is at the foot of the same panel.</p>
</item> </item>
@@ -82,6 +93,40 @@
offers a pulse width to a window trigger.</p> offers a pulse width to a window trigger.</p>
</section> </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
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
on their own trigger edge, so the pre-trigger part of the event lines up from acquisition to
acquisition and whatever changes between them — jitter, ringing, the occasional glitch — shows
as a faint ghost behind the current trace. That is what a phosphor display does with
successive sweeps.</p>
<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
immediately.</p>
<p>Two things bound how many are drawn, and the panel tells you when one of them bites.</p>
<list>
<item><p><em>Room in the plot.</em> Each older acquisition is an extra trace in the row that
plots its signal, so it needs a free trace slot there. A row holding four signals — the
layout the app starts with — has room for one older acquisition of each of them; a row per
signal has room for all seven.</p></item>
<item><p><em>Memory.</em> A retained acquisition is a whole window copied out of the ring,
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>
<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
changes neither what is acquired nor what is measured, and it does nothing while the trigger
is off.</p>
</section>
<section id="cache"> <section id="cache">
<title>The event cache, replay and compare</title> <title>The event cache, replay and compare</title>
<p><em>Every triggered acquisition is kept.</em> Latches are written to the event cache <p><em>Every triggered acquisition is kept.</em> Latches are written to the event cache
@@ -178,7 +223,10 @@
by live data.</p></item> by live data.</p></item>
<item><p>The first edge after arming is only accepted once the history reaches back a <item><p>The first edge after arming is only accepted once the history reaches back a
full pre-trigger window, so a capture may legitimately be shorter than you asked for. full pre-trigger window, so a capture may legitimately be shorter than you asked for.
The axis is pinned to what the capture actually got.</p></item> The axis is pinned to what the capture actually got. <gui>Force</gui> is the one
acquisition that skips that gate — it captures the history that exists, because "now"
is the whole of what you asked for — so on a ring that has just started filling its
left side is short even though the axis still shows only what it got.</p></item>
<item><p>Disarming <em>during</em> a capture publishes what the capture got to, so the <item><p>Disarming <em>during</em> a capture publishes what the capture got to, so the
picture on screen at that moment stays on screen. No event fired, so the event count picture on screen at that moment stays on screen. No event fired, so the event count
does not move.</p></item> does not move.</p></item>
+32
View File
@@ -1051,6 +1051,38 @@ void Acquisition::on_data(const udps_frame_t* frame) {
} }
last_stamp_ = last_stamp_used; last_stamp_ = last_stamp_used;
// A Force pressed since the last packet: one acquisition, at the present moment, without waiting
// for the condition to hold. Read *here* -- the end of the packet, past the channel loop -- for
// the reason the latch itself is deferred out of that loop: `last_stamp_` is the newest stored
// sample by now, so the capture opens on the moment the user pressed, and every channel has
// pushed that sample, so the cut the latch goes on to make cannot clamp it away
// (`publish_event()` clamps to each value ring's `produced()`).
//
// It opens the capture and nothing else. The latch below -- reached through `engine.process()`
// by an edge, through `engine.force()` here -- is what cuts the window, bumps the latch count,
// sets the trigger time and applies the mode's re-arm rule, so a forced acquisition is framed,
// pinned and recorded exactly like one the condition produced. A capture already in flight is
// not cancelled: it *is* the acquisition being asked for, and the engine refuses the request
// rather than restarting it.
if (force_want_.exchange(false, std::memory_order_acq_rel)) {
if (engine.force(last_stamp_)) {
std::fprintf(stderr,
"[udpscope] trigger: forced acquisition, edge at %.3f s -- want "
"[-%.4f..%.4f] s, %.4f s of history behind it, mode=%s\n",
last_stamp_, event_span - post_want, post_want, last_stamp_ - oldest_s,
tc.mode == TriggerMode::Single ? "single (disarms at the latch)" : "normal");
std::fflush(stderr);
} else {
// Nothing to force. Every cause is a state the user can see from the panel -- a trigger that
// is off, a signal that is not acquired, the engine disarmed, a capture already filling --
// so it is named on stderr rather than swallowed.
std::fprintf(stderr,
"[udpscope] trigger: force ignored (enabled=%d armed=%d ch=%d pending=%d)\n",
int(tc.enabled), int(engine.armed()), trig_channel, int(engine.pending()));
std::fflush(stderr);
}
}
model_.trigger_armed().store(engine.armed(), std::memory_order_relaxed); model_.trigger_armed().store(engine.armed(), std::memory_order_relaxed);
// The edge goes out before the flag that makes the UI read it (release/acquire on // The edge goes out before the flag that makes the UI read it (release/acquire on
// `trigger_pending`), so a tick that sees a capture open sees which edge it is for. A // `trigger_pending`), so a tick that sees a capture open sees which edge it is for. A
+17
View File
@@ -48,6 +48,20 @@ class Acquisition {
bool capture_want() const noexcept { bool capture_want() const noexcept {
return capture_want_.load(std::memory_order_acquire); return capture_want_.load(std::memory_order_acquire);
} }
/// Asks for one **forced trigger acquisition** -- the trigger panel's Force button. The producer
/// consumes the request on its next packet and opens a capture at that packet's newest stamp,
/// which the engine's ordinary latch then publishes with the configured pre/post window. The
/// request is dropped rather than retried when there is nothing to force: trigger mode off, no
/// channel resolved, the engine disarmed, or a capture already filling (a capture in flight *is*
/// the acquisition being asked for).
///
/// **Write it under `ScopeModel::mutex()`, together with the `armed` flag the click also sets.**
/// The producer holds that lock for a whole packet -- it reads the panel's `armed` at the top of
/// one and consumes this request at the bottom -- so a request written between the two would be
/// read by a packet whose engine was still disarmed, and then dropped.
void request_force() noexcept { force_want_.store(true, std::memory_order_release); }
/// The capture, for the popover's row and the probe. Counters and errors are read /// The capture, for the popover's row and the probe. Counters and errors are read
/// cross-thread through PacketCapture's own published fields. /// cross-thread through PacketCapture's own published fields.
const PacketCapture& capture() const noexcept { return capture_; } const PacketCapture& capture() const noexcept { return capture_; }
@@ -110,6 +124,9 @@ class Acquisition {
std::atomic<bool> paused_{false}; std::atomic<bool> paused_{false};
PacketCapture capture_; PacketCapture capture_;
std::atomic<bool> capture_want_{false}; std::atomic<bool> capture_want_{false};
/// The pending Force request (see `request_force()`): a flag and not a queue, because two clicks
/// inside one packet are one acquisition, which is what a momentary button means.
std::atomic<bool> force_want_{false};
std::uint32_t capture_slots_ = 1000; std::uint32_t capture_slots_ = 1000;
/// Written once, by the GTK thread, before `capture_want_` is ever published true; /// Written once, by the GTK thread, before `capture_want_` is ever published true;
/// the producer reads it only after the flag, so no lock is needed. /// the producer reads it only after the flag, so no lock is needed.
+23
View File
@@ -7,6 +7,7 @@
#include <algorithm> #include <algorithm>
#include <memory> #include <memory>
#include <string>
#include <vector> #include <vector>
namespace udpscope { namespace udpscope {
@@ -63,8 +64,30 @@ struct EventSnapshot {
/// already O(n), and the same channel on two rows shares it instead of building and /// already O(n), and the same channel on two rows shares it instead of building and
/// storing it twice. /// storing it twice.
std::vector<plot::data::MinMaxPyramid> pyramids; std::vector<plot::data::MinMaxPyramid> pyramids;
/// The channel *names* `values` is indexed by, so a snapshot can be re-bound after the channel
/// list was rebuilt -- and renumbered -- under it: a persistence layer names its signal and
/// resolves that name here, exactly as the trigger engine names its channel and for the same
/// reason (an index is a position in a list that any enable, disable or CONFIG renumbers).
/// Empty on a snapshot nobody re-binds later (a pause view).
std::vector<std::string> channel_names;
}; };
/// Bytes one snapshot holds: both stamp arrays, every channel's samples, the pyramids built for
/// them and the names above. The number the persistence retention is measured in -- a retained
/// snapshot is a whole window copied out of the ring, so what bounds it is bytes, not a count.
inline std::size_t snapshot_bytes(const EventSnapshot& s) {
std::size_t bytes = s.stamps.size() * sizeof(f64) + s.abs_stamps.size() * sizeof(f64) +
s.y.size() * sizeof(plot::Range) +
s.channel_names.size() * sizeof(std::string);
for (const std::vector<f32>& v : s.values) bytes += v.size() * sizeof(f32);
// A pyramid entry is 32 bytes (`MinMaxPyramid::Entry`), one per level-`l` bucket of
// `kBucket << l` samples -- the whole index is about one byte per sample.
for (const plot::data::MinMaxPyramid& p : s.pyramids) {
for (u32 l = 0; l < p.level_count(); ++l) bytes += p.level_size(l) * 32u;
}
return bytes;
}
/// A plot::data::Series view of one channel of an EventSnapshot. The stamps are /// A plot::data::Series view of one channel of an EventSnapshot. The stamps are
/// near-uniform (the arrival clock), so dx is the mean spacing and x_at returns /// near-uniform (the arrival clock), so dx is the mean spacing and x_at returns
/// the exact stamp; gather decimates by 2^level like the ring series does. /// the exact stamp; gather decimates by 2^level like the ring series does.
+5
View File
@@ -516,6 +516,10 @@ bool load_layout(const std::string& path, LayoutState& out) {
num(t.noise_band); num(t.noise_band);
} else if (k == "hf") { } else if (k == "hf") {
num(t.hf_fc); num(t.hf_fc);
} else if (k == "persistence") {
double n = 0.0;
num(n);
t.persistence = static_cast<int>(n);
} }
}; };
if (section == Section::Top) { if (section == Section::Top) {
@@ -718,6 +722,7 @@ bool save_layout(const std::string& path, const LayoutState& state) {
o << "trigger_holdoff = " << state.trigger.holdoff_s << "\n"; o << "trigger_holdoff = " << state.trigger.holdoff_s << "\n";
o << "trigger_band = " << state.trigger.noise_band << "\n"; o << "trigger_band = " << state.trigger.noise_band << "\n";
o << "trigger_hf = " << state.trigger.hf_fc << "\n"; o << "trigger_hf = " << state.trigger.hf_fc << "\n";
o << "trigger_persistence = " << state.trigger.persistence << "\n";
if (!state.comment.empty()) o << "comment = " << toml_escape(state.comment) << "\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"; if (state.buffer_mb > 0.0) o << "buffer_mb = " << state.buffer_mb << "\n";
o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n"; o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n";
+4
View File
@@ -97,6 +97,10 @@ struct LayoutState {
double holdoff_s = 0.010; double holdoff_s = 0.010;
double noise_band = 0.0; double noise_band = 0.0;
double hf_fc = 0.0; double hf_fc = 0.0;
/// 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.
int persistence = 1;
/// Whether the file said anything about the level. A session that did keeps it; one that did not /// 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. /// leaves the panel's own "follow the channel's range" default alone.
bool level_given = false; bool level_given = false;
+62
View File
@@ -891,9 +891,11 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
} }
snap->values.resize(channels_.size()); snap->values.resize(channels_.size());
snap->channel_names.resize(channels_.size());
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0}); snap->y.resize(channels_.size(), plot::Range{0.0, 0.0});
snap->pyramids.resize(channels_.size()); snap->pyramids.resize(channels_.size());
for (std::size_t c = 0; c < channels_.size(); ++c) { for (std::size_t c = 0; c < channels_.size(); ++c) {
snap->channel_names[c] = channels_[c].name;
auto& vals = snap->values[c]; auto& vals = snap->values[c];
vals.resize(static_cast<std::size_t>(n)); vals.resize(static_cast<std::size_t>(n));
if (n > 0) src.values(c).copy_from(first, n, vals.data()); if (n > 0) src.values(c).copy_from(first, n, vals.data());
@@ -918,6 +920,24 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
// capture at the first sample past `post_s` rather than exactly on it. // capture at the first sample past `post_s` rather than exactly on it.
snap->pre_s = snap->stamps.empty() ? 0.0 : -static_cast<double>(snap->stamps.front()); snap->pre_s = snap->stamps.empty() ? 0.0 : -static_cast<double>(snap->stamps.front());
snap->post_s = snap->stamps.empty() ? 0.0 : static_cast<double>(snap->stamps.back()); snap->post_s = snap->stamps.empty() ? 0.0 : static_cast<double>(snap->stamps.back());
// The persistence retention: what was on screen until now becomes the *newest of the older*
// acquisitions, and the set is cut back to the depth the panel asks for and to the retention
// budget. Nothing is retained at depth 1, so the default costs no memory and no copy at all.
if (const int depth = persist_depth_locked(); depth > 1) {
std::shared_ptr<const EventSnapshot> prev = event_.load(std::memory_order_relaxed);
if (prev != nullptr) {
// Only the slots the depth wants are shifted: the ones past it are the prune's business, and
// moving an acquisition into a slot nothing can ever read is work for a snapshot about to be
// dropped.
const std::size_t top = std::min(static_cast<std::size_t>(depth - 1), persist_.size());
for (std::size_t i = top; i-- > 1;) {
persist_[i].store(persist_[i - 1].load(std::memory_order_relaxed),
std::memory_order_relaxed);
}
persist_[0].store(std::move(prev), std::memory_order_relaxed);
}
}
prune_persist_locked(static_cast<std::size_t>(persist_depth_locked() - 1));
event_.store(std::move(snap), std::memory_order_release); event_.store(std::move(snap), std::memory_order_release);
// Bumped after the snapshot is in place, so a consumer that sees the new sequence // Bumped after the snapshot is in place, so a consumer that sees the new sequence
// number sees the snapshot it counts. // number sees the snapshot it counts.
@@ -963,6 +983,48 @@ void ScopeModel::set_trigger_enabled(bool on) {
trigger_enabled_.store(on, std::memory_order_relaxed); trigger_enabled_.store(on, std::memory_order_relaxed);
} }
void ScopeModel::prune_persist_locked(std::size_t keep) {
const std::size_t limit = std::min(keep, persist_.size());
const std::size_t budget = persist_budget_bytes();
std::size_t bytes = 0;
std::size_t kept = 0;
std::uint64_t dropped = 0;
bool full = false;
for (std::size_t i = 0; i < persist_.size(); ++i) {
const std::shared_ptr<const EventSnapshot> snap = persist_[i].load(std::memory_order_relaxed);
if (snap == nullptr) continue;
if (i < limit) {
// Either bound can cut the set: the depth the panel asks for, and the budget, which a long
// window on a fast stream reaches long before the seventh layer.
const std::size_t b = snapshot_bytes(*snap);
if (!full && bytes + b <= budget) {
bytes += b;
++kept;
continue;
}
full = true; // does not fit, and neither does anything older
// Only what the *budget* refused is counted: a slot past the depth is not a drop, it is the
// depth -- and counting it would make the reading say the memory was the reason when the
// panel's own number was.
++dropped;
}
persist_[i].store(nullptr, std::memory_order_relaxed);
}
persist_count_.store(kept, std::memory_order_relaxed);
persist_bytes_.store(bytes, std::memory_order_relaxed);
persist_dropped_.store(persist_dropped_.load(std::memory_order_relaxed) + dropped,
std::memory_order_relaxed);
}
void ScopeModel::set_persist_depth(int depth) {
const int n = std::clamp(depth, 1, kMaxPersistDepth);
std::lock_guard<std::mutex> lock(mutex_);
trigger_.persist_depth = n;
// Pruned here and not only at the next latch: lowering the depth is the user asking to stop
// paying for the layers, and a trigger that has stopped firing never reaches another publish.
prune_persist_locked(static_cast<std::size_t>(n - 1));
}
void ScopeModel::set_epoch_if_unset(f64 e) noexcept { void ScopeModel::set_epoch_if_unset(f64 e) noexcept {
if (epoch_ == 0.0) epoch_ = e; if (epoch_ == 0.0) epoch_ = e;
} }
+64
View File
@@ -10,6 +10,8 @@
#include <plot/data/ring_buffer.hpp> #include <plot/data/ring_buffer.hpp>
#include <algorithm>
#include <array>
#include <atomic> #include <atomic>
#include <unordered_map> #include <unordered_map>
#include <cstdint> #include <cstdint>
@@ -511,6 +513,55 @@ class ScopeModel {
/// while the latch count keeps answering "how many events fired". /// while the latch count keeps answering "how many events fired".
std::uint64_t event_seq() const noexcept { return event_seq_.load(std::memory_order_acquire); } std::uint64_t event_seq() const noexcept { return event_seq_.load(std::memory_order_acquire); }
// --- persistence ----------------------------------------------------------
/// The acquisitions retained for the persistence display, newest first: `persist_event(1)` is
/// the event one latch back, `(2)` the one before that. Null for an age nobody kept.
///
/// Read **without a lock**, like `event()`: the producer publishes these in `publish_event()`
/// with the same atomic-`shared_ptr` discipline, and a snapshot is immutable and shared, so a
/// reader holds it alive for as long as it draws it. What the *ring* would have given is not
/// needed afterwards -- a retained snapshot is a whole window copied out of it.
std::shared_ptr<const EventSnapshot> persist_event(std::size_t age) const noexcept {
if (age < 1 || age > persist_.size()) return nullptr;
return persist_[age - 1].load(std::memory_order_acquire);
}
/// How many acquisitions are retained right now: what the depth asks for, or fewer -- never more
/// -- when the retention budget cut the set short. A reading, so it is counted as the slots that
/// hold a snapshot rather than derived from the depth.
std::size_t persist_count() const noexcept {
return persist_count_.load(std::memory_order_relaxed);
}
/// What those snapshots hold in bytes, and how many acquisitions the *budget* -- as against the
/// depth -- has dropped. Both are producer-written and read as readings.
std::size_t persist_bytes() const noexcept {
return persist_bytes_.load(std::memory_order_relaxed);
}
std::uint64_t persist_dropped() const noexcept {
return persist_dropped_.load(std::memory_order_relaxed);
}
/// The bytes persistence may retain, which is the ring's own byte budget (`--buffer-mb`): the
/// snapshots are copies *of* what the rings hold, so holding as much again as the buffer does is
/// the honest ceiling for a display of it -- and it is the bound that cannot be driven into an
/// OOM by a long window on a fast stream, whatever depth the panel asks for.
std::size_t persist_budget_bytes() const noexcept {
const int mb = cfg_.buffer_mb > 0 ? cfg_.buffer_mb : 0;
return static_cast<std::size_t>(mb) * 1024u * 1024u;
}
/// Sets how many acquisitions the persistence display keeps, clamped to `kMaxPersistDepth`, and
/// prunes the retained set to it **at once**: lowering the depth is a user asking to stop paying
/// for the layers, and the next latch -- when the retention would otherwise catch up -- may never
/// come on a trigger that has stopped firing.
///
/// Takes `mutex()` itself; do not call it holding the lock.
void set_persist_depth(int depth);
/// How many acquisitions the persistence display keeps: the depth, clamped to the cap. Reading
/// `trigger()` needs the lock, which every caller of this already holds.
int persist_depth_locked() const noexcept {
return std::clamp(trigger_.persist_depth, 1, kMaxPersistDepth);
}
/// Publishes the live buffer's new samples to its `ChannelBuffer`s. /// Publishes the live buffer's new samples to its `ChannelBuffer`s.
void acquire_all(); void acquire_all();
@@ -742,6 +793,19 @@ class ScopeModel {
std::atomic<double> capture_edge_{0.0}; std::atomic<double> capture_edge_{0.0};
std::atomic<std::shared_ptr<const EventSnapshot>> event_; ///< the event on screen std::atomic<std::shared_ptr<const EventSnapshot>> event_; ///< the event on screen
std::atomic<std::shared_ptr<const EventSnapshot>> pause_view_; ///< the view frozen at pause std::atomic<std::shared_ptr<const EventSnapshot>> pause_view_; ///< the view frozen at pause
/// The retention behind `persist_event()`: one slot per *previous* acquisition, newest first, so
/// slot 0 is the event one latch back. `kMaxPersistDepth - 1` of them, because the acquisition on
/// screen is `event_` itself and is not retained a second time. Written by the producer in
/// `publish_event()` and by `set_persist_depth()`, both under `mutex()`; read without it.
std::array<std::atomic<std::shared_ptr<const EventSnapshot>>, kMaxPersistDepth - 1> persist_{};
/// The readings `persist_count()`/`persist_bytes()`/`persist_dropped()` answer with, maintained
/// where the slots are written.
std::atomic<std::size_t> persist_count_{0};
std::atomic<std::size_t> persist_bytes_{0};
std::atomic<std::uint64_t> persist_dropped_{0};
/// Drops the retained snapshots past `keep` (0 empties the set) and recounts the readings.
/// Caller holds `mutex()`.
void prune_persist_locked(std::size_t keep);
std::atomic<bool> paused_{false}; std::atomic<bool> paused_{false};
/// Per-signal-name calibration (see `set_calibration`). /// Per-signal-name calibration (see `set_calibration`).
std::unordered_map<std::string, std::pair<double, double>> calibration_; std::unordered_map<std::string, std::pair<double, double>> calibration_;
+209 -4
View File
@@ -68,8 +68,28 @@ constexpr float kMeasureDashOff = 4.0f;
/// stacked frames read as one tall plot with a line through it. /// stacked frames read as one tall plot with a line through it.
constexpr plot::f32 kRowGap = 6.0f; constexpr plot::f32 kRowGap = 6.0f;
/// The suffix that names a persistence layer, in the accumulate path's own vocabulary --
/// ` (A)`, ` (B)`, ` #4` -- so that `layer_base_name()` takes it back off, and the signal list, the
/// colour resolver, the measurements and the layout writer all keep seeing one signal where the
/// plot draws several traces of it.
std::string layer_suffix(int age) {
if (age == 1) return " (A)";
if (age == 2) return " (B)";
return " #" + std::to_string(age + 1);
}
} // namespace } // namespace
float RowSet::persist_alpha(int age) noexcept {
// Half per acquisition, so the seventh is 1/128 -- and halves, not a slope, because that is what
// "exponential decay" means and because it is the one schedule that can be read off a line of
// numbers: 1, 0.5, 0.25, 0.125. It does not depend on the depth, so raising N adds dimmer
// acquisitions instead of restating the ones already on screen.
float a = 1.0f;
for (int i = 0; i < age; ++i) a *= 0.5f;
return a;
}
std::string layer_base_name(const std::string& name) { std::string layer_base_name(const std::string& name) {
const std::size_t a = name.rfind(" ("); const std::size_t a = name.rfind(" (");
if (a != std::string::npos && a + 3 < name.size() && name[a + 3] == ')' && if (a != std::string::npos && a + 3 < name.size() && name[a + 3] == ')' &&
@@ -146,6 +166,11 @@ void RowSet::carry_row(Row& dst, const Row& src) {
dst.name = src.name; dst.name = src.name;
dst.channel = src.channel; dst.channel = src.channel;
dst.color_index = src.color_index; dst.color_index = src.color_index;
// A layer travels with the slot that draws it: it *is* part of what that slot holds. The tick's
// `sync_layers()` re-plans them all next frame in any case, which is what heals a row that has
// moved under a plan made for the old one.
dst.layer = src.layer;
dst.slot_alpha = src.slot_alpha;
dst.ruler_y = src.ruler_y; dst.ruler_y = src.ruler_y;
dst.ruler_b_y = src.ruler_b_y; dst.ruler_b_y = src.ruler_b_y;
@@ -161,6 +186,8 @@ void RowSet::clear_row(Row& r) {
r.name = {}; r.name = {};
r.channel = {-1, -1, -1, -1}; r.channel = {-1, -1, -1, -1};
r.color_index = {0, 1, 2, 3}; r.color_index = {0, 1, 2, 3};
r.layer = {0, 0, 0, 0};
r.slot_alpha = {1.0f, 1.0f, 1.0f, 1.0f};
r.ruler_y = 0.0; r.ruler_y = 0.0;
for (SlotSeries& s : r.slots) s.bind(nullptr); for (SlotSeries& s : r.slots) s.bind(nullptr);
} }
@@ -190,10 +217,24 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
{"", {-1.0, 1.0}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time}); {"", {-1.0, 1.0}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
r.axis_y = r.plot->add_y_axis( r.axis_y = r.plot->add_y_axis(
{"", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si}); {"", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
for (int s = 0; s < kSlotsPerRow; ++s) { // Traces are drawn in the order they were created, and a `Plot` cannot reorder them: the
// *dimmest* traces are created first and a signal's own trace last, so the newest acquisition is
// the ink on top. A layer drawn over the event would take its colour out of the event's own trace
// wherever the two agree -- the opposite of what a persistence display shows.
std::array<int, kSlotsPerRow> order{};
for (int s = 0; s < kSlotsPerRow; ++s) order[static_cast<std::size_t>(s)] = s;
std::stable_sort(order.begin(), order.end(), [&r](int a, int b) {
return r.slot_alpha[static_cast<std::size_t>(a)] < r.slot_alpha[static_cast<std::size_t>(b)];
});
for (const int s : order) {
const std::size_t i = static_cast<std::size_t>(s); const std::size_t i = static_cast<std::size_t>(s);
r.plot->add_trace({&r.slots[i], signal_color(r.signal_name[i], r.color_index[i]), 1.2f, plot::Color color = signal_color(r.signal_name[i], r.color_index[i]);
r.axis_x, r.axis_y, r.name[i]}); // The slot's own brightness times the figure-wide one the group views set: a persistence
// layer's decay and an accumulation's translucency are the same knob, and `set_trace_alpha()`
// is what reads it here. It was set and never read before, which is why an accumulation's
// traces were opaque.
color.a = r.slot_alpha[i] * trace_alpha_;
r.plot->add_trace({&r.slots[i], color, 1.2f, r.axis_x, r.axis_y, r.name[i]});
} }
r.cursor_a = r.plot->add_cursor(0.0, plot::Color::hex(kCursorAColor)); r.cursor_a = r.plot->add_cursor(0.0, plot::Color::hex(kCursorAColor));
r.cursor_b = r.plot->add_cursor(0.0, plot::Color::hex(kCursorBColor)); r.cursor_b = r.plot->add_cursor(0.0, plot::Color::hex(kCursorBColor));
@@ -320,9 +361,132 @@ void RowSet::assign(int row_index, int slot, std::string signal_name,
r.channel[s] = -1; // re-resolved by the next bind() r.channel[s] = -1; // re-resolved by the next bind()
r.name[s] = std::move(display_name); r.name[s] = std::move(display_name);
r.color_index[s] = color_index; r.color_index[s] = color_index;
// Whatever the slot held, it is a *signal's* trace now: a drop onto a slot a persistence layer
// was using is the user's arrangement, and the tick's plan moves the layers around it next frame.
r.layer[s] = 0;
r.slot_alpha[s] = 1.0f;
row_dirty_[static_cast<std::size_t>(row_index)] = true; row_dirty_[static_cast<std::size_t>(row_index)] = true;
} }
void RowSet::assign_layer(int row_index, int slot, std::string signal_name,
std::string display_name, int color_index, int age, float alpha) {
assign(row_index, slot, std::move(signal_name), std::move(display_name), color_index);
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return;
Row& r = row(row_index);
const std::size_t s = static_cast<std::size_t>(slot);
r.layer[s] = age;
r.slot_alpha[s] = alpha;
row_dirty_[static_cast<std::size_t>(row_index)] = true;
}
void RowSet::clear_layers() {
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
if (r.layer[si] == 0) continue;
assign(i, s, "", "", r.color_index[si]);
}
}
}
int RowSet::sync_layers(int depth, int* want) {
const int ages = std::clamp(depth, 1, kMaxPersistDepth) - 1;
// What each slot should hold: 0 a signal's own trace (kept as it is), -1 free, >0 a layer age.
std::array<std::array<int, kSlotsPerRow>, kMaxRows> plan{};
std::array<std::array<std::string, kSlotsPerRow>, kMaxRows> names{};
int wanted = 0;
for (int r = 0; r < rows_; ++r) {
Row& row_state = row(r);
for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
// A layer slot is free for re-planning; a signal's own trace is not.
plan[static_cast<std::size_t>(r)][si] =
row_state.layer[si] > 0 ? -1 : (row_state.signal_name[si].empty() ? -1 : 0);
}
}
// Layer by layer, as the accumulate path fills its own: age 1 for every signal first, then age 2,
// so a row with room for two layers draws the *sharper* one for each of its signals rather than
// four ages of the first one.
for (int age = 1; age <= ages; ++age) {
for (int r = 0; r < rows_; ++r) {
const std::size_t ri = static_cast<std::size_t>(r);
Row& row_state = row(r);
for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
if (plan[ri][si] != 0) continue;
++wanted;
int target = -1;
for (int t = 0; t < kSlotsPerRow; ++t) {
if (plan[ri][static_cast<std::size_t>(t)] == -1) {
target = t;
break;
}
}
if (target < 0) continue; // this row is full: it persists as far as it can
const std::size_t ti = static_cast<std::size_t>(target);
plan[ri][ti] = age;
names[ri][ti] = row_state.signal_name[si] + layer_suffix(age);
}
}
}
int placed = 0;
for (int r = 0; r < rows_; ++r) {
const std::size_t ri = static_cast<std::size_t>(r);
Row& row_state = row(r);
for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
const int now = plan[ri][si];
const int had = row_state.layer[si];
if (now > 0) {
++placed;
if (had == now && row_state.signal_name[si] == names[ri][si]) continue; // already right
assign_layer(r, s, names[ri][si], names[ri][si], row_state.color_index[si], now,
persist_alpha(now));
} else if (had > 0) {
assign(r, s, "", "", row_state.color_index[si]); // nothing wants this slot any more
}
}
}
if (want != nullptr) *want = wanted;
return placed;
}
int RowSet::layer_of(int row_index, int slot) const {
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return 0;
return row(row_index).layer[static_cast<std::size_t>(slot)];
}
float RowSet::slot_alpha_of(int row_index, int slot) const {
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return 1.0f;
return row(row_index).slot_alpha[static_cast<std::size_t>(slot)];
}
int RowSet::layer_slots() const {
int n = 0;
for (int i = 0; i < kMaxRows; ++i) {
const Row& r = row(i);
for (int s = 0; s < kSlotsPerRow; ++s) {
if (r.layer[static_cast<std::size_t>(s)] > 0) ++n;
}
}
return n;
}
std::vector<std::pair<int, float>> RowSet::row_layers(int row_index) const {
std::vector<std::pair<int, float>> out;
if (row_index < 0 || row_index >= kMaxRows) return out;
const Row& r = row(row_index);
for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
if (r.layer[si] > 0) out.push_back({r.layer[si], r.slot_alpha[si]});
}
// Oldest first: the order the decay has to read in.
std::sort(out.begin(), out.end(),
[](const auto& a, const auto& b) { return a.first > b.first; });
return out;
}
bool RowSet::slot_bound(int row_index, int slot) const { bool RowSet::slot_bound(int row_index, int slot) const {
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return false; if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return false;
return !row(row_index).signal_name[static_cast<std::size_t>(slot)].empty(); return !row(row_index).signal_name[static_cast<std::size_t>(slot)].empty();
@@ -348,9 +512,15 @@ void RowSet::bind(ScopeModel& model, DisplayMode mode) {
// Resolve the stable identity every frame: the channel list is rebuilt (and // Resolve the stable identity every frame: the channel list is rebuilt (and
// renumbered) whenever a signal is enabled, disabled or re-described, and a // renumbered) whenever a signal is enabled, disabled or re-described, and a
// stored index would then point at a different signal's channel. // stored index would then point at a different signal's channel.
//
// A persistence layer resolves the name *under its suffix*: `sine (A)` is `sine`, one
// acquisition back, and it is the same signal's trace -- so it is acquired (or not) with its
// signal, drawn in its colour, and counted by nothing that counts signals.
const std::string base_name =
r.layer[si] > 0 ? layer_base_name(r.signal_name[si]) : r.signal_name[si];
int signal_index = -1; int signal_index = -1;
for (std::size_t k = 0; k < signals.size(); ++k) { for (std::size_t k = 0; k < signals.size(); ++k) {
if (signals[k].name == r.signal_name[si]) { if (signals[k].name == base_name) {
signal_index = static_cast<int>(k); signal_index = static_cast<int>(k);
break; break;
} }
@@ -364,6 +534,36 @@ void RowSet::bind(ScopeModel& model, DisplayMode mode) {
} }
} }
} }
if (r.layer[si] > 0) {
// A layer holds no channel: every reader that asks "is this slot a trace of a signal"
// -- the trace counts, the cursors pane's `mix` mode, the measurement marks, the layout
// writer -- tests `channel >= 0`, so a layer is invisible to all of them by construction.
r.channel[si] = -1;
// It draws only while a *snapshot* is on screen, and only if its signal is still acquired
// (switching a signal off takes its traces off every plot, layers included), and it
// resolves its channel **by name inside the snapshot**: the channel list may have been
// rebuilt and renumbered many times since that acquisition was cut, which is exactly why
// the trigger engine names its channel too.
std::shared_ptr<const EventSnapshot> snap;
if (channel >= 0 && (mode == DisplayMode::Held || mode == DisplayMode::Capturing)) {
snap = model.persist_event(static_cast<std::size_t>(r.layer[si]));
}
std::size_t layer_channel = snap != nullptr ? snap->channel_names.size() : 0;
if (snap != nullptr) {
for (std::size_t c = 0; c < snap->channel_names.size(); ++c) {
if (snap->channel_names[c] == base_name) {
layer_channel = c;
break;
}
}
}
if (snap != nullptr && layer_channel < snap->values.size()) {
r.slots[si].bind_event(std::move(snap), layer_channel);
} else {
r.slots[si].bind(nullptr);
}
continue;
}
r.channel[si] = channel; r.channel[si] = channel;
if (channel < 0) { if (channel < 0) {
// Its signal is present but not acquired: keep the identity (so enabling it // Its signal is present but not acquired: keep the identity (so enabling it
@@ -818,6 +1018,11 @@ bool RowSet::event_data_window(double& lo, double& hi) const {
const Row& r = row(i); const Row& r = row(i);
if (!r.plot) continue; if (!r.plot) continue;
for (int s = 0; s < kSlotsPerRow; ++s) { for (int s = 0; s < kSlotsPerRow; ++s) {
// A persistence layer is bound to an event series too, and it is the *past*: letting one
// widen this window would move the pin -- and with it the framing and the numbers a pinned
// measurement reads -- every time an older acquisition happened to be wider than the one on
// screen. What is pinned is the current event's window, and only that.
if (r.layer[static_cast<std::size_t>(s)] > 0) continue;
const EventSeries* ev = r.slots[static_cast<std::size_t>(s)].bound_event(); const EventSeries* ev = r.slots[static_cast<std::size_t>(s)].bound_event();
if (ev == nullptr || ev->size() < 2) continue; if (ev == nullptr || ev->size() < 2) continue;
const plot::Range b = ev->x_bounds(); const plot::Range b = ev->x_bounds();
+46
View File
@@ -112,6 +112,16 @@ class RowSet {
/// `add_trace()`, which is why this is stored rather than resolved per frame -- /// `add_trace()`, which is why this is stored rather than resolved per frame --
/// changing it needs a rebuild, and `assign()` already forces one. /// changing it needs a rebuild, and `assign()` already forces one.
std::array<int, kSlotsPerRow> color_index = {0, 1, 2, 3}; std::array<int, kSlotsPerRow> color_index = {0, 1, 2, 3};
/// The *persistence layer* this slot draws: 0 for a signal's own trace (whatever the frame's
/// display mode binds it to), `a` > 0 for the acquisition `a` latches back, drawn at
/// `slot_alpha`. A layer slot holds a suffixed name (`sine (A)`) and no channel at all -- it is
/// not a signal, and `channel[]` stays -1 for it (see `assign_layer()`).
std::array<int, kSlotsPerRow> layer = {0, 0, 0, 0};
/// How bright the slot's trace is built: 1 for a signal's own trace, `persist_alpha(layer)` for
/// a persistence layer, multiplied into `trace_alpha_`. It lives on the slot because a `Plot`
/// fixes a trace's colour -- alpha included -- at `add_trace()`, so a per-layer brightness is a
/// property of the trace's *creation*, which is what a rebuild is for.
std::array<float, kSlotsPerRow> slot_alpha = {1.0f, 1.0f, 1.0f, 1.0f};
plot::u32 axis_x = 0; plot::u32 axis_x = 0;
plot::u32 axis_y = 0; plot::u32 axis_y = 0;
plot::u32 cursor_a = 0; plot::u32 cursor_a = 0;
@@ -200,6 +210,42 @@ class RowSet {
/// The channel index a row/slot holds, or -1. /// The channel index a row/slot holds, or -1.
int channel_of(int row, int slot) const; int channel_of(int row, int slot) const;
// --- persistence layers ---------------------------------------------------
/// The brightness a persistence layer is drawn at, `a` acquisitions back: the acquisition on
/// screen is the full-bright one and each older one is **half** the previous, so the schedule is
/// exponential in age -- 1, 0.5, 0.25, 0.125, ... and by the seventh (0.008, `kMaxPersistDepth`)
/// the layer is gone. Halving is per acquisition and not per *depth*, so changing N re-cuts how
/// many are kept without rescaling the ones already on screen.
static float persist_alpha(int age) noexcept;
/// Binds a row's slot to a persistence *layer*: the acquisition `age` latches back, at `alpha`
/// (see `Row::layer`). The name carries the layer's suffix (`sine (A)`, `sine #4`), which is what
/// keeps a layer out of the signal list and out of the measurements -- it is not a signal -- while
/// `signal_color()` still resolves it to the base signal's own colour.
void assign_layer(int row, int slot, std::string signal_name, std::string display_name,
int color_index, int age, float alpha);
/// Frees every slot bound to a layer. What turning persistence off -- or the trigger -- is.
void clear_layers();
/// Lays the persistence layers out over the rows' free slots, one per (signal, age): the signal's
/// own trace stays where it is and its layers take the free slots of the *same* row, layer by
/// layer (age 1 for every signal first, then age 2, as the accumulate path does), so a row that
/// runs out of room draws as many layers as it has room for and no more. Every slot the plan does
/// not fill is freed, which is what makes the display follow a layout change, a depth change and a
/// signal's removal without any caller having to reset anything.
///
/// Returns the number of layers placed; `want` is how many the depth asked for, so a caller can
/// say when a layout could not show them all.
int sync_layers(int depth, int* want);
/// The layer a slot is drawing, 0 for a signal's own trace.
int layer_of(int row, int slot) const;
/// The slot's own brightness (1 unless it is a layer).
float slot_alpha_of(int row, int slot) const;
/// How many slots are layers, over every row: the reading that says the layers exist.
int layer_slots() const;
/// The layers of one row, oldest first, as `(age, alpha)` pairs -- the ordering a reader has to
/// see to know the decay is the right way round.
std::vector<std::pair<int, float>> row_layers(int row) const;
/// Rebinds every slot to what `mode` says it should show this frame. Called every /// Rebinds every slot to what `mode` says it should show this frame. Called every
/// frame, because the channel list is renumbered by any structural change. /// frame, because the channel list is renumbered by any structural change.
/// ///
+14
View File
@@ -39,6 +39,20 @@ void TriggerEngine::arm(double now_s) noexcept {
detector_.reset(); detector_.reset();
} }
bool TriggerEngine::force(double now_s) noexcept {
if (!cfg_.enabled || !armed_ || cfg_.channel < 0 || pending_) return false;
// Straight into the pending state `process()` maintains, with the present moment as the event's
// own time: the post window is counted from here by the same accumulation an edge's capture uses,
// and the latch that ends it is the same latch. `last_trigger_s_` is the edge, so the marker, the
// cut's centre and the snapshot's `x_offset` all land on the instant of the press.
last_t_s_ = now_s;
last_trigger_s_ = now_s;
pending_ = true;
pending_start_s_ = now_s;
post_accum_count_ = 0;
return true;
}
bool TriggerEngine::process(double value, double now_s) noexcept { bool TriggerEngine::process(double value, double now_s) noexcept {
if (!cfg_.enabled || !armed_ || cfg_.channel < 0) return false; if (!cfg_.enabled || !armed_ || cfg_.channel < 0) return false;
+30
View File
@@ -30,12 +30,25 @@ struct TriggerConfig {
double pretrigger_pct = 50.0; ///< share of the buffer shown before the event. double pretrigger_pct = 50.0; ///< share of the buffer shown before the event.
double holdoff_s = 0.010; ///< default from the spec. double holdoff_s = 0.010; ///< default from the spec.
TriggerMode mode = TriggerMode::Normal; TriggerMode mode = TriggerMode::Normal;
/// How many of the most recent acquisitions the plot keeps on screen, the older ones drawn
/// 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.
int persist_depth = 1;
/// Seconds of post-trigger data to collect before latching the event, given /// Seconds of post-trigger data to collect before latching the event, given
/// the buffer's span. /// the buffer's span.
double post_span_s(double buffer_span_s) const; double post_span_s(double buffer_span_s) const;
}; };
/// The deepest persistence the trigger panel offers. Seven retained acquisitions is what fits: the
/// layer in that position is drawn at half the seventh power (0.8 %, i.e. already gone, see
/// `RowSet::persist_alpha()`), and seven is also the number of free slots a row has beside a single
/// signal, so a deeper request could not be drawn on the simplest layout there is. It is **not**
/// the bound on what persistence may cost in memory -- `ScopeModel::persist_budget_bytes()` is, and
/// a window of millions of samples reaches it long before the seventh layer.
inline constexpr int kMaxPersistDepth = 8;
/// The producer-side trigger state machine. Fed one sample at a time in stream /// The producer-side trigger state machine. Fed one sample at a time in stream
/// order; it says when the event buffer should be latched. /// order; it says when the event buffer should be latched.
class TriggerEngine { class TriggerEngine {
@@ -75,6 +88,23 @@ class TriggerEngine {
void disarm() noexcept { armed_ = false; pending_ = false; } void disarm() noexcept { armed_ = false; pending_ = false; }
void arm(double now_s) noexcept; void arm(double now_s) noexcept;
/// Opens a capture **now**, as if the condition had just fired: `now_s` becomes the event's own
/// time, so the engine collects the post window from the samples that follow it and the ordinary
/// latch publishes `[-pre, +post]` around that instant. This is the Force button -- the one
/// acquisition a scope takes when the edge is not coming -- and it is deliberately not a
/// publish of its own: the capture, the cut, the marker, the count and the mode's re-arm rule are
/// the edge path's, which is what keeps a forced acquisition framed and pinned exactly like a
/// latched one.
///
/// The pre-trigger gate is the one thing *not* applied: the history behind the present moment is
/// whatever the ring holds, and a caller who asks to capture now is asking for that. A snapshot
/// shorter than the request reports its own `pre_s`, and the view is pinned to it.
///
/// Returns false when there is no engine to force -- trigger mode off, no channel resolved, not
/// armed -- or when a capture is already filling: a capture in flight *is* the acquisition being
/// asked for, and restarting it would discard the post window it has already collected.
bool force(double now_s) noexcept;
/// Feeds one sample of the trigger channel. `now_s` is the sample's timestamp /// Feeds one sample of the trigger channel. `now_s` is the sample's timestamp
/// in stream seconds. Returns true when the event has just been latched. /// in stream seconds. Returns true when the event has just been latched.
bool process(double value, double now_s) noexcept; bool process(double value, double now_s) noexcept;
+72 -1
View File
@@ -3514,11 +3514,27 @@ gboolean tick(gpointer user) {
app->capturing = display == DisplayMode::Capturing; app->capturing = display == DisplayMode::Capturing;
if (new_capture) app->capture_edge = cap_edge; if (new_capture) app->capture_edge = cap_edge;
// The persistence plan this frame: what the depth asked for and what the rows had room to draw.
// 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();
int persist_depth = 1;
int persist_want = 0;
{ {
// Serialise against the acquisition thread's CONFIG handling and against the // Serialise against the acquisition thread's CONFIG handling and against the
// UI's own structural changes: the bind takes shared handles into the live // UI's own structural changes: the bind takes shared handles into the live
// store, and acquire() reads it. // store, and acquire() reads it.
std::lock_guard<std::mutex> lock(model.mutex()); std::lock_guard<std::mutex> lock(model.mutex());
// Persistence first, so the bind below is what *resolves* the layers: the last N acquisitions
// are planned into the rows' free slots, and a depth of 1 -- or the trigger off, or a *file* on
// screen, whose data is not a stream of acquisitions at all -- frees every layer slot, which is
// the display without the feature. The plan is a function of the rows and the depth and not of
// the data, so a latch costs no rebuild and the names and ages on screen stay put.
persist_depth = live_display ? 1 : model.persist_depth_locked();
const int placed = app->rows->sync_layers(persist_depth, &persist_want);
app->persist_want = persist_want;
app->persist_placed = placed;
app->rows->bind(model, display); app->rows->bind(model, display);
app->rows->acquire(model); app->rows->acquire(model);
if (app->comparing && app->rows_b != nullptr) { if (app->comparing && app->rows_b != nullptr) {
@@ -3564,6 +3580,28 @@ gboolean tick(gpointer user) {
// acquisition thread mutates, so it belongs under this lock and not after it. // acquisition thread mutates, so it belongs under this lock and not after it.
app->rows->set_invalid_spans(model, display); app->rows->set_invalid_spans(model, display);
} }
// A depth the room or the retention budget could not show in full says so, once per *plan* rather
// than once per frame: a user who asked for eight layers and sees two has no other way to tell
// "the display is deliberately shallower" from "the feature is broken". Both bounds are named,
// because they are different numbers and only one of them is the user's own: a *slot* shortfall is
// `placed < want` (the rows had no free trace slot), and a *budget* shortfall is fewer retained
// acquisitions than the depth asks for, in which case the slots exist and draw nothing at all.
const int want_ages = persist_depth > 1 ? persist_depth - 1 : 0;
const int kept_ages = static_cast<int>(std::min<std::size_t>(
model.persist_count(), static_cast<std::size_t>(std::max(0, want_ages))));
if (persist_depth > 1 &&
(app->persist_placed < persist_want || kept_ages < want_ages) &&
(app->persist_noted_depth != persist_depth || app->persist_noted_want != persist_want ||
app->persist_noted_placed != app->persist_placed)) {
app->persist_noted_depth = persist_depth;
app->persist_noted_want = persist_want;
app->persist_noted_placed = app->persist_placed;
set_notice(*app,
"persistence: " + std::to_string(app->persist_placed) + " of " +
std::to_string(persist_want) + " layers fit, " + std::to_string(kept_ages) +
" of " + std::to_string(want_ages) + " older acquisitions retained",
5);
}
// Outside the lock: it reads and writes the two plots' own windows, not the model. // Outside the lock: it reads and writes the two plots' own windows, not the model.
sync_compare_axes(*app); sync_compare_axes(*app);
sync_events_divider(*app); sync_events_divider(*app);
@@ -5777,6 +5815,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_DISARM_MS")) { if (const char* env = g_getenv("UDPSCOPE_PROBE_DISARM_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), disarm_probe_step1, &app); g_timeout_add(static_cast<guint>(std::atoi(env)), disarm_probe_step1, &app);
} }
if (const char* env = g_getenv("UDPSCOPE_PROBE_FORCE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), force_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_ARM_TRIGGER_MS")) { if (const char* env = g_getenv("UDPSCOPE_ARM_TRIGGER_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), arm_probe, &app); g_timeout_add(static_cast<guint>(std::atoi(env)), arm_probe, &app);
} }
@@ -5909,6 +5950,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_RESEQ_MS")) { if (const char* env = g_getenv("UDPSCOPE_PROBE_RESEQ_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), reseq_probe, &app); g_timeout_add(static_cast<guint>(std::atoi(env)), reseq_probe, &app);
} }
if (const char* env = g_getenv("UDPSCOPE_PROBE_PERSIST_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), persist_probe, &app);
}
// Our own options were consumed by parse_args(); GTK would reject them, so only // Our own options were consumed by parse_args(); GTK would reject them, so only
// the program name is handed to g_application_run(). // the program name is handed to g_application_run().
@@ -6340,7 +6384,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
(app.search_run_button != nullptr) + (app.search_cancel_button != nullptr) + (app.search_run_button != nullptr) + (app.search_cancel_button != nullptr) +
(app.search_prev_button != nullptr) + (app.search_next_button != nullptr); (app.search_prev_button != nullptr) + (app.search_next_button != nullptr);
std::printf( std::printf(
"[udpscope] trigger panel: boxes=%d controls_inside=%d/15 search_widgets=%d/4 " "[udpscope] trigger panel: boxes=%d controls_inside=%d/16 search_widgets=%d/4 "
"search_inside=%d/4\n", "search_inside=%d/4\n",
boxes, boxes,
inside(app.trigger_signal_drop, app.trigger_body) + inside(app.trigger_signal_drop, app.trigger_body) +
@@ -6357,6 +6401,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
inside(app.trigger_holdoff_spin, app.trigger_body) + inside(app.trigger_holdoff_spin, app.trigger_body) +
inside(app.trigger_band_spin, app.trigger_body) + inside(app.trigger_band_spin, app.trigger_body) +
inside(app.trigger_hf_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), inside(app.events_record_switch, app.trigger_body),
search_present, search_present,
inside(app.search_run_button, search_root) + inside(app.search_cancel_button, search_root) + inside(app.search_run_button, search_root) + inside(app.search_cancel_button, search_root) +
@@ -6600,6 +6645,32 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
static_cast<unsigned long long>(buf != nullptr ? buf->size() : 0)); static_cast<unsigned long long>(buf != nullptr ? buf->size() : 0));
} }
} }
{
// Persistence: what is retained, what the rows had room to draw, and the decay schedule in
// use (oldest first, so the half-per-acquisition fall-off reads straight off the line). The
// layer slots are named in the `layout:` line above, as `sine (A)`, and are deliberately
// absent from the per-slot channel lines below it: a layer is not a channel.
std::lock_guard<std::mutex> lock(model.mutex());
const int depth = model.persist_depth_locked();
std::printf("[udpscope] persistence: depth=%d kept=%zu bytes=%zu budget=%zu dropped=%llu "
"layers=%d want=%d placed=%d schedule=[",
depth, model.persist_count(), model.persist_bytes(),
model.persist_budget_bytes(),
static_cast<unsigned long long>(model.persist_dropped()), rows.layer_slots(),
app.persist_want, app.persist_placed);
// The decay schedule the depth asks for, oldest first: half per acquisition, so this line
// reads the rule and the probe's `alphas=` reads what the rows were actually built with.
for (int age = std::min(depth - 1, kMaxPersistDepth - 1); age >= 1; --age) {
std::printf("%s%.4f", age != depth - 1 ? "," : "",
static_cast<double>(RowSet::persist_alpha(age)));
}
std::printf("] alphas=[");
const std::vector<std::pair<int, float>> layers = rows.row_layers(0);
for (std::size_t i = 0; i < layers.size(); ++i) {
std::printf("%s%.4f", i != 0 ? "," : "", static_cast<double>(layers[i].second));
}
std::printf("]\n");
}
} }
return result; return result;
} }
+31 -4
View File
@@ -2,10 +2,11 @@
<!-- SPDX-License-Identifier: MIT --> <!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari --> <!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- <!--
The trigger panel's frame: one labelled row per setting, the two buttons that run it, and the search The trigger panel's frame: one labelled row per setting, the pair of buttons that run it
block. Its *heading* is not here: the heading row carries the switch that turns trigger mode on and (Arm/Disarm and Force), and the search block. Its *heading* is not here: the heading row carries the
off, and this panel's body is what goes insensitive when it is off, so the heading has to be its switch that turns trigger mode on and off, and this panel's body is what goes insensitive when it is
sibling rather than a child of it (`build_trigger_panel()` returns the two together). off, so the heading has to be its sibling rather than a child of it (`build_trigger_panel()` returns
the two together).
Every row here is a label and a place for a control, and the control itself is built in code. A Every row here is a label and a place for a control, and the control itself is built in code. A
dropdown's model and a spin button's range are the *application's* (the condition list, the level dropdown's model and a spin button's range are the *application's* (the condition list, the level
@@ -207,9 +208,35 @@
</child> </child>
</object> </object>
</child> </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>
</object>
</child>
</object>
</child>
<child>
<object class="GtkBox" id="arm_row">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<child> <child>
<object class="GtkButton" id="trigger_arm"> <object class="GtkButton" id="trigger_arm">
<property name="label" translatable="yes">Arm</property> <property name="label" translatable="yes">Arm</property>
<property name="hexpand">1</property>
</object>
</child>
<child>
<object class="GtkButton" id="trigger_force">
<property name="label" translatable="yes">Force</property>
<property name="hexpand">1</property>
<property name="tooltip-text" translatable="yes">take one acquisition now, with the pre/post window above, without waiting for the condition</property>
</object>
</child>
</object> </object>
</child> </child>
<child> <child>
+58
View File
@@ -203,6 +203,11 @@ struct App {
/// sidebar. "signals" on a first run. /// sidebar. "signals" on a first run.
std::string side_tab = "signals"; std::string side_tab = "signals";
GtkWidget* arm_button = nullptr; GtkWidget* arm_button = nullptr;
/// The Force button, beside the Arm/Disarm one: one acquisition now, with the configured pre/post
/// window. It carries no state of its own -- `sync_arm_buttons()` states the one it has
/// (sensitivity, from trigger mode), so the two buttons beside each other cannot disagree about
/// whether the trigger has an engine at all.
GtkWidget* force_button = nullptr;
GtkWidget* trigger_signal_drop = nullptr; GtkWidget* trigger_signal_drop = nullptr;
GtkWidget* sidebar_toggle = nullptr; GtkWidget* sidebar_toggle = nullptr;
GtkWidget* trigger_edge_drop = nullptr; GtkWidget* trigger_edge_drop = nullptr;
@@ -226,6 +231,31 @@ struct App {
GtkWidget* trigger_holdoff_spin = nullptr; GtkWidget* trigger_holdoff_spin = nullptr;
GtkWidget* trigger_band_spin = nullptr; ///< EventCondition::noise_band GtkWidget* trigger_band_spin = nullptr; ///< EventCondition::noise_band
GtkWidget* trigger_hf_spin = nullptr; ///< EventCondition::hf_fc, 0 is off GtkWidget* trigger_hf_spin = nullptr; ///< EventCondition::hf_fc, 0 is off
/// The persistence spin: how many of the most recent triggered acquisitions stay on screen, drawn
/// dimmer with age (1 = the event alone, which is the display without the feature). Its row is held
/// for a `.ui` that lost it, as the other rows are.
GtkWidget* trigger_persist_spin = nullptr;
GtkWidget* trigger_persist_row = nullptr;
/// What this frame's persistence plan drew, against what the depth asked for: `sync_layers()`
/// returns the former and fills the latter, and the two differ exactly when a row had no free
/// slot left for a layer or the retention budget refused an acquisition. Read by the exit dump and
/// by the persistence probe, and what the "only some layers fit" notice is keyed on.
int persist_want = 0;
int persist_placed = 0;
/// The last plan a notice was raised for, so a plan that stays short says so once rather than
/// every frame.
int persist_noted_depth = 0;
int persist_noted_want = -1;
int persist_noted_placed = -1;
/// The persistence probe's own state: the depth its first leg asks for (kept between steps,
/// because the steps are separate `GSourceFunc`s), how many polls it has spent waiting for the
/// retention to fill, and whether that set has stopped growing (which is how a budget that keeps
/// fewer acquisitions than the depth asks for still gets a report).
int dbg_persist_depth = 4;
int dbg_persist_polls = 0;
std::size_t dbg_persist_last_kept = 0;
int dbg_persist_stable_polls = 0;
bool dbg_persist_settled = false;
GtkWidget* signal_list = nullptr; GtkWidget* signal_list = nullptr;
GtkWidget* pause_button = nullptr; GtkWidget* pause_button = nullptr;
/// When the pause began, on the **monotonic wall clock**, or 0 while running. The elapsed time /// When the pause began, on the **monotonic wall clock**, or 0 while running. The elapsed time
@@ -942,6 +972,24 @@ struct App {
bool dbg_capturing_at_disarm = false; bool dbg_capturing_at_disarm = false;
int dbg_disarm_polls = 0; int dbg_disarm_polls = 0;
int dbg_disarm_in_capture = 0; int dbg_disarm_in_capture = 0;
/// The force probe's state: the button's stated state on both sides of the trigger toggle (a
/// width read a frame after the state it belongs to), and the two legs of its press. See
/// `force_probe()` in the probes' translation unit.
int dbg_force_off_sensitive = -1;
int dbg_force_off_visible = -1;
int dbg_force_off_width = -1; ///< its allocation with the trigger *off*
int dbg_force_on_sensitive = -1;
int dbg_force_on_width = -1; ///< ... and with the trigger on: the size the user can press
int dbg_force_arm_width = -1; ///< the Arm button beside it, so the pair's widths can be compared
int dbg_force_label_found = -1; ///< `find_labelled_button("Force")` *is* the panel's button
int dbg_force_leg = 0; ///< 1 while leg A is in flight, 2 for leg B
int dbg_force_polls = 0;
int dbg_force_capture_seen = 0; ///< the axis was read while the forced capture was filling
double dbg_force_capture_lo = 0.0; ///< that axis: the *requested* window, before the latch
double dbg_force_capture_hi = 0.0;
double dbg_force_want_pre = 0.0; ///< the request the engine will cut by, at the moment of the press
double dbg_force_want_post = 0.0;
std::uint64_t dbg_force_latches_before = 0;
int dbg_swaps = 0; int dbg_swaps = 0;
int dbg_axes_w_min = 1 << 20; int dbg_axes_w_min = 1 << 20;
int dbg_axes_w_max = -(1 << 20); int dbg_axes_w_max = -(1 << 20);
@@ -1764,6 +1812,16 @@ gboolean disarm_probe_step2(gpointer user);
gboolean disarm_probe_step3(gpointer user); gboolean disarm_probe_step3(gpointer user);
gboolean disarm_probe_wait(gpointer user); gboolean disarm_probe_wait(gpointer user);
gboolean disarm_probe_step1(gpointer user); gboolean disarm_probe_step1(gpointer user);
int persist_probe_depth();
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);
void force_probe_report(App& a, const char* leg);
gboolean force_probe_press(gpointer user);
gboolean force_probe_poll(gpointer user);
gboolean force_probe_step2(gpointer user);
gboolean force_probe(gpointer user);
void pause_probe_report(App& a, const char* what); void pause_probe_report(App& a, const char* what);
gboolean pause_probe(gpointer user); gboolean pause_probe(gpointer user);
gboolean export_probe(gpointer user); gboolean export_probe(gpointer user);
+12 -1
View File
@@ -22,7 +22,13 @@ LayoutState collect_layout(App& app) {
LayoutState::Row row; LayoutState::Row row;
const RowSet::Row& r = app.rows->row(i); const RowSet::Row& r = app.rows->row(i);
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
row.signals.push_back(r.signal_name[static_cast<std::size_t>(s)]); const std::size_t si = static_cast<std::size_t>(s);
// A persistence layer is written as an *empty* slot: it is the display's own artefact, not
// the user's arrangement, and a name like `sine (A)` read back would be a slot the next
// session could not resolve -- the same reason a group's extra layers are stripped before
// they are re-derived.
row.signals.push_back(r.layer[si] > 0 ? std::string()
: r.signal_name[si]);
} }
const std::vector<float>& weights = app.rows->row_weights(); const std::vector<float>& weights = app.rows->row_weights();
if (static_cast<std::size_t>(i) < weights.size()) row.weight = weights[static_cast<std::size_t>(i)]; if (static_cast<std::size_t>(i) < weights.size()) row.weight = weights[static_cast<std::size_t>(i)];
@@ -81,6 +87,7 @@ LayoutState collect_layout(App& app) {
st.trigger.holdoff_s = tc.holdoff_s; st.trigger.holdoff_s = tc.holdoff_s;
st.trigger.noise_band = tc.condition.noise_band; st.trigger.noise_band = tc.condition.noise_band;
st.trigger.hf_fc = tc.condition.hf_fc; st.trigger.hf_fc = tc.condition.hf_fc;
st.trigger.persistence = tc.persist_depth;
st.trigger.level_given = true; // written means given, so a load keeps it 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. // The connection is the session's too: the file's name carries only host and port.
@@ -335,6 +342,10 @@ void apply_trigger_layout(App& app, const LayoutState::Trigger& t) {
tc.pretrigger_pct = t.pretrigger_pct; tc.pretrigger_pct = t.pretrigger_pct;
tc.holdoff_s = t.holdoff_s; tc.holdoff_s = t.holdoff_s;
} }
// 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
// itself and clamps to the cap).
app.model->set_persist_depth(t.persistence);
// A file that carried a level keeps it: the panel's own default follows the channel's range, and // A file that carried a level keeps it: the panel's own default follows the channel's range, and
// `trigger_level_touched` is what says the user has a level of their own. // `trigger_level_touched` is what says the user has a level of their own.
if (t.level_given) app.trigger_level_touched = true; if (t.level_given) app.trigger_level_touched = true;
+410
View File
@@ -10,6 +10,7 @@
#include <sys/stat.h> #include <sys/stat.h>
#include <cmath>
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
@@ -2802,6 +2803,186 @@ gboolean disarm_probe_step1(gpointer user) {
return G_SOURCE_REMOVE; return G_SOURCE_REMOVE;
} }
/// `UDPSCOPE_PROBE_FORCE_MS`: **Force** takes one acquisition at the present moment, with the
/// pre/post window the panel is set to, without waiting for the condition to hold.
///
/// The two legs are the two things the button has to get right, and they need different geometry:
///
/// - **Leg A** presses it where the ring *can* satisfy the request -- a 2 s window at 50 % pre asks
/// for 1 s of history before the edge and 1 s after it, and the probe has let the ring fill past
/// that -- so the whole ordinary path is asserted: a latch count that moves by exactly one,
/// `display=event(frozen)`, and a snapshot spanning what was asked for.
/// - **Leg B** presses it where the ring *cannot*: a 100 s window at 99 % pre asks for 99 s of
/// history, and no run this short has it. That is the only state in which "pinned to the
/// snapshot, not to the request" is a reading rather than a coincidence, and the axis must come
/// out at the snapshot's own `[-pre_got..+post_got]`, a few seconds wide, not at the requested
/// hundred.
///
/// The condition is set to a level the mock never reaches (9 V against +/-1 V signals), so the
/// engine cannot latch on its own and every latch the probe sees is the force's -- which is also
/// what makes the request *unmet* rather than merely untested: without the button, nothing would be
/// captured at all. The `during=` reading is the axis while the forced capture was filling: the
/// *requested* window, which is what a capture is framed on before the latch re-pins it.
///
/// Run it on its own (the trigger must start off, which is the state whose button reading is half
/// the point) against the plain mock, with enough time for both legs: `UDPSCOPE_PROBE_FORCE_MS=3000
/// UDPSCOPE_EXIT_AFTER_MS=14000`.
void force_probe_report(App& a, const char* leg) {
const DisplayMode d = a.model->display_mode();
auto snap = a.model->event();
const RowSet::Row& r = a.rows->row(0);
const plot::Range axis = r.plot != nullptr ? r.plot->x_range(r.axis_x) : plot::Range{0.0, 0.0};
// `pre_s`/`post_s` are the snapshot's own split as *magnitudes* (`[-pre_s..+post_s]` is the window
// it holds), which is the same pair the frame tick states the pin from -- so the axis must equal
// `[-pre_s, +post_s]`.
const double got_pre = snap ? snap->pre_s : 0.0;
const double got_post = snap ? snap->post_s : 0.0;
const bool pin_is_snapshot = snap != nullptr &&
std::abs(double(axis.min) + got_pre) < 1.0e-3 &&
std::abs(double(axis.max) - got_post) < 1.0e-3;
// "What was asked for" is satisfied to within a stored interval (the cut's ends are samples), and
// leg B *must* fail it: the request is what the ring could not reach back to.
const bool matches_request = std::abs(got_pre - a.dbg_force_want_pre) < 0.01 &&
std::abs(got_post - a.dbg_force_want_post) < 0.01;
std::fprintf(stderr,
"[force-probe] leg %s want=[-%.4f..%.4f] during=[%.4f..%.4f] snap=[-%.4f..%.4f] "
"n=%llu axis=[%.4f..%.4f] latches=%llu->%llu display=%s matches_request=%d "
"pin_is_snapshot=%d\n",
leg, a.dbg_force_want_pre, a.dbg_force_want_post, a.dbg_force_capture_lo,
a.dbg_force_capture_hi, got_pre, got_post,
static_cast<unsigned long long>(snap ? snap->stamps.size() : 0), double(axis.min),
double(axis.max),
static_cast<unsigned long long>(a.dbg_force_latches_before),
static_cast<unsigned long long>(a.model->trigger_latches().load()),
d == DisplayMode::Held ? "event(frozen)"
: d == DisplayMode::Live ? "live"
: d == DisplayMode::Capturing ? "live(capturing)"
: "nothing",
int(matches_request), int(pin_is_snapshot));
std::fflush(stderr);
}
gboolean force_probe_poll(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
++a->dbg_force_polls;
if (a->model->trigger_pending() && !a->dbg_force_capture_seen) {
// The sweep is filling: the axis is the *requested* window now (a capture is framed on what was
// asked for, there being no snapshot to measure yet), and this is the one moment it is readable.
a->dbg_force_capture_seen = 1;
const RowSet::Row& r = a->rows->row(0);
const plot::Range ax = r.plot != nullptr ? r.plot->x_range(r.axis_x) : plot::Range{0.0, 0.0};
a->dbg_force_capture_lo = ax.min;
a->dbg_force_capture_hi = ax.max;
}
const std::uint64_t latches = a->model->trigger_latches().load();
if (latches <= a->dbg_force_latches_before && a->dbg_force_polls < 200) return G_SOURCE_CONTINUE;
if (latches <= a->dbg_force_latches_before) {
std::fprintf(stderr, "[force-probe] no latch within 10 s of the force (leg %d)\n",
a->dbg_force_leg);
std::fflush(stderr);
}
force_probe_report(*a, a->dbg_force_leg == 1 ? "A" : "B");
a->dbg_force_polls = 0;
if (a->dbg_force_leg == 1) {
// Leg B's geometry: a window the ring cannot cover back to the edge, so the cut is clamped and
// the pin has to come from the snapshot rather than from the ask. 99 % is the panel's ceiling
// (a trigger with no post side at all is not a capture), which leaves 1 s of post window -- the
// leg's own latch delay, and the reason the press is a step of its own.
a->dbg_force_leg = 2;
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_pre_spin), 99.0);
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->window_unit_drop), 2);
gtk_editable_set_text(GTK_EDITABLE(a->window_entry), "100");
on_window_entry_activate(GTK_ENTRY(a->window_entry), a);
g_timeout_add(400, force_probe_press, a);
}
return G_SOURCE_REMOVE;
}
gboolean force_probe_press(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
a->dbg_force_latches_before = a->model->trigger_latches().load();
a->dbg_force_polls = 0;
a->dbg_force_capture_seen = 0;
a->dbg_force_capture_lo = 0.0;
a->dbg_force_capture_hi = 0.0;
{
// The request the engine will cut by, taken from the producer's own expression rather than
// recomputed here, so the reading compares the snapshot against what was actually asked for.
std::lock_guard<std::mutex> lock(a->model->mutex());
const double span = std::max(1.0e-3, a->model->event_span());
a->dbg_force_want_post = a->model->trigger().post_span_s(span);
a->dbg_force_want_pre = span - a->dbg_force_want_post;
}
// The button's own click, as every other probe drives a control. `label_found` was checked in
// step 2: this is the widget the window would press.
if (a->force_button != nullptr) g_signal_emit_by_name(a->force_button, "clicked");
g_timeout_add(50, force_probe_poll, a);
return G_SOURCE_REMOVE;
}
gboolean force_probe_step2(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
// The trigger-off state, one frame after step 1 forced it: the *sensitivity* is the rule
// `sync_arm_buttons()` states (a trigger that is off has no engine to force), the *width* is the
// allocation the layout gave the button, and a width read in the tick it was last stated is 0 --
// the trap the compact arm control fell into. `label_found` is the same reading at the user's
// level: the button the window shows under the word "Force" is this one.
a->dbg_force_off_sensitive =
a->force_button != nullptr && gtk_widget_get_sensitive(a->force_button) ? 1 : 0;
a->dbg_force_off_visible =
a->force_button != nullptr && gtk_widget_is_visible(a->force_button) ? 1 : 0;
a->dbg_force_off_width = a->force_button != nullptr ? gtk_widget_get_width(a->force_button) : -1;
a->dbg_force_arm_width = a->arm_button != nullptr ? gtk_widget_get_width(a->arm_button) : -1;
a->dbg_force_label_found =
a->force_button != nullptr && find_labelled_button(a->window, "Force") == a->force_button ? 1
: 0;
// The geometry both legs start from, set through the real widgets: a level no mock signal
// reaches, so nothing latches on its own (every latch the probe sees is then the force's, which
// is also what makes the request *unmet* rather than merely untested), and a 2 s window at 50 %
// pre, which leg A's ring can fill. The window write may re-plan the buffer and clear the
// history, which is why the press is 2.5 s later rather than in this tick.
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_level_spin), 9.0);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_pre_spin), 50.0);
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->window_unit_drop), 2);
gtk_editable_set_text(GTK_EDITABLE(a->window_entry), "2");
on_window_entry_activate(GTK_ENTRY(a->window_entry), a);
// A signal has to be *acquired* for the trigger to have a channel at all (the panel lists the
// acquired ones, and on a fresh source none is switched on): the mock's `sine`, which with the
// level above is a trigger nothing crosses. Set after the level, which is what marks it
// hand-edited -- a rebuild re-defaults an untouched level to the middle of the declared range.
a->model->set_signal_enabled(1, true);
if (a->trigger_toggle != nullptr)
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
a->dbg_force_on_sensitive =
a->force_button != nullptr && gtk_widget_get_sensitive(a->force_button) ? 1 : 0;
a->dbg_force_on_width = a->force_button != nullptr ? gtk_widget_get_width(a->force_button) : -1;
std::fprintf(stderr,
"[force-probe] button off: sensitive=%d visible=%d w=%d | on: sensitive=%d w=%d | "
"arm w=%d | label_found=%d\n",
a->dbg_force_off_sensitive, a->dbg_force_off_visible, a->dbg_force_off_width,
a->dbg_force_on_sensitive, a->dbg_force_on_width, a->dbg_force_arm_width,
a->dbg_force_label_found);
std::fflush(stderr);
a->dbg_force_leg = 1;
g_timeout_add(2500, force_probe_press, a);
return G_SOURCE_REMOVE;
}
gboolean force_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || a->trigger_toggle == nullptr) return G_SOURCE_REMOVE;
// Trigger mode *off* first, driven through the toggle as a user would: the state whose reading
// step 2 takes, and a state this probe states rather than assumes -- a session file remembers the
// trigger, so a second run against the same source would otherwise start armed and never read the
// off case at all.
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), FALSE);
g_timeout_add(300, force_probe_step2, a);
return G_SOURCE_REMOVE;
}
void pause_probe_report(App& a, const char* what) { void pause_probe_report(App& a, const char* what) {
double bounds_lo = 0.0; double bounds_lo = 0.0;
double bounds_hi = 0.0; double bounds_hi = 0.0;
@@ -5651,5 +5832,234 @@ gboolean tour_surface_probe_step(gpointer user) {
return G_SOURCE_REMOVE; return G_SOURCE_REMOVE;
} }
// --- persistence (UDPSCOPE_PROBE_PERSIST_MS) ---------------------------------
//
// The phosphor display: the last N acquisitions drawn behind the current one, dimmer with age, with
// `N = 1` -- the default -- drawn exactly like the display without the feature. One leg, and the
// depth comes from the environment, because the *reading* is a pair of runs: `..._PERSIST_DEPTH=4`
// must show the layers at the right brightnesses and ages with the framing still the current event's,
// and the same command at `=1` must show none of them, the same framing, and nothing retained -- and
// the second of those is also the baseline the exit dump's `frame cost:` line is read against.
/// The depth the first leg asks for: `UDPSCOPE_PROBE_PERSIST_DEPTH`, default 4, clamped to the
/// panel's own range. **Set it to 1 and the same run is the unpersisted display**, which is the
/// baseline the exit dump's `frame cost:` line has to be read against.
int persist_probe_depth() {
const char* env = g_getenv("UDPSCOPE_PROBE_PERSIST_DEPTH");
const int n = env != nullptr ? std::atoi(env) : 4;
return std::clamp(n, 1, kMaxPersistDepth);
}
/// The event window the probe arms with: `UDPSCOPE_PROBE_PERSIST_WINDOW` seconds, default 0.5. A
/// *long* window on a fast stream is how the **memory** bound is reached -- one retained acquisition
/// is a whole window copied out of the ring -- so the run that has to show `dropped` and a `kept`
/// below the depth is `=2` against the fast mock.
double persist_probe_window() {
const char* env = g_getenv("UDPSCOPE_PROBE_PERSIST_WINDOW");
const double s = env != nullptr ? std::atof(env) : 0.5;
return s > 0.0 ? s : 0.5;
}
/// One leg's reading: what the model retained, what the rows drew, and whether the framing is still
/// the event on screen and nothing else.
void persist_probe_report(App& a, const char* what) {
int depth = 1;
int snap_n = 0;
double snap_lo = 0.0;
double snap_hi = 0.0;
std::size_t kept = 0;
std::size_t bytes = 0;
std::size_t budget = 0;
std::size_t signals = 0;
std::size_t channels = 0;
unsigned long long dropped = 0;
{
std::lock_guard<std::mutex> lock(a.model->mutex());
depth = a.model->persist_depth_locked();
kept = a.model->persist_count();
bytes = a.model->persist_bytes();
budget = a.model->persist_budget_bytes();
dropped = static_cast<unsigned long long>(a.model->persist_dropped());
signals = a.model->signals().size();
channels = a.model->channels().size();
if (const std::shared_ptr<const EventSnapshot> snap = a.model->event()) {
snap_lo = -snap->pre_s;
snap_hi = snap->post_s;
snap_n = static_cast<int>(snap->stamps.size());
}
}
// The decay schedule in use, oldest first: the reading's point is that the numbers descend by half
// and that the *oldest* is the dimmest.
const std::vector<std::pair<int, float>> layers = a.rows->row_layers(0);
std::fprintf(stderr,
"[persist-probe] %s depth=%d kept=%zu bytes=%zu budget=%zu dropped=%llu signals=%zu "
"channels=%zu layers=%d want=%d placed=%d alphas=[",
what, depth, kept, bytes, budget, dropped, signals, channels, a.rows->layer_slots(),
a.persist_want, a.persist_placed);
for (std::size_t i = 0; i < layers.size(); ++i) {
std::fprintf(stderr, "%s%.4f", i != 0 ? "," : "", static_cast<double>(layers[i].second));
}
std::fprintf(stderr, "] ages=[");
for (std::size_t i = 0; i < layers.size(); ++i) {
std::fprintf(stderr, "%s%d", i != 0 ? "," : "", layers[i].first);
}
std::fprintf(stderr, "] row0=[");
const RowSet::Row& r0 = a.rows->row(0);
bool first = true;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
if (r0.layer[si] == 0) continue;
std::fprintf(stderr, "%s%d:'%s'@%.4f", first ? "" : " ", s, r0.signal_name[si].c_str(),
static_cast<double>(r0.slot_alpha[si]));
first = false;
}
// How many layer slots are actually *bound* to a retained acquisition. It is not the same number
// as `layers=`/`placed=`: a slot can be planned for an age the retention budget refused, and it
// then draws nothing at all -- so the two pairs together say which of the two bounds bit.
int drawn = 0;
for (int r = 0; r < a.rows->row_count(); ++r) {
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const RowSet::Row& row_state = a.rows->row(r);
const std::size_t si = static_cast<std::size_t>(s);
if (row_state.layer[si] == 0) continue;
if (row_state.slots[si].bound_event() != nullptr) ++drawn;
}
}
std::fprintf(stderr, "] drawn=%d\n", drawn);
// The framing. `ev` is what a held event's pin is re-stated from (`event_data_window()`), and it
// must be the *snapshot's* own window: layers are bound event series on the same rows, so a union
// that had grown with them reads here as `ev_is_snap=0`.
const plot::Range axis = a.rows->view_range();
const plot::Range pin = a.rows->pinned_range();
double ev_lo = 0.0;
double ev_hi = 0.0;
const bool have_ev = a.rows->event_data_window(ev_lo, ev_hi);
const bool ev_is_snap =
have_ev && std::abs(ev_lo - snap_lo) < 1.0e-6 && std::abs(ev_hi - snap_hi) < 1.0e-6;
const bool pin_is_snap = a.rows->window_pinned() && std::abs(pin.min - snap_lo) < 1.0e-6 &&
std::abs(pin.max - snap_hi) < 1.0e-6;
std::fprintf(stderr,
"[persist-probe] %s axis=[%.4f..%.4f] pin=[%.4f..%.4f] snap=[%.4f..%.4f] n=%d "
"ev=[%.4f..%.4f] ev_is_snap=%d pin_is_snap=%d\n",
what, double(axis.min), double(axis.max), double(pin.min), double(pin.max), snap_lo,
snap_hi, snap_n, ev_lo, ev_hi, ev_is_snap ? 1 : 0, pin_is_snap ? 1 : 0);
std::fflush(stderr);
}
/// Sets the panel's own depth control and publishes it: the *widget* is the path a user takes, so the
/// probe exercises the handler that reads it and not the model setter.
void persist_probe_set_depth(App& a, int depth) {
if (a.trigger_persist_spin != nullptr) {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a.trigger_persist_spin), double(depth));
}
publish_trigger_fields(&a);
}
/// Step 1: enable the signals **itself** -- one call, the way a restored session does it, and
/// deliberately not `UDPSCOPE_ENABLE_ALL_MS`: that probe ends by switching one signal off two seconds
/// later, so a run that relied on it would see its first row's signal leave the plots (and its base
/// slot go with it) in the middle of the measurement. Then one signal per row -- the arrangement the
/// app itself gives an opened file, and the one with room for every age (a row of four signals has one
/// free slot per signal, a row of one has seven) -- the depth through the panel's own spin, and the
/// trigger armed on the mock's `square`, which crosses its level several times a second so that several
/// acquisitions land in a couple of seconds.
gboolean persist_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
{
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);
}
const std::vector<ChannelInfo>& channels = a->model->channels();
const std::vector<SignalInfo>& signals = a->model->signals();
const int rows = std::min<int>(RowSet::kMaxRows, static_cast<int>(channels.size()));
if (rows > 0) {
a->rows->set_row_count(rows);
for (int r = 0; r < rows; ++r) {
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) a->rows->assign(r, s, "", "", 0);
a->rows->assign(r, 0, channels[static_cast<std::size_t>(r)].name,
channels[static_cast<std::size_t>(r)].name,
signal_color_index(signals, channels[static_cast<std::size_t>(r)].signal));
}
a->relayout_pending = true;
}
a->dbg_persist_depth = persist_probe_depth();
persist_probe_set_depth(*a, a->dbg_persist_depth);
int sel = -1;
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);
const double window_s = persist_probe_window();
apply_time_window(*a, window_s);
publish_trigger_fields(a);
std::fprintf(stderr, "[persist-probe] %d row(s), one signal each, depth=%d, window=%.3f s, arming on '%s'\n",
rows, a->dbg_persist_depth, window_s,
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_persist_polls = 0;
g_timeout_add(25, persist_probe_wait, a);
return G_SOURCE_REMOVE;
}
/// Waits for the retention to be *full*: an event on screen and as many older ones kept as the depth
/// asks for. The trigger's cadence is the stream's, so a fixed delay would report whatever happened
/// to have latched; the 15 s cap is for a stream that never latches, and the probe still reports.
///
/// A full set is not always *reachable*: the retention budget can keep fewer acquisitions than the
/// depth asks for, and then the condition above can never come true -- which is the state the memory
/// bound is read in, so waiting for it would report nothing at all. The wait therefore also ends when
/// the set has **settled** (nothing new retained for two seconds) with something in it: that is the
/// budget speaking, and the report's `kept`/`dropped`/`drawn` are what say so.
///
/// The depth is **left where the first leg put it**: the run's own tail is then the display at that
/// depth, which is what the exit dump's `frame cost:` line is a mean over -- and the run to read it
/// against is the same command with `UDPSCOPE_PROBE_PERSIST_DEPTH=1`, whose two legs are both the
/// display without the feature. Reading it in one run that turns the depth off partway would have
/// meant comparing two means over different displays.
gboolean persist_probe_wait(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
++a->dbg_persist_polls;
const bool held = a->model->display_mode() == DisplayMode::Held;
const std::size_t kept = a->model->persist_count();
const std::size_t want = static_cast<std::size_t>(std::max(0, a->dbg_persist_depth - 1));
if (kept != a->dbg_persist_last_kept) {
a->dbg_persist_last_kept = kept;
a->dbg_persist_stable_polls = 0;
} else if (++a->dbg_persist_stable_polls > 160) { // 4 s at 25 ms a poll
a->dbg_persist_settled = true;
}
const bool full = kept >= want;
// ... and the rows must already be *bound* to it: the report reads what the frame bound, and the
// mode can flip to `Held` between two ticks (a latch lands mid-frame), so a reading taken on the
// tick boundary can describe a display whose bindings are still the live ring's. `Held`, bound and
// full -- the state every reading below is about -- is what the wait is for.
double lo = 0.0;
double hi = 0.0;
const bool bound = a->rows->event_data_window(lo, hi);
if ((held && bound && (full || (a->dbg_persist_settled && kept > 0))) ||
a->dbg_persist_polls > 600) {
if (!full) {
std::fprintf(stderr, "[persist-probe] settled: kept=%zu of %zu after %d polls\n", kept, want,
a->dbg_persist_polls);
}
persist_probe_report(*a, "held");
return G_SOURCE_REMOVE;
}
return G_SOURCE_CONTINUE;
}
} // namespace ui_impl } // namespace ui_impl
} // namespace udpscope } // namespace udpscope
+131 -3
View File
@@ -5,6 +5,8 @@
#include "ui_app.hpp" #include "ui_app.hpp"
#include <algorithm>
#include <cmath>
#include <cstring> #include <cstring>
#include <gtk/gtk.h> #include <gtk/gtk.h>
@@ -22,6 +24,13 @@ namespace ui_impl {
/// the model lock. Visibility is `enabled && locked`: with trigger mode off the line's own text says /// the model lock. Visibility is `enabled && locked`: with trigger mode off the line's own text says
/// "stream (trigger off)" and there is no engine to arm, and with the pane open the body's button is the /// "stream (trigger off)" and there is no engine to arm, and with the pane open the body's button is the
/// one on screen -- never both at once. /// one on screen -- never both at once.
///
/// It states the Force button too, from the same `enabled`, and that is the *only* place either
/// button's availability is decided: a trigger that is off has no engine to force, and one that is
/// on can be forced whether or not it is armed -- in Single mode the previous acquisition leaves the
/// engine disarmed, which is exactly when a user reaches for Force again, so a Force that grays out
/// with the engine's armed state would be unavailable for the one thing it is for. Force carries no
/// state of its own, so nothing else about it can drift.
void sync_arm_buttons(App& app) { void sync_arm_buttons(App& app) {
const bool armed = app.model->trigger_armed().load(std::memory_order_relaxed); const bool armed = app.model->trigger_armed().load(std::memory_order_relaxed);
const bool enabled = const bool enabled =
@@ -32,6 +41,9 @@ void sync_arm_buttons(App& app) {
gtk_button_set_label(GTK_BUTTON(app.arm_button), label); gtk_button_set_label(GTK_BUTTON(app.arm_button), label);
gtk_widget_set_sensitive(app.arm_button, enabled); gtk_widget_set_sensitive(app.arm_button, enabled);
} }
if (app.force_button != nullptr) {
gtk_widget_set_sensitive(app.force_button, enabled);
}
if (app.trigger_summary_arm != nullptr) { if (app.trigger_summary_arm != nullptr) {
gtk_button_set_label(GTK_BUTTON(app.trigger_summary_arm), label); gtk_button_set_label(GTK_BUTTON(app.trigger_summary_arm), label);
gtk_widget_set_sensitive(app.trigger_summary_arm, enabled); gtk_widget_set_sensitive(app.trigger_summary_arm, enabled);
@@ -39,11 +51,28 @@ void sync_arm_buttons(App& app) {
} }
} }
/// The **signal** the trigger's channel carries, or -1 when the trigger has no channel at all.
///
/// `TriggerConfig::channel` is an index into `ScopeModel::channels()` -- the *acquired* ones, which
/// `rebuild_channels()` renumbers -- while `ScopeModel::set_signal_enabled()` indexes the stream's
/// `signals()`. Passing one for the other is not a near miss: with one signal acquired, channel 0 is
/// that signal while signal 0 is the stream's *clock*, so arming switched on the time base and left
/// the signal the trigger watches exactly as it was -- and a signal the panel was not showing *is* a
/// change, so the click also rebuilt the buffer and dropped its history, which is the pre-trigger
/// window the arming (or the force) was about to capture. The two indices agree only in the one case
/// where every signal is acquired in order.
int trigger_signal_index(App& app) {
std::lock_guard<std::mutex> lock(app.model->mutex());
const int ch = app.model->trigger().channel;
const std::vector<ChannelInfo>& channels = app.model->channels();
if (ch < 0 || ch >= static_cast<int>(channels.size())) return -1;
return static_cast<int>(channels[static_cast<std::size_t>(ch)].signal);
}
void on_arm_clicked(GtkButton* button, gpointer user) { void on_arm_clicked(GtkButton* button, gpointer user) {
App* app = static_cast<App*>(user); App* app = static_cast<App*>(user);
(void)button; (void)button;
bool armed = false; bool armed = false;
int signal = -1;
{ {
// The producer reads (and, on a single-shot latch, writes) these fields under // The producer reads (and, on a single-shot latch, writes) these fields under
// the same lock; an unguarded read-modify-write here can lose its update. // the same lock; an unguarded read-modify-write here can lose its update.
@@ -51,7 +80,6 @@ void on_arm_clicked(GtkButton* button, gpointer user) {
TriggerConfig& tc = app->model->trigger(); TriggerConfig& tc = app->model->trigger();
tc.armed = !tc.armed; tc.armed = !tc.armed;
armed = tc.armed; armed = tc.armed;
signal = tc.channel;
} }
if (armed) { if (armed) {
app->event_held = false; app->event_held = false;
@@ -61,10 +89,67 @@ void on_arm_clicked(GtkButton* button, gpointer user) {
// Arming is a statement that this signal should be watched, so its acquisition // Arming is a statement that this signal should be watched, so its acquisition
// is switched on with it -- otherwise the trigger could never see a sample. // is switched on with it -- otherwise the trigger could never see a sample.
// Outside the lock: set_signal_enabled() takes it. // Outside the lock: set_signal_enabled() takes it.
const int signal = trigger_signal_index(*app);
if (armed && signal >= 0) app->model->set_signal_enabled(static_cast<std::size_t>(signal), true); if (armed && signal >= 0) app->model->set_signal_enabled(static_cast<std::size_t>(signal), true);
sync_arm_buttons(*app); sync_arm_buttons(*app);
} }
/// **Force**: one acquisition, now, with the pre/post window the panel is set to -- what a scope's
/// Force button does when the condition is not coming, and the reason it does not wait for one.
///
/// It states no state of its own. The capture it opens goes through the engine's ordinary latch,
/// which is what cuts the window, publishes the snapshot, counts it and applies the mode's re-arm
/// rule, so the two buttons above read afterwards exactly as that latch left them: still "Disarm" in
/// Normal mode (the trigger goes back to waiting for the next edge), and "Arm" in Single mode, where
/// one acquisition is the whole of what the mode means whether an edge produced it or this click.
void on_force_clicked(GtkButton* button, gpointer user) {
App* app = static_cast<App*>(user);
(void)button;
// The signal the trigger watches, resolved from its channel -- `set_signal_enabled()` indexes
// `signals()` while `TriggerConfig::channel` indexes `channels()` (see the helper) -- and refused
// up front when there is none, see the refusal below.
const int signal = trigger_signal_index(*app);
if (signal < 0) {
// The trigger is pointed at nothing -- no signal is being acquired, which the panel's own
// dropdown says ("(no acquired channels)") -- so there is no channel to take an acquisition
// from. Refused here and not left to the producer, which never looks at a frame at all for a
// model with no *streamed* channel: a request made in this state would sit behind that early
// return and fire on the first signal switched on afterwards, which is an acquisition the user
// asked for minutes ago.
std::fprintf(stderr,
"[udpscope] trigger: force refused -- no signal is being acquired to trigger on; "
"switch one on in the signal list\n");
std::fflush(stderr);
return;
}
// A force is an instruction to acquire the signal the trigger watches, so its acquisition is
// switched on with it -- exactly as arming switches it on, and *before* the request below: the
// enable rebuilds the channel list (which is what gives the trigger's signal a channel at all),
// and a packet landing between the two would otherwise consume the request on a trigger whose
// channel is still -1, and drop it. It is a no-op when the signal is already acquired, which is
// what keeps a force from clearing the very history it is about to capture.
app->model->set_signal_enabled(static_cast<std::size_t>(signal), true);
{
// The model lock, for both writes and together: the producer holds it for a whole packet --
// reading the panel's `armed` at the top of one and consuming the request at the bottom -- so a
// request written outside it could be read by a packet whose engine was still disarmed, and is
// then dropped rather than replayed.
std::lock_guard<std::mutex> lock(app->model->mutex());
TriggerConfig& tc = app->model->trigger();
// A disarmed engine has nothing to acquire with. In Single mode the previous acquisition left it
// disarmed, and "capture now" is the whole of what a user means by pressing Force there, so the
// click arms it -- the same field the arm button writes, which the producer's next packet turns
// into an armed engine and a new latch count.
tc.armed = true;
if (app->acq != nullptr) app->acq->request_force();
}
// Nothing is cleared here on purpose: the held event stays on screen until the coming capture is
// bound, one packet later, rather than flashing back to live in between. The frame tick clears it
// itself when it sees the capture open (`new_capture`), which is also where the axis is pinned to
// the requested window for the sweep.
sync_arm_buttons(*app);
}
/// The trigger type dropdown's order, as a table rather than a cast: the dropdown lists /// The trigger type dropdown's order, as a table rather than a cast: the dropdown lists
/// only the conditions that are implemented, so its index is not `TriggerType`'s value. /// only the conditions that are implemented, so its index is not `TriggerType`'s value.
/// The trigger's four enumerations as words, and back. These are the panel's dropdown labels *and* /// The trigger's four enumerations as words, and back. These are the panel's dropdown labels *and*
@@ -156,6 +241,13 @@ void write_trigger_widgets(App& app) {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_timeout_spin), tc.condition.timeout_s); gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_timeout_spin), tc.condition.timeout_s);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_band_spin), tc.condition.noise_band); gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_band_spin), tc.condition.noise_band);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_hf_spin), tc.condition.hf_fc); gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_hf_spin), tc.condition.hf_fc);
// Clamped here as well as in the setter: a session file may carry any number, and the spin's own
// range would refuse one outside it.
if (app.trigger_persist_spin != nullptr) {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_persist_spin),
static_cast<double>(
std::clamp(tc.persist_depth, 1, kMaxPersistDepth)));
}
gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_type_drop), gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_type_drop),
dropdown_index_of(app.trigger_type_drop, dropdown_index_of(app.trigger_type_drop,
trigger_type_text(tc.condition.type))); trigger_type_text(tc.condition.type)));
@@ -299,6 +391,8 @@ void publish_trigger_fields(App* app) {
const double timeout = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_timeout_spin)); const double timeout = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_timeout_spin));
const double band = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_band_spin)); const double band = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_band_spin));
const double hf = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_hf_spin)); 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))));
{ {
std::lock_guard<std::mutex> lock(app->model->mutex()); std::lock_guard<std::mutex> lock(app->model->mutex());
TriggerConfig& tc = app->model->trigger(); TriggerConfig& tc = app->model->trigger();
@@ -318,6 +412,11 @@ void publish_trigger_fields(App* app) {
tc.condition.noise_band = band; tc.condition.noise_band = band;
tc.condition.hf_fc = hf; tc.condition.hf_fc = hf;
} }
// 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
// it while the trigger is quiet -- when no latch will come along to apply it -- still frees the
// memory. It takes the model lock itself, hence outside the block above.
app->model->set_persist_depth(persist);
} }
/// Shows only the rows the selected condition reads, so the panel never offers a figure the /// Shows only the rows the selected condition reads, so the panel never offers a figure the
@@ -572,15 +671,44 @@ GtkWidget* build_trigger_panel(App& app) {
app.trigger_record_row = record_row; app.trigger_record_row = record_row;
} }
// Persistence: how many of the most recent triggered acquisitions stay on screen at once, each
// older one drawn at half the brightness of the one before it. 1 is the event alone, which is the
// display without the feature -- so there is no separate switch for it, and its default is what
// every run had before it existed.
int persist_seed = 1;
{
std::lock_guard<std::mutex> lock(app.model->mutex());
persist_seed = app.model->persist_depth_locked();
}
app.trigger_persist_spin = spin(1.0, static_cast<double>(kMaxPersistDepth), 1.0, 0,
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");
app.trigger_persist_row = row_for(ui, "persistence_row", app.trigger_persist_spin);
app.arm_button = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_arm")); app.arm_button = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_arm"));
if (app.arm_button != nullptr) { if (app.arm_button != nullptr) {
g_signal_connect(app.arm_button, "clicked", G_CALLBACK(on_arm_clicked), &app); g_signal_connect(app.arm_button, "clicked", G_CALLBACK(on_arm_clicked), &app);
} }
// Beside the arm button, and in the *body* rather than in the compact heading with it: the heading's
// one control is the runtime *state* a locked pane exists to change, while a force is an action
// whose subject -- the condition, the level, the pre/post split and the mode that decides whether
// one acquisition is all of it -- is in the body. A second Force in the heading would be a second
// control for one action whose settings are off screen, and the heading already carries [edit],
// Arm/Disarm and the mode switch.
app.force_button = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_force"));
if (app.force_button != nullptr) {
g_signal_connect(app.force_button, "clicked", G_CALLBACK(on_force_clicked), &app);
}
for (GtkWidget* w : {app.trigger_level_spin, app.trigger_pre_spin, app.trigger_holdoff_spin, 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_width_spin, app.trigger_width2_spin,
app.trigger_level2_spin, app.trigger_timeout_spin, app.trigger_band_spin, app.trigger_level2_spin, app.trigger_timeout_spin, app.trigger_band_spin,
app.trigger_hf_spin}) { app.trigger_hf_spin, app.trigger_persist_spin}) {
g_signal_connect(w, "value-changed", G_CALLBACK(on_trigger_field_changed), &app); g_signal_connect(w, "value-changed", G_CALLBACK(on_trigger_field_changed), &app);
} }
g_signal_connect(app.trigger_edge_drop, "notify::selected", g_signal_connect(app.trigger_edge_drop, "notify::selected",