// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #include "condition.hpp" #include namespace udpscope { 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(); } void EventDetector::reset() noexcept { have_prev_ = false; prev_ = 0.0; in_pulse_ = false; pulse_up_ = false; pulse_start_s_ = 0.0; 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; // The window condition needs a side to change *from*, and the dropout a last-seen time // to count from; neither can be derived from a crossing that has not happened. Seeding // them here is what lets both handlers run without a "have we started" guard. const double lo = cond_.level < cond_.level2 ? cond_.level : cond_.level2; const double hi = cond_.level < cond_.level2 ? cond_.level2 : cond_.level; inside_ = value >= lo && value <= hi; last_cross_s_ = t; dropped_ = false; return kNoEvent; } double event = kNoEvent; switch (cond_.type) { case TriggerType::Edge: event = feed_edge(value, t); break; case TriggerType::PulseWidth: event = feed_pulse(value, t); break; case TriggerType::Window: event = feed_window(value, t); break; case TriggerType::Dropout: event = feed_dropout(value, t); break; } // 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 { 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)); return fired ? t : kNoEvent; } bool EventDetector::qualifies(double width_s) const noexcept { const double a = cond_.width_s < cond_.width2_s ? cond_.width_s : cond_.width2_s; const double b = cond_.width_s < cond_.width2_s ? cond_.width2_s : cond_.width_s; switch (cond_.cmp) { case WidthCmp::Less: return width_s < cond_.width_s; case WidthCmp::Greater: return width_s > cond_.width_s; case WidthCmp::Within: return width_s >= a && width_s <= b; case WidthCmp::Outside: return width_s < a || width_s > b; } return false; } double EventDetector::feed_pulse(double value, double t) noexcept { const bool up = crossed_up(value); const bool down = crossed_down(value); if (!in_pulse_) { // Which crossing opens a pulse is what `edge` selects: Rising measures the time spent // above the level, Falling the time below, Both every excursion either way. const bool opens = (cond_.edge == TriggerEdge::Rising && up) || (cond_.edge == TriggerEdge::Falling && down) || (cond_.edge == TriggerEdge::Both && (up || down)); if (opens) { in_pulse_ = true; pulse_up_ = up; pulse_start_s_ = t; } return kNoEvent; } const bool closes = pulse_up_ ? down : up; if (!closes) return kNoEvent; const double width = t - pulse_start_s_; if (cond_.edge == TriggerEdge::Both) { // The closing edge of one excursion is the opening edge of the next, so a square wave // measures *every* half-period. Dropping the state here instead would measure every // other one, and the hits would come out half as dense as the waveform. pulse_up_ = !pulse_up_; pulse_start_s_ = t; } else { in_pulse_ = false; } return qualifies(width) ? t : kNoEvent; } 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; // 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` // as "out of it". A band of zero width is not a band: `inside` reduces to an exact // float-equality test against the level, so the condition fires on none of an ordinary // signal's samples and on every transition of one that sits exactly at the level -- a // +/-1 square against a band of [1, 1] fired 19 times in a standalone check. Neither // answer is a band, so the panel refuses one. const bool fired = (cond_.edge == TriggerEdge::Rising && inside) || (cond_.edge == TriggerEdge::Falling && !inside) || cond_.edge == TriggerEdge::Both; return fired ? t : kNoEvent; } double EventDetector::feed_dropout(double value, double t) noexcept { const bool up = crossed_up(value); const bool down = crossed_down(value); const bool crossed = (cond_.edge == TriggerEdge::Rising && up) || (cond_.edge == TriggerEdge::Falling && down) || (cond_.edge == TriggerEdge::Both && (up || down)); if (crossed) { last_cross_s_ = t; dropped_ = false; return kNoEvent; } // One event per silence, not one per sample of it: without the latch a dead signal would // report an event on every sample after the timeout and fill the search's hit list with // one answer repeated. if (dropped_) return kNoEvent; if (t - last_cross_s_ <= cond_.timeout_s) return kNoEvent; dropped_ = true; return t; } } // namespace udpscope