From d34bff3f03ada067172af1e700f43ecf3da217ee Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 13 Oct 2026 15:04:52 +0200 Subject: [PATCH] feat: the trigger can ignore a noise band and reject HF before comparing --- src/acquisition.cpp | 3 ++- src/condition.cpp | 64 +++++++++++++++++++++++++++++++++++++++++---- src/condition.hpp | 26 ++++++++++++++++-- src/search.cpp | 4 +-- src/search.hpp | 2 +- src/trigger.cpp | 5 ++-- src/trigger.hpp | 3 ++- src/ui_probe.cpp | 4 ++- src/ui_trigger.cpp | 4 ++- 9 files changed, 99 insertions(+), 16 deletions(-) diff --git a/src/acquisition.cpp b/src/acquisition.cpp index 9748c27..d0c7b8f 100644 --- a/src/acquisition.cpp +++ b/src/acquisition.cpp @@ -728,7 +728,8 @@ void Acquisition::on_data(const udps_frame_t* frame) { trig_channel >= 0 && trig_channel < static_cast(channels.size()) ? channels[static_cast(trig_channel)].name : kNoTriggerChannel; - engine.configure(tc, trig_name, post_want, event_span - post_want, oldest_s); + engine.configure(tc, trig_name, post_want, event_span - post_want, oldest_s, + model_.partition().slot_dt()); // The engine's armed state follows the panel's, both ways: without the disarm // half, Disarm left the engine firing (its own `armed_` was only ever set), and // re-entering trigger mode kept the stale prev/edge/hold-off state. diff --git a/src/condition.cpp b/src/condition.cpp index 2d3c13e..14a556f 100644 --- a/src/condition.cpp +++ b/src/condition.cpp @@ -3,11 +3,22 @@ #include "condition.hpp" +#include + namespace udpscope { -void EventDetector::configure(const EventCondition& c) noexcept { - if (c == cond_) return; +void EventDetector::configure(const EventCondition& c, double dt) noexcept { + if (c == cond_ && dt == dt_) return; cond_ = c; + dt_ = dt; + // a = 1 - exp(-2*pi*fc*dt), clamped just under Nyquist so a never exceeds 1. + // fc == 0 leaves a == 1, which is a pass-through. + pole_a_ = 1.0; + if (cond_.hf_fc > 0.0 && dt > 0.0) { + const double nyquist = 0.5 / dt; + const double fc = cond_.hf_fc < nyquist * 0.99 ? cond_.hf_fc : nyquist * 0.99; + pole_a_ = 1.0 - std::exp(-2.0 * 3.14159265358979323846 * fc * dt); + } reset(); } @@ -20,9 +31,45 @@ void EventDetector::reset() noexcept { inside_ = false; last_cross_s_ = 0.0; dropped_ = false; + pole_y_ = 0.0; + pole_have_ = false; + side_ = 0; +} + +double EventDetector::filter(double v) noexcept { + // A NaN must stay a NaN and must not poison the state: a dropout is a real + // reading here, not an error. + if (!std::isfinite(v)) return v; + if (!pole_have_) { + pole_y_ = v; + pole_have_ = true; + return pole_y_; + } + pole_y_ += pole_a_ * (v - pole_y_); + return pole_y_; +} + +void EventDetector::decide(double v, bool& up, bool& down) noexcept { + up = false; + down = false; + if (cond_.noise_band <= 0.0) { + up = crossed_up(v); + down = crossed_down(v); + return; + } + // A Schmitt trigger: a sample inside the band decides nothing, and a crossing + // is a change between two *decided* sides. Seeding from the undecided side + // would fire on a signal that starts inside the band and leaves it without + // ever reaching `level`. + const int was = side_; + if (v >= cond_.level + cond_.noise_band) side_ = 1; + else if (v <= cond_.level - cond_.noise_band) side_ = -1; + up = was == -1 && side_ == 1; + down = was == 1 && side_ == -1; } double EventDetector::feed(double value, double t) noexcept { + value = filter(value); if (!have_prev_) { prev_ = value; have_prev_ = true; @@ -57,8 +104,9 @@ double EventDetector::feed(double value, double t) noexcept { } double EventDetector::feed_edge(double value, double t) noexcept { - const bool up = crossed_up(value); - const bool down = crossed_down(value); + bool up = false; + bool down = false; + decide(value, up, down); const bool fired = (cond_.edge == TriggerEdge::Rising && up) || (cond_.edge == TriggerEdge::Falling && down) || (cond_.edge == TriggerEdge::Both && (up || down)); @@ -115,7 +163,13 @@ double EventDetector::feed_pulse(double value, double t) noexcept { double EventDetector::feed_window(double value, double t) noexcept { const double lo = cond_.level < cond_.level2 ? cond_.level : cond_.level2; const double hi = cond_.level < cond_.level2 ? cond_.level2 : cond_.level; - const bool inside = value >= lo && value <= hi; + // The band is applied on the edge the signal must *leave*, and clamped to just + // under half the window so `lo + band` can never pass `hi - band`. + double band = cond_.noise_band > 0.0 ? cond_.noise_band : 0.0; + const double room = 0.49 * (hi - lo); + if (band > room) band = room; + const bool inside = inside_ ? (value >= lo - band && value <= hi + band) + : (value >= lo + band && value <= hi - band); if (inside == inside_) return kNoEvent; inside_ = inside; // The event is the *change of side*, so `Rising` reads as "into the band" and `Falling` diff --git a/src/condition.hpp b/src/condition.hpp index 2abba10..aa59ade 100644 --- a/src/condition.hpp +++ b/src/condition.hpp @@ -33,11 +33,17 @@ struct EventCondition { 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. + /// Half-width of the amplitude band around `level` a sample must leave before + /// a crossing counts. 0 is the bare comparator. In signal units. + double noise_band = 0.0; + /// Corner of a one-pole low pass applied to the sample before any comparison. + /// 0 is off. In Hz. + double hf_fc = 0.0; 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; + timeout_s == o.timeout_s && noise_band == o.noise_band && hf_fc == o.hf_fc; } bool operator!=(const EventCondition& o) const noexcept { return !(*this == o); } }; @@ -60,7 +66,10 @@ class EventDetector { /// 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; + /// + /// `dt` is the stored sample interval (`Partition::slot_dt()`), needed for the + /// HF reject's pole. A no-op unless the condition or the interval changed. + void configure(const EventCondition& c, double dt) noexcept; void reset() noexcept; /// One sample, in stream order. Returns the event's own stream time, or `kNoEvent`. @@ -80,6 +89,10 @@ class EventDetector { bool crossed_down(double v) const noexcept { return prev_ > cond_.level && v <= cond_.level; } + /// The HF reject, applied to every sample before any comparison. + double filter(double v) noexcept; + /// `up`/`down` for the edge condition, through the band if there is one. + void decide(double v, bool& up, bool& down) noexcept; double feed_edge(double value, double t) noexcept; double feed_pulse(double value, double t) noexcept; double feed_window(double value, double t) noexcept; @@ -98,6 +111,15 @@ class EventDetector { bool inside_ = false; ///< Window: which side of the band the previous sample was. double last_cross_s_ = 0.0; ///< Dropout: when the signal was last seen crossing. bool dropped_ = false; ///< Dropout: the timeout has already been reported. + /// One-pole prefilter. `pole_a_ == 1` passes the input through, so "off" + /// costs no branch. `pole_y_` is the filtered value. + double dt_ = 0.0; + double pole_a_ = 1.0; + double pole_y_ = 0.0; + bool pole_have_ = false; + /// Which side of the band the signal was last *decided* to be on: +1 above, + /// -1 below, 0 undecided. A sample inside the band decides nothing. + int side_ = 0; }; } // namespace udpscope diff --git a/src/search.cpp b/src/search.cpp index 012e58b..0aa0b30 100644 --- a/src/search.cpp +++ b/src/search.cpp @@ -16,11 +16,11 @@ double ms_since(const std::chrono::steady_clock::time_point& t0) { } } // namespace -void BufferSearch::start(const EventCondition& c, int channel) { +void BufferSearch::start(const EventCondition& c, int channel, double dt) { clear(); channel_ = channel; detector_.reset(); - detector_.configure(c); + detector_.configure(c, dt); // `configure()` is a no-op for an unchanged condition, so the explicit reset above is what // makes a re-run of the *same* search start from a clean machine. detector_.reset(); diff --git a/src/search.hpp b/src/search.hpp index 4832cf8..6c8afe3 100644 --- a/src/search.hpp +++ b/src/search.hpp @@ -48,7 +48,7 @@ class BufferSearch { }; /// Begins a scan of the channel for the condition. Discards any previous result. - void start(const EventCondition& c, int channel); + void start(const EventCondition& c, int channel, double dt); void cancel() noexcept; void clear() noexcept; diff --git a/src/trigger.cpp b/src/trigger.cpp index c55365c..ca7f8cd 100644 --- a/src/trigger.cpp +++ b/src/trigger.cpp @@ -14,7 +14,8 @@ double TriggerConfig::post_span_s(double buffer_span_s) const { } void TriggerEngine::configure(const TriggerConfig& cfg, const std::string& channel_name, - double post_seconds, double pre_seconds, double oldest_s) { + double post_seconds, double pre_seconds, double oldest_s, + double dt) { const bool channel_changed = channel_name != channel_name_; if (channel_changed) channel_name_ = channel_name; cfg_ = cfg; @@ -22,7 +23,7 @@ void TriggerEngine::configure(const TriggerConfig& cfg, const std::string& chann 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); + detector_.configure(cfg_.condition, dt); if (channel_changed) { detector_.reset(); pending_ = false; diff --git a/src/trigger.hpp b/src/trigger.hpp index 1d3f570..63cbafc 100644 --- a/src/trigger.hpp +++ b/src/trigger.hpp @@ -69,7 +69,8 @@ class TriggerEngine { /// 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 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; diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index ec27f0a..88a82ff 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -1421,12 +1421,14 @@ gboolean search_probe_run(gpointer user) { g_search_t0 = a->model->trigger_time(); EventCondition c; int channel = -1; + double dt = 0.0; { std::lock_guard lock(a->model->mutex()); c = a->model->trigger().condition; channel = a->model->trigger().channel; + dt = a->model->partition().slot_dt(); } - a->search.start(c, channel); + a->search.start(c, channel, dt); std::fprintf(stderr, "[search-probe] started on channel %d, armed t0=%.6f\n", channel, g_search_t0); std::fflush(stderr); diff --git a/src/ui_trigger.cpp b/src/ui_trigger.cpp index 5fe1b55..140e0aa 100644 --- a/src/ui_trigger.cpp +++ b/src/ui_trigger.cpp @@ -341,12 +341,14 @@ GtkWidget* build_trigger_panel(App& app) { publish_trigger_fields(a); EventCondition c; int channel = -1; + double dt = 0.0; { std::lock_guard lock(a->model->mutex()); c = a->model->trigger().condition; channel = a->model->trigger().channel; + dt = a->model->partition().slot_dt(); } - a->search.start(c, channel); + a->search.start(c, channel, dt); update_search_panel(*a); }), &app);