trigger: a phosphor display keeps the last N acquisitions; and a Force button

Two changes in the trigger pane, landed together.

**Persistence.** Trigger mode keeps the last N published acquisitions and draws them fading toward
the background, N being a panel control (1..8, default 1) with N=1 meaning today's behaviour
exactly, so the knob is the switch. The layers are the accumulate path's own -- one layer per
(signal, age) in the row's free trace slots, named with the accumulate vocabulary so
`layer_base_name()` strips the suffix and `signal_color()` resolves the base signal -- so a layer is
not a signal: the signal list, the colours and the measurements see one row per signal.
`ScopeModel` retains the outgoing snapshot in seven atomic `shared_ptr` slots (no lock, no copy; the
snapshot already exists) and a layer's channel is resolved **by name inside the snapshot**, because
the channel list is renumbered by any structural change since that acquisition was cut.
`RowSet::sync_layers()` re-plans from the rows and the depth alone, so a latch costs no rebuild, and
`event_data_window()` skips layer slots so an older acquisition can never re-pin the axis. The
schedule is 0.5^age -- the acquisition on screen at full brightness and each older one half the
previous, independent of N, so raising the depth adds dimmer acquisitions rather than rescaling the
ones already on screen. Memory is bounded by the ring's own `--buffer-mb` rather than by the depth,
which is the bound that cannot be driven into an OOM, and the panel says when either bound bites.
This also wired up `set_trace_alpha()`, which the accumulate path was setting and nothing was
reading -- so the documented 0.35 translucency of an accumulation was opaque in fact.

**Force.** In the trigger pane beside Arm/Disarm, shown only while trigger mode is on: one
acquisition now, with the configured window. It is a synthetic edge rather than a second publisher
-- the engine opens one capture at the present moment and everything else is the ordinary latch, so
a forced acquisition is cut, pinned, framed, recorded and cached exactly like a latched one, and
the button owns no window and no publish. The pre-trigger gate is deliberately skipped (capture now
is a request for the history that exists, and the snapshot reports its own shorter pre_s), a
capture already filling is refused rather than restarted, and the state afterwards is the mode's:
Normal stays armed, Single disarms. The request is one atomic flag written under the model mutex
with `armed` -- the producer reads `armed` at the top of a packet and the request at the bottom, so
a request written between the two would be dropped -- and consumed at the end of a packet, after
the channel loop and before the pending publish.

**A defect fixed in passing, in the function Force was modelled on:** `on_arm_clicked()` passed
`TriggerConfig::channel` (an index into `channels()`) to `set_signal_enabled()` (an index into
`signals()`). With one signal acquired, arming switched on the stream's clock signal and left the
watched signal untouched -- and since a signal the panel was not showing is a change, it rebuilt
the buffer under the click, dropping the very pre-trigger window the arming, and now the Force, was
about to capture. Measured before: `0.0100 s of history behind it`; after: `2.5299 s`.
`trigger_signal_index()` is now the one resolution, used by both buttons.
This commit is contained in:
Martino Ferrari committed 2026-10-16 16:46:17 +02:00
1 parent fc4700d1c9
commit e14e1ee069
22 files changed
+1652 -26

No files matched your search

+30
View File
@@ -30,12 +30,25 @@ struct TriggerConfig {
double pretrigger_pct = 50.0; ///< share of the buffer shown before the event.
double holdoff_s = 0.010; ///< default from the spec.
TriggerMode mode = TriggerMode::Normal;
/// How many of the most recent acquisitions the plot keeps on screen, the older ones drawn
/// dimmer (see `RowSet::persist_alpha()`): 1 -- the default -- keeps nothing but the event on
/// screen, which is the display without persistence, and there is deliberately no second switch
/// for it.
int persist_depth = 1;
/// Seconds of post-trigger data to collect before latching the event, given
/// the buffer's span.
double post_span_s(double buffer_span_s) const;
};
/// The deepest persistence the trigger panel offers. Seven retained acquisitions is what fits: the
/// layer in that position is drawn at half the seventh power (0.8 %, i.e. already gone, see
/// `RowSet::persist_alpha()`), and seven is also the number of free slots a row has beside a single
/// signal, so a deeper request could not be drawn on the simplest layout there is. It is **not**
/// the bound on what persistence may cost in memory -- `ScopeModel::persist_budget_bytes()` is, and
/// a window of millions of samples reaches it long before the seventh layer.
inline constexpr int kMaxPersistDepth = 8;
/// The producer-side trigger state machine. Fed one sample at a time in stream
/// order; it says when the event buffer should be latched.
class TriggerEngine {
@@ -75,6 +88,23 @@ class TriggerEngine {
void disarm() noexcept { armed_ = false; pending_ = false; }
void arm(double now_s) noexcept;
/// Opens a capture **now**, as if the condition had just fired: `now_s` becomes the event's own
/// time, so the engine collects the post window from the samples that follow it and the ordinary
/// latch publishes `[-pre, +post]` around that instant. This is the Force button -- the one
/// acquisition a scope takes when the edge is not coming -- and it is deliberately not a
/// publish of its own: the capture, the cut, the marker, the count and the mode's re-arm rule are
/// the edge path's, which is what keeps a forced acquisition framed and pinned exactly like a
/// latched one.
///
/// The pre-trigger gate is the one thing *not* applied: the history behind the present moment is
/// whatever the ring holds, and a caller who asks to capture now is asking for that. A snapshot
/// shorter than the request reports its own `pre_s`, and the view is pinned to it.
///
/// Returns false when there is no engine to force -- trigger mode off, no channel resolved, not
/// armed -- or when a capture is already filling: a capture in flight *is* the acquisition being
/// asked for, and restarting it would discard the post window it has already collected.
bool force(double now_s) noexcept;
/// Feeds one sample of the trigger channel. `now_s` is the sample's timestamp
/// in stream seconds. Returns true when the event has just been latched.
bool process(double value, double now_s) noexcept;