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