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
+94 -19

No files matched your search

+61 -3
View File
@@ -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<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
/// 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);