Files
udpscope/src/condition.cpp
T

208 lines
7.4 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "condition.hpp"
#include <cmath>
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