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:
1 parent
fec0a1032e
commit
2730707372
2 files changed
+94
-19
No files matched your search
+33
-16
@@ -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) {
|
||||||
for (std::size_t k = 0; k < level_count; ++k) {
|
// Samples outer, levels inner -- the reverse of what reads more naturally, and deliberately. The filter
|
||||||
MeasureLevel& acc = levels[k];
|
// holds one state for the whole walk, so a levels-outer loop would step it once per level per sample and
|
||||||
for (std::size_t i = 0; i < chunk.count; ++i) {
|
// every level after the first would read a signal filtered `k` times over. Each level's own state is
|
||||||
const double v = static_cast<double>(chunk.value[i]);
|
// independent of the others, so the swap costs nothing else.
|
||||||
if (!std::isfinite(v)) continue;
|
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) {
|
||||||
|
MeasureLevel& acc = levels[k];
|
||||||
// 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
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user