feat: a measurement accumulates how it varies, so one reading can be told from a trend

This commit is contained in:
Martino Ferrari committed 2026-10-08 09:22:35 +02:00
1 parent d79810c7ba
commit 1a8218887c
2 files changed
+79 -8

No files matched your search

+43
View File
@@ -2,7 +2,9 @@
// SPDX-FileCopyrightText: 2026 Martino Ferrari // SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <cmath>
#include <cstddef> #include <cstddef>
#include <cstdint>
#include <functional> #include <functional>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -244,4 +246,45 @@ bool measure_uses_side(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);
/// 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 } // namespace udpscope
+36 -8
View File
@@ -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 /// 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). /// a pass lands on a window with no samples (a rebuild, a capture boundary, a mode change).
std::vector<MeasureResult> measure_last_valid; 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 /// The marks the last computation produced: re-placed every frame, which is what makes the
/// measurement cursors read-only. /// measurement cursors read-only.
/// ///
@@ -2179,6 +2184,13 @@ void sync_window_display(App& app, double seconds) {
app.updating_panels = false; 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 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 /// 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 /// 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. // The partition before the axis, so the axis is framed over the rings that will exist.
app.rows->set_time_window(clamped); app.rows->set_time_window(clamped);
sync_window_display(app, 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); schedule_layout_save(app);
} }
@@ -2659,6 +2674,9 @@ void rebuild_measures_pane(App& app) {
} }
app.measure_labels.clear(); app.measure_labels.clear();
app.measure_text.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()) { if (app.measures.empty()) {
GtkWidget* empty = gtk_label_new("no measurements -- [+] to add one"); GtkWidget* empty = gtk_label_new("no measurements -- [+] to add one");
gtk_widget_add_css_class(empty, "dim-label"); 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); const MeasureResult r = compute_measure(w.stats, level, m.type, m.options);
app.measure_results[i] = r; app.measure_results[i] = r;
if (r.valid) app.measure_last_valid[i] = r; if (r.valid) app.measure_last_valid[i] = r;
// 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) { if (label != nullptr) {
std::string text = "--"; std::string text = "--";
if (r.valid) { 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;
const std::string unit = is_time ? "s"
: m.type == MeasureType::Frequency ? "Hz"
: measure_unit(app, m.signal);
text = format_measure(r.primary, unit); text = format_measure(r.primary, unit);
if (r.has_secondary) { if (r.has_secondary) {
// Pulse duration also reports the duty, which has no unit. // 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; app.measure_text[i] = text;
gtk_label_set_text(GTK_LABEL(label), text.c_str()); 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 // 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_w = gtk_widget_get_width(app->measures_list);
app->dbg_measures_pane_h = gtk_widget_get_height(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 // 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. // vertical markers one time across every row, and what the pane then reads.
app->rows->update_time_labels(); app->rows->update_time_labels();