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
|
// 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
@@ -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();
|
||||||
|
|||||||
Reference in new issue
Block a user