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:
1 parent
178cf54ef6
commit
b3fd9ae00f
6 files changed
+189
-50
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -4,6 +4,7 @@
|
||||
#include "trigger.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
+11
-9
@@ -6,10 +6,9 @@
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
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;
|
||||
|
||||
+9
-8
@@ -1609,12 +1609,12 @@ void publish_trigger_fields(App* app) {
|
||||
{
|
||||
std::lock_guard<std::mutex> 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<int>(model.channel_count())) {
|
||||
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 "
|
||||
"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<std::mutex> 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;
|
||||
|
||||
Reference in new issue
Block a user