From a26770ec323429e54feb9b0eedf56ae22a48fbd0 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Wed, 7 Oct 2026 18:01:07 +0200 Subject: [PATCH] feat: a level band and a signal that stops can be the trigger's event too Co-Authored-By: Claude Opus 4.6 --- src/condition.cpp | 51 ++++++++++++- src/condition.hpp | 5 ++ src/ui.cpp | 183 ++++++++++++++++++++++++++++++++++++---------- 3 files changed, 198 insertions(+), 41 deletions(-) diff --git a/src/condition.cpp b/src/condition.cpp index ecbd9bf..f02a403 100644 --- a/src/condition.cpp +++ b/src/condition.cpp @@ -17,12 +17,23 @@ void EventDetector::reset() noexcept { in_pulse_ = false; pulse_up_ = false; pulse_start_s_ = 0.0; + inside_ = false; + last_cross_s_ = 0.0; + dropped_ = false; } double EventDetector::feed(double value, double t) noexcept { if (!have_prev_) { prev_ = value; have_prev_ = true; + // The window condition needs a side to change *from*, and the dropout a last-seen time + // to count from; neither can be derived from a crossing that has not happened. Seeding + // them here is what lets both handlers run without a "have we started" guard. + const double lo = cond_.level < cond_.level2 ? cond_.level : cond_.level2; + const double hi = cond_.level < cond_.level2 ? cond_.level2 : cond_.level; + inside_ = value >= lo && value <= hi; + last_cross_s_ = t; + dropped_ = false; return kNoEvent; } double event = kNoEvent; @@ -34,8 +45,11 @@ double EventDetector::feed(double value, double t) noexcept { event = feed_pulse(value, t); break; case TriggerType::Window: + event = feed_window(value, t); + break; case TriggerType::Dropout: - break; // implemented in the tasks that add their panel rows + event = feed_dropout(value, t); + break; } // After the handler: every crossing test above reads `prev_` as the previous sample. prev_ = value; @@ -98,4 +112,39 @@ double EventDetector::feed_pulse(double value, double t) noexcept { return qualifies(width) ? t : kNoEvent; } +double EventDetector::feed_window(double value, double t) noexcept { + const double lo = cond_.level < cond_.level2 ? cond_.level : cond_.level2; + const double hi = cond_.level < cond_.level2 ? cond_.level2 : cond_.level; + const bool inside = value >= lo && value <= hi; + if (inside == inside_) return kNoEvent; + inside_ = inside; + // The event is the *change of side*, so `Rising` reads as "into the band" and `Falling` + // as "out of it". A band of zero width therefore never fires at all, because no sample + // can be on a different side of it than its neighbour: the panel refuses one. + const bool fired = (cond_.edge == TriggerEdge::Rising && inside) || + (cond_.edge == TriggerEdge::Falling && !inside) || + cond_.edge == TriggerEdge::Both; + return fired ? t : kNoEvent; +} + +double EventDetector::feed_dropout(double value, double t) noexcept { + const bool up = crossed_up(value); + const bool down = crossed_down(value); + const bool crossed = (cond_.edge == TriggerEdge::Rising && up) || + (cond_.edge == TriggerEdge::Falling && down) || + (cond_.edge == TriggerEdge::Both && (up || down)); + if (crossed) { + last_cross_s_ = t; + dropped_ = false; + return kNoEvent; + } + // One event per silence, not one per sample of it: without the latch a dead signal would + // report an event on every sample after the timeout and fill the search's hit list with + // one answer repeated. + if (dropped_) return kNoEvent; + if (t - last_cross_s_ <= cond_.timeout_s) return kNoEvent; + dropped_ = true; + return t; +} + } // namespace udpscope diff --git a/src/condition.hpp b/src/condition.hpp index 318ae37..768054b 100644 --- a/src/condition.hpp +++ b/src/condition.hpp @@ -83,6 +83,8 @@ class EventDetector { } double feed_edge(double value, double t) noexcept; double feed_pulse(double value, double t) noexcept; + double feed_window(double value, double t) noexcept; + double feed_dropout(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. @@ -94,6 +96,9 @@ class EventDetector { 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; + bool inside_ = false; ///< Window: which side of the band the previous sample was. + double last_cross_s_ = 0.0; ///< Dropout: when the signal was last seen crossing. + bool dropped_ = false; ///< Dropout: the timeout has already been reported. }; } // namespace udpscope diff --git a/src/ui.cpp b/src/ui.cpp index e3a88b1..3e7aa1e 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -135,12 +135,16 @@ struct App { GtkWidget* trigger_cmp_drop = nullptr; GtkWidget* trigger_width_spin = nullptr; GtkWidget* trigger_width2_spin = nullptr; + GtkWidget* trigger_level2_spin = nullptr; + GtkWidget* trigger_timeout_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_level2_row = nullptr; + GtkWidget* trigger_timeout_row = nullptr; GtkWidget* trigger_mode_drop = nullptr; GtkWidget* trigger_level_spin = nullptr; GtkWidget* trigger_pre_spin = nullptr; @@ -1612,6 +1616,10 @@ TriggerType trigger_type_at(guint i) { switch (i) { case 1: return TriggerType::PulseWidth; + case 2: + return TriggerType::Window; + case 3: + return TriggerType::Dropout; default: return TriggerType::Edge; } @@ -1646,6 +1654,8 @@ void publish_trigger_fields(App* app) { 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)); + const double level2 = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_level2_spin)); + const double timeout = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_timeout_spin)); { std::lock_guard lock(app->model->mutex()); TriggerConfig& tc = app->model->trigger(); @@ -1660,6 +1670,8 @@ void publish_trigger_fields(App* app) { tc.condition.cmp = width_cmp_at(cmp); tc.condition.width_s = width; tc.condition.width2_s = width2; + tc.condition.level2 = level2; + tc.condition.timeout_s = timeout; } } @@ -1673,10 +1685,27 @@ void sync_trigger_rows(App& app) { const WidthCmp cmp = width_cmp_at(gtk_drop_down_get_selected(GTK_DROP_DOWN(app.trigger_cmp_drop))); const bool pulse = type == TriggerType::PulseWidth; + const bool window = type == TriggerType::Window; 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)); + gtk_widget_set_visible(app.trigger_level2_row, window); + gtk_widget_set_visible(app.trigger_timeout_row, type == TriggerType::Dropout); + // A band of zero width is not a degenerate edge trigger -- a window event is a change of + // *side*, so equal levels silently never fire, which looks exactly like a broken trigger. + // The panel is where that is refused: the second level is pushed at least one increment + // clear of the first, in whichever direction it already lay. + if (window) { + GtkSpinButton* l2 = GTK_SPIN_BUTTON(app.trigger_level2_spin); + const double level = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app.trigger_level_spin)); + double step = 0.0; + gtk_spin_button_get_increments(l2, &step, nullptr); + const double other = gtk_spin_button_get_value(l2); + if (std::fabs(other - level) < step) { + gtk_spin_button_set_value(l2, other < level ? level - step : level + step); + } + } } void on_trigger_field_changed(GtkWidget*, gpointer user) { @@ -6614,7 +6643,7 @@ 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}; + const char* types[] = {"edge", "pulse width", "window", "dropout", 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 " @@ -6643,6 +6672,19 @@ GtkWidget* build_trigger_panel(App& app) { app.trigger_width2_row = labeled_box("and s", app.trigger_width2_spin); gtk_box_append(GTK_BOX(box), app.trigger_width2_row); + app.trigger_level2_spin = spin(-1.0e12, 1.0e12, 0.01, 4); + gtk_widget_set_tooltip_text(app.trigger_level2_spin, + "the band's other edge; it is kept at least one increment away " + "from the level, because a band of zero width can never be " + "crossed"); + app.trigger_level2_row = labeled_box("to level", app.trigger_level2_spin); + gtk_box_append(GTK_BOX(box), app.trigger_level2_row); + + app.trigger_timeout_spin = spin(0.0, 3600.0, 0.001, 6); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_timeout_spin), 0.001); + app.trigger_timeout_row = labeled_box("timeout s", app.trigger_timeout_spin); + gtk_box_append(GTK_BOX(box), app.trigger_timeout_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; @@ -6674,7 +6716,8 @@ GtkWidget* build_trigger_panel(App& app) { gtk_box_append(GTK_BOX(box), live); for (GtkWidget* w : {app.trigger_level_spin, app.trigger_pre_spin, app.trigger_holdoff_spin, - app.trigger_width_spin, app.trigger_width2_spin}) { + app.trigger_width_spin, app.trigger_width2_spin, + app.trigger_level2_spin, app.trigger_timeout_spin}) { g_signal_connect(w, "value-changed", G_CALLBACK(on_trigger_field_changed), &app); } g_signal_connect(app.trigger_edge_drop, "notify::selected", @@ -6685,6 +6728,14 @@ GtkWidget* build_trigger_panel(App& app) { G_CALLBACK(on_trigger_shape_changed), &app); g_signal_connect(app.trigger_cmp_drop, "notify::selected", G_CALLBACK(on_trigger_shape_changed), &app); + // The clamp lives in `sync_trigger_rows()`, so the two level spins have to run it rather + // than the plain field publish. + for (GtkWidget* w : {app.trigger_level_spin, app.trigger_level2_spin}) { + g_signal_connect(w, "value-changed", G_CALLBACK(+[](GtkWidget*, gpointer u) { + sync_trigger_rows(*static_cast(u)); + }), + &app); + } sync_trigger_rows(app); return box; } @@ -8401,28 +8452,39 @@ 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 measured pulse width from the held event snapshot. +/// Headless self-check for the qualified conditions. Selected by +/// `UDPSCOPE_PROBE_CONDITION_SHAPE` (0 = pulse `<`, 1 = pulse `>`, 2 = window, 3 = dropout). /// /// The assertion is snapshot-relative rather than an absolute t0. The app's stream clock /// epoch is the first packet it received, not the mock's t = 0; the two clocks differ by /// however many packets the CONNECT handshake cost (typically a whole number of 100-sample / /// 10 ms packets), so comparing trigger_time() against a mock-clock target is meaningless. /// -/// A held event snapshot's stamps are edge-relative: stamps == 0 is the latching event, -/// which for a pulse-width condition is the pulse's *closing* edge. The pulse's opening -/// edge therefore sits at stamps ≈ -glitch_width. The probe scans backwards from x = 0 to -/// find the most recent upward crossing of level 0 (the same prev < level && value >= level -/// test the detector uses) and negates the crossing's stamp to get the measured pulse width. +/// Shape 0 (pulse `<`): x = 0 is the glitch's closing edge. Scans backwards for the most +/// recent upward crossing; `measured = -stamp` is the glitch width (≈ 0.002 s). /// -/// A `measured` near 0.002 s means the condition caught the 2 ms glitch pulse exactly. -/// A `measured` near 0.200 s (the ordinary square half-period) means the condition fired on -/// an ordinary edge rather than the narrow pulse -- that is the wrong answer to distinguish -/// against. If no snapshot is available or no upward crossing is found before x = 0, the -/// line still prints with `measured=nan` rather than being skipped, so a failure is still -/// one readable line. +/// Shape 1 (pulse `>`): same scan; `measured` is the ordinary square half-period (≈ 0.200 s). +/// +/// Shape 2 (window): x = 0 is the moment the signal left the band. Reports `at0` (the +/// sample value at x = 0, which must be outside the band) and `earlier` (a sample a few +/// stored periods back, which must be inside). +/// +/// Shape 3 (dropout): x = 0 is `quiet_at + timeout`, the moment the timeout expired. Scans +/// backwards for the most recent level crossing; `measured = -stamp` should equal `timeout`. +/// +/// All legs print one `[condition-probe]` line with `nan` for any unmeasurable field. struct ConditionProbe { + double glitch_at = 0.25; + double glitch_width = 0.002; + double square_period = 0.4; + /// With `--quiet-at`, the sine stops at a level on the other side of zero, so `quiet_at` + /// is its last crossing and the dropout's event time is exactly `quiet_at + timeout`. + /// Unlike the glitch this happens *once*, so the assertion is the absolute t0. + double quiet_at = -1.0; + double timeout = 0.050; + /// 0 = pulse width `<` (the glitch), 1 = pulse width `>`, 2 = window, 3 = dropout. Selected + /// by `UDPSCOPE_PROBE_CONDITION_SHAPE`; the default keeps Task 3's glitch leg. + int shape = 0; int waited = 0; }; ConditionProbe g_condition; @@ -8437,37 +8499,53 @@ gboolean condition_probe_report(gpointer user) { std::fflush(stderr); return G_SOURCE_REMOVE; } - // Measure the pulse width from the snapshot's own edge-relative stamps rather than - // comparing t0 against a mock-clock target. stamps == 0 is the closing edge (the - // latching event for a pulse-width condition); the opening edge sits at stamps ≈ -width. - // Scan backwards from the last sample at or before x = 0 for the most recent upward - // crossing of the condition level, then negate the crossing stamp to get the width. - double measured = std::numeric_limits::quiet_NaN(); + const double nan = std::numeric_limits::quiet_NaN(); const double level = a->model->trigger().condition.level; + const double level2 = a->model->trigger().condition.level2; const std::size_t trig_ch = static_cast(a->model->trigger().channel); + + // Shape 2 reports two sample values rather than a measured width. + double at0 = nan; + double earlier = nan; + double measured = nan; + if (std::shared_ptr snap = a->model->display_snapshot()) { if (snap != nullptr && trig_ch < snap->values.size()) { const std::vector& st = snap->stamps; const std::vector& vals = snap->values[trig_ch]; if (vals.size() == st.size() && !st.empty()) { - // Find the last index whose stamp is <= 0. + // Index of the last stamp <= 0 (the latching event). const auto it = std::upper_bound(st.begin(), st.end(), 0.0); - std::size_t end = static_cast(it - st.begin()); - // Scan backwards for the most recent upward crossing (prev < level, value >= level). - for (std::size_t i = end; i >= 2; --i) { - const double prev = static_cast(vals[i - 2]); - const double val = static_cast(vals[i - 1]); - if (prev < level && val >= level) { - measured = -st[i - 1]; - break; + const std::size_t end = static_cast(it - st.begin()); + + if (g_condition.shape == 2) { + // Window leg: x=0 is the moment the signal left the band. + // `at0` is the sample at x=0 (must be outside [lo,hi]). + // `earlier` is a sample a few stored periods back (must be inside). + if (end >= 1) at0 = static_cast(vals[end - 1]); + // "A few stored periods" back: use half the snapshot length as a rough offset. + const std::size_t back = end > 4 ? end / 2 : 0; + if (back < vals.size()) earlier = static_cast(vals[back]); + } else { + // Pulse and dropout legs: scan backwards for the most recent upward crossing + // (prev < level, value >= level) and negate the stamp to get a duration. + for (std::size_t i = end; i >= 2; --i) { + const double prev = static_cast(vals[i - 2]); + const double val = static_cast(vals[i - 1]); + if (prev < level && val >= level) { + measured = -st[i - 1]; + break; + } } } } } } - std::fprintf(stderr, "[condition-probe] type=%d cmp=%d width=%.6f t0=%.6f measured=%.6f mode=%d\n", + std::fprintf(stderr, + "[condition-probe] type=%d cmp=%d width=%.6f t0=%.6f measured=%.6f " + "at0=%.6f earlier=%.6f level2=%.6f mode=%d\n", int(a->model->trigger().condition.type), int(a->model->trigger().condition.cmp), - a->model->trigger().condition.width_s, t0, measured, + a->model->trigger().condition.width_s, t0, measured, at0, earlier, level2, int(a->model->display_mode())); std::fflush(stderr); return G_SOURCE_REMOVE; @@ -8480,19 +8558,35 @@ gboolean condition_probe_arm(gpointer user) { apply_time_window(*a, 0.2); const std::vector& channels = a->model->channels(); int sel = -1; + const bool dropout = g_condition.shape == 3; + const char* want_name = dropout ? "sine" : "square"; for (std::size_t i = 0; i < channels.size(); ++i) { - if (channels[i].name == "square") sel = static_cast(i); + if (channels[i].name == want_name) sel = static_cast(i); } - std::fprintf(stderr, "[condition-probe] square=%d of %zu channels\n", sel, channels.size()); + std::fprintf(stderr, "[condition-probe] %s=%d of %zu channels\n", want_name, 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 + // Shape 2 is `window`, which the dropdown numbers 2; the two pulse-width legs share type 1 + // and differ only in the comparison. + const guint type = g_condition.shape == 2 ? 2u : (dropout ? 3u : 1u); + gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_type_drop), type); + gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_cmp_drop), + g_condition.shape == 1 ? 1u : 0u); // `>` for leg 1, `<` otherwise + // `both` for the window and the dropout: a band is entered *and* left, and a dropout has no + // direction at all. Rising for the two pulse-width legs, which open on a rising excursion. + gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_edge_drop), + (dropout || g_condition.shape == 2) ? 2u : 0u); 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_timeout_spin), g_condition.timeout); + // The band for the window leg: straddling zero, well inside the square's +/-1, so each + // transition both enters and leaves it. Harmless for the other legs, which ignore `level2`. + gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_level2_spin), 0.5); + // Zero for every leg but the window, whose band is `[-0.5, +0.5]`. + gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_level_spin), + g_condition.shape == 2 ? -0.5 : 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); @@ -8506,11 +8600,20 @@ gboolean condition_probe_arm(gpointer user) { gboolean condition_probe(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; + // `UDPSCOPE_PROBE_QUIET_AT` mirrors the mock's `--quiet-at`: it selects the dropout leg of + // the probe and tells it where the sine's last crossing is. Absent, the glitch leg runs. + if (const char* q = g_getenv("UDPSCOPE_PROBE_QUIET_AT")) g_condition.quiet_at = std::atof(q); + if (const char* s = g_getenv("UDPSCOPE_PROBE_CONDITION_SHAPE")) { + g_condition.shape = std::atoi(s); + } else if (g_condition.quiet_at >= 0.0) { + g_condition.shape = 3; + } // `square` is not acquired until it is enabled, and the trigger can only name a channel // that is: enabling is what starts acquisition. + const char* want = g_condition.shape == 3 ? "sine" : "square"; 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); + if (signals[i].name == want) 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);