From 1a8218887c1ec591f4141d2867624c4d81594859 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Thu, 8 Oct 2026 09:22:35 +0200 Subject: [PATCH] feat: a measurement accumulates how it varies, so one reading can be told from a trend --- src/measure.hpp | 43 +++++++++++++++++++++++++++++++++++++++++++ src/ui.cpp | 44 ++++++++++++++++++++++++++++++++++++-------- 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/src/measure.hpp b/src/measure.hpp index e635ab3..9c65008 100644 --- a/src/measure.hpp +++ b/src/measure.hpp @@ -2,7 +2,9 @@ // SPDX-FileCopyrightText: 2026 Martino Ferrari #pragma once +#include #include +#include #include #include #include @@ -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(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(n - 1)); + } +}; + } // namespace udpscope diff --git a/src/ui.cpp b/src/ui.cpp index d534e6c..fa5c5f1 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -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 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 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; + // 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) { - // 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); 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();