Three defects, all of them a window describing data that is not there. A math edit refilled the derived ring with `clear()`, which renumbers it from 0, leaving `produced()` short by the inputs' retained base. Since `live_window_locked()` clamps against every channel's `produced()`, that one lagging channel answered `n = 0` for the live view, every pause view and every trigger capture until a whole ring of fresh data had gone by -- the reported freeze, invisible until the ring first wraps, hence its intermittence. It now restarts with `clear_to(base)`, and `ChannelBuffer::republish_from(base)` puts the published window back: `acquire()` is incremental, so a rewrite in place published nothing and the pyramid went on describing the samples the edit replaced. The trigger's channel is an index, re-resolved by name after a renumbering -- but only in `rebuild_enabled_locked()`, so a math add or delete went through `set_math_channels()` with no restore, the index was clamped to -1, and `process()` returned at once while the panel still named the channel. The restore moves into `rebuild_channels()`, which *is* the renumbering, so no caller can forget it. And `update_measures()` suppressed its "the window moved" test while an event was held -- the one mode where a gesture is the only thing that can move it. Zooming a latched event left the pane describing the whole captured event. A pinned window needs no exemption: it lands on the range it already measured and fails the 2 % test by itself. The checks that missed all three: `UDPSCOPE_PROBE_MATHWRAP_MS` edits a graph whose ring has already wrapped, which `math_probe` structurally cannot reach; `[measure-trace]` now prints the mode, the window on screen and the window the numbers describe; and the dump's `marks_in_window` counts against the window on screen instead of against the one the marks were computed over, where it was a tautology. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
766 lines
40 KiB
C++
766 lines
40 KiB
C++
// 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 <gtk/gtk.h>
|
|
|
|
#include <cstdio>
|
|
#include <string>
|
|
|
|
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<std::mutex> lock(app.model->mutex());
|
|
const std::vector<SignalInfo>& signals = app.model->signals();
|
|
const std::vector<ChannelInfo>& 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<int>(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<std::size_t>(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<std::mutex> 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<std::size_t>(
|
|
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<MeasureDialog*>(user));
|
|
}
|
|
|
|
void on_measure_dialog_apply(GtkButton*, gpointer user) {
|
|
MeasureDialog* d = static_cast<MeasureDialog*>(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<std::size_t>(d->edit) < app.measures.size()) {
|
|
app.measures[static_cast<std::size_t>(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<MeasureDialog*>(user);
|
|
App& app = *d->app;
|
|
if (d->edit >= 0 && static_cast<std::size_t>(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 visible window and writes the results into the pane,
|
|
/// collecting the marks the ones asking to be shown need.
|
|
///
|
|
/// A measurement is a statistic of the 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);
|
|
const plot::Range swin{view.min + s0, view.max + s0};
|
|
app.measure_view_window = swin;
|
|
const bool held = app.model->display_mode() == DisplayMode::Held;
|
|
// "It moved" is asked in **every** mode, a held event included. This test used to carry a
|
|
// `&& !held`, and a held event is precisely the mode where a *gesture* is the only thing that
|
|
// can move the window: with it, zooming a latched event forty times over left the pane
|
|
// describing the whole 2 s event (`for=[0.47..2.47]` against a `swin=[1.417..1.467]`, `passes`
|
|
// frozen), which is what "the measurements move when zooming or panning" was -- the numbers and
|
|
// the time marks belonged to a window the plot had left. The term was aimed at a *pinned*
|
|
// window recomputing every frame, and nothing is needed for that: a settled window satisfies
|
|
// `swin == measure_window` exactly and so reads `moved == false` on its own. A pause is the
|
|
// standing proof, since `display_mode()` reports `Paused` rather than `Held` for one and the
|
|
// statistics probe already requires `n` to advance by at most one across a two-second pause.
|
|
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<std::mutex> 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<RowSet::MeasureMark> 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<int> chan(app.measures.size(), -1);
|
|
std::vector<int> want_window(app.measures.size(), -1);
|
|
std::vector<int> 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<int> 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<int>(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<int>(k);
|
|
} else {
|
|
want_level2[i] = static_cast<int>(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<MeasureBucket>& 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<std::size_t>(w.channel));
|
|
const std::size_t n = reader.size();
|
|
const std::function<double(std::size_t)> 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<std::size_t>(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<std::uint64_t>(n);
|
|
app.measure_bucketed += static_cast<std::uint64_t>(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<std::size_t>(want_window[i]) < app.measure_samples.size()
|
|
? &app.measure_samples[static_cast<std::size_t>(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<std::size_t>(want[j]) < w.level.size()) {
|
|
levels[j] = &w.level[static_cast<std::size_t>(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<App*>(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.
|
|
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) + "] 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<MeasureDialog*>(
|
|
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<int>(count);
|
|
}
|
|
}
|
|
int drop_h = -1;
|
|
char options[192] = "";
|
|
if (a.measure_dialog != nullptr) {
|
|
auto* dd = static_cast<MeasureDialog*>(
|
|
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<int>(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
|