feat: a measurement accumulates how it varies, so one reading can be told from a trend
This commit is contained in:
1 parent
d79810c7ba
commit
1a8218887c
2 files changed
+79
-8
No files matched your search
@@ -2,7 +2,9 @@
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
#pragma once
|
||||
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -244,4 +246,45 @@ bool measure_uses_side(MeasureType type);
|
||||
/// Whether a type draws its result on the plot when asked.
|
||||
bool measure_can_show_on_plot(MeasureType type);
|
||||
|
||||
/// Running statistics over a measurement's successive results.
|
||||
///
|
||||
/// One observation per *recompute* of the measurements pane, never one per sample: a
|
||||
/// measurement is a figure derived from a whole window, and counting its samples would
|
||||
/// describe the window rather than the measurement's stability. `n` is therefore a count of
|
||||
/// passes, which is what makes the readout comparable with a bench scope's.
|
||||
///
|
||||
/// The mean and spread are Welford's recurrence rather than running sums of `v` and `v*v`:
|
||||
/// the results are absolute signal values, so a channel sitting at a large offset with a
|
||||
/// small ripple -- the case the readout is *for* -- cancels to nothing in a naive
|
||||
/// sum-of-squares long before the ripple is visible.
|
||||
struct MeasureStatsAccum {
|
||||
std::uint64_t n = 0;
|
||||
double mean = 0.0; ///< Welford's running mean.
|
||||
double m2 = 0.0; ///< Welford's sum of squared deviations from the running mean.
|
||||
double lo = 0.0;
|
||||
double hi = 0.0;
|
||||
|
||||
void add(double v) noexcept {
|
||||
if (n == 0) {
|
||||
lo = v;
|
||||
hi = v;
|
||||
} else {
|
||||
if (v < lo) lo = v;
|
||||
if (v > hi) hi = v;
|
||||
}
|
||||
++n;
|
||||
const double delta = v - mean;
|
||||
mean += delta / static_cast<double>(n);
|
||||
m2 += delta * (v - mean);
|
||||
}
|
||||
|
||||
void reset() noexcept { *this = MeasureStatsAccum{}; }
|
||||
|
||||
/// The sample standard deviation (n-1), and 0 rather than NaN for fewer than two
|
||||
/// observations: the readout is a figure in a label, and a single pass has no spread.
|
||||
double sd() const noexcept {
|
||||
return n < 2 ? 0.0 : std::sqrt(m2 / static_cast<double>(n - 1));
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace udpscope
|
||||
+36
-8
@@ -269,6 +269,11 @@ struct App {
|
||||
/// of the signal and survives the window it was measured in, so the rulers do not blink off when
|
||||
/// a pass lands on a window with no samples (a rebuild, a capture boundary, a mode change).
|
||||
std::vector<MeasureResult> measure_last_valid;
|
||||
/// Running statistics, one per entry of `measures` and reset whenever that vector is
|
||||
/// rebuilt. Not reset by a pause, a latch or a pan: those change what is measured *now*,
|
||||
/// not what the measurement means, and a statistic the user cannot keep across them is
|
||||
/// not worth having.
|
||||
std::vector<MeasureStatsAccum> measure_stats;
|
||||
/// The marks the last computation produced: re-placed every frame, which is what makes the
|
||||
/// measurement cursors read-only.
|
||||
///
|
||||
@@ -2179,6 +2184,13 @@ void sync_window_display(App& app, double seconds) {
|
||||
app.updating_panels = false;
|
||||
}
|
||||
|
||||
/// Clears every measurement's statistics. Called explicitly by the pane's Reset, and
|
||||
/// implicitly wherever the series of results stops being one series: a measurement edit
|
||||
/// (`rebuild_measures_pane()`), a change of display window, and a CONFIG.
|
||||
void reset_measure_stats(App& app) {
|
||||
for (MeasureStatsAccum& s : app.measure_stats) s.reset();
|
||||
}
|
||||
|
||||
/// The one place the time scale changes. Clamps to what the buffer can hold, moves the plot and
|
||||
/// the trigger's event span together -- its pre-trigger share is a share of *this* window -- and
|
||||
/// then restates the result in the control, so a clamped request reads back as the window that
|
||||
@@ -2209,6 +2221,9 @@ void apply_time_window(App& app, double seconds) {
|
||||
// The partition before the axis, so the axis is framed over the rings that will exist.
|
||||
app.rows->set_time_window(clamped);
|
||||
sync_window_display(app, clamped);
|
||||
// A measurement over a different window is a different figure -- a mean over 20 ms and a
|
||||
// mean over 2 s are not observations of one quantity -- so the history behind it goes.
|
||||
reset_measure_stats(app);
|
||||
schedule_layout_save(app);
|
||||
}
|
||||
|
||||
@@ -2659,6 +2674,9 @@ void rebuild_measures_pane(App& app) {
|
||||
}
|
||||
app.measure_labels.clear();
|
||||
app.measure_text.clear();
|
||||
// Editing a measurement changes what the figures *mean*, so the history behind them is no
|
||||
// longer one series. This is the implicit reset for an add, an edit and a delete alike.
|
||||
app.measure_stats.assign(app.measures.size(), MeasureStatsAccum{});
|
||||
if (app.measures.empty()) {
|
||||
GtkWidget* empty = gtk_label_new("no measurements -- [+] to add one");
|
||||
gtk_widget_add_css_class(empty, "dim-label");
|
||||
@@ -3422,16 +3440,17 @@ void update_measures(App& app, bool force) {
|
||||
const MeasureResult r = compute_measure(w.stats, level, m.type, m.options);
|
||||
app.measure_results[i] = r;
|
||||
if (r.valid) app.measure_last_valid[i] = r;
|
||||
if (label != nullptr) {
|
||||
std::string text = "--";
|
||||
if (r.valid) {
|
||||
// Time-valued types carry seconds and a frequency carries hertz whatever the
|
||||
// signal's own unit is; everything else carries that unit.
|
||||
const bool is_time = m.type == MeasureType::PulseDuration ||
|
||||
m.type == MeasureType::Period;
|
||||
// Time-valued types carry seconds and a frequency carries hertz whatever the signal's
|
||||
// own unit is; everything else carries that unit. Hoisted out of the valid branch below
|
||||
// because the statistics line needs the same unit.
|
||||
const bool is_time =
|
||||
m.type == MeasureType::PulseDuration || m.type == MeasureType::Period;
|
||||
const std::string unit = is_time ? "s"
|
||||
: m.type == MeasureType::Frequency ? "Hz"
|
||||
: measure_unit(app, m.signal);
|
||||
if (label != nullptr) {
|
||||
std::string text = "--";
|
||||
if (r.valid) {
|
||||
text = format_measure(r.primary, unit);
|
||||
if (r.has_secondary) {
|
||||
// Pulse duration also reports the duty, which has no unit.
|
||||
@@ -3441,6 +3460,10 @@ void update_measures(App& app, bool force) {
|
||||
app.measure_text[i] = text;
|
||||
gtk_label_set_text(GTK_LABEL(label), text.c_str());
|
||||
}
|
||||
// One observation per recompute, and only when the pass produced one. An invalid pass
|
||||
// contributes nothing -- the marks' `measure_last_valid` fallback below is a *drawing*
|
||||
// convenience, and accumulating it would count one answer as many observations.
|
||||
if (r.valid && i < app.measure_stats.size()) app.measure_stats[i].add(r.primary);
|
||||
}
|
||||
|
||||
// The marks last, in their own pass over the measurements, so that no `continue` above can skip
|
||||
@@ -5891,7 +5914,12 @@ gboolean tick(gpointer user) {
|
||||
app->dbg_measures_pane_w = gtk_widget_get_width(app->measures_list);
|
||||
app->dbg_measures_pane_h = gtk_widget_get_height(app->measures_list);
|
||||
}
|
||||
if (model.generation() != app->signal_generation) rebuild_signal_panels(*app);
|
||||
if (model.generation() != app->signal_generation) {
|
||||
// A CONFIG re-describes the stream and drops the buffer, so nothing measured before it
|
||||
// belongs in the same series as what comes after.
|
||||
reset_measure_stats(*app);
|
||||
rebuild_signal_panels(*app);
|
||||
}
|
||||
// A gesture moves the *plot's* cursor; adopting it here is what makes the two
|
||||
// vertical markers one time across every row, and what the pane then reads.
|
||||
app->rows->update_time_labels();
|
||||
|
||||
Reference in new issue
Block a user