fix(streamhub): stop the trigger going deaf between captures

TriggerEngine::CheckSample returned early in every state but ARMED, so an
edge arriving while a capture was being collected or handed out was
dropped, and the automatic rearm then waited for a FRESH edge. The engine
was therefore blind from its own trigger point until the capture had been
harvested — a post-window — and for the holdoff on top of that.

On a sparse pulse train that rounds the capture spacing up to a whole
pulse period: at the default 1 s window the blind stretch is 1 s, so a
1 Hz train was caught at 0.5 Hz and a wider window lost whole multiples.

The comparator now keeps running through COLLECTING and TRIGGERED and
remembers the first edge at or past trigTime + max(postSec, holdoffSec).
The holdoff guards against re-triggering on the ringing of the same
event and is measured from the trigger point, so it overlaps the
post-window rather than adding to it. Rearm() fires on the remembered
edge; it also keeps the tracked level, so the first sample after it has
a real predecessor instead of being spent seeding one.

Arm() stays the operator's arm and discards the held edge — they asked
for the next event, not one already been and gone — and SetConfig() and
Disarm() drop it too, since it was never judged against the new window.

This is the same defect and the same remedy already validated in the Go
hub (wshub/trigger.go, trigger_sporadic_test.go); the C++ hub had been
left with the original semantics.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-09-02 01:45:13 +02:00
co-authored by Claude Opus 4.6
parent fbae7d712c
commit 092fd3c775
5 changed files with 315 additions and 31 deletions
+21 -2
View File
@@ -107,12 +107,31 @@ Hub-side, web-client semantics (`setTrigger` fields in
``` ```
IDLE --arm--> ARMED --edge crossing--> COLLECTING --every source past trigTime+postSec+0.15s--> TRIGGERED IDLE --arm--> ARMED --edge crossing--> COLLECTING --every source past trigTime+postSec+0.15s--> TRIGGERED
TRIGGERED --rearm (single) / auto ~200ms (normal, unless stopped)--> ARMED TRIGGERED --rearm (single) / auto after holdoffSec (normal, unless stopped)--> ARMED
└─ or straight to COLLECTING on a held edge
any --disarm--> IDLE any --disarm--> IDLE
``` ```
`UDPSourceSession` calls `TriggerEngine::CheckSample` for every decoded sample `UDPSourceSession` calls `TriggerEngine::CheckSample` for every decoded sample
of the configured signal (signal index cached per config epoch). Each source is of the configured signal (signal index cached per config epoch).
The comparator keeps running through COLLECTING and TRIGGERED. It cannot fire
there — the capture in flight owns that stretch — but it remembers the first
edge at or past `trigTime + max(postSec, holdoffSec)`, and `Rearm()` fires on
that remembered edge instead of waiting for a fresh one. Without this the engine
is deaf from its own trigger point until the capture has been harvested and the
holdoff has run, which on a sparse pulse train rounds the capture spacing up to
a whole pulse period: at a 1 s window a 1 Hz train was caught at 0.5 Hz, and a
wider window lost whole multiples. The capture is built from the edge's own
timestamp out of rings that still hold everything around it, so honouring it
costs nothing.
`Arm()` and `Rearm()` differ only in this: `Arm()` is the operator's own arm and
discards the held edge (they asked for the next event), while `Rearm()` is the
automatic end-of-capture arm and consumes it. `Rearm()` also keeps the tracked
level, so the first sample after it is compared against its real predecessor
rather than being spent seeding one. `SetConfig()` and `Disarm()` drop the held
edge as well — it was never judged against the new window. Each source is
read `[trigTimepreSec, trigTime+postSec]`, LTTB-capped to 20 000 pts/signal and read `[trigTimepreSec, trigTime+postSec]`, LTTB-capped to 20 000 pts/signal and
appended to a binary **version 2** capture frame; every FSM transition appended to a binary **version 2** capture frame; every FSM transition
broadcasts a `triggerState` event. broadcasts a `triggerState` event.
+5 -1
View File
@@ -1736,7 +1736,11 @@ void StreamHub::TriggerTick(float64 wallNowS) {
(wallNowS >= rearmAtWallS_)) { (wallNowS >= rearmAtWallS_)) {
rearmPending_ = false; rearmPending_ = false;
if (!trigger_.GetStopped()) { if (!trigger_.GetStopped()) {
trigger_.Arm(); /* Rearm, not Arm: an edge that arrived while this capture was being
* collected is fired on at once instead of being thrown away, which
* is what kept sparse pulse trains from being caught at their own
* rate. */
trigger_.Rearm();
} }
} }
+64 -12
View File
@@ -19,7 +19,17 @@ TriggerEngine::TriggerEngine()
trigTime_(0.0), trigTime_(0.0),
firedPreSec_(0.0), firedPreSec_(0.0),
firedPostSec_(0.0), firedPostSec_(0.0),
firedValid_(false) { firedValid_(false),
pendingTime_(0.0),
pendingValid_(false) {
}
void TriggerEngine::LatchWindowLocked(float64 t) {
state_ = kTrigCollecting;
trigTime_ = t;
firedPreSec_ = config_.windowSec * config_.prePercent / 100.0;
firedPostSec_ = config_.windowSec - firedPreSec_;
firedValid_ = true;
} }
void TriggerEngine::SetConfig(const TriggerConfig &cfg) { void TriggerEngine::SetConfig(const TriggerConfig &cfg) {
@@ -40,6 +50,9 @@ void TriggerEngine::SetConfig(const TriggerConfig &cfg) {
epoch_++; epoch_++;
prevValid_ = false; prevValid_ = false;
prevValue_ = 0.0; prevValue_ = 0.0;
/* An edge held over from the old configuration would be latched against the
* new window, which it was never judged against. */
pendingValid_ = false;
mutex_.FastUnLock(); mutex_.FastUnLock();
} }
@@ -62,6 +75,26 @@ void TriggerEngine::Arm() {
state_ = kTrigArmed; state_ = kTrigArmed;
prevValid_ = false; prevValid_ = false;
prevValue_ = 0.0; prevValue_ = 0.0;
pendingValid_ = false;
mutex_.FastUnLock();
}
void TriggerEngine::Rearm() {
(void) mutex_.FastLock();
if (pendingValid_) {
const float64 t = pendingTime_;
pendingValid_ = false;
LatchWindowLocked(t);
REPORT_ERROR_STATIC(MARTe::ErrorManagement::Information,
"TriggerEngine: rearmed onto the edge held at t=%.6f "
"(pre=%.4fs post=%.4fs)",
t, firedPreSec_, firedPostSec_);
}
else {
/* prevValue_/prevValid_ are deliberately kept: the comparator ran right
* through the dead time, so the next sample has a real predecessor. */
state_ = kTrigArmed;
}
mutex_.FastUnLock(); mutex_.FastUnLock();
} }
@@ -72,6 +105,7 @@ void TriggerEngine::Disarm() {
prevValid_ = false; prevValid_ = false;
prevValue_ = 0.0; prevValue_ = 0.0;
firedValid_ = false; firedValid_ = false;
pendingValid_ = false;
mutex_.FastUnLock(); mutex_.FastUnLock();
} }
@@ -96,7 +130,10 @@ void TriggerEngine::CheckSample(float64 t, float64 v) {
lastTime_ = t; lastTime_ = t;
lastTimeValid_ = true; lastTimeValid_ = true;
if (state_ != kTrigArmed) { /* A capture in flight does not stop the comparator; it only changes what an
* edge does. See pendingTime_. */
const bool inFlight = (state_ == kTrigCollecting) || (state_ == kTrigTriggered);
if ((state_ != kTrigArmed) && !inFlight) {
mutex_.FastUnLock(); mutex_.FastUnLock();
return; return;
} }
@@ -121,17 +158,36 @@ void TriggerEngine::CheckSample(float64 t, float64 v) {
} }
if (fired) { if (fired) {
state_ = kTrigCollecting; if (!inFlight) {
trigTime_ = t;
/* Latch the window at fire time so later config edits do not /* Latch the window at fire time so later config edits do not
* affect this capture (web client snap._preS/_postS). */ * affect this capture (web client snap._preS/_postS). */
firedPreSec_ = config_.windowSec * config_.prePercent / 100.0; LatchWindowLocked(t);
firedPostSec_ = config_.windowSec - firedPreSec_;
firedValid_ = true;
REPORT_ERROR_STATIC(MARTe::ErrorManagement::Information, REPORT_ERROR_STATIC(MARTe::ErrorManagement::Information,
"TriggerEngine: fired at t=%.6f (pre=%.4fs post=%.4fs)", "TriggerEngine: fired at t=%.6f (pre=%.4fs post=%.4fs)",
t, firedPreSec_, firedPostSec_); t, firedPreSec_, firedPostSec_);
} }
else if (!pendingValid_ && firedValid_) {
/* The earliest trigger point a new capture may take. The one in
* flight owns everything up to the end of its own post-window, and
* the holdoff — a guard against re-triggering on the ringing of the
* SAME event — is measured from its trigger point too, so the two
* overlap rather than add.
*
* Keep only the FIRST qualifying edge: a later one would deliver
* the same capture a pulse further on and skip the one between. */
float64 guard = firedPostSec_;
if (config_.holdoffSec > guard) {
guard = config_.holdoffSec;
}
if (t >= (trigTime_ + guard)) {
pendingTime_ = t;
pendingValid_ = true;
}
}
else {
/* Already holding an edge, or no window latched to measure against. */
}
}
mutex_.FastUnLock(); mutex_.FastUnLock();
} }
@@ -141,11 +197,7 @@ bool TriggerEngine::Force() {
bool ok = lastTimeValid_ && (state_ != kTrigCollecting); bool ok = lastTimeValid_ && (state_ != kTrigCollecting);
if (ok) { if (ok) {
state_ = kTrigCollecting; LatchWindowLocked(lastTime_);
trigTime_ = lastTime_;
firedPreSec_ = config_.windowSec * config_.prePercent / 100.0;
firedPostSec_ = config_.windowSec - firedPreSec_;
firedValid_ = true;
REPORT_ERROR_STATIC(MARTe::ErrorManagement::Information, REPORT_ERROR_STATIC(MARTe::ErrorManagement::Information,
"TriggerEngine: forced at t=%.6f (pre=%.4fs post=%.4fs)", "TriggerEngine: forced at t=%.6f (pre=%.4fs post=%.4fs)",
trigTime_, firedPreSec_, firedPostSec_); trigTime_, firedPreSec_, firedPostSec_);
+35 -3
View File
@@ -88,9 +88,24 @@ public:
*/ */
uint32 GetConfigEpoch() const; uint32 GetConfigEpoch() const;
/** @brief Arm: any state → ARMED (resets edge detection). */ /**
* @brief Arm: any state → ARMED (resets edge detection).
* This is the user's own arm, so it discards any edge remembered during the
* previous capture: the user asked for the next event, not for one that has
* already been and gone.
*/
void Arm(); void Arm();
/**
* @brief The automatic arm at the end of a capture (normal mode).
* Unlike Arm() it honours an edge seen while the capture was being
* collected, firing on it at once rather than waiting for the next one, and
* it keeps the tracked level so the first sample afterwards is compared
* against its real predecessor. TRIGGERED → COLLECTING when an edge was
* remembered, otherwise → ARMED.
*/
void Rearm();
/** @brief Disarm: any state → IDLE; clears the stopped flag. */ /** @brief Disarm: any state → IDLE; clears the stopped flag. */
void Disarm(); void Disarm();
@@ -102,8 +117,10 @@ public:
/** /**
* @brief Edge-detect one decoded sample of the configured signal. * @brief Edge-detect one decoded sample of the configured signal.
* Receive-thread context. Only acts in ARMED state; on a matching edge * Receive-thread context. In ARMED state a matching edge latches trigTime
* latches trigTime and the pre/post window and moves to COLLECTING. * and the pre/post window and moves to COLLECTING. While a capture is in
* flight (COLLECTING/TRIGGERED) the comparator keeps running and the first
* edge clear of that capture is remembered for the next Rearm().
*/ */
void CheckSample(float64 t, float64 v); void CheckSample(float64 t, float64 v);
@@ -143,6 +160,21 @@ private:
float64 firedPreSec_; ///< Window pre-part latched at fire time float64 firedPreSec_; ///< Window pre-part latched at fire time
float64 firedPostSec_; ///< Window post-part latched at fire time float64 firedPostSec_; ///< Window post-part latched at fire time
bool firedValid_; ///< true after a fire, until Disarm() bool firedValid_; ///< true after a fire, until Disarm()
/**
* The edge to fire on as soon as the FSM rearms, in sample time, recorded
* while a capture is still being collected or handed out. Without it the
* trigger is deaf from its own trigger point until the capture has been
* harvested and the holdoff has run, and then waits for a fresh edge, which
* on a sparse pulse train rounds the capture spacing up to a whole pulse
* period. Remembering the edge instead makes the blind stretch exactly the
* guard interval it has to be, since the capture is built from the edge's
* own timestamp and the rings still hold everything around it.
*/
float64 pendingTime_;
bool pendingValid_;
/** @brief Freeze the pre/post split at fire time; caller holds the mutex. */
void LatchWindowLocked(float64 t);
}; };
inline TriggerConfig::TriggerConfig() inline TriggerConfig::TriggerConfig()
@@ -196,6 +196,183 @@ TEST(TriggerEngineGTest, TestRearmResetsEdgeDetection) {
EXPECT_DOUBLE_EQ(4.0, tt); EXPECT_DOUBLE_EQ(4.0, tt);
} }
/* An edge that arrives while a capture is still being collected, or while it is
* being handed out, used to be dropped on the floor: CheckSample returned early
* in every state but ARMED, and the automatic rearm then waited for a FRESH
* edge. The engine is therefore deaf from its own trigger point until the
* capture has been harvested — a post-window — and then for the holdoff on top.
*
* On a sparse pulse train that rounds the capture spacing up to a whole pulse
* period: at the default 1 s window the blind stretch is 1 s, so a 1 Hz train
* was caught at 0.5 Hz and a wider window lost whole multiples. Remembering the
* edge costs nothing, because the capture is built from the edge's own
* timestamp out of a ring that still holds everything around it. */
TEST(TriggerEngineGTest, TestEdgeDuringCaptureFiresOnRearm) {
TriggerEngine eng;
eng.SetConfig(MakeConfig(kEdgeRising, 0.5, 1.0, 20.0)); /* post = 0.8 */
eng.Arm();
eng.CheckSample(1.0, 0.0);
eng.CheckSample(1.1, 1.0);
ASSERT_EQ(kTrigCollecting, eng.GetState());
/* A second pulse, clear of the capture in flight (1.1 + 0.8 = 1.9). */
eng.CheckSample(2.4, 0.0);
eng.CheckSample(2.5, 1.0);
eng.MarkTriggered();
eng.Rearm();
EXPECT_EQ(kTrigCollecting, eng.GetState());
float64 tt, pre, post;
ASSERT_TRUE(eng.GetFiredWindow(tt, pre, post));
EXPECT_DOUBLE_EQ(2.5, tt); /* the remembered edge, not the rearm instant */
}
/* The remembered edge must not be one the capture in flight already covers, nor
* one inside the holdoff — that guard exists to stop the ringing of a single
* event re-triggering on itself, and it is measured from the trigger point, so
* the two overlap rather than add. */
TEST(TriggerEngineGTest, TestEdgeInsideOwnCaptureIsNotRemembered) {
TriggerEngine eng;
eng.SetConfig(MakeConfig(kEdgeRising, 0.5, 1.0, 20.0)); /* post = 0.8 */
eng.Arm();
eng.CheckSample(1.0, 0.0);
eng.CheckSample(1.1, 1.0);
ASSERT_EQ(kTrigCollecting, eng.GetState());
/* Inside 1.1 + max(0.8, 0.2 holdoff) = 1.9: the capture owns this stretch. */
eng.CheckSample(1.4, 0.0);
eng.CheckSample(1.5, 1.0);
eng.MarkTriggered();
eng.Rearm();
EXPECT_EQ(kTrigArmed, eng.GetState());
}
/* A holdoff longer than the post-window is what decides the guard interval. */
TEST(TriggerEngineGTest, TestHoldoffOutlastingPostWindowGovernsRearm) {
TriggerEngine eng;
TriggerConfig cfg = MakeConfig(kEdgeRising, 0.5, 1.0, 80.0); /* post = 0.2 */
cfg.holdoffSec = 2.0;
eng.SetConfig(cfg);
eng.Arm();
eng.CheckSample(1.0, 0.0);
eng.CheckSample(1.1, 1.0);
ASSERT_EQ(kTrigCollecting, eng.GetState());
/* Past the post-window but inside the holdoff (1.1 + 2.0 = 3.1): ignored. */
eng.CheckSample(1.9, 0.0);
eng.CheckSample(2.0, 1.0);
/* Clear of it: remembered. */
eng.CheckSample(3.4, 0.0);
eng.CheckSample(3.5, 1.0);
eng.MarkTriggered();
eng.Rearm();
ASSERT_EQ(kTrigCollecting, eng.GetState());
float64 tt, pre, post;
ASSERT_TRUE(eng.GetFiredWindow(tt, pre, post));
EXPECT_DOUBLE_EQ(3.5, tt);
}
/* Only the first qualifying edge is worth keeping; a later one would deliver
* the same capture a pulse further on and skip the one in between. */
TEST(TriggerEngineGTest, TestFirstQualifyingEdgeWins) {
TriggerEngine eng;
eng.SetConfig(MakeConfig(kEdgeRising, 0.5, 1.0, 20.0));
eng.Arm();
eng.CheckSample(1.0, 0.0);
eng.CheckSample(1.1, 1.0);
ASSERT_EQ(kTrigCollecting, eng.GetState());
eng.CheckSample(2.4, 0.0);
eng.CheckSample(2.5, 1.0); /* first past 1.9 */
eng.CheckSample(3.4, 0.0);
eng.CheckSample(3.5, 1.0); /* later, must not displace it */
eng.MarkTriggered();
eng.Rearm();
float64 tt, pre, post;
ASSERT_TRUE(eng.GetFiredWindow(tt, pre, post));
EXPECT_DOUBLE_EQ(2.5, tt);
}
/* Arm() is the user's own arm: it asks for the next event, not for one that has
* already been and gone, so it drops anything remembered. */
TEST(TriggerEngineGTest, TestUserArmDiscardsRememberedEdge) {
TriggerEngine eng;
eng.SetConfig(MakeConfig(kEdgeRising, 0.5, 1.0, 20.0));
eng.Arm();
eng.CheckSample(1.0, 0.0);
eng.CheckSample(1.1, 1.0);
eng.CheckSample(2.4, 0.0);
eng.CheckSample(2.5, 1.0);
eng.MarkTriggered();
eng.Arm();
EXPECT_EQ(kTrigArmed, eng.GetState());
}
/* Reconfiguring drops it too: the edge would be latched against a window it was
* never judged against. */
TEST(TriggerEngineGTest, TestSetConfigDiscardsRememberedEdge) {
TriggerEngine eng;
eng.SetConfig(MakeConfig(kEdgeRising, 0.5, 1.0, 20.0));
eng.Arm();
eng.CheckSample(1.0, 0.0);
eng.CheckSample(1.1, 1.0);
eng.CheckSample(2.4, 0.0);
eng.CheckSample(2.5, 1.0);
eng.MarkTriggered();
eng.SetConfig(MakeConfig(kEdgeRising, 0.5, 2.0, 20.0));
eng.Rearm();
EXPECT_EQ(kTrigArmed, eng.GetState());
}
/* The comparator keeps running through the dead time, so the first sample after
* an automatic rearm is measured against its real predecessor rather than being
* spent seeding one. A rearm landing mid-pulse would otherwise miss that
* pulse's edge as well as the ones it slept through. */
TEST(TriggerEngineGTest, TestRearmKeepsTrackingTheLevel) {
TriggerEngine eng;
eng.SetConfig(MakeConfig(kEdgeRising, 0.5, 1.0, 20.0));
eng.Arm();
eng.CheckSample(1.0, 0.0);
eng.CheckSample(1.1, 1.0);
ASSERT_EQ(kTrigCollecting, eng.GetState());
/* Falls back low during the capture: no rising edge, nothing remembered,
* but the level is now known to be 0. */
eng.CheckSample(2.0, 0.0);
eng.MarkTriggered();
eng.Rearm();
ASSERT_EQ(kTrigArmed, eng.GetState());
eng.CheckSample(2.1, 1.0); /* 0.0 → 1.0 across 0.5, on the very first sample */
EXPECT_EQ(kTrigCollecting, eng.GetState());
float64 tt, pre, post;
ASSERT_TRUE(eng.GetFiredWindow(tt, pre, post));
EXPECT_DOUBLE_EQ(2.1, tt);
}
/* Idle is genuinely deaf: nothing is tracked and nothing is remembered, so a
* disarmed hub cannot fire the moment it is armed again. */
TEST(TriggerEngineGTest, TestDisarmDiscardsRememberedEdge) {
TriggerEngine eng;
eng.SetConfig(MakeConfig(kEdgeRising, 0.5, 1.0, 20.0));
eng.Arm();
eng.CheckSample(1.0, 0.0);
eng.CheckSample(1.1, 1.0);
eng.CheckSample(2.4, 0.0);
eng.CheckSample(2.5, 1.0);
eng.MarkTriggered();
eng.Disarm();
eng.Rearm();
EXPECT_EQ(kTrigArmed, eng.GetState());
}
TEST(TriggerEngineGTest, TestStoppedFlag) { TEST(TriggerEngineGTest, TestStoppedFlag) {
TriggerEngine eng; TriggerEngine eng;
EXPECT_FALSE(eng.GetStopped()); EXPECT_FALSE(eng.GetStopped());