feat: a measurement's band is a setting and its walks can reject HF

`MeasureOptions` gains `noise_pct` (the hysteresis band, % of the window's peak-to-peak,
defaulting to the 10 % the file-scope constant held) and `hf_fc` (a one-pole HF reject over
the measured window). The band's measured rationale moves with the number rather than
becoming an orphan comment, and `measure_set_level()` takes the fraction as an argument
because the band is a per-measurement setting while the call is once per level.

`MeasureFilter` is that reject as a walk carries it: one pole, one state word, `a == 1` a
pass-through so an unfiltered walk pays a multiply and no branch, and a non-finite sample
passing through without touching the state. Both raw walks step it -- and stepping it is why
`measure_accumulate()` now loops samples-outer/levels-inner: a levels-outer loop would step
one filter state once per level per sample, and every level after the first would read a
signal filtered k times over. The inner `continue` is a `continue` of the *level* loop, which
is what it has to be: a sample inside one level's band may be outside another's.

`measure_uses_noise()` names the three types the band reaches (the crossing types -- a band
moves the edge decision and nothing else), and both bucket functions say in their comments
that a bucket aggregates unfiltered samples, so a walk with a reject must not use them.

This leaves `ui_measure.cpp`'s five call sites failing to compile, deliberately: they are the
next task, and pretending otherwise would mean a shim that the task then deletes.
This commit is contained in:
Martino Ferrari committed 2026-10-13 15:36:16 +02:00
1 parent fec0a1032e
commit 2730707372
2 files changed
+92 -17

No files matched your search

+31 -14
View File
@@ -28,16 +28,13 @@ void enter_side(MeasureLevel& acc, std::size_t e, double t) {
acc.open[e] = true; acc.open[e] = true;
} }
/// The hysteresis band's half-width, as a share of the window's peak-to-peak. Measured rather than // The hysteresis band and its rationale moved to `MeasureOptions::noise_pct`: it is a per-measurement
/// chosen: 5 % reads the mock's 0.1 interferer correctly and fails at 0.2 (`period=0.075` for a // setting now, and a second copy of the number here would be a second thing to keep in step.
/// true 0.2), while 10 % holds to an interferer amplitude of 0.3 -- a third of the waveform -- and constexpr double kPi = 3.14159265358979323846;
/// 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;
} // namespace } // 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; const double span = s.hi - s.lo;
acc.level = s.lo + pct * span; 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 // 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 // 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. // fixed band, `enters=4 period=0.200000` with the clamp.
const double room = std::min(s.hi - acc.level, acc.level - s.lo); 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 // 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 // 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 // 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; std::size_t at_hi = 0;
bool any = false; bool any = false;
for (std::size_t i = 0; i < chunk.count; ++i) { for (std::size_t i = 0; i < chunk.count; ++i) {
const double v = static_cast<double>(chunk.value[i]); const double v = filter.step(static_cast<double>(chunk.value[i]));
if (!std::isfinite(v)) continue; if (!std::isfinite(v)) continue;
if (!any) { if (!any) {
lo = hi = v; 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, void measure_accumulate(const MeasureChunk& chunk,
const std::function<double(std::size_t)>& time_of, MeasureLevel* levels, const std::function<double(std::size_t)>& time_of, MeasureLevel* levels,
std::size_t level_count) { 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<double>(chunk.value[i]));
if (!std::isfinite(v)) continue;
for (std::size_t k = 0; k < level_count; ++k) { for (std::size_t k = 0; k < level_count; ++k) {
MeasureLevel& acc = levels[k]; MeasureLevel& acc = levels[k];
for (std::size_t i = 0; i < chunk.count; ++i) {
const double v = static_cast<double>(chunk.value[i]);
if (!std::isfinite(v)) continue;
// The *sums* split at the bare level: `Top` and `Bottom` are defined as the mean above and // 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. // below the threshold, and a band here would move a documented exact reading.
const int s = v > acc.level ? 1 : 0; 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_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; } bool measure_can_show_on_plot(MeasureType) { return true; }
MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level, MeasureType type, MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level, MeasureType type,
+61 -3
View File
@@ -46,6 +46,23 @@ struct MeasureOptions {
/// extremes (min/max), the pulse's two edges as vertical markers (pulse duration). The /// 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. /// markers are readouts -- they follow the window, and the user cannot drag them.
bool show_on_plot = false; 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 /// One entry in the measurements pane, and everything needed to recompute it: the signal by
@@ -105,6 +122,37 @@ struct MeasureStats {
bool valid = false; 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. /// 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 /// 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 /// 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 /// to read. A 1.7 M-sample window is 26 k buckets, and only the handful that *straddle* a level
/// are opened. /// 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 { struct MeasureBucket {
double lo = 0.0; double lo = 0.0;
double hi = 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 /// 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 /// `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. /// 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 /// 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 /// 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` /// 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 /// 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. /// 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 /// 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 /// 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. /// measurements on one signal costs two walks, not two per measurement.
void measure_accumulate(const MeasureChunk& chunk, void measure_accumulate(const MeasureChunk& chunk,
const std::function<double(std::size_t)>& time_of, MeasureLevel* levels, const std::function<double(std::size_t)>& 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 /// 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 /// 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 /// Whether a type uses the `above` side (only `PulseDuration`, where it says which half of
/// the waveform is the pulse). /// the waveform is the pulse).
bool measure_uses_side(MeasureType type); 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. /// Whether a type draws its result on the plot when asked.
bool measure_can_show_on_plot(MeasureType type); bool measure_can_show_on_plot(MeasureType type);