refactor: what counts as an event moves into its own evaluator, so a search can ask the same question the trigger asks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Sonnet 4.6 committed 2026-10-07 17:15:37 +02:00
1 parent 178cf54ef6
commit b3fd9ae00f
6 files changed
+187 -48

No files matched your search

+1
View File
@@ -105,6 +105,7 @@ add_executable(udpscope
src/config.cpp src/config.cpp
src/channel.cpp src/channel.cpp
src/model.cpp src/model.cpp
src/condition.cpp
src/trigger.cpp src/trigger.cpp
src/acquisition.cpp src/acquisition.cpp
src/plot_host.cpp src/plot_host.cpp
+49
View File
@@ -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
+91
View File
@@ -0,0 +1,91 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <limits>
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<double>::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
+28 -33
View File
@@ -4,6 +4,7 @@
#include "trigger.hpp" #include "trigger.hpp"
#include <algorithm> #include <algorithm>
#include <cmath>
namespace udpscope { 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; post_seconds_ = post_seconds > 0.0 ? post_seconds : 0.0;
pre_seconds_ = pre_seconds > 0.0 ? pre_seconds : 0.0; pre_seconds_ = pre_seconds > 0.0 ? pre_seconds : 0.0;
oldest_s_ = oldest_s; oldest_s_ = oldest_s;
// A no-op unless the condition actually changed: this runs on every packet.
detector_.configure(cfg_.condition);
if (channel_changed) { if (channel_changed) {
have_prev_ = false; detector_.reset();
pending_ = false; pending_ = false;
post_accum_count_ = 0; post_accum_count_ = 0;
} }
@@ -32,23 +35,21 @@ void TriggerEngine::arm(double now_s) noexcept {
pending_ = false; pending_ = false;
post_accum_count_ = 0.0; post_accum_count_ = 0.0;
holdoff_until_s_ = now_s; holdoff_until_s_ = now_s;
have_prev_ = false; detector_.reset();
} }
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;
// Hold-off: settled, waiting for the next edge. // The detector is fed **first and unconditionally**, on every sample the engine sees --
if (!pending_ && now_s < holdoff_until_s_) { // including during the hold-off and during an open capture, where its answer is thrown
prev_ = value; // away. It is a sequential machine: a pulse whose opening edge fell in a skipped stretch
have_prev_ = true; // can never be closed, and the symptom would be a width condition that silently stops
last_t_s_ = now_s; // firing after the first hold-off.
return false; const double event_t = detector_.feed(value, now_s);
}
if (!have_prev_) { // Hold-off: settled, waiting for the next event.
prev_ = value; if (!pending_ && now_s < holdoff_until_s_) {
have_prev_ = true;
last_t_s_ = now_s; last_t_s_ = now_s;
return false; 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, // 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. // so the capture covers exactly the requested span whatever the cadence is.
++post_accum_count_; ++post_accum_count_;
prev_ = value;
if (now_s - pending_start_s_ >= post_seconds_) { if (now_s - pending_start_s_ >= post_seconds_) {
pending_ = false; pending_ = false;
post_accum_count_ = 0; post_accum_count_ = 0;
@@ -74,33 +74,28 @@ bool TriggerEngine::process(double value, double now_s) noexcept {
return false; return false;
} }
// Edge detection against the level. if (!std::isfinite(event_t)) return false;
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;
// The pre-trigger window has to already be behind this edge, or the capture opens // The pre-trigger window has to already be behind the event, or the capture opens on a
// on a half-filled pre window and the event is short on its left side -- the plot // half-filled pre window and the event is short on its left side -- the plot then shows a
// then shows a window that is correctly sized but only partly drawn. The buffer // window that is correctly sized but only partly drawn. The buffer fills whether or not the
// fills whether or not the trigger is armed, so this normally passes on the first // trigger is armed, so this normally passes on the first event; it only bites just after a
// edge; it only bites just after a rebuild (a CONFIG, or enabling the signal), // rebuild (a CONFIG, or enabling the signal), when the history genuinely is not there yet.
// when the history genuinely is not there yet. //
if (now_s - oldest_s_ < pre_seconds_) return false; // 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 // The event's own time, which is what a trigger marker marks: the latch itself may happen
// may happen a post-window later. // a post-window later.
last_trigger_s_ = now_s; 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 // 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 // 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. // from seconds to a sample count, so no conversion to get wrong.
pending_ = true; pending_ = true;
pending_start_s_ = now_s; pending_start_s_ = event_t;
post_accum_count_ = 0; post_accum_count_ = 0;
return false; return false;
} }
+11 -9
View File
@@ -6,10 +6,9 @@
#include <cstdint> #include <cstdint>
#include <string> #include <string>
namespace udpscope { #include "condition.hpp"
/// Which way the trigger signal crosses the level. namespace udpscope {
enum class TriggerEdge { Rising, Falling, Both };
/// How an armed trigger re-arms after it fired. /// How an armed trigger re-arms after it fired.
enum class TriggerMode { Normal, Single }; enum class TriggerMode { Normal, Single };
@@ -24,8 +23,10 @@ struct TriggerConfig {
bool enabled = false; ///< trigger mode on/off (toolbar). bool enabled = false; ///< trigger mode on/off (toolbar).
bool armed = false; ///< arm/disarm button. bool armed = false; ///< arm/disarm button.
int channel = -1; ///< index into ScopeModel::channels(), -1 = none. int channel = -1; ///< index into ScopeModel::channels(), -1 = none.
double level = 0.0; /// What counts as an event on that channel: the level and edge, and for the qualified
TriggerEdge edge = TriggerEdge::Rising; /// 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 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;
@@ -92,8 +93,8 @@ class TriggerEngine {
/// pre-trigger window an edge needs before it may open a capture. /// pre-trigger window an edge needs before it may open a capture.
double history() const noexcept { return last_t_s_ - oldest_s_; } double history() const noexcept { return last_t_s_ - oldest_s_; }
double pre_seconds() const noexcept { return pre_seconds_; } double pre_seconds() const noexcept { return pre_seconds_; }
double last_value() const noexcept { return prev_; } double last_value() const noexcept { return detector_.last_value(); }
bool have_last() const noexcept { return have_prev_; } bool have_last() const noexcept { return detector_.have_last(); }
double last_t() const noexcept { return last_t_s_; } double last_t() const noexcept { return last_t_s_; }
double holdoff_until() const noexcept { return holdoff_until_s_; } double holdoff_until() const noexcept { return holdoff_until_s_; }
@@ -107,8 +108,9 @@ class TriggerEngine {
TriggerConfig cfg_{}; TriggerConfig cfg_{};
std::string channel_name_; ///< the trigger channel's stable identity (see `configure`). std::string channel_name_; ///< the trigger channel's stable identity (see `configure`).
bool armed_ = false; bool armed_ = false;
bool have_prev_ = false; /// What an event *is*. The engine owns when one may open a capture; this owns whether
double prev_ = 0.0; /// the sample is one at all.
EventDetector detector_{};
bool pending_ = false; ///< an edge was seen; collecting the post window bool pending_ = false; ///< an edge was seen; collecting the post window
double pending_start_s_ = 0.0; double pending_start_s_ = 0.0;
double holdoff_until_s_ = 0.0; double holdoff_until_s_ = 0.0;
+7 -6
View File
@@ -1609,10 +1609,10 @@ void publish_trigger_fields(App* app) {
{ {
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();
tc.level = level; tc.condition.level = level;
tc.pretrigger_pct = pre; tc.pretrigger_pct = pre;
tc.holdoff_s = holdoff; tc.holdoff_s = holdoff;
tc.edge = edge == 0 ? TriggerEdge::Rising tc.condition.edge = edge == 0 ? TriggerEdge::Rising
: edge == 1 ? TriggerEdge::Falling : edge == 1 ? TriggerEdge::Falling
: TriggerEdge::Both; : TriggerEdge::Both;
tc.mode = mode == 0 ? TriggerMode::Normal : TriggerMode::Single; 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) && if (std::isfinite(sig.range_min) && std::isfinite(sig.range_max) &&
sig.range_max > sig.range_min) { sig.range_max > sig.range_min) {
const double mid = 0.5 * (sig.range_min + sig.range_max); 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); 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; span_s = model.partition().span_s;
time_stride = model.partition().time_stride; time_stride = model.partition().time_stride;
const int chan = model.trigger().channel; const int chan = model.trigger().channel;
trigger_level = model.trigger().level; trigger_level = model.trigger().condition.level;
if (chan >= 0 && chan < static_cast<int>(model.channel_count())) { if (chan >= 0 && chan < static_cast<int>(model.channel_count())) {
trigger_name = model.channels()[static_cast<std::size_t>(chan)].name; trigger_name = model.channels()[static_cast<std::size_t>(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 " "holdoff=%.4f level=%.4g edge=%d mode=%d enabled=%d armed=%d ch=%d "
"channels=%zu\n", "channels=%zu\n",
a->rows->time_window(), a->model->event_span(), tc.pretrigger_pct, 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, int(tc.mode), int(tc.enabled), int(tc.armed), tc.channel,
a->model->channel_count()); a->model->channel_count());
} }
@@ -8295,7 +8296,7 @@ gboolean fields_probe(gpointer user) {
std::lock_guard<std::mutex> lock(a->model->mutex()); std::lock_guard<std::mutex> lock(a->model->mutex());
std::fprintf(stderr, "[fields-probe] mode=%u edge=%u published mode=%d edge=%d capturing=%d\n", 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), 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())); int(a->model->trigger_pending()));
std::fflush(stderr); std::fflush(stderr);
return G_SOURCE_REMOVE; return G_SOURCE_REMOVE;