diff --git a/CMakeLists.txt b/CMakeLists.txt index 61d5c9a..fa2b67b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -105,6 +105,7 @@ add_executable(udpscope src/config.cpp src/channel.cpp src/model.cpp + src/condition.cpp src/trigger.cpp src/acquisition.cpp src/plot_host.cpp diff --git a/src/condition.cpp b/src/condition.cpp new file mode 100644 index 0000000..a4f4ac7 --- /dev/null +++ b/src/condition.cpp @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "condition.hpp" + +namespace udpscope { + +void EventDetector::configure(const EventCondition& c) noexcept { + if (c == cond_) return; + cond_ = c; + reset(); +} + +void EventDetector::reset() noexcept { + have_prev_ = false; + prev_ = 0.0; +} + +double EventDetector::feed(double value, double t) noexcept { + if (!have_prev_) { + prev_ = value; + have_prev_ = true; + return kNoEvent; + } + double event = kNoEvent; + switch (cond_.type) { + case TriggerType::Edge: + event = feed_edge(value, t); + break; + case TriggerType::PulseWidth: + case TriggerType::Window: + case TriggerType::Dropout: + break; // implemented in the tasks that add their panel rows + } + // After the handler: every crossing test above reads `prev_` as the previous sample. + prev_ = value; + return event; +} + +double EventDetector::feed_edge(double value, double t) noexcept { + const bool up = crossed_up(value); + const bool down = crossed_down(value); + const bool fired = (cond_.edge == TriggerEdge::Rising && up) || + (cond_.edge == TriggerEdge::Falling && down) || + (cond_.edge == TriggerEdge::Both && (up || down)); + return fired ? t : kNoEvent; +} + +} // namespace udpscope diff --git a/src/condition.hpp b/src/condition.hpp new file mode 100644 index 0000000..34d4831 --- /dev/null +++ b/src/condition.hpp @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include + +namespace udpscope { + +/// Which way through a level counts. Shared by every condition type: for `Window` it is +/// the direction through the *band* (into it, out of it, or either), and for `Dropout` it +/// selects which crossings count as the signal still being alive. +/// +/// It lives here rather than in `trigger.hpp` because the buffer search needs the +/// evaluator without the engine's arming state. +enum class TriggerEdge { Rising, Falling, Both }; + +/// What shape in the signal counts as an event. +enum class TriggerType { Edge, PulseWidth, Window, Dropout }; + +/// How a measured pulse width is compared with the condition's bounds. `Less` is the +/// glitch trigger: "a pulse shorter than it should be". +enum class WidthCmp { Less, Greater, Within, Outside }; + +/// Everything that decides whether a sample is an event. No arming, no capture window: +/// those belong to `TriggerConfig`. +struct EventCondition { + TriggerType type = TriggerType::Edge; + double level = 0.0; + double level2 = 0.0; ///< Window: the band's other edge. Ignored otherwise. + TriggerEdge edge = TriggerEdge::Rising; + WidthCmp cmp = WidthCmp::Greater; ///< PulseWidth only. + double width_s = 1.0e-3; ///< PulseWidth only. + double width2_s = 2.0e-3; ///< PulseWidth, Within/Outside only. + double timeout_s = 1.0e-3; ///< Dropout only. + + bool operator==(const EventCondition& o) const noexcept { + return type == o.type && level == o.level && level2 == o.level2 && edge == o.edge && + cmp == o.cmp && width_s == o.width_s && width2_s == o.width2_s && + timeout_s == o.timeout_s; + } + bool operator!=(const EventCondition& o) const noexcept { return !(*this == o); } +}; + +/// The event condition's state machine: one sample in, an event time or nothing out. +/// +/// It holds no arming state and no capture state -- that is `TriggerEngine`'s job -- which +/// is what lets the buffer search run the *same* evaluator over retained samples and get +/// the answers the live trigger would have given. +/// +/// Every condition but `Edge` is sequential: a pulse's width is only known at its closing +/// edge, and a dropout is a measured absence. Callers must therefore feed it **every** +/// sample in stream order, including samples they intend to ignore; a gap is a pulse it can +/// never close. +class EventDetector { + public: + /// What `feed()` returns when the sample is not an event. + static constexpr double kNoEvent = std::numeric_limits::quiet_NaN(); + + /// Re-applying the same condition is a no-op, deliberately: the producer calls this on + /// every packet, and resetting there would restart the machine before any pulse could + /// close. A genuinely different condition resets, because its state means nothing. + void configure(const EventCondition& c) noexcept; + void reset() noexcept; + + /// One sample, in stream order. Returns the event's own stream time, or `kNoEvent`. + /// + /// There is no finite-value check: a NaN sample is benign here (every comparison against + /// it is false, so it cannot fire), and rejecting one would leave `prev_` at the last good + /// value and let the sample *after* the NaN fire a crossing that today cannot happen. + double feed(double value, double t) noexcept; + + double last_value() const noexcept { return prev_; } + bool have_last() const noexcept { return have_prev_; } + const EventCondition& condition() const noexcept { return cond_; } + + private: + bool crossed_up(double v) const noexcept { + return prev_ < cond_.level && v >= cond_.level; + } + bool crossed_down(double v) const noexcept { + return prev_ > cond_.level && v <= cond_.level; + } + double feed_edge(double value, double t) noexcept; + + EventCondition cond_{}; + bool have_prev_ = false; + double prev_ = 0.0; +}; + +} // namespace udpscope diff --git a/src/trigger.cpp b/src/trigger.cpp index f199919..c55365c 100644 --- a/src/trigger.cpp +++ b/src/trigger.cpp @@ -4,6 +4,7 @@ #include "trigger.hpp" #include +#include namespace udpscope { @@ -20,8 +21,10 @@ void TriggerEngine::configure(const TriggerConfig& cfg, const std::string& chann post_seconds_ = post_seconds > 0.0 ? post_seconds : 0.0; pre_seconds_ = pre_seconds > 0.0 ? pre_seconds : 0.0; oldest_s_ = oldest_s; + // A no-op unless the condition actually changed: this runs on every packet. + detector_.configure(cfg_.condition); if (channel_changed) { - have_prev_ = false; + detector_.reset(); pending_ = false; post_accum_count_ = 0; } @@ -32,23 +35,21 @@ void TriggerEngine::arm(double now_s) noexcept { pending_ = false; post_accum_count_ = 0.0; holdoff_until_s_ = now_s; - have_prev_ = false; + detector_.reset(); } bool TriggerEngine::process(double value, double now_s) noexcept { if (!cfg_.enabled || !armed_ || cfg_.channel < 0) return false; - // Hold-off: settled, waiting for the next edge. - if (!pending_ && now_s < holdoff_until_s_) { - prev_ = value; - have_prev_ = true; - last_t_s_ = now_s; - return false; - } + // The detector is fed **first and unconditionally**, on every sample the engine sees -- + // including during the hold-off and during an open capture, where its answer is thrown + // away. It is a sequential machine: a pulse whose opening edge fell in a skipped stretch + // can never be closed, and the symptom would be a width condition that silently stops + // firing after the first hold-off. + const double event_t = detector_.feed(value, now_s); - if (!have_prev_) { - prev_ = value; - have_prev_ = true; + // Hold-off: settled, waiting for the next event. + if (!pending_ && now_s < holdoff_until_s_) { last_t_s_ = now_s; return false; } @@ -60,7 +61,6 @@ bool TriggerEngine::process(double value, double now_s) noexcept { // past it. Time, not a sample count: `now_s` is the same clock the x axis reads, // so the capture covers exactly the requested span whatever the cadence is. ++post_accum_count_; - prev_ = value; if (now_s - pending_start_s_ >= post_seconds_) { pending_ = false; post_accum_count_ = 0; @@ -74,33 +74,28 @@ bool TriggerEngine::process(double value, double now_s) noexcept { return false; } - // Edge detection against the level. - const bool crossed_up = prev_ < cfg_.level && value >= cfg_.level; - const bool crossed_down = prev_ > cfg_.level && value <= cfg_.level; - const bool fired = (cfg_.edge == TriggerEdge::Rising && crossed_up) || - (cfg_.edge == TriggerEdge::Falling && crossed_down) || - (cfg_.edge == TriggerEdge::Both && (crossed_up || crossed_down)); - prev_ = value; - if (!fired) return false; + if (!std::isfinite(event_t)) return false; - // The pre-trigger window has to already be behind this edge, or the capture opens - // on a half-filled pre window and the event is short on its left side -- the plot - // then shows a window that is correctly sized but only partly drawn. The buffer - // fills whether or not the trigger is armed, so this normally passes on the first - // edge; it only bites just after a rebuild (a CONFIG, or enabling the signal), - // when the history genuinely is not there yet. - if (now_s - oldest_s_ < pre_seconds_) return false; + // The pre-trigger window has to already be behind the event, or the capture opens on a + // half-filled pre window and the event is short on its left side -- the plot then shows a + // window that is correctly sized but only partly drawn. The buffer fills whether or not the + // trigger is armed, so this normally passes on the first event; it only bites just after a + // rebuild (a CONFIG, or enabling the signal), when the history genuinely is not there yet. + // + // Measured from the *event's own* time, not from `now_s`: for a qualified condition the two + // differ (a pulse is reported at its closing edge), and the pre window belongs to the event. + if (event_t - oldest_s_ < pre_seconds_) return false; - // The edge's own time, which is what a trigger marker marks: the latch itself - // may happen a post-window later. - last_trigger_s_ = now_s; + // The event's own time, which is what a trigger marker marks: the latch itself may happen + // a post-window later. + last_trigger_s_ = event_t; - // The edge always opens a capture; there is no "latch on the edge itself" case. + // The event always opens a capture; there is no "latch on the event itself" case. // A zero-length post window latches on the very next sample, which is the least a // capture can hold, and every longer window is just more samples -- no conversion // from seconds to a sample count, so no conversion to get wrong. pending_ = true; - pending_start_s_ = now_s; + pending_start_s_ = event_t; post_accum_count_ = 0; return false; } diff --git a/src/trigger.hpp b/src/trigger.hpp index c7817dd..1d3f570 100644 --- a/src/trigger.hpp +++ b/src/trigger.hpp @@ -6,10 +6,9 @@ #include #include -namespace udpscope { +#include "condition.hpp" -/// Which way the trigger signal crosses the level. -enum class TriggerEdge { Rising, Falling, Both }; +namespace udpscope { /// How an armed trigger re-arms after it fired. enum class TriggerMode { Normal, Single }; @@ -24,8 +23,10 @@ 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. - double level = 0.0; - TriggerEdge edge = TriggerEdge::Rising; + /// 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; @@ -92,8 +93,8 @@ class TriggerEngine { /// 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 prev_; } - bool have_last() const noexcept { return have_prev_; } + 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_; } @@ -107,8 +108,9 @@ class TriggerEngine { TriggerConfig cfg_{}; std::string channel_name_; ///< the trigger channel's stable identity (see `configure`). bool armed_ = false; - bool have_prev_ = false; - double prev_ = 0.0; + /// 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; diff --git a/src/ui.cpp b/src/ui.cpp index f258fdb..07ec92a 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -1609,12 +1609,12 @@ void publish_trigger_fields(App* app) { { std::lock_guard lock(app->model->mutex()); TriggerConfig& tc = app->model->trigger(); - tc.level = level; + tc.condition.level = level; tc.pretrigger_pct = pre; tc.holdoff_s = holdoff; - tc.edge = edge == 0 ? TriggerEdge::Rising - : edge == 1 ? TriggerEdge::Falling - : TriggerEdge::Both; + tc.condition.edge = edge == 0 ? TriggerEdge::Rising + : edge == 1 ? TriggerEdge::Falling + : TriggerEdge::Both; tc.mode = mode == 0 ? TriggerMode::Normal : TriggerMode::Single; } } @@ -2050,7 +2050,7 @@ void rebuild_trigger_signals(App& app) { if (std::isfinite(sig.range_min) && std::isfinite(sig.range_max) && sig.range_max > sig.range_min) { const double mid = 0.5 * (sig.range_min + sig.range_max); - tc.level = mid; + tc.condition.level = mid; gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_level_spin), mid); } } @@ -6344,7 +6344,7 @@ gboolean tick(gpointer user) { span_s = model.partition().span_s; time_stride = model.partition().time_stride; const int chan = model.trigger().channel; - trigger_level = model.trigger().level; + trigger_level = model.trigger().condition.level; if (chan >= 0 && chan < static_cast(model.channel_count())) { trigger_name = model.channels()[static_cast(chan)].name; } @@ -8270,7 +8270,8 @@ gboolean trigrenum_probe(gpointer user) { "holdoff=%.4f level=%.4g edge=%d mode=%d enabled=%d armed=%d ch=%d " "channels=%zu\n", a->rows->time_window(), a->model->event_span(), tc.pretrigger_pct, - tc.post_span_s(a->model->event_span()), tc.holdoff_s, tc.level, int(tc.edge), + tc.post_span_s(a->model->event_span()), tc.holdoff_s, tc.condition.level, + int(tc.condition.edge), int(tc.mode), int(tc.enabled), int(tc.armed), tc.channel, a->model->channel_count()); } @@ -8295,7 +8296,7 @@ gboolean fields_probe(gpointer user) { std::lock_guard lock(a->model->mutex()); std::fprintf(stderr, "[fields-probe] mode=%u edge=%u published mode=%d edge=%d capturing=%d\n", gtk_drop_down_get_selected(mode), gtk_drop_down_get_selected(edge), - int(a->model->trigger().mode), int(a->model->trigger().edge), + int(a->model->trigger().mode), int(a->model->trigger().condition.edge), int(a->model->trigger_pending())); std::fflush(stderr); return G_SOURCE_REMOVE;