feat: the buffer can be searched for the trigger's own condition, and the hits stepped through

This commit is contained in:
Martino Ferrari committed 2026-10-08 09:56:47 +02:00
1 parent 2cee85c52b
commit 415fceca87
4 files changed
+517

No files matched your search

+264
View File
@@ -25,6 +25,7 @@
#include "measure.hpp"
#include "model.hpp"
#include "plot_host.hpp"
#include "search.hpp"
#include <adwaita.h>
#include <gtk/gtk.h>
@@ -157,6 +158,15 @@ struct App {
/// the stream's own clock advances whether the view is frozen or not and would say nothing
/// about how long the user has been looking at a still picture.
gint64 pause_started_us = 0;
/// The buffer search. Driven a few milliseconds at a time from the frame tick, because its
/// samples live behind the model's mutex and the producer needs it back between chunks.
BufferSearch search;
GtkWidget* search_run_button = nullptr;
GtkWidget* search_cancel_button = nullptr;
GtkWidget* search_status_label = nullptr;
GtkWidget* search_pos_label = nullptr;
GtkWidget* search_prev_button = nullptr;
GtkWidget* search_next_button = nullptr;
GtkWidget* menu_button = nullptr;
GtkWidget* cursors_toggle = nullptr;
/// Where the panel controls ended up, decided once at build time: the dump runs after the window
@@ -2341,6 +2351,76 @@ void on_pause_toggled(GtkToggleButton* button, gpointer user) {
: "pause acquisition");
}
/// Freezes the view on a hit, centred, with the hit itself at x = 0.
///
/// This is the existing pause, not a fifth display mode: the toggle has to end up on, or the
/// next frame's `clamp_to_data()` would follow the live edge and walk the frozen window off
/// the hit entirely.
void search_jump(App& app, double t) {
const double w = app.rows->time_window();
if (app.pause_button != nullptr &&
!gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.pause_button))) {
// Its handler publishes the *current* window; the publish below replaces it.
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.pause_button), TRUE);
}
app.model->publish_pause_view(t - w / 2.0, t + w / 2.0, /*x_offset=*/t);
app.pause_started_us = g_get_monotonic_time();
// Pinned from the snapshot's *own* stamps, never from the window asked for: a cut is
// clamped to what the ring holds, so a hit near the oldest sample yields a shorter
// snapshot, and pinning the request would leave an empty margin that a zoom moves into.
if (std::shared_ptr<const EventSnapshot> snap = app.model->pause_view()) {
if (!snap->stamps.empty()) {
app.rows->pin_pause_window(static_cast<double>(snap->stamps.front()),
static_cast<double>(snap->stamps.back()));
}
}
}
void update_search_panel(App& app) {
if (app.search_status_label == nullptr) return;
const BufferSearch& s = app.search;
const std::size_t n = s.hits().size();
char text[192];
switch (s.state()) {
case BufferSearch::State::Idle:
std::snprintf(text, sizeof(text), "not searched");
break;
case BufferSearch::State::Running:
std::snprintf(text, sizeof(text), "scanning %s of %s, %zu found",
format_window(s.scanned_s()).c_str(), format_window(s.total_s()).c_str(), n);
break;
case BufferSearch::State::Done:
std::snprintf(text, sizeof(text), "%zu found in %s", n, format_window(s.total_s()).c_str());
break;
case BufferSearch::State::Truncated:
std::snprintf(text, sizeof(text), "%zu found; the stream overtook the oldest samples", n);
break;
case BufferSearch::State::Capped:
std::snprintf(text, sizeof(text), "%zu found (the limit) in the newest %s", n,
format_window(s.scanned_s()).c_str());
break;
case BufferSearch::State::Cancelled:
std::snprintf(text, sizeof(text), "stopped after %zu", n);
break;
}
gtk_label_set_text(GTK_LABEL(app.search_status_label), text);
char pos[64];
if (s.cursor() < 0 || n == 0) {
std::snprintf(pos, sizeof(pos), "--");
} else {
std::snprintf(pos, sizeof(pos), "%d of %zu", s.cursor() + 1, n);
}
gtk_label_set_text(GTK_LABEL(app.search_pos_label), pos);
const bool running = s.state() == BufferSearch::State::Running;
gtk_widget_set_sensitive(app.search_run_button, !running);
gtk_widget_set_sensitive(app.search_cancel_button, running);
gtk_widget_set_sensitive(app.search_prev_button, s.cursor() > 0);
gtk_widget_set_sensitive(app.search_next_button,
n > 0 && s.cursor() + 1 < static_cast<int>(n));
}
void on_trigger_toggled(GtkToggleButton* button, gpointer user) {
App* app = static_cast<App*>(user);
const bool on = gtk_toggle_button_get_active(button);
@@ -6014,6 +6094,15 @@ gboolean tick(gpointer user) {
app->dbg_measures_pane_w = gtk_widget_get_width(app->measures_list);
app->dbg_measures_pane_h = gtk_widget_get_height(app->measures_list);
}
// A few milliseconds of scanning per frame: enough that a full buffer finishes in a
// handful of seconds, little enough that neither the producer (which waits on the mutex
// for one chunk) nor the frame budget notices.
if (app->search.state() == BufferSearch::State::Running) {
const gint64 budget_end_us = g_get_monotonic_time() + 5000;
while (app->search.pump(*app->model) && g_get_monotonic_time() < budget_end_us) {
}
update_search_panel(*app);
}
if (model.generation() != app->signal_generation) {
// A CONFIG re-describes the stream and drops the buffer, so nothing measured before it
// belongs in the same series as what comes after.
@@ -6852,6 +6941,94 @@ GtkWidget* build_trigger_panel(App& app) {
G_CALLBACK(on_trigger_drop_changed), &app);
g_signal_connect(app.trigger_mode_drop, "notify::selected",
G_CALLBACK(on_trigger_drop_changed), &app);
// Searching *is* the trigger's condition applied to history, so it belongs in this panel
// rather than in one of its own: a second place to spell the same condition would be a
// second place to get it wrong. Collapsed by default -- it is not part of running a trigger.
GtkWidget* search_exp = gtk_expander_new("Search the buffer");
gtk_widget_set_tooltip_text(search_exp,
"find every place in the retained buffer where the condition "
"above holds, and step through them");
GtkWidget* sbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
gtk_widget_set_margin_top(sbox, 4);
GtkWidget* srun = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
app.search_run_button = gtk_button_new_with_label("Search");
gtk_widget_set_hexpand(app.search_run_button, true);
g_signal_connect(app.search_run_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
App* a = static_cast<App*>(user);
// Published, not read back off the widgets: the producer's copy is the
// one the live trigger uses, so the search asks exactly its question.
publish_trigger_fields(a);
EventCondition c;
int channel = -1;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
c = a->model->trigger().condition;
channel = a->model->trigger().channel;
}
a->search.start(c, channel);
update_search_panel(*a);
}),
&app);
gtk_box_append(GTK_BOX(srun), app.search_run_button);
app.search_cancel_button = gtk_button_new_with_label("Stop");
gtk_widget_set_sensitive(app.search_cancel_button, false);
g_signal_connect(app.search_cancel_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
App* a = static_cast<App*>(user);
a->search.cancel();
update_search_panel(*a);
}),
&app);
gtk_box_append(GTK_BOX(srun), app.search_cancel_button);
gtk_box_append(GTK_BOX(sbox), srun);
app.search_status_label = gtk_label_new("not searched");
gtk_widget_set_halign(app.search_status_label, GTK_ALIGN_START);
gtk_label_set_wrap(GTK_LABEL(app.search_status_label), true);
gtk_widget_add_css_class(app.search_status_label, "dim-label");
gtk_box_append(GTK_BOX(sbox), app.search_status_label);
GtkWidget* snav = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
app.search_prev_button = gtk_button_new_from_icon_name("go-previous-symbolic");
gtk_widget_set_tooltip_text(app.search_prev_button, "the previous hit");
gtk_widget_set_sensitive(app.search_prev_button, false);
g_signal_connect(app.search_prev_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
App* a = static_cast<App*>(user);
if (a->search.prev()) search_jump(*a, a->search.hits()[static_cast<std::size_t>(
a->search.cursor())]);
update_search_panel(*a);
}),
&app);
gtk_box_append(GTK_BOX(snav), app.search_prev_button);
app.search_pos_label = gtk_label_new("--");
gtk_widget_set_hexpand(app.search_pos_label, true);
gtk_box_append(GTK_BOX(snav), app.search_pos_label);
app.search_next_button = gtk_button_new_from_icon_name("go-next-symbolic");
gtk_widget_set_tooltip_text(app.search_next_button, "the next hit");
gtk_widget_set_sensitive(app.search_next_button, false);
g_signal_connect(app.search_next_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
App* a = static_cast<App*>(user);
// The first press with no cursor goes to the first hit, not the second.
if (a->search.cursor() < 0) {
a->search.select(0);
} else if (!a->search.next()) {
update_search_panel(*a);
return;
}
search_jump(*a, a->search.hits()[static_cast<std::size_t>(
a->search.cursor())]);
update_search_panel(*a);
}),
&app);
gtk_box_append(GTK_BOX(snav), app.search_next_button);
gtk_box_append(GTK_BOX(sbox), snav);
gtk_expander_set_child(GTK_EXPANDER(search_exp), sbox);
gtk_box_append(GTK_BOX(box), search_exp);
g_signal_connect(app.trigger_type_drop, "notify::selected",
G_CALLBACK(on_trigger_shape_changed), &app);
g_signal_connect(app.trigger_cmp_drop, "notify::selected",
@@ -8830,6 +9007,90 @@ gboolean condition_probe(gpointer user) {
return G_SOURCE_REMOVE;
}
/// Headless self-check for the buffer search, and for the claim that makes it worth having:
/// a scanned hit is an instant the *live trigger* would have fired on.
///
/// It arms the same pulse-width condition `condition_probe` arms, waits for a real latch,
/// then searches the buffer for that condition and compares the latched t0 with the nearest
/// scanned hit. `agree=1` is the assertion; `agree=0` means the two evaluators have drifted
/// apart, which is the whole failure mode separating `EventDetector` out was meant to
/// prevent. It then steps to a hit and reports the pinned axis against it.
int g_search_phase = 0;
double g_search_t0 = 0.0;
gboolean search_probe_report(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const BufferSearch& s = a->search;
if (s.state() == BufferSearch::State::Running) return G_SOURCE_CONTINUE;
double nearest = 0.0;
double err = 0.0;
bool have = false;
for (const double h : s.hits()) {
const double d = std::fabs(h - g_search_t0);
if (!have || d < std::fabs(err)) {
have = true;
nearest = h;
err = h - g_search_t0;
}
}
// One stored sample period is the tolerance: the trigger saw the stored (filtered) stream
// and so does the scan, so they must agree to the sample, not merely to the neighbourhood.
const double tol = a->model->partition().stored_rate() > 0.0
? 1.0 / a->model->partition().stored_rate()
: 1.0e-3;
std::fprintf(stderr,
"[search-probe] state=%d hits=%zu scanned=%.4f total=%.4f chunks=%" G_GUINT64_FORMAT
" worst=%.3fms total=%.3fms armed t0=%.6f vs scanned first=%.6f err=%.6g agree=%d\n",
int(s.state()), s.hits().size(), s.scanned_s(), s.total_s(), s.chunks(),
s.worst_chunk_ms(), s.total_ms(), g_search_t0, have ? nearest : 0.0, err,
int(have && std::fabs(err) <= tol));
if (!s.hits().empty()) {
a->search.select(0);
search_jump(*a, a->search.hits()[0]);
update_search_panel(*a);
const plot::Range view = a->rows->view_range();
std::fprintf(stderr,
"[search-probe] jumped to hit 0 t=%.6f mode=%d axis=[%.4f..%.4f] pos=%d\n",
a->search.hits()[0], int(a->model->display_mode()), view.min, view.max,
a->search.cursor() + 1);
}
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean search_probe_run(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
g_search_t0 = a->model->trigger_time();
EventCondition c;
int channel = -1;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
c = a->model->trigger().condition;
channel = a->model->trigger().channel;
}
a->search.start(c, channel);
std::fprintf(stderr, "[search-probe] started on channel %d, armed t0=%.6f\n", channel,
g_search_t0);
std::fflush(stderr);
g_timeout_add(100, search_probe_report, a);
return G_SOURCE_REMOVE;
}
gboolean search_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
// Reuse the condition probe's arming entirely: the point is that one condition gives two
// evaluators the same answer, so the condition must be set in exactly one place.
g_search_phase = 0;
condition_probe(a);
g_timeout_add(6000, search_probe_run, a);
return G_SOURCE_REMOVE;
}
/// Headless self-check: drives the window control the four ways a user can -- type a number,
/// change the unit under it, and step a decade either way -- and prints what each produced.
/// Each line reports the typed request, the window the plot took and the trigger's event
@@ -10212,6 +10473,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_STATS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), stats_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_SEARCH_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), search_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_FIELDS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), fields_probe, &app);
}