diff --git a/src/measure.cpp b/src/measure.cpp index 6600f1e..e1e05a7 100644 --- a/src/measure.cpp +++ b/src/measure.cpp @@ -28,16 +28,13 @@ void enter_side(MeasureLevel& acc, std::size_t e, double t) { acc.open[e] = true; } -/// The hysteresis band's half-width, as a share of the window's peak-to-peak. Measured rather than -/// chosen: 5 % reads the mock's 0.1 interferer correctly and fails at 0.2 (`period=0.075` for a -/// true 0.2), while 10 % holds to an interferer amplitude of 0.3 -- a third of the waveform -- and -/// to white noise at sigma=0.05. Above that the band starts eating a real waveform's own extremes -/// on anything less square than a sine. -constexpr double kHysteresisFrac = 0.10; +// The hysteresis band and its rationale moved to `MeasureOptions::noise_pct`: it is a per-measurement +// setting now, and a second copy of the number here would be a second thing to keep in step. +constexpr double kPi = 3.14159265358979323846; } // namespace -void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct) { +void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct, double noise_frac) { const double span = s.hi - s.lo; acc.level = s.lo + pct * span; // Half the room to the nearer extreme is the clamp. A threshold at 90 % has a tenth of the span @@ -46,10 +43,21 @@ void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct) { // a noisy reading. Measured on a 3.3 V logic signal with ringing at thr=90 %: `enters=0` with a // fixed band, `enters=4 period=0.200000` with the clamp. const double room = std::min(s.hi - acc.level, acc.level - s.lo); - acc.hysteresis = std::max(0.0, std::min(kHysteresisFrac * span, 0.5 * room)); + const double want = noise_frac > 0.0 ? noise_frac * span : 0.0; + acc.hysteresis = std::max(0.0, std::min(want, 0.5 * room)); } -void measure_scan(const MeasureChunk& chunk, MeasureStats& s) { +void measure_filter_set(MeasureFilter& f, double fc, double dt) { + f.reset(); + f.a = 1.0; + if (fc > 0.0 && dt > 0.0) { + const double nyquist = 0.5 / dt; + const double use = fc < nyquist * 0.99 ? fc : nyquist * 0.99; + f.a = 1.0 - std::exp(-2.0 * kPi * use * dt); + } +} + +void measure_scan(const MeasureChunk& chunk, MeasureStats& s, MeasureFilter& filter) { // The chunk's own extremes first, in a loop that touches nothing but the values: *where* an // extreme is costs an index, and the caller turns the two that survive into stamps once at the // end. The sum is accumulated per chunk and folded in once, which is also kinder numerically @@ -62,7 +70,7 @@ void measure_scan(const MeasureChunk& chunk, MeasureStats& s) { std::size_t at_hi = 0; bool any = false; for (std::size_t i = 0; i < chunk.count; ++i) { - const double v = static_cast(chunk.value[i]); + const double v = filter.step(static_cast(chunk.value[i])); if (!std::isfinite(v)) continue; if (!any) { lo = hi = v; @@ -143,12 +151,16 @@ void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, Measu void measure_accumulate(const MeasureChunk& chunk, const std::function& time_of, MeasureLevel* levels, - std::size_t level_count) { - for (std::size_t k = 0; k < level_count; ++k) { - MeasureLevel& acc = levels[k]; - for (std::size_t i = 0; i < chunk.count; ++i) { - const double v = static_cast(chunk.value[i]); - if (!std::isfinite(v)) continue; + std::size_t level_count, MeasureFilter& filter) { + // Samples outer, levels inner -- the reverse of what reads more naturally, and deliberately. The filter + // holds one state for the whole walk, so a levels-outer loop would step it once per level per sample and + // every level after the first would read a signal filtered `k` times over. Each level's own state is + // independent of the others, so the swap costs nothing else. + for (std::size_t i = 0; i < chunk.count; ++i) { + const double v = filter.step(static_cast(chunk.value[i])); + if (!std::isfinite(v)) continue; + for (std::size_t k = 0; k < level_count; ++k) { + MeasureLevel& acc = levels[k]; // The *sums* split at the bare level: `Top` and `Bottom` are defined as the mean above and // below the threshold, and a band here would move a documented exact reading. const int s = v > acc.level ? 1 : 0; @@ -278,6 +290,11 @@ bool measure_uses_threshold(MeasureType type) { bool measure_uses_side(MeasureType type) { return type == MeasureType::PulseDuration; } +bool measure_uses_noise(MeasureType type) { + return type == MeasureType::PulseDuration || type == MeasureType::Period || + type == MeasureType::Frequency; +} + bool measure_can_show_on_plot(MeasureType) { return true; } MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level, MeasureType type, diff --git a/src/measure.hpp b/src/measure.hpp index 9c65008..9a1ae88 100644 --- a/src/measure.hpp +++ b/src/measure.hpp @@ -46,6 +46,23 @@ struct MeasureOptions { /// extremes (min/max), the pulse's two edges as vertical markers (pulse duration). The /// markers are readouts -- they follow the window, and the user cannot drag them. bool show_on_plot = false; + /// `PulseDuration`/`Period`/`Frequency`: the half-width of the hysteresis band about the threshold, as + /// a percentage of the window's own peak-to-peak. A crossing counts only once the signal has passed the + /// band, so an edge count is a count of the *waveform's* cycles rather than of its noise. + /// + /// The default is measured rather than chosen: 5 % reads the mock's 0.1 interferer correctly and fails + /// at 0.2 (`period=0.075` for a true 0.2), while 10 % holds to an interferer amplitude of 0.3 -- a third + /// of the waveform -- and to white noise at sigma=0.05. Above that the band starts eating a real + /// waveform's own extremes on anything less square than a sine. 0 reduces the walk exactly to a single + /// level. + double noise_pct = 10.0; + /// Every type: the corner of a one-pole low pass applied to each sample before *either* walk reads it, + /// so the extremes, the mean and the crossings all describe the filtered signal. 0 is off. In Hz. + /// + /// It rejects what a band cannot: an impulse large enough to cross the band on its own. It is not free + /// -- a filtered window cannot use the LOD pyramid, because a bucket aggregates unfiltered samples -- + /// so a non-zero corner costs a full raw walk. + double hf_fc = 0.0; }; /// One entry in the measurements pane, and everything needed to recompute it: the signal by @@ -105,6 +122,37 @@ struct MeasureStats { bool valid = false; }; +/// The HF reject, as a walk carries it: one pole, one state word. +/// +/// `a == 1` is a pass-through and is the default, so an unfiltered walk pays one multiply and no branch, +/// and every caller can step it unconditionally. A non-finite sample passes through unchanged and leaves +/// the state alone -- a dropout is a real reading, and letting it poison `y` would make every sample +/// after it non-finite too. +struct MeasureFilter { + double a = 1.0; + double y = 0.0; + bool have = false; + + double step(double v) noexcept { + if (!std::isfinite(v)) return v; + if (!have) { + y = v; + have = true; + return y; + } + y += a * (v - y); + return y; + } + void reset() noexcept { + y = 0.0; + have = false; + } +}; + +/// `a = 1 - exp(-2*pi*fc*dt)`, clamped just under Nyquist so `a` cannot exceed 1. `fc == 0` or `dt == 0` +/// leaves it at 1, which is the pass-through. Resets the state. +void measure_filter_set(MeasureFilter& f, double fc, double dt); + /// One chunk of a window's values, in time order, exactly as the rings hold them. /// /// The engine is *fed* the window this way rather than handed a materialized copy. A visible @@ -126,6 +174,9 @@ struct MeasureChunk { /// as it arrives, at amortised O(1), so a bucket is 64 samples the measurement walks never have /// to read. A 1.7 M-sample window is 26 k buckets, and only the handful that *straddle* a level /// are opened. +/// +/// A bucket aggregates **unfiltered** samples, so a walk with an HF reject must not use these at all -- +/// that is what makes a non-zero `hf_fc` cost a full raw walk. struct MeasureBucket { double lo = 0.0; double hi = 0.0; @@ -139,7 +190,7 @@ struct MeasureBucket { /// and the sum and count of the finite samples. Feed the chunks in window order into a zeroed /// `stats` -- the first sample to reach an extreme is the one the mark names, so a later chunk /// that merely ties it must not move it. -void measure_scan(const MeasureChunk& chunk, MeasureStats& stats); +void measure_scan(const MeasureChunk& chunk, MeasureStats& stats, MeasureFilter& filter); /// Pass one from whole buckets: the same figures, exactly, for 1/64 of the reads. The extreme's /// position comes out as a *bucket* (`lo_search`/`hi_search` non-zero), which one raw @@ -192,14 +243,17 @@ struct MeasureLevel { /// nearer extreme. The clamp is what keeps a threshold near an extreme usable -- `PulseDuration` /// lets the user put one at 90 %, and a fixed band there puts the upper threshold above every /// sample in the window, which reads as nothing measured at all rather than as a noisy reading. -void measure_set_level(MeasureLevel& level, const MeasureStats& stats, double pct); +/// `noise_frac` is `MeasureOptions::noise_pct / 100`. It is the caller's because the band is a +/// per-measurement setting and this is called once per *level*, which several measurements may share; the +/// clamp to half the room to the nearer extreme stays here, so every caller gets it. +void measure_set_level(MeasureLevel& level, const MeasureStats& stats, double pct, double noise_frac); /// Pass two: one chunk into every level at once. The usual case is a single level -- every /// crossing type reads the mid level and `Top`/`Bottom` default to it too -- so a pane full of /// measurements on one signal costs two walks, not two per measurement. void measure_accumulate(const MeasureChunk& chunk, const std::function& time_of, MeasureLevel* levels, - std::size_t level_count); + std::size_t level_count, MeasureFilter& filter); /// Pass two from one whole bucket, into one level. True when the bucket answered it: a bucket /// every one of whose samples is on the same side of the level holds no crossing, so its sums @@ -243,6 +297,10 @@ bool measure_uses_threshold(MeasureType type); /// Whether a type uses the `above` side (only `PulseDuration`, where it says which half of /// the waveform is the pulse). bool measure_uses_side(MeasureType type); +/// Whether a type reads the hysteresis band, so the dialog can show the field only then. The band moves +/// the *edge* decision and nothing else, so it is exactly the crossing types: `Top`/`Bottom` split their +/// sums at the bare level and read the same answer at any band. +bool measure_uses_noise(MeasureType type); /// Whether a type draws its result on the plot when asked. bool measure_can_show_on_plot(MeasureType type);