fix: a dropout probe outlasts the sine's own crossings, so only a real silence latches it

- Leg 1: widen to 1.0 s / 50% pre so the square's opening edge is in the snapshot
- Leg 2: use sine (not square) for the window channel, edge = falling so the band exit
  is deterministic, and read earlier from vals[end-2] rather than half the snapshot
- Leg 3: raise timeout to 0.3 s (> half-period), accept either crossing direction,
  widen to 1.0 s / 50% pre to reach the crossing that sits 0.3 s before x=0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Sonnet 4.6 committed 2026-10-07 18:13:26 +02:00
1 parent a26770ec32
commit a00663510f
1 file changed
+49 -25
+49 -25
View File
@@ -8461,16 +8461,25 @@ gboolean fields_probe(gpointer user) {
/// 10 ms packets), so comparing trigger_time() against a mock-clock target is meaningless. /// 10 ms packets), so comparing trigger_time() against a mock-clock target is meaningless.
/// ///
/// Shape 0 (pulse `<`): x = 0 is the glitch's closing edge. Scans backwards for the most /// 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). /// recent upward crossing; `measured = -stamp` is the glitch width (≈ 0.002 s). Window 0.2 s,
/// 10% pre (0.02 s back) — the glitch is 0.002 s wide so 0.02 s comfortably contains it.
/// ///
/// Shape 1 (pulse `>`): same scan; `measured` is the ordinary square half-period (≈ 0.200 s). /// Shape 1 (pulse `>`): same scan; `measured` is the ordinary square half-period (≈ 0.200 s).
/// Needs a 1.0 s window with 50% pre (0.5 s) because the opening edge of the captured pulse
/// is 0.2 s before x = 0, beyond the 0.02 s pre a 0.2 s window would provide.
/// ///
/// Shape 2 (window): x = 0 is the moment the signal left the band. Reports `at0` (the /// Shape 2 (window): edge is set to `falling` (out-of-band), so x = 0 is the moment the
/// sample value at x = 0, which must be outside the band) and `earlier` (a sample a few /// signal left the band. Uses the sine channel because the square has no sample inside
/// stored periods back, which must be inside). /// [-0.5, +0.5] and can never produce a band exit. Reports `at0` (the sample at x = 0, which
/// must be outside the band) and `earlier` (the sample immediately before it, which must be
/// inside). Window 0.2 s, 10% pre — the crossing is one sample before x = 0.
/// ///
/// Shape 3 (dropout): x = 0 is `quiet_at + timeout`, the moment the timeout expired. Scans /// 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`. /// backwards for the most recent crossing in either direction, because the last sine crossing
/// before the quiet period is downward for the `--quiet-at 4.26` case and an upward-only scan
/// would miss it. `timeout` must exceed the sine's 0.1 s half-period; 0.3 s is used so that
/// an ordinary live sine cannot satisfy the condition. Needs a 1.0 s window with 50% pre
/// because the crossing is `timeout` ≈ 0.3 s before x = 0.
/// ///
/// All legs print one `[condition-probe]` line with `nan` for any unmeasurable field. /// All legs print one `[condition-probe]` line with `nan` for any unmeasurable field.
struct ConditionProbe { struct ConditionProbe {
@@ -8479,9 +8488,8 @@ struct ConditionProbe {
double square_period = 0.4; double square_period = 0.4;
/// With `--quiet-at`, the sine stops at a level on the other side of zero, so `quiet_at` /// 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`. /// 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 quiet_at = -1.0;
double timeout = 0.050; double timeout = 0.300;
/// 0 = pulse width `<` (the glitch), 1 = pulse width `>`, 2 = window, 3 = dropout. Selected /// 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. /// by `UDPSCOPE_PROBE_CONDITION_SHAPE`; the default keeps Task 3's glitch leg.
int shape = 0; int shape = 0;
@@ -8519,20 +8527,24 @@ gboolean condition_probe_report(gpointer user) {
const std::size_t end = static_cast<std::size_t>(it - st.begin()); const std::size_t end = static_cast<std::size_t>(it - st.begin());
if (g_condition.shape == 2) { if (g_condition.shape == 2) {
// Window leg: x=0 is the moment the signal left the band. // Window leg: x=0 is the moment the signal left the band (falling / out-of-band edge).
// `at0` is the sample at x=0 (must be outside [lo,hi]). // `at0` is the sample at x=0: must be outside [lo, hi], i.e. |at0| >= 0.5.
// `earlier` is a sample a few stored periods back (must be inside). // `earlier` is the sample immediately before x=0: must be inside, i.e. |earlier| < 0.5.
// Using the adjacent sample makes the assertion exact and independent of snapshot size.
if (end >= 1) at0 = static_cast<double>(vals[end - 1]); if (end >= 1) at0 = static_cast<double>(vals[end - 1]);
// "A few stored periods" back: use half the snapshot length as a rough offset. if (end >= 2) earlier = static_cast<double>(vals[end - 2]);
const std::size_t back = end > 4 ? end / 2 : 0;
if (back < vals.size()) earlier = static_cast<double>(vals[back]);
} else { } else {
// Pulse and dropout legs: scan backwards for the most recent upward crossing // Pulse legs: scan backwards for the most recent upward crossing
// (prev < level, value >= level) and negate the stamp to get a duration. // (prev < level, value >= level) and negate the stamp to get a duration.
// Dropout leg: accept a crossing in either direction because the last sine crossing
// before the quiet period may be downward, and an upward-only scan would miss it.
for (std::size_t i = end; i >= 2; --i) { for (std::size_t i = end; i >= 2; --i) {
const double prev = static_cast<double>(vals[i - 2]); const double prev = static_cast<double>(vals[i - 2]);
const double val = static_cast<double>(vals[i - 1]); const double val = static_cast<double>(vals[i - 1]);
if (prev < level && val >= level) { const bool crossed = (g_condition.shape == 3)
? ((prev < level) != (val < level)) // either direction
: (prev < level && val >= level); // upward only
if (crossed) {
measured = -st[i - 1]; measured = -st[i - 1];
break; break;
} }
@@ -8554,12 +8566,20 @@ gboolean condition_probe_report(gpointer user) {
gboolean condition_probe_arm(gpointer user) { gboolean condition_probe_arm(gpointer user) {
App* a = static_cast<App*>(user); App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE; if (a->window == nullptr) return G_SOURCE_REMOVE;
// A window short enough that the pre-trigger share is already behind the glitch. // Legs 0 and 2 need only a short snapshot (the event is one sample or one glitch-width back).
apply_time_window(*a, 0.2); // Legs 1 and 3 need a 1.0 s window with 50% pre so the opening edge of a 0.2 s pulse and
// the last sine crossing before a 0.3 s timeout are both captured.
const bool wide = (g_condition.shape == 1 || g_condition.shape == 3);
const double win_s = wide ? 1.0 : 0.2;
const double pre_pct = wide ? 50.0 : 10.0;
apply_time_window(*a, win_s);
const std::vector<ChannelInfo>& channels = a->model->channels(); const std::vector<ChannelInfo>& channels = a->model->channels();
int sel = -1; int sel = -1;
const bool dropout = g_condition.shape == 3; const bool dropout = g_condition.shape == 3;
const char* want_name = dropout ? "sine" : "square"; // Shape 2 (window) and shape 3 (dropout) both use the sine: the square has no sample inside
// [-0.5, +0.5] and cannot produce a band change-of-side, and the dropout relies on the sine
// going quiet. Shapes 0 and 1 (pulse-width) use the square.
const char* want_name = (g_condition.shape == 2 || dropout) ? "sine" : "square";
for (std::size_t i = 0; i < channels.size(); ++i) { for (std::size_t i = 0; i < channels.size(); ++i) {
if (channels[i].name == want_name) sel = static_cast<int>(i); if (channels[i].name == want_name) sel = static_cast<int>(i);
} }
@@ -8575,10 +8595,13 @@ gboolean condition_probe_arm(gpointer user) {
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_type_drop), type); 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), gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_cmp_drop),
g_condition.shape == 1 ? 1u : 0u); // `>` for leg 1, `<` otherwise 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 // Window leg uses `falling` (index 1), meaning out-of-band, which makes the latch deterministic:
// direction at all. Rising for the two pulse-width legs, which open on a rising excursion. // x=0 is always a band exit, so at0 is outside and the previous sample is inside.
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_edge_drop), // Dropout has no direction (index 2, `both`). Pulse-width legs use `rising` (index 0).
(dropout || g_condition.shape == 2) ? 2u : 0u); guint edge_idx = 0u;
if (g_condition.shape == 2) edge_idx = 1u;
else if (dropout) edge_idx = 2u;
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_edge_drop), edge_idx);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_width_spin), 0.010); gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_width_spin), 0.010);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_timeout_spin), g_condition.timeout); 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 // The band for the window leg: straddling zero, well inside the square's +/-1, so each
@@ -8587,7 +8610,7 @@ gboolean condition_probe_arm(gpointer user) {
// Zero for every leg but the window, whose band is `[-0.5, +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), gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_level_spin),
g_condition.shape == 2 ? -0.5 : 0.0); 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_pre_spin), pre_pct);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_holdoff_spin), 0.0); gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_holdoff_spin), 0.0);
publish_trigger_fields(a); publish_trigger_fields(a);
if (a->trigger_toggle != nullptr) if (a->trigger_toggle != nullptr)
@@ -8609,8 +8632,9 @@ gboolean condition_probe(gpointer user) {
g_condition.shape = 3; g_condition.shape = 3;
} }
// `square` is not acquired until it is enabled, and the trigger can only name a channel // `square` is not acquired until it is enabled, and the trigger can only name a channel
// that is: enabling is what starts acquisition. // that is: enabling is what starts acquisition. Shape 2 (window) needs the sine because the
const char* want = g_condition.shape == 3 ? "sine" : "square"; // square has no sample inside [-0.5, +0.5] and can never change sides of the band.
const char* want = (g_condition.shape == 2 || g_condition.shape == 3) ? "sine" : "square";
const std::vector<SignalInfo>& signals = a->model->signals(); const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t i = 0; i < signals.size(); ++i) { for (std::size_t i = 0; i < signals.size(); ++i) {
if (signals[i].name == want) a->model->set_signal_enabled(i, true); if (signals[i].name == want) a->model->set_signal_enabled(i, true);