// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #pragma once #include #include #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