diff --git a/Docs/StreamHub-Developer.md b/Docs/StreamHub-Developer.md index 174c7b5..e8649ca 100644 --- a/Docs/StreamHub-Developer.md +++ b/Docs/StreamHub-Developer.md @@ -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 -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 ``` `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 `[trigTime−preSec, trigTime+postSec]`, LTTB-capped to 20 000 pts/signal and appended to a binary **version 2** capture frame; every FSM transition broadcasts a `triggerState` event. diff --git a/Source/Applications/StreamHub/StreamHub.cpp b/Source/Applications/StreamHub/StreamHub.cpp index 29dada3..61f3a21 100644 --- a/Source/Applications/StreamHub/StreamHub.cpp +++ b/Source/Applications/StreamHub/StreamHub.cpp @@ -1736,7 +1736,11 @@ void StreamHub::TriggerTick(float64 wallNowS) { (wallNowS >= rearmAtWallS_)) { rearmPending_ = false; 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(); } } diff --git a/Source/Applications/StreamHub/TriggerEngine.cpp b/Source/Applications/StreamHub/TriggerEngine.cpp index 1281036..0b584c4 100644 --- a/Source/Applications/StreamHub/TriggerEngine.cpp +++ b/Source/Applications/StreamHub/TriggerEngine.cpp @@ -19,7 +19,17 @@ TriggerEngine::TriggerEngine() trigTime_(0.0), firedPreSec_(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) { @@ -40,6 +50,9 @@ void TriggerEngine::SetConfig(const TriggerConfig &cfg) { epoch_++; prevValid_ = false; 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(); } @@ -59,19 +72,40 @@ uint32 TriggerEngine::GetConfigEpoch() const { void TriggerEngine::Arm() { (void) mutex_.FastLock(); - state_ = kTrigArmed; - prevValid_ = false; - prevValue_ = 0.0; + state_ = kTrigArmed; + prevValid_ = false; + 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(); } void TriggerEngine::Disarm() { (void) mutex_.FastLock(); - state_ = kTrigIdle; - stopped_ = false; - prevValid_ = false; - prevValue_ = 0.0; - firedValid_ = false; + state_ = kTrigIdle; + stopped_ = false; + prevValid_ = false; + prevValue_ = 0.0; + firedValid_ = false; + pendingValid_ = false; mutex_.FastUnLock(); } @@ -96,7 +130,10 @@ void TriggerEngine::CheckSample(float64 t, float64 v) { lastTime_ = t; 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(); return; } @@ -121,16 +158,35 @@ void TriggerEngine::CheckSample(float64 t, float64 v) { } if (fired) { - state_ = kTrigCollecting; - trigTime_ = t; - /* Latch the window at fire time so later config edits do not - * affect this capture (web client snap._preS/_postS). */ - firedPreSec_ = config_.windowSec * config_.prePercent / 100.0; - firedPostSec_ = config_.windowSec - firedPreSec_; - firedValid_ = true; - REPORT_ERROR_STATIC(MARTe::ErrorManagement::Information, - "TriggerEngine: fired at t=%.6f (pre=%.4fs post=%.4fs)", - t, firedPreSec_, firedPostSec_); + if (!inFlight) { + /* Latch the window at fire time so later config edits do not + * affect this capture (web client snap._preS/_postS). */ + LatchWindowLocked(t); + REPORT_ERROR_STATIC(MARTe::ErrorManagement::Information, + "TriggerEngine: fired at t=%.6f (pre=%.4fs post=%.4fs)", + 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(); @@ -141,11 +197,7 @@ bool TriggerEngine::Force() { bool ok = lastTimeValid_ && (state_ != kTrigCollecting); if (ok) { - state_ = kTrigCollecting; - trigTime_ = lastTime_; - firedPreSec_ = config_.windowSec * config_.prePercent / 100.0; - firedPostSec_ = config_.windowSec - firedPreSec_; - firedValid_ = true; + LatchWindowLocked(lastTime_); REPORT_ERROR_STATIC(MARTe::ErrorManagement::Information, "TriggerEngine: forced at t=%.6f (pre=%.4fs post=%.4fs)", trigTime_, firedPreSec_, firedPostSec_); diff --git a/Source/Applications/StreamHub/TriggerEngine.h b/Source/Applications/StreamHub/TriggerEngine.h index b3e0015..42ed199 100644 --- a/Source/Applications/StreamHub/TriggerEngine.h +++ b/Source/Applications/StreamHub/TriggerEngine.h @@ -88,9 +88,24 @@ public: */ 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(); + /** + * @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. */ void Disarm(); @@ -102,8 +117,10 @@ public: /** * @brief Edge-detect one decoded sample of the configured signal. - * Receive-thread context. Only acts in ARMED state; on a matching edge - * latches trigTime and the pre/post window and moves to COLLECTING. + * Receive-thread context. In ARMED state a matching edge latches trigTime + * 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); @@ -143,6 +160,21 @@ private: float64 firedPreSec_; ///< Window pre-part latched at fire time float64 firedPostSec_; ///< Window post-part latched at fire time 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() diff --git a/Test/Applications/StreamHub/TriggerEngineGTest.cpp b/Test/Applications/StreamHub/TriggerEngineGTest.cpp index 7e4472b..b7f03bc 100644 --- a/Test/Applications/StreamHub/TriggerEngineGTest.cpp +++ b/Test/Applications/StreamHub/TriggerEngineGTest.cpp @@ -196,6 +196,183 @@ TEST(TriggerEngineGTest, TestRearmResetsEdgeDetection) { 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) { TriggerEngine eng; EXPECT_FALSE(eng.GetStopped());