From d147678b1c946991840b1b47132a1878661fd418 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Wed, 7 Oct 2026 17:32:11 +0200 Subject: [PATCH] feat: a pulse whose width is wrong can be the trigger's event, so a glitch can be caught --- src/condition.cpp | 52 +++++++++++++ src/condition.hpp | 8 ++ src/ui.cpp | 186 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 245 insertions(+), 1 deletion(-) diff --git a/src/condition.cpp b/src/condition.cpp index a4f4ac7..ecbd9bf 100644 --- a/src/condition.cpp +++ b/src/condition.cpp @@ -14,6 +14,9 @@ void EventDetector::configure(const EventCondition& c) noexcept { void EventDetector::reset() noexcept { have_prev_ = false; prev_ = 0.0; + in_pulse_ = false; + pulse_up_ = false; + pulse_start_s_ = 0.0; } double EventDetector::feed(double value, double t) noexcept { @@ -28,6 +31,8 @@ double EventDetector::feed(double value, double t) noexcept { event = feed_edge(value, t); break; case TriggerType::PulseWidth: + event = feed_pulse(value, t); + break; case TriggerType::Window: case TriggerType::Dropout: break; // implemented in the tasks that add their panel rows @@ -46,4 +51,51 @@ double EventDetector::feed_edge(double value, double t) noexcept { return fired ? t : kNoEvent; } +bool EventDetector::qualifies(double width_s) const noexcept { + const double a = cond_.width_s < cond_.width2_s ? cond_.width_s : cond_.width2_s; + const double b = cond_.width_s < cond_.width2_s ? cond_.width2_s : cond_.width_s; + switch (cond_.cmp) { + case WidthCmp::Less: + return width_s < cond_.width_s; + case WidthCmp::Greater: + return width_s > cond_.width_s; + case WidthCmp::Within: + return width_s >= a && width_s <= b; + case WidthCmp::Outside: + return width_s < a || width_s > b; + } + return false; +} + +double EventDetector::feed_pulse(double value, double t) noexcept { + const bool up = crossed_up(value); + const bool down = crossed_down(value); + if (!in_pulse_) { + // Which crossing opens a pulse is what `edge` selects: Rising measures the time spent + // above the level, Falling the time below, Both every excursion either way. + const bool opens = (cond_.edge == TriggerEdge::Rising && up) || + (cond_.edge == TriggerEdge::Falling && down) || + (cond_.edge == TriggerEdge::Both && (up || down)); + if (opens) { + in_pulse_ = true; + pulse_up_ = up; + pulse_start_s_ = t; + } + return kNoEvent; + } + const bool closes = pulse_up_ ? down : up; + if (!closes) return kNoEvent; + const double width = t - pulse_start_s_; + if (cond_.edge == TriggerEdge::Both) { + // The closing edge of one excursion is the opening edge of the next, so a square wave + // measures *every* half-period. Dropping the state here instead would measure every + // other one, and the hits would come out half as dense as the waveform. + pulse_up_ = !pulse_up_; + pulse_start_s_ = t; + } else { + in_pulse_ = false; + } + return qualifies(width) ? t : kNoEvent; +} + } // namespace udpscope diff --git a/src/condition.hpp b/src/condition.hpp index 34d4831..318ae37 100644 --- a/src/condition.hpp +++ b/src/condition.hpp @@ -82,10 +82,18 @@ class EventDetector { return prev_ > cond_.level && v <= cond_.level; } double feed_edge(double value, double t) noexcept; + double feed_pulse(double value, double t) noexcept; + /// Whether a measured width satisfies `cond_.cmp`. `Within`/`Outside` take their two + /// bounds in either order, so a panel cannot make an empty band by typing the larger + /// figure into the first spin. + bool qualifies(double width_s) const noexcept; EventCondition cond_{}; bool have_prev_ = false; double prev_ = 0.0; + bool in_pulse_ = false; ///< PulseWidth: an opening edge has been seen. + bool pulse_up_ = false; ///< PulseWidth: its polarity, so we know which edge closes it. + double pulse_start_s_ = 0.0; }; } // namespace udpscope diff --git a/src/ui.cpp b/src/ui.cpp index 07ec92a..29708b4 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -131,6 +131,16 @@ struct App { GtkWidget* sidebar_toggle = nullptr; GtkWidget* side_scroll = nullptr; GtkWidget* trigger_edge_drop = nullptr; + GtkWidget* trigger_type_drop = nullptr; + GtkWidget* trigger_cmp_drop = nullptr; + GtkWidget* trigger_width_spin = nullptr; + GtkWidget* trigger_width2_spin = nullptr; + /// The `labeled_box` rows a condition may not use: held so `sync_trigger_rows()` can + /// hide them. A hidden row keeps its widget's value, so switching condition and back + /// does not lose what was typed. + GtkWidget* trigger_cmp_row = nullptr; + GtkWidget* trigger_width_row = nullptr; + GtkWidget* trigger_width2_row = nullptr; GtkWidget* trigger_mode_drop = nullptr; GtkWidget* trigger_level_spin = nullptr; GtkWidget* trigger_pre_spin = nullptr; @@ -1596,6 +1606,32 @@ void on_arm_clicked(GtkButton* button, gpointer user) { gtk_button_set_label(GTK_BUTTON(app->arm_button), armed ? "Disarm" : "Arm"); } +/// The trigger type dropdown's order, as a table rather than a cast: the dropdown lists +/// only the conditions that are implemented, so its index is not `TriggerType`'s value. +TriggerType trigger_type_at(guint i) { + switch (i) { + case 1: + return TriggerType::PulseWidth; + default: + return TriggerType::Edge; + } +} + +/// The width-comparison dropdown's order. `<` first, because that is the glitch trigger +/// and the reason this condition exists. +WidthCmp width_cmp_at(guint i) { + switch (i) { + case 0: + return WidthCmp::Less; + case 2: + return WidthCmp::Within; + case 3: + return WidthCmp::Outside; + default: + return WidthCmp::Greater; + } +} + void publish_trigger_fields(App* app) { if (app->updating_panels) return; // Read the widgets first (they may be touched again by a re-entrant handler if a @@ -1606,6 +1642,10 @@ void publish_trigger_fields(App* app) { const double holdoff = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_holdoff_spin)); const guint edge = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_edge_drop)); const guint mode = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_mode_drop)); + const guint type = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_type_drop)); + const guint cmp = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_cmp_drop)); + const double width = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_width_spin)); + const double width2 = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_width2_spin)); { std::lock_guard lock(app->model->mutex()); TriggerConfig& tc = app->model->trigger(); @@ -1616,9 +1656,29 @@ void publish_trigger_fields(App* app) { : edge == 1 ? TriggerEdge::Falling : TriggerEdge::Both; tc.mode = mode == 0 ? TriggerMode::Normal : TriggerMode::Single; + tc.condition.type = trigger_type_at(type); + tc.condition.cmp = width_cmp_at(cmp); + tc.condition.width_s = width; + tc.condition.width2_s = width2; } } +/// Shows only the rows the selected condition reads, so the panel never offers a figure the +/// condition ignores. The widgets keep their values while hidden: `EventCondition` carries +/// all eight fields whatever its type, and switching away and back must not lose a setting. +void sync_trigger_rows(App& app) { + if (app.trigger_type_drop == nullptr) return; + const TriggerType type = + trigger_type_at(gtk_drop_down_get_selected(GTK_DROP_DOWN(app.trigger_type_drop))); + const WidthCmp cmp = + width_cmp_at(gtk_drop_down_get_selected(GTK_DROP_DOWN(app.trigger_cmp_drop))); + const bool pulse = type == TriggerType::PulseWidth; + gtk_widget_set_visible(app.trigger_cmp_row, pulse); + gtk_widget_set_visible(app.trigger_width_row, pulse); + gtk_widget_set_visible(app.trigger_width2_row, + pulse && (cmp == WidthCmp::Within || cmp == WidthCmp::Outside)); +} + void on_trigger_field_changed(GtkWidget*, gpointer user) { publish_trigger_fields(static_cast(user)); } @@ -1631,6 +1691,14 @@ void on_trigger_drop_changed(GObject*, GParamSpec*, gpointer user) { publish_trigger_fields(static_cast(user)); } +/// The two dropdowns that change *which rows exist*, as opposed to a value within them. +/// Same `notify::selected` signature trap as `on_trigger_drop_changed`. +void on_trigger_shape_changed(GObject*, GParamSpec*, gpointer user) { + App* app = static_cast(user); + publish_trigger_fields(app); + sync_trigger_rows(*app); +} + void on_trigger_signal_changed(GObject* obj, GParamSpec*, gpointer user) { App* app = static_cast(user); if (app->updating_panels) return; @@ -6546,10 +6614,35 @@ GtkWidget* build_trigger_panel(App& app) { G_CALLBACK(on_trigger_signal_changed), &app); gtk_box_append(GTK_BOX(box), labeled_box("signal", app.trigger_signal_drop)); + const char* types[] = {"edge", "pulse width", nullptr}; + app.trigger_type_drop = gtk_drop_down_new_from_strings(types); + gtk_widget_set_tooltip_text(app.trigger_type_drop, + "what counts as an event: a level crossing, or a pulse whose " + "width satisfies the comparison below"); + gtk_box_append(GTK_BOX(box), labeled_box("condition", app.trigger_type_drop)); + const char* edges[] = {"rising", "falling", "both", nullptr}; app.trigger_edge_drop = gtk_drop_down_new_from_strings(edges); gtk_box_append(GTK_BOX(box), labeled_box("edge", app.trigger_edge_drop)); + const char* cmps[] = {"<", ">", "within", "outside", nullptr}; + app.trigger_cmp_drop = gtk_drop_down_new_from_strings(cmps); + gtk_widget_set_tooltip_text(app.trigger_cmp_drop, + "how the measured pulse width is compared: '<' is the glitch " + "trigger -- a pulse shorter than it should be"); + app.trigger_cmp_row = labeled_box("width is", app.trigger_cmp_drop); + gtk_box_append(GTK_BOX(box), app.trigger_cmp_row); + + app.trigger_width_spin = spin(0.0, 3600.0, 0.001, 6); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_width_spin), 0.001); + app.trigger_width_row = labeled_box("width s", app.trigger_width_spin); + gtk_box_append(GTK_BOX(box), app.trigger_width_row); + + app.trigger_width2_spin = spin(0.0, 3600.0, 0.001, 6); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_width2_spin), 0.002); + app.trigger_width2_row = labeled_box("and s", app.trigger_width2_spin); + gtk_box_append(GTK_BOX(box), app.trigger_width2_row); + app.trigger_level_spin = spin(-1.0e12, 1.0e12, 0.01, 4); g_signal_connect(app.trigger_level_spin, "value-changed", G_CALLBACK(+[](GtkWidget*, gpointer u) { static_cast(u)->trigger_level_touched = true; @@ -6580,13 +6673,19 @@ GtkWidget* build_trigger_panel(App& app) { g_signal_connect(live, "clicked", G_CALLBACK(on_go_live), &app); gtk_box_append(GTK_BOX(box), live); - for (GtkWidget* w : {app.trigger_level_spin, app.trigger_pre_spin, app.trigger_holdoff_spin}) { + for (GtkWidget* w : {app.trigger_level_spin, app.trigger_pre_spin, app.trigger_holdoff_spin, + app.trigger_width_spin, app.trigger_width2_spin}) { g_signal_connect(w, "value-changed", G_CALLBACK(on_trigger_field_changed), &app); } g_signal_connect(app.trigger_edge_drop, "notify::selected", G_CALLBACK(on_trigger_drop_changed), &app); g_signal_connect(app.trigger_mode_drop, "notify::selected", G_CALLBACK(on_trigger_drop_changed), &app); + 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", + G_CALLBACK(on_trigger_shape_changed), &app); + sync_trigger_rows(app); return box; } @@ -8302,6 +8401,88 @@ gboolean fields_probe(gpointer user) { return G_SOURCE_REMOVE; } +/// Headless self-check for the qualified conditions: arms a pulse-width condition on the +/// mock's `square`, whose narrow pulse the mock injects once per square period, and reports +/// the latched t0 against where the pulse's closing edge must be. +/// +/// The assertion is the *phase* rather than an absolute t0: the probe arms at whatever stream +/// time the app happened to reach, so which glitch it catches is not fixed, but every glitch +/// closes at `--glitch-at + --glitch-width + k * (2 / --freq)`. A `phase` of a few sample +/// periods is the pulse being found exactly; a phase near half the square period means the +/// condition fired on an ordinary edge instead. +struct ConditionProbe { + double glitch_at = 0.25; + double glitch_width = 0.002; + double square_period = 0.4; + int waited = 0; +}; +ConditionProbe g_condition; + +gboolean condition_probe_report(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + const double t0 = a->model->trigger_time(); + if (!std::isfinite(t0) || t0 <= 0.0) { + if (++g_condition.waited < 200) return G_SOURCE_CONTINUE; + std::fprintf(stderr, "[condition-probe] no latch after %d polls\n", g_condition.waited); + std::fflush(stderr); + return G_SOURCE_REMOVE; + } + const double want = g_condition.glitch_at + g_condition.glitch_width; + double phase = std::fmod(t0 - want, g_condition.square_period); + if (phase < 0.0) phase += g_condition.square_period; + if (phase > 0.5 * g_condition.square_period) phase -= g_condition.square_period; + std::fprintf(stderr, "[condition-probe] type=%d cmp=%d width=%.6f t0=%.6f phase=%.6f mode=%d\n", + int(a->model->trigger().condition.type), int(a->model->trigger().condition.cmp), + a->model->trigger().condition.width_s, t0, phase, int(a->model->display_mode())); + std::fflush(stderr); + return G_SOURCE_REMOVE; +} + +gboolean condition_probe_arm(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + // A window short enough that the pre-trigger share is already behind the glitch. + apply_time_window(*a, 0.2); + const std::vector& channels = a->model->channels(); + int sel = -1; + for (std::size_t i = 0; i < channels.size(); ++i) { + if (channels[i].name == "square") sel = static_cast(i); + } + std::fprintf(stderr, "[condition-probe] square=%d of %zu channels\n", sel, channels.size()); + if (sel >= 0) { + gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_signal_drop), + static_cast(sel)); + } + gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_type_drop), 1); // pulse width + gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_cmp_drop), 0); // < + gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_edge_drop), 0); // rising + gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_width_spin), 0.010); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_level_spin), 0.0); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_pre_spin), 10.0); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_holdoff_spin), 0.0); + publish_trigger_fields(a); + if (a->trigger_toggle != nullptr) + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE); + g_condition.waited = 0; + g_timeout_add(25, condition_probe_report, a); + return G_SOURCE_REMOVE; +} + +gboolean condition_probe(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + // `square` is not acquired until it is enabled, and the trigger can only name a channel + // that is: enabling is what starts acquisition. + const std::vector& signals = a->model->signals(); + for (std::size_t i = 0; i < signals.size(); ++i) { + if (signals[i].name == "square") a->model->set_signal_enabled(i, true); + } + // One beat for the channel list to be rebuilt and the ring to refill behind the glitch. + g_timeout_add(1500, condition_probe_arm, 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 @@ -9678,6 +9859,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit if (const char* env = g_getenv("UDPSCOPE_PROBE_TRIGRENUM_MS")) { g_timeout_add(static_cast(std::atoi(env)), trigrenum_probe, &app); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_CONDITION_MS")) { + g_timeout_add(static_cast(std::atoi(env)), condition_probe, &app); + } if (const char* env = g_getenv("UDPSCOPE_PROBE_FIELDS_MS")) { g_timeout_add(static_cast(std::atoi(env)), fields_probe, &app); }