From e14e1ee069c4818f0f9d906988d07d7847ba32df Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Fri, 16 Oct 2026 16:44:15 +0200 Subject: [PATCH] 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. --- README.md | 48 ++ docs/claude/invariants-trigger-and-pinning.md | 136 +++++- docs/claude/probe-readings.md | 148 ++++++- docs/claude/verification.md | 57 +++ help/C/org.udpscope.app/trigger.page | 52 ++- src/acquisition.cpp | 32 ++ src/acquisition.hpp | 17 + src/event_series.hpp | 23 + src/layout_store.cpp | 5 + src/layout_store.hpp | 4 + src/model.cpp | 62 +++ src/model.hpp | 64 +++ src/plot_host.cpp | 213 ++++++++- src/plot_host.hpp | 46 ++ src/trigger.cpp | 14 + src/trigger.hpp | 30 ++ src/ui.cpp | 73 +++- src/ui/trigger-panel.ui | 39 +- src/ui_app.hpp | 58 +++ src/ui_layout.cpp | 13 +- src/ui_probe.cpp | 410 ++++++++++++++++++ src/ui_trigger.cpp | 134 +++++- 22 files changed, 1652 insertions(+), 26 deletions(-) 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 @@