// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari // // The measurements pane and its dialog: the list of measurements, the window statistics they compute\n// on a throttle, and the marks they place on the plot. #include "ui_app.hpp" #include #include #include namespace udpscope { namespace ui_impl { /// A measurement's own colour, so the pane's badge and the marks it puts on the plot are /// recognisably the same measurement. Picked from the trace palette's neighbours rather than /// it: a measurement's mark must not look like one of the signals. plot::u32 measure_color(std::size_t index) { static const plot::u32 kPalette[] = {0x9BE8A0u, 0xE8A0C8u, 0xE8D79Bu, 0x9BD7E8u, 0xC8A0E8u, 0xA0E8D7u, 0xE8B9A0u, 0xA0A8E8u}; return kPalette[index % (sizeof(kPalette) / sizeof(kPalette[0]))]; } /// One line naming what a measurement's type measures, with the shares it reads them at. The pane's /// row shows the type alone, as it does for every type, so this is the only place a reader sees /// *which* side of *which* split an `above`/`below` is averaging over, or how wide a `between` /// band is -- and the reading of those three is entirely a function of those two numbers. std::string measure_row_summary(const Measure& m) { const auto pct_text = [](double v) { char text[32]; std::snprintf(text, sizeof(text), "%g%% of the range", v); return std::string(text); }; switch (m.type) { case MeasureType::Above: return "the mean of the samples above " + pct_text(m.options.threshold_pct); case MeasureType::Below: return "the mean of the samples below " + pct_text(m.options.threshold_pct); case MeasureType::Between: return "the mean of the samples between " + pct_text(m.options.threshold_pct) + " and " + pct_text(m.options.threshold_pct2); case MeasureType::Top: case MeasureType::Bottom: return "the flat level on one side of the split at " + pct_text(m.options.threshold_pct); case MeasureType::PulseDuration: return std::string(m.options.above ? "the pulse above " : "the pulse below ") + pct_text(m.options.threshold_pct); default: return std::string("the ") + measure_type_name(m.type) + " of this window"; } } /// The channel a measurement's signal resolved to, or -1 while the stream does not carry it. int measure_channel(App& app, const std::string& signal) { std::lock_guard lock(app.model->mutex()); const std::vector& signals = app.model->signals(); const std::vector& channels = app.model->channels(); for (std::size_t s = 0; s < signals.size(); ++s) { if (signals[s].name != signal) continue; for (std::size_t c = 0; c < channels.size(); ++c) { if (channels[c].signal == s) return static_cast(c); } return -1; // the signal exists but is not acquired } return -1; } /// Which row plots a signal (the first slot that does), or 0: a measurement's marks belong on /// the plot showing the signal, and a signal nothing plots still has to go somewhere. int measure_row(App& app, const std::string& signal) { for (int r = 0; r < app.rows->row_count(); ++r) { const RowSet::Row& row = app.rows->row(r); for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { if (row.signal_name[static_cast(s)] == signal) return r; } } return 0; } /// The unit of a signal's channel, for the pane's readout. std::string measure_unit(App& app, const std::string& signal) { std::lock_guard lock(app.model->mutex()); for (const ChannelInfo& c : app.model->channels()) { if (c.name == signal) return c.unit; } return ""; } void rebuild_measures_pane(App& app) { if (app.measures_list == nullptr) return; GtkWidget* child = gtk_widget_get_first_child(app.measures_list); while (child != nullptr) { GtkWidget* next = gtk_widget_get_next_sibling(child); gtk_box_remove(GTK_BOX(app.measures_list), child); 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. 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"); gtk_widget_set_halign(empty, GTK_ALIGN_START); gtk_box_append(GTK_BOX(app.measures_list), empty); } for (std::size_t i = 0; i < app.measures.size(); ++i) { gtk_box_append(GTK_BOX(app.measures_list), measures_row(app, i, app.measures[i])); app.measure_text.emplace_back(); } update_measures(app, /*force=*/true, /*accumulate=*/true); } /// Shows the fields a type actually uses: the threshold and the side only where the engine /// reads them, and the plot option only for a type that can be drawn. A field that does /// nothing is worse than an absent one -- it invites the user to set it. void measure_dialog_sync(MeasureDialog* d) { const int sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(d->type_drop)); const MeasureType type = measure_type_at(sel); const bool threshold = measure_uses_threshold(type); const bool side = measure_uses_side(type); gtk_widget_set_visible(d->threshold_row, threshold); gtk_widget_set_visible(d->side_row, side); // The band's second share: a `Between` is the one type whose two edges are *both* parameters. gtk_widget_set_visible(d->band_row, measure_uses_band(type)); // The band moves the edge decision, so it is shown for exactly the crossing types. The HF reject sits // before both walks, so it is shown for all of them. gtk_widget_set_visible(d->noise_row, measure_uses_noise(type)); gtk_widget_set_visible(d->hf_row, TRUE); gtk_widget_set_visible(d->show, measure_can_show_on_plot(type)); // The words follow the type: one row, several meanings. const std::string signal = d->signals.empty() ? std::string("the signal") : d->signals[std::min( gtk_drop_down_get_selected(GTK_DROP_DOWN(d->signal_drop)), d->signals.size() - 1)]; switch (type) { case MeasureType::Average: gtk_label_set_text(GTK_LABEL(d->threshold_label), ""); gtk_label_set_text(GTK_LABEL(d->side_label), ""); gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), "show on plot (ruler at the mean)"); break; case MeasureType::Min: case MeasureType::Max: gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), "show on plot (ruler at the value, marker where it is)"); break; case MeasureType::PeakToPeak: gtk_label_set_text(GTK_LABEL(d->threshold_label), ""); gtk_label_set_text(GTK_LABEL(d->side_label), ""); gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), "show on plot (rulers at both extremes)"); break; case MeasureType::Amplitude: gtk_label_set_text(GTK_LABEL(d->threshold_label), ""); gtk_label_set_text(GTK_LABEL(d->side_label), ""); // Amplitude is the delta between the two *flat* levels, so its rulers are those two and not // the extremes a peak-to-peak draws: the pair of rulers is the reading. gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), "show on plot (rulers at both flat levels)"); break; case MeasureType::Top: case MeasureType::Bottom: // The split picks the *side*: the level is the densest band either above or below it, so any // split between the two plateaus reads the same -- it is not a mean over the side any more. gtk_label_set_text(GTK_LABEL(d->threshold_label), "split % of the range (picks the side)"); gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), "show on plot (ruler at the level)"); break; case MeasureType::Above: case MeasureType::Below: { // These *are* a mean over one side of the split -- the reading the old `top`/`bottom` gave -- // so the words have to say which side of which share, and they have to say it beside the // field rather than only in the docs: the same spin means a different side for each type. const bool above = type == MeasureType::Above; gtk_label_set_text(GTK_LABEL(d->threshold_label), above ? "above % of the range" : "below % of the range"); gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), "show on plot (ruler at the mean)"); break; } case MeasureType::Between: // Two shares, so two rows: the band's edges, lower first (the engine orders a reversed pair // rather than refusing it, so either order the user types reads the same band). gtk_label_set_text(GTK_LABEL(d->threshold_label), "from % of the range"); gtk_label_set_text(GTK_LABEL(d->band_label), "to % of the range"); gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), "show on plot (ruler at the mean)"); break; case MeasureType::PulseDuration: gtk_label_set_text(GTK_LABEL(d->threshold_label), "split % of the range"); gtk_label_set_text(GTK_LABEL(d->side_label), "pulse above the level"); gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), "show on plot (markers at the pulse's edges)"); break; case MeasureType::Period: case MeasureType::Frequency: // No split to choose: the engine counts rising crossings of the mid level (see // `compute_measure`), and the row that would offer one is not shown at all. gtk_label_set_text(GTK_LABEL(d->threshold_label), ""); gtk_label_set_text(GTK_LABEL(d->side_label), ""); gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), "show on plot (markers at two edges of a cycle)"); break; } gtk_widget_set_tooltip_text( d->show, ("draw what the measurement found on the row plotting " + signal + " -- it follows the " "window and cannot be dragged").c_str()); } void on_measure_type_changed(GObject* obj, GParamSpec*, gpointer user) { (void)obj; measure_dialog_sync(static_cast(user)); } void on_measure_dialog_apply(GtkButton*, gpointer user) { MeasureDialog* d = static_cast(user); App& app = *d->app; const guint sig = gtk_drop_down_get_selected(GTK_DROP_DOWN(d->signal_drop)); if (sig >= d->signals.size()) { gtk_window_destroy(GTK_WINDOW(d->window)); return; } Measure m; m.signal = d->signals[sig]; m.type = measure_type_at(gtk_drop_down_get_selected(GTK_DROP_DOWN(d->type_drop))); m.options.threshold_pct = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->threshold)); m.options.threshold_pct2 = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->band)); m.options.above = gtk_switch_get_active(GTK_SWITCH(d->above)); m.options.show_on_plot = gtk_check_button_get_active(GTK_CHECK_BUTTON(d->show)); m.options.noise_pct = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->noise)); m.options.hf_fc = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->hf)); if (d->edit >= 0 && static_cast(d->edit) < app.measures.size()) { app.measures[static_cast(d->edit)] = std::move(m); } else { app.measures.push_back(std::move(m)); } rebuild_measures_pane(app); gtk_window_destroy(GTK_WINDOW(d->window)); } void on_measure_dialog_delete(GtkButton*, gpointer user) { MeasureDialog* d = static_cast(user); App& app = *d->app; if (d->edit >= 0 && static_cast(d->edit) < app.measures.size()) { app.measures.erase(app.measures.begin() + d->edit); rebuild_measures_pane(app); } gtk_window_destroy(GTK_WINDOW(d->window)); } /// Puts `App::measure_marks` on the plots, converting each **time** mark from the stream seconds /// it is stored in to the x the axis is reading this frame. Called every frame -- that is what /// makes the marks read-only (a gesture moves one, and this puts it back) and, since a live axis /// is newest-relative, also what rolls each mark leftwards with the sample it measured. void place_measure_marks(App& app) { const double s0 = app.model->stream_at_x0(); app.measure_marks_display = app.measure_marks; for (RowSet::MeasureMark& mk : app.measure_marks_display) { if (!mk.level) mk.value -= s0; } app.rows->set_measure_marks(app.measure_marks_display); } /// Recomputes every measurement over the window it describes and writes the results into the /// pane, collecting the marks the ones asking to be shown need. /// /// **The window is the viewport live and the pin while the view is pinned** (see below): a still /// frame is measured as a frame, so on a held event or a pause a pan or a zoom changes no value. /// /// A measurement is a statistic of that window, so it is recomputed when the *window* moves -- /// not on a timer. A timer decouples the two: with the window scrolling under a 5 Hz cadence /// the stored marks describe a range the plot has already left, which is how a min/max marker /// ended up outside the viewport, and how a dragged marker survived until the next tick. The /// threshold is a small fraction of the span, so the marks stay within a few pixels of where /// the current window would put them, and the cost is bounded by the window's own motion /// rather than by the frame rate. void update_measures(App& app, bool force, bool accumulate) { if (app.measure_labels.size() != app.measures.size()) return; // mid-rebuild const RowSet::Row& r0 = app.rows->row(0); if (r0.plot == nullptr) return; // The window on the **stream's** clock, which is the only space in which "it moved" is a // question with an answer: a live axis is newest-relative, so `view` itself is a fixed // `[-window, 0]` that never changes while the data under it does. const double s0 = app.model->stream_at_x0(); const plot::Range view = r0.plot->x_range(r0.axis_x); // The window a statistic describes. A **pinned** view -- a held event, a paused view, an opened // file -- is a *still frame*: the data behind it does not change, so a pan or a zoom only looks // closer and no number may move because of one. What the pane describes there is the **pin's own // extent**, which is the same accessor the framing itself uses (`hold_pinned_window()` clamps a // pinned view into exactly this, so the pin *is* what frames it), and it is decided here rather // than inside `MeasureWindowReader` because the decision is booked as well as read: `swin` below // is what the "it moved" test compares against the last result's window, and a reader that chose // its own window would leave a pinned view recomputing on every gesture frame for a number that // cannot change -- the 457-pass run this pane's cadence was fixed for. // // A live view is the other case and is unchanged: there the window genuinely moves and the // statistic follows it, and the accumulator's rule about what resets the history (`measure_*` // stats) is about neither -- a pan or a zoom still changes *which samples* answer a live // measurement, which is what averaging over many windows is for. const plot::Range pin = app.rows->pinned_range(); const bool pinned_view = app.rows->window_pinned() && pin.span() > 0.0 && std::isfinite(pin.min) && std::isfinite(pin.max); const plot::Range window = pinned_view ? pin : view; const plot::Range swin{window.min + s0, window.max + s0}; // Both windows of the tick, on the stream's clock: what is **on screen**, and (while pinned) // what the pin *is*. The dump reads them instead of calling `stream_at_x0()` itself, which by // the time it runs answers for a mode no frame was drawn in. app.measure_view_window = plot::Range{view.min + s0, view.max + s0}; app.measure_pin_valid = pinned_view; if (pinned_view) app.measure_pin_window = swin; const bool held = app.model->display_mode() == DisplayMode::Held; // "It moved" is asked in **every** mode, a held event included -- but it is asked about the // window the measurement *describes* (`swin`, the viewport live and the pin when pinned), never // about the viewport. That distinction is the whole of a reported defect in both directions. // This test used to compare the viewport, with a `&& !held` on it: exempting a held event left // the pane describing the whole 2 s event (`for=[0.47..2.47]`) while a 50 ms slice of it was on // screen, and *removing* the exemption made the opposite mistake -- the numbers followed the // zoom into the slice, so an average or a top moved whenever the user moved the wheel on a // frozen event, which is the report this pins down. Neither is a staleness question: on a // pinned view the pin is what the pane is about, so a gesture cannot move it at all, and the // numbers a gesture would have disturbed are the frame's and stay put. A pinned view still // recomputes when it should -- a fresh latch, a re-stated pin, a new snapshot and the mode // change below all move `swin` -- and a settled one satisfies `swin == measure_window` exactly // and so reads `moved == false` on its own, which is what keeps it from recomputing every // frame (457 passes in one run is the shape that mistake took). const bool moved = app.measure_window_valid && std::fabs(swin.min - app.measure_window.min) + std::fabs(swin.max - app.measure_window.max) > 0.02 * std::max(1.0e-12, swin.span()); // A measurement is a statistic of the visible window, and *a live view moves that window every // frame* -- so "recompute when the window moves" is "recompute every frame", at O(visible // samples) per measurement. That is what stalled the frame tick (0.4 ms -> 13.7 ms, a third of // the run's frames lost) once the window held a few million samples. The pane is a *readout*, // so it is refreshed on the terms the status line's own fields already use: at most every // `kMeasureRefreshUs`, plus immediately whenever the window stops moving or the mode changes, // which is when the number has to be exact. Between those a tenth of a second of lag is // invisible on a statistic that is itself a window average, and the marks below are re-placed // from the last result every frame either way. const gint64 now_us = g_get_monotonic_time(); const gint64 interval = app.measure_last_pass_us > kMeasureHeavyUs ? 4 * kMeasureRefreshUs : kMeasureRefreshUs; const bool due = !app.measure_stamp_valid || now_us - app.measure_stamp_us >= interval; // Two reasons to skip, and they are different questions. `measure_window` is the window the // *last recompute described*, so no change at all means the pane already says the right thing: // that is the settled case, and it must recompute once when it settles and then never again -- // getting this backwards is what had a *pinned* window recomputing every single frame (457 // passes in one run) instead of once. The other is a window still scrolling: at most one pass // per `kMeasureRefreshUs`, because the number is a window average and a tenth of a second of // lag on it is not readable. const bool changed = !app.measure_window_valid || held != app.measure_was_held || moved; if (!force && !changed) { place_measure_marks(app); return; } if (!force && moved && held == app.measure_was_held && !due) { // Nothing to recompute -- but the marks are still re-placed below, which is what makes // them read-only: a gesture moves one on the plot, and this is what puts it back. place_measure_marks(app); return; } app.measure_window = swin; app.measure_window_valid = true; app.measure_was_held = held; app.measure_stamp_us = now_us; app.measure_stamp_valid = true; const gint64 pass_start_us = g_get_monotonic_time(); app.measure_samples.clear(); // Before any `MeasureWindowReader` exists: the reader holds `model->mutex()` for a live window, and asking // the model for anything while one is alive would deadlock. double slot_dt = 0.0; { std::lock_guard lock(app.model->mutex()); slot_dt = app.model->partition().slot_dt(); } app.measure_results.assign(app.measures.size(), MeasureResult{}); app.measure_last_valid.resize(app.measures.size()); app.measure_text.assign(app.measures.size(), std::string("--")); app.measure_marks.clear(); std::vector marks; struct PassTimer { App& app; gint64 start; ~PassTimer() { const gint64 took = g_get_monotonic_time() - start; app.measure_cost_us += took; app.measure_last_pass_us = took; ++app.measure_passes; } } pass_timer{app, pass_start_us}; // Who needs what, before any data is touched: the channel each measurement resolved to, and // *which* level accumulator answers it. Several measurements on one signal share both walks, and // several sharing a threshold share one accumulator -- so a pane full of measurements on one // trace costs the same two walks as a single one. The common case is exactly one level: every // crossing type reads the mid level and `Top`/`Bottom` default to it. std::vector chan(app.measures.size(), -1); std::vector want_window(app.measures.size(), -1); std::vector want_level(app.measures.size(), -1); // `Between` reads two levels, and they are two ordinary entries of the same list: one walk answers // every level in the pane, so a band costs the same pass two as any other measurement plus one // more accumulator in it. std::vector want_level2(app.measures.size(), -1); for (std::size_t i = 0; i < app.measures.size(); ++i) { const Measure& m = app.measures[i]; const int channel = measure_channel(app, m.signal); chan[i] = channel; if (channel < 0) continue; // unresolvable this pass: a rebuild has emptied the channel list const double fc = m.options.hf_fc > 0.0 ? m.options.hf_fc : 0.0; std::size_t wi = 0; while (wi < app.measure_samples.size() && (app.measure_samples[wi].channel != channel || app.measure_samples[wi].fc != fc)) { ++wi; } if (wi == app.measure_samples.size()) { App::MeasureWindow fresh; fresh.channel = channel; fresh.fc = fc; measure_filter_set(fresh.scan_filter, fc, slot_dt); measure_filter_set(fresh.acc_filter, fc, slot_dt); app.measure_samples.push_back(fresh); } want_window[i] = static_cast(wi); App::MeasureWindow& w = app.measure_samples[wi]; // The three types whose value is a flat level: pass one folds the bucket histogram for this // window only if one of them is in the pane. if (m.type == MeasureType::Top || m.type == MeasureType::Bottom || m.type == MeasureType::Amplitude) { w.flat_wanted = true; } double pct[2] = {0.0, 0.0}; const int level_count = measure_level_pcts(m.type, m.options, pct); // A type that does not read the band must not split the accumulator over it: its sums split at the bare // level, so a `Top` is the same walk whatever the band is. Grouping those at the default is what keeps // the common pane at exactly one level. const double noise = measure_uses_noise(m.type) ? m.options.noise_pct : MeasureOptions{}.noise_pct; for (int j = 0; j < level_count; ++j) { std::size_t k = 0; while (k < w.pct.size() && (w.pct[k] != pct[j] || w.noise[k] != noise)) ++k; if (k == w.pct.size()) { w.pct.push_back(pct[j]); w.noise.push_back(noise); } if (j == 0) { want_level[i] = static_cast(k); } else { want_level2[i] = static_cast(k); } } } // The data work: two walks per channel, whatever the pane holds. The first fixes the window's // extremes (and so every level, which is a share of them); the second is skipped entirely when // no measurement on this channel reads a level. // // Both go through the **LOD pyramid** rather than over the samples. The producer already folds // every sample into it as it arrives, which is what "evaluate only on the new samples" means // here: a 64-sample bucket carries its own extremes and its exact sum, so pass one reads 1/64 // of the window, and pass two opens only the buckets that *straddle* its level -- a handful per // crossing. The raw path is still here, and still exact, for the window's partial edge buckets, // for the samples produced since this frame's `acquire_all()`, and for those straddling // buckets; a window shorter than a bucket, or a signal noisy enough to straddle everywhere, // degrades back to the O(window) cost and nothing else changes. // // The flat-level types (`top`/`bottom`/`amplitude`) are the one thing that reads a *third* time // -- and it is the same fetched bucket list, not a third fetch: pass one's bucket walk folds its // own histogram while it has the buckets in hand (see `measure_scan_buckets`). It is O(buckets) // and it reads no sample, so `raw` does not move; a pane with none of those three types does // not pay for it at all. constexpr std::size_t kChunk = 8192; std::vector& buckets = app.measure_bucket_scratch; for (App::MeasureWindow& w : app.measure_samples) { const gint64 fetch_start_us = g_get_monotonic_time(); MeasureWindowReader reader(app, static_cast(w.channel), window); const std::size_t n = reader.size(); const std::function time_of = [&reader](std::size_t i) { return reader.time_of(i); }; // The window's whole buckets, fetched once and walked by both passes. u64 kb = 0; u64 ke = 0; buckets.clear(); // A filtered window cannot use the pyramid: a bucket aggregates *unfiltered* samples, so its extremes // and its sum describe a different signal. Leaving `buckets` empty is the whole disable -- `bulk_from == // bulk_to == n` makes the raw walk cover the window, and `lo_search`/`hi_search` stay 0, which makes // `pin()` below a no-op. One `if` turns off both fast paths, and nothing downstream needs to know. const bool filtered = w.fc > 0.0; if (!filtered && reader.bucket_range(kb, ke)) { buckets.reserve(static_cast(ke - kb)); for (u64 k = kb; k < ke; ++k) buckets.push_back(reader.bucket(k)); } const std::size_t bulk_from = buckets.empty() ? n : buckets.front().offset; const std::size_t bulk_to = buckets.empty() ? n : buckets.back().offset + buckets.back().span; std::size_t raw_read = 0; // In window order, always: the first sample to reach an extreme is the one the mark names, so // a later chunk that merely ties it must not move it. const auto raw = [&](std::size_t from, std::size_t to, const auto& fn) { for (std::size_t at = from; at < to; at += kChunk) { const std::size_t take = std::min(kChunk, to - at); fn(MeasureChunk{reader.values(at, take), take, at}); raw_read += take; } }; const auto scan = [&](const MeasureChunk& c) { measure_scan(c, w.stats, w.scan_filter); }; raw(0, bulk_from, scan); if (!buckets.empty()) { measure_scan_buckets(buckets.data(), buckets.size(), w.stats, w.flat_wanted); } raw(bulk_to, n, scan); // A bucket named the 64 samples that hold an extreme, not the sample. One scan of that bucket // pins it -- two buckets at most, whatever the window's size. if (w.stats.valid) { const auto pin = [&](std::size_t& index, std::size_t& search, bool want_hi) { if (search == 0) return; // Only the bucket path leaves `search` non-zero, and a filtered window has no buckets, so this is // reached pass-through only: a fresh filter here is the *same* filter. MeasureFilter unfiltered; MeasureStats one; measure_scan(MeasureChunk{reader.values(index, search), search, index}, one, unfiltered); if (one.valid) index = want_hi ? one.hi_i : one.lo_i; raw_read += search; search = 0; }; pin(w.stats.lo_i, w.stats.lo_search, false); pin(w.stats.hi_i, w.stats.hi_search, true); // The two stamps the whole scan needed, read now rather than at every chunk that moved an // extreme: a stamp is a ring read, and an extreme moves many times over a long window. w.stats.at_lo = reader.time_of(w.stats.lo_i); w.stats.at_hi = reader.time_of(w.stats.hi_i); } w.ok = w.stats.valid; if (w.ok && !w.pct.empty()) { w.level.assign(w.pct.size(), MeasureLevel{}); for (std::size_t k = 0; k < w.pct.size(); ++k) { // The level *and* its hysteresis band, in the engine: how wide the band is is a property of // how an edge is counted, not of the pane, and two expressions for it is how the walk and // the bucket fast path end up disagreeing about where a crossing was. measure_set_level(w.level[k], w.stats, w.pct[k], 0.01 * w.noise[k]); } const auto accumulate = [&](const MeasureChunk& c) { measure_accumulate(c, time_of, w.level.data(), w.level.size(), w.acc_filter); }; raw(0, bulk_from, accumulate); for (const MeasureBucket& b : buckets) { const f32* values = nullptr; for (MeasureLevel& level : w.level) { if (measure_accumulate_bucket(b, time_of, level)) continue; // Fetched once for however many levels this bucket straddles, and only then: the usual // case is one level, and the usual bucket answers without being opened at all. if (values == nullptr) { values = reader.values(b.offset, b.span); raw_read += b.span; } measure_accumulate(MeasureChunk{values, b.span, b.offset}, time_of, &level, 1, w.acc_filter); } } raw(bulk_to, n, accumulate); } app.measure_fetch_us += g_get_monotonic_time() - fetch_start_us; app.measure_covered += static_cast(n); app.measure_bucketed += static_cast(buckets.size()); app.measure_raw += raw_read; } for (std::size_t i = 0; i < app.measures.size(); ++i) { const Measure& m = app.measures[i]; GtkWidget* label = app.measure_labels[i]; const App::MeasureWindow* wp = want_window[i] >= 0 && static_cast(want_window[i]) < app.measure_samples.size() ? &app.measure_samples[static_cast(want_window[i])] : nullptr; if (wp == nullptr || !wp->ok) { // Its signal is momentarily unresolvable, or the window holds nothing -- so there is nothing // to measure *this* pass. The marks still fall back (below); `continue`ing out of the marks // was the other half of the rulers blinking off. app.measure_text[i] = "--"; if (label != nullptr) gtk_label_set_text(GTK_LABEL(label), "--"); continue; } const App::MeasureWindow& w = *wp; // In the order `measure_level_pcts()` asked for them: the band's lower edge first. const MeasureLevel* levels[2] = {nullptr, nullptr}; const int want[2] = {want_level[i], want_level2[i]}; for (int j = 0; j < 2; ++j) { if (want[j] >= 0 && static_cast(want[j]) < w.level.size()) { levels[j] = &w.level[static_cast(want[j])]; } } const MeasureResult r = compute_measure(w.stats, levels, 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) { text = format_measure(r.primary, unit); if (r.has_secondary) { // Pulse duration also reports the duty, which has no unit. text += " " + format_measure(100.0 * r.secondary, "%"); } } 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 and the caller // asked for it. An invalid pass contributes nothing -- the marks' `measure_last_valid` // fallback is a *drawing* convenience. `accumulate=false` is the toggle handler, which // forces a repaint of the newly-visible labels without counting the window again. if (accumulate && 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 // them. That is exactly how the rulers came to blink off -- first at a mode change, then at a // rebuild where the signal is momentarily unresolvable -- while the plot put them back a few // frames later, which reads as the lines flickering. // // A pass with no answer falls back to the last valid one, and the split is by *kind*: a // **level** is a property of the signal (a mean, a top, an amplitude) and outlives the window it // was measured in, so its ruler stays; a **time** mark is a position *inside* the window that // result describes, so it may not outlive it and is dropped. for (std::size_t i = 0; i < app.measures.size() && i < app.measure_results.size(); ++i) { const Measure& m = app.measures[i]; if (!m.options.show_on_plot) continue; const MeasureResult& r = app.measure_results[i]; const MeasureResult* mk = &r; if (!r.valid && i < app.measure_last_valid.size() && app.measure_last_valid[i].valid) { mk = &app.measure_last_valid[i]; } const int row = measure_row(app, m.signal); const plot::u32 color = measure_color(i); for (const MeasureMarkSpec& spec : mk->marks) { if (!spec.level && !r.valid) continue; // A time mark is stored on the **stream's** clock -- the walks report display x, so `s0` // puts it back -- and `place_measure_marks()` converts it again on every frame it draws. // That is what keeps the mark on its own sample as a live view rolls underneath it; a level // mark is a y and is stored as it comes. marks.push_back({row, spec.level, spec.level ? spec.value : spec.value + s0, color}); } } app.measure_marks = std::move(marks); place_measure_marks(app); } /// Repeating trace of the pane's numbers beside the *current* window and the marks actually /// placed: the only way a stale result is visible as a number rather than as a stutter. gboolean measure_trace(gpointer user) { App* a = static_cast(user); if (a->rows->row_count() <= 0) return G_SOURCE_REMOVE; RowSet::Row& r0 = a->rows->row(0); if (r0.plot == nullptr) return G_SOURCE_REMOVE; const plot::Range view = r0.plot->x_range(r0.axis_x); // The window on screen *and* the window the numbers describe, both on the stream's clock, with // the offset that converts between them: a reading that moved without the view moving is a // change of `s0` (a new snapshot under the same mode), and only these three fields tell the two // apart. `passes` beside them says whether a recompute answered it. // // `pin` is the fourth, and it is where a *pinned* view's `for` comes from: while the pin is what // frames the plot the numbers describe it and not `swin`, so a gesture inside the pin leaves // `for`, `passes` and every value exactly where they were. Both are captured on the tick, which // is why `for` and `pin` are readable together; `view`/`s0` are read here, as they always were. const double s0 = a->model->stream_at_x0(); std::string line = "[measure-trace] mode=" + std::string(display_mode_name(a->model->display_mode())) + " view=[" + std::to_string(view.min) + ".." + std::to_string(view.max) + "] s0=" + std::to_string(s0) + " swin=[" + std::to_string(view.min + s0) + ".." + std::to_string(view.max + s0) + "] pin=[" + (a->measure_pin_valid ? std::to_string(a->measure_pin_window.min) + ".." + std::to_string(a->measure_pin_window.max) : std::string("off")) + "] for=[" + std::to_string(a->measure_window.min) + ".." + std::to_string(a->measure_window.max) + "] passes=" + std::to_string(a->measure_passes); for (std::size_t i = 0; i < a->measures.size() && i < a->measure_results.size(); ++i) { const MeasureResult& r = a->measure_results[i]; line += " " + std::string(measure_type_name(a->measures[i].type)) + "=" + (r.valid ? std::to_string(r.primary) : std::string("--")); } // The marks as *placed*, not as stored: a time mark is kept on the stream's clock and converted // for the axis each frame (see `place_measure_marks()`), and it is the converted value that is // comparable with the `view=` printed beside it. line += " marks=" + std::to_string(a->measure_marks_display.size()); for (const RowSet::MeasureMark& m : a->measure_marks_display) { line += " " + std::string(m.level ? "level" : "time") + ":" + std::to_string(m.value); } std::fprintf(stderr, "%s\n", line.c_str()); std::fflush(stderr); return G_SOURCE_CONTINUE; } /// Adds one measurement of every type on the longest-cycling scalar signal the stream /// describes, then reports each result -- through the pane's own list and the tick's own /// recomputation, so what it prints is what a user would read. /// Clicks a labelled button inside the measurement dialog, which is a window of its own. void measure_dialog_click(App& a, const char* label) { if (a.measure_dialog == nullptr) { std::fprintf(stderr, "[measures-probe] no dialog to click '%s' in\n", label); // (the zoom-in is judged on the next frame, after the layout pushes it) } GtkWidget* b = find_labelled_button(a.measure_dialog, label); if (b == nullptr) { std::fprintf(stderr, "[measures-probe] no '%s' button in the dialog\n", label); // (the zoom-in is judged on the next frame, after the layout pushes it) } g_signal_emit_by_name(b, "clicked"); } /// What the dialog's signal dropdown holds: the probe's assertion that the list is the /// *acquired* signals (plus the edited measurement's own, when the stream no longer acquires /// it), and that every entry is a name rather than the mangled pointer it used to be. void measure_dialog_report(App& a, const char* what) { std::string items; int n = 0; if (a.measure_dialog != nullptr) { // The dialog keeps its fields in the context it stores on its own window, which is how a // probe reaches a dropdown inside a window that is not the main one. auto* d = static_cast( g_object_get_data(G_OBJECT(a.measure_dialog), "udpscope-measure-ctx")); if (d != nullptr && d->signal_drop != nullptr) { GListModel* model = gtk_drop_down_get_model(GTK_DROP_DOWN(d->signal_drop)); const guint count = model != nullptr ? g_list_model_get_n_items(model) : 0; for (guint i = 0; i < count; ++i) { GObject* obj = G_OBJECT(g_list_model_get_item(model, i)); const char* text = GTK_IS_STRING_OBJECT(obj) ? gtk_string_object_get_string(GTK_STRING_OBJECT(obj)) : "?"; if (i != 0) items += ", "; items += text; g_object_unref(obj); } n = static_cast(count); } } int drop_h = -1; char options[192] = ""; if (a.measure_dialog != nullptr) { auto* dd = static_cast( g_object_get_data(G_OBJECT(a.measure_dialog), "udpscope-measure-ctx")); if (dd != nullptr && dd->signal_drop != nullptr) { drop_h = gtk_widget_get_height(dd->signal_drop); } if (dd != nullptr) { // What the dialog is offering for the type on screen: the option rows' visibility and // the words on them, which is the per-type behaviour a user sees. std::snprintf(options, sizeof(options), "type=%d thr=%d band=%d side=%d show=%d " "shares=%.1f/%.1f '%s'", static_cast(gtk_drop_down_get_selected(GTK_DROP_DOWN(dd->type_drop))), dd->threshold_row != nullptr && gtk_widget_get_visible(dd->threshold_row) ? 1 : 0, dd->band_row != nullptr && gtk_widget_get_visible(dd->band_row) ? 1 : 0, dd->side_row != nullptr && gtk_widget_get_visible(dd->side_row) ? 1 : 0, dd->show != nullptr && gtk_widget_get_visible(dd->show) ? 1 : 0, // What the two share fields are *showing*: the pair a `between` reads, read back // out of the widgets, so a field that is built but not seeded or not read back is // visible in the line rather than only in the number it produces. dd->threshold != nullptr ? gtk_spin_button_get_value(GTK_SPIN_BUTTON(dd->threshold)) : 0.0, dd->band != nullptr ? gtk_spin_button_get_value(GTK_SPIN_BUTTON(dd->band)) : 0.0, dd->show != nullptr && gtk_check_button_get_child(GTK_CHECK_BUTTON(dd->show)) != nullptr ? gtk_label_get_text(GTK_LABEL(gtk_check_button_get_child( GTK_CHECK_BUTTON(dd->show)))) : ""); } } std::fprintf(stderr, "[measures-probe] %-14s measures=%zu dialog=%d signals=%d drop_h=%d [%s] %s\n", what, a.measures.size(), a.measure_dialog != nullptr ? 1 : 0, n, drop_h, items.c_str(), options); std::fflush(stderr); } } // namespace ui_impl } // namespace udpscope