feat: the measurements pane can show how each reading varies, and remembers that it was asked to
This commit is contained in:
1 parent
1a8218887c
commit
4f496b14b7
3 files changed
+166
-1
No files matched your search
+158
-1
@@ -274,6 +274,13 @@ struct App {
|
||||
/// not what the measurement means, and a statistic the user cannot keep across them is
|
||||
/// not worth having.
|
||||
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
|
||||
/// 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.
|
||||
st.math = app.math;
|
||||
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.
|
||||
st.multicast = app.source_multicast;
|
||||
st.interface_addr = app.source_interface;
|
||||
@@ -808,6 +816,11 @@ void apply_layout(App& app, const LayoutState& st) {
|
||||
app.math = st.math;
|
||||
apply_math_channels(app);
|
||||
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);
|
||||
|
||||
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
|
||||
/// list.
|
||||
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* chip = gtk_label_new("");
|
||||
gtk_label_set_markup(GTK_LABEL(chip),
|
||||
@@ -2660,8 +2677,21 @@ GtkWidget* measures_row(App& app, std::size_t index, const Measure& m) {
|
||||
}),
|
||||
&app);
|
||||
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);
|
||||
return box;
|
||||
app.measure_stats_labels.push_back(stats);
|
||||
return outer;
|
||||
}
|
||||
|
||||
void rebuild_measures_pane(App& app) {
|
||||
@@ -2673,6 +2703,7 @@ void rebuild_measures_pane(App& app) {
|
||||
child = next;
|
||||
}
|
||||
app.measure_labels.clear();
|
||||
app.measure_stats_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.
|
||||
@@ -3464,6 +3495,22 @@ void update_measures(App& app, bool force) {
|
||||
// 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);
|
||||
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
|
||||
@@ -5081,6 +5128,46 @@ GtkWidget* build_measures_panel(App& app) {
|
||||
gtk_box_append(GTK_BOX(head), add);
|
||||
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);
|
||||
gtk_box_append(GTK_BOX(box), app.measures_list);
|
||||
return box;
|
||||
@@ -8326,6 +8413,73 @@ gboolean measure_trace(gpointer user) {
|
||||
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.
|
||||
///
|
||||
/// 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")) {
|
||||
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")) {
|
||||
g_timeout_add(static_cast<guint>(std::atoi(env)), fields_probe, &app);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user