trigger: a phosphor display keeps the last N acquisitions; and a Force button
Two changes in the trigger pane, landed together. **Persistence.** Trigger mode keeps the last N published acquisitions and draws them fading toward the background, N being a panel control (1..8, default 1) with N=1 meaning today's behaviour exactly, so the knob is the switch. The layers are the accumulate path's own -- one layer per (signal, age) in the row's free trace slots, named with the accumulate vocabulary so `layer_base_name()` strips the suffix and `signal_color()` resolves the base signal -- so a layer is not a signal: the signal list, the colours and the measurements see one row per signal. `ScopeModel` retains the outgoing snapshot in seven atomic `shared_ptr` slots (no lock, no copy; the snapshot already exists) and a layer's channel is resolved **by name inside the snapshot**, because the channel list is renumbered by any structural change since that acquisition was cut. `RowSet::sync_layers()` re-plans from the rows and the depth alone, so a latch costs no rebuild, and `event_data_window()` skips layer slots so an older acquisition can never re-pin the axis. The schedule is 0.5^age -- the acquisition on screen at full brightness and each older one half the previous, independent of N, so raising the depth adds dimmer acquisitions rather than rescaling the ones already on screen. Memory is bounded by the ring's own `--buffer-mb` rather than by the depth, which is the bound that cannot be driven into an OOM, and the panel says when either bound bites. This also wired up `set_trace_alpha()`, which the accumulate path was setting and nothing was reading -- so the documented 0.35 translucency of an accumulation was opaque in fact. **Force.** In the trigger pane beside Arm/Disarm, shown only while trigger mode is on: one acquisition now, with the configured window. It is a synthetic edge rather than a second publisher -- the engine opens one capture at the present moment and everything else is the ordinary latch, so a forced acquisition is cut, pinned, framed, recorded and cached exactly like a latched one, and the button owns no window and no publish. The pre-trigger gate is deliberately skipped (capture now is a request for the history that exists, and the snapshot reports its own shorter pre_s), a capture already filling is refused rather than restarted, and the state afterwards is the mode's: Normal stays armed, Single disarms. The request is one atomic flag written under the model mutex with `armed` -- the producer reads `armed` at the top of a packet and the request at the bottom, so a request written between the two would be dropped -- and consumed at the end of a packet, after the channel loop and before the pending publish. **A defect fixed in passing, in the function Force was modelled on:** `on_arm_clicked()` passed `TriggerConfig::channel` (an index into `channels()`) to `set_signal_enabled()` (an index into `signals()`). With one signal acquired, arming switched on the stream's clock signal and left the watched signal untouched -- and since a signal the panel was not showing is a change, it rebuilt the buffer under the click, dropping the very pre-trigger window the arming, and now the Force, was about to capture. Measured before: `0.0100 s of history behind it`; after: `2.5299 s`. `trigger_signal_index()` is now the one resolution, used by both buttons.
This commit is contained in:
1 parent
fc4700d1c9
commit
e14e1ee069
22 files changed
+1652
-26
No files matched your search
@@ -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
|
||||
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.
|
||||
**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
|
||||
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
|
||||
@@ -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
|
||||
`is_visible()` or not at all.
|
||||
- **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
|
||||
`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
|
||||
@@ -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
|
||||
ring, or an export and a capture of the same window would hold different samples. Call
|
||||
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,
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
|
||||
The pane that lists the cache is **on demand**: the toolbar's event-history button and the pane are
|
||||
|
||||
@@ -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
|
||||
`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.
|
||||
- `[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
|
||||
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,
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -1323,7 +1372,7 @@ popover row reads `failed: <strerror>` — one stderr line, no retry storm again
|
||||
## `[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
|
||||
[configs] before=default active=default
|
||||
[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] 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] 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 |
|
||||
@@ -1850,7 +1899,7 @@ through one row type, for the reason in `invariants-gtk-and-chrome.md`. The form
|
||||
## `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
|
||||
@@ -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.
|
||||
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`
|
||||
counts the fifteen controls the code built and then appended into those rows -- the last two being the
|
||||
noise band and the HF reject, whose rows arrived with the noise-rejection work, `search_widgets` is the four
|
||||
counts the sixteen controls the code built and then appended into those rows -- the last three being the
|
||||
noise band and the HF reject, whose rows arrived with the noise-rejection work, and the persistence depth
|
||||
whose row arrived with the persistence display (the Arm/Force pair is a row of its own too, so `boxes` counts
|
||||
it while `controls_inside` does not: the pair is a *button* row, not a value) -- `search_widgets` is the four
|
||||
search buttons having resolved by id, and `search_inside` is those four being inside the block the expander
|
||||
opens onto. A control built and never placed -- an id that drifted between the file and the code -- reads as
|
||||
`controls_inside` below 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.
|
||||
|
||||
The search walk starts at `gtk_expander_get_child()`, not at the panel: an expander's child is not in the
|
||||
widget-parent chain at all, so walking up from a search button reaches no ancestor of the panel (see
|
||||
`invariants-gtk-and-chrome.md`). `[defaults] trigger:` prints the same panel's 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.
|
||||
|
||||
## `[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:`
|
||||
|
||||
```
|
||||
|
||||
@@ -57,6 +57,63 @@ precedent, not an exception.
|
||||
`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,
|
||||
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
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user