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
|
||||
|
||||
Reference in new issue
Block a user