feat: the measurements pane can show how each reading varies, and remembers that it was asked to

This commit is contained in:
Martino Ferrari committed 2026-10-08 09:34:06 +02:00
1 parent 1a8218887c
commit 4f496b14b7
3 files changed
+166 -1

No files matched your search

+3
View File
@@ -309,6 +309,8 @@ bool load_layout(const std::string& path, LayoutState& out) {
plot_count = std::max(0, std::stoi(value)); plot_count = std::max(0, std::stoi(value));
} catch (...) { } catch (...) {
} }
} else if (key == "measure_stats") {
st.measure_stats = value == "true";
} else if (key == "multicast") { } else if (key == "multicast") {
st.multicast = strip_quote(value); st.multicast = strip_quote(value);
} else if (key == "interface") { } else if (key == "interface") {
@@ -431,6 +433,7 @@ bool save_layout(const std::string& path, const LayoutState& state) {
} }
o << "]\n"; o << "]\n";
} }
o << "measure_stats = " << (state.measure_stats ? "true" : "false") << "\n";
for (const auto& row : state.rows) { for (const auto& row : state.rows) {
o << "\n[[plot]]\nsignals = ["; o << "\n[[plot]]\nsignals = [";
for (std::size_t i = 0; i < row.signals.size(); ++i) { for (std::size_t i = 0; i < row.signals.size(); ++i) {
+5
View File
@@ -58,6 +58,11 @@ struct LayoutState {
/// `math_node_text()` for the field order. /// `math_node_text()` for the field order.
std::vector<MathChannel> math; std::vector<MathChannel> math;
/// Whether the measurements pane shows its statistics line. The only piece of the
/// statistics that is persisted: the accumulated figures themselves describe a session's
/// stream and mean nothing restored against another one.
bool measure_stats = false;
/// The measurements pane, by signal *name* and with the type and options a row was built with. /// The measurements pane, by signal *name* and with the type and options a row was built with.
std::vector<Measure> measures; std::vector<Measure> measures;
}; };
+158 -1
View File
@@ -274,6 +274,13 @@ struct App {
/// not what the measurement means, and a statistic the user cannot keep across them is /// not what the measurement means, and a statistic the user cannot keep across them is
/// not worth having. /// not worth having.
std::vector<MeasureStatsAccum> measure_stats; std::vector<MeasureStatsAccum> measure_stats;
/// The second line of each measurement's row, one per entry of `measures`. Hidden rather
/// than removed when the toggle is off, so turning it on costs no rebuild -- and a rebuild
/// is what would reset the statistics the user just asked to see.
std::vector<GtkWidget*> measure_stats_labels;
bool measure_stats_on = false;
GtkWidget* measures_stats_check = nullptr;
GtkWidget* measures_stats_reset = nullptr;
/// 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.
/// ///
@@ -746,6 +753,7 @@ LayoutState collect_layout(App& app) {
// The app's own lists are the pane's source of truth for both of these. // The app's own lists are the pane's source of truth for both of these.
st.math = app.math; st.math = app.math;
st.measures = app.measures; st.measures = app.measures;
st.measure_stats = app.measure_stats_on;
// The connection is the session's too: the file's name carries only host and port. // The connection is the session's too: the file's name carries only host and port.
st.multicast = app.source_multicast; st.multicast = app.source_multicast;
st.interface_addr = app.source_interface; st.interface_addr = app.source_interface;
@@ -808,6 +816,11 @@ void apply_layout(App& app, const LayoutState& st) {
app.math = st.math; app.math = st.math;
apply_math_channels(app); apply_math_channels(app);
app.measures = st.measures; app.measures = st.measures;
app.measure_stats_on = st.measure_stats;
if (app.measures_stats_check != nullptr) {
gtk_check_button_set_active(GTK_CHECK_BUTTON(app.measures_stats_check),
app.measure_stats_on);
}
rebuild_measures_pane(app); rebuild_measures_pane(app);
int rows = static_cast<int>(st.rows.size()); int rows = static_cast<int>(st.rows.size());
@@ -2605,6 +2618,10 @@ void open_measure_dialog(App& app, int edit_index);
/// label is the one widget `update_measures()` rewrites; everything else is rebuilt with the /// label is the one widget `update_measures()` rewrites; everything else is rebuilt with the
/// list. /// list.
GtkWidget* measures_row(App& app, std::size_t index, const Measure& m) { GtkWidget* measures_row(App& app, std::size_t index, const Measure& m) {
// The row is two lines: the reading, and the statistics under it. A vertical wrapper
// rather than more columns, because the statistics line is four figures wide and would
// push the reading itself off the pane.
GtkWidget* outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
GtkWidget* chip = gtk_label_new(""); GtkWidget* chip = gtk_label_new("");
gtk_label_set_markup(GTK_LABEL(chip), gtk_label_set_markup(GTK_LABEL(chip),
@@ -2660,8 +2677,21 @@ GtkWidget* measures_row(App& app, std::size_t index, const Measure& m) {
}), }),
&app); &app);
gtk_box_append(GTK_BOX(box), edit); gtk_box_append(GTK_BOX(box), edit);
gtk_box_append(GTK_BOX(outer), box);
GtkWidget* stats = gtk_label_new("");
gtk_widget_set_halign(stats, GTK_ALIGN_END);
gtk_widget_add_css_class(stats, "dim-label");
gtk_label_set_selectable(GTK_LABEL(stats), true);
gtk_widget_set_tooltip_text(stats,
"how this reading has varied since the last reset: the number "
"of passes, the mean, the standard deviation, and the extremes");
gtk_widget_set_visible(stats, app.measure_stats_on);
gtk_box_append(GTK_BOX(outer), stats);
app.measure_labels.push_back(value); app.measure_labels.push_back(value);
return box; app.measure_stats_labels.push_back(stats);
return outer;
} }
void rebuild_measures_pane(App& app) { void rebuild_measures_pane(App& app) {
@@ -2673,6 +2703,7 @@ void rebuild_measures_pane(App& app) {
child = next; child = next;
} }
app.measure_labels.clear(); app.measure_labels.clear();
app.measure_stats_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 // 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. // longer one series. This is the implicit reset for an add, an edit and a delete alike.
@@ -3464,6 +3495,22 @@ void update_measures(App& app, bool force) {
// contributes nothing -- the marks' `measure_last_valid` fallback below is a *drawing* // contributes nothing -- the marks' `measure_last_valid` fallback below is a *drawing*
// convenience, and accumulating it would count one answer as many observations. // 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); if (r.valid && i < app.measure_stats.size()) app.measure_stats[i].add(r.primary);
if (i < app.measure_stats_labels.size() && app.measure_stats_labels[i] != nullptr) {
GtkWidget* sl = app.measure_stats_labels[i];
gtk_widget_set_visible(sl, app.measure_stats_on);
if (app.measure_stats_on && i < app.measure_stats.size()) {
const MeasureStatsAccum& s = app.measure_stats[i];
std::string text = "no passes yet";
if (s.n > 0) {
// The same formatter the reading above uses, so the two lines are comparable at a
// glance; sd and the extremes carry the measurement's unit, n does not.
text = "n " + std::to_string(s.n) + " mean " + format_measure(s.mean, unit) +
" sd " + format_measure(s.sd(), unit) + " " +
format_measure(s.lo, unit) + " .. " + format_measure(s.hi, unit);
}
gtk_label_set_text(GTK_LABEL(sl), text.c_str());
}
}
} }
// 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
@@ -5081,6 +5128,46 @@ GtkWidget* build_measures_panel(App& app) {
gtk_box_append(GTK_BOX(head), add); gtk_box_append(GTK_BOX(head), add);
gtk_box_append(GTK_BOX(box), head); gtk_box_append(GTK_BOX(box), head);
// One toggle for the whole pane, not one per measurement: statistics are either what the
// user is looking at or they are not, and a per-row switch is a column of chrome paid for
// on every row to answer a question asked once.
GtkWidget* stats_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
app.measures_stats_check = gtk_check_button_new_with_label("Statistics");
gtk_widget_set_hexpand(app.measures_stats_check, true);
gtk_widget_set_tooltip_text(app.measures_stats_check,
"show how each reading has varied: passes, mean, standard "
"deviation and extremes, under the reading itself");
g_signal_connect(app.measures_stats_check, "toggled",
G_CALLBACK(+[](GtkCheckButton* b, gpointer user) {
App* a = static_cast<App*>(user);
a->measure_stats_on = gtk_check_button_get_active(b);
// Deliberately *not* a rebuild: that would reset the statistics the
// user just asked to look at. The rows are already there and hidden.
for (GtkWidget* w : a->measure_stats_labels) {
if (w != nullptr) gtk_widget_set_visible(w, a->measure_stats_on);
}
gtk_widget_set_sensitive(a->measures_stats_reset, a->measure_stats_on);
update_measures(*a, true);
schedule_layout_save(*a);
}),
&app);
gtk_box_append(GTK_BOX(stats_row), app.measures_stats_check);
app.measures_stats_reset = gtk_button_new_with_label("Reset");
gtk_widget_add_css_class(app.measures_stats_reset, "flat");
gtk_widget_set_tooltip_text(app.measures_stats_reset,
"start every measurement's statistics again from the next pass");
gtk_widget_set_sensitive(app.measures_stats_reset, false);
g_signal_connect(app.measures_stats_reset, "clicked",
G_CALLBACK(+[](GtkButton*, gpointer user) {
App* a = static_cast<App*>(user);
reset_measure_stats(*a);
update_measures(*a, true);
}),
&app);
gtk_box_append(GTK_BOX(stats_row), app.measures_stats_reset);
gtk_box_append(GTK_BOX(box), stats_row);
app.measures_list = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2); app.measures_list = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
gtk_box_append(GTK_BOX(box), app.measures_list); gtk_box_append(GTK_BOX(box), app.measures_list);
return box; return box;
@@ -8326,6 +8413,73 @@ gboolean measure_trace(gpointer user) {
return G_SOURCE_CONTINUE; return G_SOURCE_CONTINUE;
} }
/// Headless self-check for the statistics line: turns the pane-wide toggle on over the
/// measurements `measure_probe` added, lets several recomputes land, and reports each
/// accumulator. `n` must grow with the passes and never with the samples, and a Reset must
/// put every `n` back to zero.
///
/// Three phases, because "one observation per recompute" needs two different witnesses. Phase 0
/// is the running count. Phase 1 is the same count after a **pause held for two seconds**: a
/// pinned window recomputes once when it settles and then never again, so `n` must advance by
/// *at most one* across the pause -- that is what separates accumulating per recompute from
/// accumulating per frame, which would add a hundred and twenty observations of the identical
/// window and collapse `sd`. Phase 2 is after a Reset.
int g_stats_phase = 0;
std::vector<std::uint64_t> g_stats_n_before;
gboolean stats_probe_report(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
std::fprintf(stderr, "[stats-probe] phase=%d on=%d passes=%" G_GUINT64_FORMAT,
g_stats_phase, int(a->measure_stats_on),
static_cast<std::uint64_t>(a->measure_passes));
for (std::size_t i = 0; i < a->measure_stats.size(); ++i) {
const MeasureStatsAccum& s = a->measure_stats[i];
std::fprintf(stderr, " | n=%" G_GUINT64_FORMAT " mean=%.6g sd=%.6g lo=%.6g hi=%.6g", s.n,
s.mean, s.sd(), s.lo, s.hi);
if (g_stats_phase == 1 && i < g_stats_n_before.size()) {
std::fprintf(stderr, " dn=%" G_GUINT64_FORMAT, s.n - g_stats_n_before[i]);
}
}
std::fprintf(stderr, "\n");
std::fflush(stderr);
if (g_stats_phase == 0) {
// Hold a pause and come back: `dn` on the next line is the assertion.
g_stats_n_before.clear();
for (const MeasureStatsAccum& s : a->measure_stats) g_stats_n_before.push_back(s.n);
g_stats_phase = 1;
if (a->pause_button != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->pause_button), TRUE);
}
g_timeout_add(2000, stats_probe_report, a);
return G_SOURCE_REMOVE;
}
if (g_stats_phase == 1) {
// Resume -- a resume must not reset, so phase 2's `n` starts from phase 1's -- then reset
// explicitly, which must.
if (a->pause_button != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->pause_button), FALSE);
}
g_stats_phase = 2;
reset_measure_stats(*a);
update_measures(*a, true);
g_timeout_add(50, stats_probe_report, a);
}
return G_SOURCE_REMOVE;
}
gboolean stats_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
if (a->measures_stats_check != nullptr) {
gtk_check_button_set_active(GTK_CHECK_BUTTON(a->measures_stats_check), TRUE);
}
g_stats_phase = 0;
// Long enough for several throttled recomputes (one per 100 ms while the window moves).
g_timeout_add(2000, stats_probe_report, a);
return G_SOURCE_REMOVE;
}
/// Headless self-check: a gesture must not move a pinned window. /// Headless self-check: a gesture must not move a pinned window.
/// ///
/// It writes the far-away range the way a gesture does -- on the *member* axis, which /// It writes the far-away range the way a gesture does -- on the *member* axis, which
@@ -10042,6 +10196,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_CONDITION_MS")) { if (const char* env = g_getenv("UDPSCOPE_PROBE_CONDITION_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), condition_probe, &app); g_timeout_add(static_cast<guint>(std::atoi(env)), condition_probe, &app);
} }
if (const char* env = g_getenv("UDPSCOPE_PROBE_STATS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), stats_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_FIELDS_MS")) { if (const char* env = g_getenv("UDPSCOPE_PROBE_FIELDS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), fields_probe, &app); g_timeout_add(static_cast<guint>(std::atoi(env)), fields_probe, &app);
} }