diff --git a/README.md b/README.md index 2007139..f466146 100644 --- a/README.md +++ b/README.md @@ -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 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 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 @@ -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 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. Persistence 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 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 diff --git a/docs/claude/invariants-trigger-and-pinning.md b/docs/claude/invariants-trigger-and-pinning.md index 8a22ec4..885236c 100644 --- a/docs/claude/invariants-trigger-and-pinning.md +++ b/docs/claude/invariants-trigger-and-pinning.md @@ -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 diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index e5998f2..1992e1a 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -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: ` — 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:` ``` diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 1823012..5c10705 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -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 diff --git a/help/C/org.udpscope.app/trigger.page b/help/C/org.udpscope.app/trigger.page index 1bcf3cd..c50903d 100644 --- a/help/C/org.udpscope.app/trigger.page +++ b/help/C/org.udpscope.app/trigger.page @@ -5,7 +5,7 @@ - The trigger's four shapes, arming and hold-off, the event cache with its replay, + 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. Martino Ferrari @@ -35,6 +35,17 @@ offered only while trigger mode is on, because a trigger that is off has no engine to arm.

+ +

Force takes one acquisition now, 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 Normal mode the trigger goes back to waiting afterwards; in + Single 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.

+

Search the buffer is at the foot of the same panel.

@@ -82,6 +93,40 @@ offers a pulse width to a window trigger.

+
+ Persistence: keeping the last few acquisitions +

Persistence decides how many of the most recent triggered acquisitions stay on + screen at once. It is 1 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.

+ +

The older acquisitions are drawn behind the current one and nothing else changes + because of them: the axis still frames the event on screen, the trigger marker still marks + its edge, and the measurements still describe the current frame. Lowering + Persistence back to 1 remembers nothing and frees the retained acquisitions + immediately.

+ +

Two things bound how many are drawn, and the panel tells you when one of them bites.

+ +

Room in the plot. 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.

+

Memory. A retained acquisition is a whole window copied out of the ring, + so persistence may hold at most as much again as the buffer (--buffer-mb) does. + On a long window at a high sample rate that bound is reached before the eighth layer.

+
+ +

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.

+
+
The event cache, replay and compare

Every triggered acquisition is kept. Latches are written to the event cache @@ -178,7 +223,10 @@ by live data.

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. - The axis is pinned to what the capture actually got.

+ The axis is pinned to what the capture actually got. Force 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.

Disarming during 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 does not move.

diff --git a/src/acquisition.cpp b/src/acquisition.cpp index 51c21a1..a00dcb8 100644 --- a/src/acquisition.cpp +++ b/src/acquisition.cpp @@ -1051,6 +1051,38 @@ void Acquisition::on_data(const udps_frame_t* frame) { } 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); // 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 diff --git a/src/acquisition.hpp b/src/acquisition.hpp index a008bb6..c2fa94e 100644 --- a/src/acquisition.hpp +++ b/src/acquisition.hpp @@ -48,6 +48,20 @@ class Acquisition { bool capture_want() const noexcept { 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 /// cross-thread through PacketCapture's own published fields. const PacketCapture& capture() const noexcept { return capture_; } @@ -110,6 +124,9 @@ class Acquisition { std::atomic paused_{false}; PacketCapture capture_; std::atomic 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 force_want_{false}; std::uint32_t capture_slots_ = 1000; /// 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. diff --git a/src/event_series.hpp b/src/event_series.hpp index cf2b707..009514a 100644 --- a/src/event_series.hpp +++ b/src/event_series.hpp @@ -7,6 +7,7 @@ #include #include +#include #include namespace udpscope { @@ -63,8 +64,30 @@ struct EventSnapshot { /// already O(n), and the same channel on two rows shares it instead of building and /// storing it twice. std::vector 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 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& 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 /// 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. diff --git a/src/layout_store.cpp b/src/layout_store.cpp index 7a77fcf..ef8d9be 100644 --- a/src/layout_store.cpp +++ b/src/layout_store.cpp @@ -516,6 +516,10 @@ bool load_layout(const std::string& path, LayoutState& out) { num(t.noise_band); } else if (k == "hf") { num(t.hf_fc); + } else if (k == "persistence") { + double n = 0.0; + num(n); + t.persistence = static_cast(n); } }; 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_band = " << state.trigger.noise_band << "\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.buffer_mb > 0.0) o << "buffer_mb = " << state.buffer_mb << "\n"; o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n"; diff --git a/src/layout_store.hpp b/src/layout_store.hpp index a89f7d5..37809c8 100644 --- a/src/layout_store.hpp +++ b/src/layout_store.hpp @@ -97,6 +97,10 @@ struct LayoutState { double holdoff_s = 0.010; double noise_band = 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 /// leaves the panel's own "follow the channel's range" default alone. bool level_given = false; diff --git a/src/model.cpp b/src/model.cpp index 60a987b..79fe9f1 100644 --- a/src/model.cpp +++ b/src/model.cpp @@ -891,9 +891,11 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) { } snap->values.resize(channels_.size()); + snap->channel_names.resize(channels_.size()); snap->y.resize(channels_.size(), plot::Range{0.0, 0.0}); snap->pyramids.resize(channels_.size()); for (std::size_t c = 0; c < channels_.size(); ++c) { + snap->channel_names[c] = channels_[c].name; auto& vals = snap->values[c]; vals.resize(static_cast(n)); 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. snap->pre_s = snap->stamps.empty() ? 0.0 : -static_cast(snap->stamps.front()); snap->post_s = snap->stamps.empty() ? 0.0 : static_cast(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 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(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(persist_depth_locked() - 1)); event_.store(std::move(snap), std::memory_order_release); // Bumped after the snapshot is in place, so a consumer that sees the new sequence // 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); } +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 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 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(n - 1)); +} + void ScopeModel::set_epoch_if_unset(f64 e) noexcept { if (epoch_ == 0.0) epoch_ = e; } diff --git a/src/model.hpp b/src/model.hpp index e155fe7..338caef 100644 --- a/src/model.hpp +++ b/src/model.hpp @@ -10,6 +10,8 @@ #include +#include +#include #include #include #include @@ -511,6 +513,55 @@ class ScopeModel { /// 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); } + // --- 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 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(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. void acquire_all(); @@ -742,6 +793,19 @@ class ScopeModel { std::atomic capture_edge_{0.0}; std::atomic> event_; ///< the event on screen std::atomic> 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>, kMaxPersistDepth - 1> persist_{}; + /// The readings `persist_count()`/`persist_bytes()`/`persist_dropped()` answer with, maintained + /// where the slots are written. + std::atomic persist_count_{0}; + std::atomic persist_bytes_{0}; + std::atomic 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 paused_{false}; /// Per-signal-name calibration (see `set_calibration`). std::unordered_map> calibration_; diff --git a/src/plot_host.cpp b/src/plot_host.cpp index efef5e5..770a70b 100644 --- a/src/plot_host.cpp +++ b/src/plot_host.cpp @@ -68,8 +68,28 @@ constexpr float kMeasureDashOff = 4.0f; /// stacked frames read as one tall plot with a line through it. 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 +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) { const std::size_t a = name.rfind(" ("); 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.channel = src.channel; 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_b_y = src.ruler_b_y; @@ -161,6 +186,8 @@ void RowSet::clear_row(Row& r) { r.name = {}; r.channel = {-1, -1, -1, -1}; 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; 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}); r.axis_y = r.plot->add_y_axis( {"", {-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 order{}; + for (int s = 0; s < kSlotsPerRow; ++s) order[static_cast(s)] = s; + std::stable_sort(order.begin(), order.end(), [&r](int a, int b) { + return r.slot_alpha[static_cast(a)] < r.slot_alpha[static_cast(b)]; + }); + for (const int s : order) { const std::size_t i = static_cast(s); - r.plot->add_trace({&r.slots[i], signal_color(r.signal_name[i], r.color_index[i]), 1.2f, - r.axis_x, r.axis_y, r.name[i]}); + plot::Color color = signal_color(r.signal_name[i], r.color_index[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_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.name[s] = std::move(display_name); 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(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(slot); + r.layer[s] = age; + r.slot_alpha[s] = alpha; + row_dirty_[static_cast(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(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, kMaxRows> plan{}; + std::array, 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(s); + // A layer slot is free for re-planning; a signal's own trace is not. + plan[static_cast(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(r); + Row& row_state = row(r); + for (int s = 0; s < kSlotsPerRow; ++s) { + const std::size_t si = static_cast(s); + if (plan[ri][si] != 0) continue; + ++wanted; + int target = -1; + for (int t = 0; t < kSlotsPerRow; ++t) { + if (plan[ri][static_cast(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(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(r); + Row& row_state = row(r); + for (int s = 0; s < kSlotsPerRow; ++s) { + const std::size_t si = static_cast(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(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(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(s)] > 0) ++n; + } + } + return n; +} + +std::vector> RowSet::row_layers(int row_index) const { + std::vector> 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(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 { if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return false; return !row(row_index).signal_name[static_cast(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 // renumbered) whenever a signal is enabled, disabled or re-described, and a // 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; 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(k); 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 snap; + if (channel >= 0 && (mode == DisplayMode::Held || mode == DisplayMode::Capturing)) { + snap = model.persist_event(static_cast(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; if (channel < 0) { // 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); if (!r.plot) continue; 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(s)] > 0) continue; const EventSeries* ev = r.slots[static_cast(s)].bound_event(); if (ev == nullptr || ev->size() < 2) continue; const plot::Range b = ev->x_bounds(); diff --git a/src/plot_host.hpp b/src/plot_host.hpp index e4b99f5..e809cf0 100644 --- a/src/plot_host.hpp +++ b/src/plot_host.hpp @@ -112,6 +112,16 @@ class RowSet { /// `add_trace()`, which is why this is stored rather than resolved per frame -- /// changing it needs a rebuild, and `assign()` already forces one. std::array 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 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 slot_alpha = {1.0f, 1.0f, 1.0f, 1.0f}; plot::u32 axis_x = 0; plot::u32 axis_y = 0; plot::u32 cursor_a = 0; @@ -200,6 +210,42 @@ class RowSet { /// The channel index a row/slot holds, or -1. 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> row_layers(int row) const; + /// 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. /// diff --git a/src/trigger.cpp b/src/trigger.cpp index ca7f8cd..90163f8 100644 --- a/src/trigger.cpp +++ b/src/trigger.cpp @@ -39,6 +39,20 @@ void TriggerEngine::arm(double now_s) noexcept { 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 { if (!cfg_.enabled || !armed_ || cfg_.channel < 0) return false; diff --git a/src/trigger.hpp b/src/trigger.hpp index 63cbafc..04fe2f0 100644 --- a/src/trigger.hpp +++ b/src/trigger.hpp @@ -30,12 +30,25 @@ struct TriggerConfig { double pretrigger_pct = 50.0; ///< share of the buffer shown before the event. double holdoff_s = 0.010; ///< default from the spec. 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 /// the buffer's span. 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 /// order; it says when the event buffer should be latched. class TriggerEngine { @@ -75,6 +88,23 @@ class TriggerEngine { void disarm() noexcept { armed_ = false; pending_ = false; } 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 /// in stream seconds. Returns true when the event has just been latched. bool process(double value, double now_s) noexcept; diff --git a/src/ui.cpp b/src/ui.cpp index 1e27c1b..73288fb 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -3514,11 +3514,27 @@ gboolean tick(gpointer user) { app->capturing = display == DisplayMode::Capturing; 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 // UI's own structural changes: the bind takes shared handles into the live // store, and acquire() reads it. std::lock_guard 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->acquire(model); 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. 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(std::min( + model.persist_count(), static_cast(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. sync_compare_axes(*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")) { g_timeout_add(static_cast(std::atoi(env)), disarm_probe_step1, &app); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_FORCE_MS")) { + g_timeout_add(static_cast(std::atoi(env)), force_probe, &app); + } if (const char* env = g_getenv("UDPSCOPE_ARM_TRIGGER_MS")) { g_timeout_add(static_cast(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")) { g_timeout_add(static_cast(std::atoi(env)), reseq_probe, &app); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_PERSIST_MS")) { + g_timeout_add(static_cast(std::atoi(env)), persist_probe, &app); + } // Our own options were consumed by parse_args(); GTK would reject them, so only // the program name is handed to g_application_run(). @@ -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_prev_button != nullptr) + (app.search_next_button != nullptr); 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", boxes, 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_band_spin, app.trigger_body) + inside(app.trigger_hf_spin, app.trigger_body) + + inside(app.trigger_persist_spin, app.trigger_body) + inside(app.events_record_switch, app.trigger_body), search_present, inside(app.search_run_button, search_root) + inside(app.search_cancel_button, search_root) + @@ -6600,6 +6645,32 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit static_cast(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 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(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(RowSet::persist_alpha(age))); + } + std::printf("] alphas=["); + const std::vector> layers = rows.row_layers(0); + for (std::size_t i = 0; i < layers.size(); ++i) { + std::printf("%s%.4f", i != 0 ? "," : "", static_cast(layers[i].second)); + } + std::printf("]\n"); + } } return result; } diff --git a/src/ui/trigger-panel.ui b/src/ui/trigger-panel.ui index 6e814fb..da9c566 100644 --- a/src/ui/trigger-panel.ui +++ b/src/ui/trigger-panel.ui @@ -2,10 +2,11 @@