Files
udpscope/src/trigger.hpp
T
Martino Ferrari 974d6aa2d9 trigger: a phosphor persistence, driven by the plotter's density image
The trigger pane gains the display the user asked for and the library now provides, and the
persistence it already had is renamed to what it is.

**Persistence is one image; the last-N-traces display is "multiple events".** Both live in a new
`Advanced` expander in the trigger panel, together with the settings that are set up once and left
alone. `persistence` (a switch) turns on the plotter's `Plot::set_density()` for every row:
every triggered acquisition is accumulated into a screen-sized intensity grid drawn behind the
traces, brightness being how many acquisitions covered a cell -- no depth to choose, no memory
that grows with the acquisitions (2.2 MB over four 573x412 rows in the probe's run). `fades by`
picks the fade the user asked for: **acquisitions** (each keeps a factor of what was before it,
0.5 by default -- the phosphor afterglow) or **time** (a time constant in stream seconds, so the
display fades while you watch whatever the trigger rate).

- **Trigger mode only, as asked.** `apply_density()` (the tick) states the display on for `Held`
  and `Capturing` and off for `Live`, `Waiting`, `Paused` and a file, and turning it off drops the
  grid at once rather than at an acquisition a quiet trigger never sends. It is unpickable and
  reserves no layout space, exactly as the invalid-span bands are: a density can never be in the
  way of a cursor or a drop, and it cannot move the framing.
- **One deposit per acquisition, one frame after the frame that frames it.** `Plot::accumulate_density()`
  deposits what the plot holds through the transforms of its *last* layout -- the frame's own is
  computed inside the paint that follows the tick -- so `density_pending` is set beside
  `record_pending` and consumed by the next tick. In the same tick it was right on a Held-to-Held
  transition and wrong on the first latch, whose axis was still the live ring's. Switching the
  display on under an acquisition already on screen deposits it too, so the switch does not appear
  to do nothing until the next latch (`single` mode may never send one).
- **The session carries it**: `trigger_density`, `trigger_density_decay` ("acquisitions"/"time",
  the words the panel shows), `trigger_density_tau` and `trigger_density_factor`, written whether
  or not it is on -- and `trigger_persistence` still means the multiple-event depth, so an older
  file is unchanged.
- **The library changed twice under this**: the deposit now counts a cell **once per trace per
  sweep** (counting the hits put one dense sweep at the ceiling of the brightness scale and hid the
  sweeps behind it -- the probe's `on` leg read `peak=18` for a single sweep and reads `peak=1`
  now), and a frame's upload is every row's rather than the last row's. Both are in the plotter's
  own commit.

`UDPSCOPE_PROBE_DENSITY_MS` reads it all back off the plots -- the plotter's own `ink()`/`peak()` --
with the panel's own widgets driving the switch and the fade, in four legs: **off** (`rows=0
ink=0.0 peak=0.0000`), **on** (`rows=4`, `peak=1.0000`), **step-acquisitions** (`peak=1.0000 ->
1.5000`, the factor then the new sweep) and **step-time** (`ratio=0.367879`, `1/e` for `dt = tau`).
It also reads the frame cost as an A/B **inside one run** (the accumulators at the switch against
the frames after it): `tick 0.74 -> 9.99 ms`, `draw 2.09 -> 20.91 ms` at four rows and 2.2 M cells
on llvmpipe.

Acceptance: clean build 0 errors and exactly the three documented warnings (12352-byte GResource
blob, two `MathNode::source`), `test-ui` 13/13, `test-help` green, a plain run with **0
Gtk-CRITICAL / Gtk-WARNING** and `gtk warnings: 0`, and the panel's own frame reading
`boxes=16 controls_inside=15/15 advanced_widgets=5/5 advanced_inside=5/5` (the block's controls
counted from the expander's child, which is not in the widget-parent chain -- and the search
block's walk now names *its* expander instead of "the first one", which the second expander would
have stolen).

Docs: the trigger/pinning invariants carry the rule and its five consequences, `probe-readings.md`
the four legs and the dump line, `verification.md` the probe's sequence and assertions, and the
manual's trigger page explains both displays to a user.
2026-10-16 17:28:54 +02:00

196 lines
11 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <cstdint>
#include <string>
#include "condition.hpp"
namespace udpscope {
/// How an armed trigger re-arms after it fired.
enum class TriggerMode { Normal, Single };
/// How a trigger-mode **phosphor density** fades (the plotter's `plot::DensityDecay`, restated here
/// so the panel's dropdown, the session file and the model's copy of the setting all name one
/// thing). The two answer different questions and the user asked for both: see the longer note on
/// `TriggerConfig::density_decay`.
enum class PersistDecay {
/// Every acquisition multiplies the image by `density_factor` before depositing its own sweep,
/// so the N-th acquisition back is the factor to the N. Scales with the trigger rate.
Acquisitions,
/// Every frame fades the image by `exp(-dt / density_tau_s)` of stream time, so the display
/// fades while you watch whatever the trigger rate.
Time,
};
/// The trigger panel's configuration (spec item 2.2).
///
/// The trigger names a *signal* (an index into `ScopeModel::signals()`), not a
/// channel: the panel lists every signal the stream describes, whether or not it
/// is being acquired, and picking one is what arms the trigger on it. The
/// acquisition resolves the signal to the channel carrying it.
struct TriggerConfig {
bool enabled = false; ///< trigger mode on/off (toolbar).
bool armed = false; ///< arm/disarm button.
int channel = -1; ///< index into ScopeModel::channels(), -1 = none.
/// What counts as an event on that channel: the level and edge, and for the qualified
/// shapes the width, band or timeout as well. The engine does not interpret it -- it
/// hands it to an `EventDetector`.
EventCondition condition{};
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.
///
/// This is the **multiple event** display (the trigger pane's own words for it): a *set of
/// retained acquisitions*, one trace per (signal, age). It is not the phosphor density below,
/// which is one image and no count -- the two are different displays and the panel offers both.
int persist_depth = 1;
/// The **phosphor density** (the plotter's `Plot::set_density()`): off by default, and trigger
/// mode only. Where `persist_depth` keeps N whole acquisitions as N traces, this accumulates
/// every acquisition into one screen-sized intensity image -- brightness is how often a trace
/// passed a cell, so the cost of a display of a thousand acquisitions is the cost of one, and
/// there is no depth to get wrong. The image is drawn behind the traces, it is unpickable and it
/// reserves no layout space, exactly as the invalid-span bands do.
///
/// It applies while an acquisition is on screen -- `Held`, and `Capturing` as it fills -- and
/// not on a live view, which is what the user asked for: a live view is the stream, and a
/// display of the last few sweeps of it is a *trigger* display. Turning the trigger off (or
/// pausing, or opening a file) takes the display away, and a fresh presentation starts from an
/// empty image rather than a stale one drawn at another scale.
bool density = false;
/// Which way it fades (see `PersistDecay`) -- the choice the user asked for, and the panel's
/// dropdown. Deliberately one field and not two switches: a density fades one way or the other.
PersistDecay density_decay = PersistDecay::Acquisitions;
/// `PersistDecay::Time`'s constant, in stream seconds. The step the panel offers is 0.5 s.
double density_tau_s = 1.0;
/// `PersistDecay::Acquisitions`'s factor. 0.5 -- half per acquisition -- is the schedule a
/// phosphor's afterglow has, and the panel does not offer another.
double density_factor = 0.5;
/// 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 {
public:
/// Configures the channel and the post-trigger window. Resets the state when the
/// trigger moves to a different channel.
///
/// `channel_name` is that channel's name, and it -- not `cfg.channel` -- is what
/// identifies it here. The index is not stable: every rebuild of the channel list
/// renumbers it (enabling a signal that sorts before the trigger's own is enough),
/// and the model re-resolves the trigger by name afterwards. Keying the reset on the
/// index therefore made any such rebuild abandon an open capture *and set no
/// hold-off*, so the next edge inside the post window became t0 -- which is the
/// "re-triggers mid-capture and ignores the hold-off" defect. Choosing a genuinely
/// different source still resets, because the name changes with it.
///
/// `post_seconds` is the post-trigger window in **stream seconds**, the same clock
/// `process()` is fed and the same clock the x axis reads, so the capture covers
/// exactly that much axis by construction.
///
/// It used to be a count of samples, converted from seconds by a measured estimate
/// of the channel's cadence. That estimate is what made the window wrong: when it
/// collapsed (any stream whose time signal advances in large steps drove it to the
/// 1 S/s floor) the count came out as 1, the engine latched on the edge sample
/// itself, and the event had no post-trigger data at all. Seconds need no estimate.
///
/// `pre_seconds` is the pre-trigger window, and `oldest_s` the stream time of the
/// oldest sample the buffer still holds. Together they gate the edge: a capture is
/// only allowed once the history reaches back a full `pre_seconds` before it, so an
/// event cannot latch onto a half-filled pre window. `oldest_s` has to be the real
/// buffer's, not a timer since arming -- the ring keeps filling with the trigger
/// off, so the history is usually already there and waiting would be wrong.
void configure(const TriggerConfig& cfg, const std::string& channel_name,
double post_seconds, double pre_seconds, double oldest_s,
double dt);
/// Disarms without forgetting the configuration.
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;
bool pending() const noexcept { return pending_; }
bool armed() const noexcept { return armed_; }
/// Diagnostics for the capture state machine: how many samples have landed in
/// the post-trigger window, the window it is filling (in stream seconds), how
/// much of that has elapsed, and the last value fed.
std::uint64_t post_accum() const noexcept { return post_accum_count_; }
double post_seconds() const noexcept { return post_seconds_; }
double post_elapsed() const noexcept {
return pending_ ? last_t_s_ - pending_start_s_ : 0.0;
}
/// How much history the buffer holds behind the newest sample, against the
/// pre-trigger window an edge needs before it may open a capture.
double history() const noexcept { return last_t_s_ - oldest_s_; }
double pre_seconds() const noexcept { return pre_seconds_; }
double last_value() const noexcept { return detector_.last_value(); }
bool have_last() const noexcept { return detector_.have_last(); }
double last_t() const noexcept { return last_t_s_; }
double holdoff_until() const noexcept { return holdoff_until_s_; }
/// Stream time of the edge that armed the event now on screen. Valid once
/// `process()` has fired; the view is centred on this instant (pre-trigger share
/// before it, post-trigger share after), so it is the x a trigger marker belongs
/// at. Unchanged while a later event has not fired yet.
double last_trigger_time() const noexcept { return last_trigger_s_; }
private:
TriggerConfig cfg_{};
std::string channel_name_; ///< the trigger channel's stable identity (see `configure`).
bool armed_ = false;
/// What an event *is*. The engine owns when one may open a capture; this owns whether
/// the sample is one at all.
EventDetector detector_{};
bool pending_ = false; ///< an edge was seen; collecting the post window
double pending_start_s_ = 0.0;
double holdoff_until_s_ = 0.0;
std::uint64_t post_accum_count_ = 0;
double post_seconds_ = 0.0; ///< post-trigger window, in stream seconds.
double pre_seconds_ = 0.0; ///< pre-trigger window an edge needs behind it.
double oldest_s_ = 0.0; ///< stream time of the oldest sample still buffered.
double last_t_s_ = 0.0;
double last_trigger_s_ = 0.0;
};
} // namespace udpscope