- The comment in `feed_window()` and three companion sites claimed a zero-width band "never fires, because no sample can be on a different side of it than its neighbour". That is false: `inside = value >= lo && value <= hi` with `lo == hi == 1.0` is true at +1 and false at -1, so a ±1 square fires 19 times in 20 samples. The correct reason the panel refuses one is that it reduces the condition to a float-equality test on a real-valued signal, which in practice never fires (fix 4). - `EventDetector::condition()`, `BufferSearch::channel()` and `BufferSearch::condition_` were confirmed unused by grep and deleted (fix 10). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
104 lines
4.6 KiB
C++
104 lines
4.6 KiB
C++
// 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_; }
|
|
|
|
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;
|
|
double feed_pulse(double value, double t) noexcept;
|
|
double feed_window(double value, double t) noexcept;
|
|
double feed_dropout(double value, double t) noexcept;
|
|
/// Whether a measured width satisfies `cond_.cmp`. `Within`/`Outside` take their two
|
|
/// bounds in either order, so a panel cannot make an empty band by typing the larger
|
|
/// figure into the first spin.
|
|
bool qualifies(double width_s) const noexcept;
|
|
|
|
EventCondition cond_{};
|
|
bool have_prev_ = false;
|
|
double prev_ = 0.0;
|
|
bool in_pulse_ = false; ///< PulseWidth: an opening edge has been seen.
|
|
bool pulse_up_ = false; ///< PulseWidth: its polarity, so we know which edge closes it.
|
|
double pulse_start_s_ = 0.0;
|
|
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.
|
|
};
|
|
|
|
} // namespace udpscope
|