diff --git a/src/layout_store.cpp b/src/layout_store.cpp index edfdcb0..4935992 100644 --- a/src/layout_store.cpp +++ b/src/layout_store.cpp @@ -309,6 +309,8 @@ bool load_layout(const std::string& path, LayoutState& out) { plot_count = std::max(0, std::stoi(value)); } catch (...) { } + } else if (key == "measure_stats") { + st.measure_stats = value == "true"; } else if (key == "multicast") { st.multicast = strip_quote(value); } else if (key == "interface") { @@ -431,6 +433,7 @@ bool save_layout(const std::string& path, const LayoutState& state) { } o << "]\n"; } + o << "measure_stats = " << (state.measure_stats ? "true" : "false") << "\n"; for (const auto& row : state.rows) { o << "\n[[plot]]\nsignals = ["; for (std::size_t i = 0; i < row.signals.size(); ++i) { diff --git a/src/layout_store.hpp b/src/layout_store.hpp index 35963d0..24bcb77 100644 --- a/src/layout_store.hpp +++ b/src/layout_store.hpp @@ -58,6 +58,11 @@ struct LayoutState { /// `math_node_text()` for the field order. std::vector 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. std::vector measures; }; diff --git a/src/ui.cpp b/src/ui.cpp index fa5c5f1..8d08b3e 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -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 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 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(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(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(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 g_stats_n_before; + +gboolean stats_probe_report(gpointer user) { + App* a = static_cast(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(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(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(std::atoi(env)), condition_probe, &app); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_STATS_MS")) { + g_timeout_add(static_cast(std::atoi(env)), stats_probe, &app); + } if (const char* env = g_getenv("UDPSCOPE_PROBE_FIELDS_MS")) { g_timeout_add(static_cast(std::atoi(env)), fields_probe, &app); }