Files
MARTe-Integrated-Components/Test/Applications/StreamHub/TriggerEngineGTest.cpp
T
Martino FerrariandClaude Opus 4.6 092fd3c775 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>
2026-09-02 01:45:13 +02:00

400 lines
14 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file TriggerEngineGTest.cpp
* @brief Unit tests for the StreamHub hub-side trigger FSM.
*
* @copyright Copyright 2015 F4E | European Joint Undertaking for ITER and
* the Development of Fusion Energy ('Fusion for Energy').
* Licensed under the EUPL, Version 1.1 or - as soon they will be approved
* by the European Commission - subsequent versions of the EUPL (the "Licence")
* You may not use this work except in compliance with the Licence.
* You may obtain a copy of the Licence at: http://ec.europa.eu/idabc/eupl
*
* @warning Unless required by applicable law or agreed to in writing,
* software distributed under the Licence is distributed on an "AS IS"
* basis, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
* or implied. See the Licence permissions and limitations under the Licence.
*/
#include "TriggerEngine.h"
#include <gtest/gtest.h>
using namespace StreamHub;
using MARTe::float64;
static TriggerConfig MakeConfig(TrigEdge edge, float64 threshold,
float64 windowSec, float64 prePercent,
TrigAcqMode mode = kTrigNormal) {
TriggerConfig cfg;
cfg.signalKey = "src:sig";
cfg.edge = edge;
cfg.threshold = threshold;
cfg.windowSec = windowSec;
cfg.prePercent = prePercent;
cfg.mode = mode;
return cfg;
}
TEST(TriggerEngineGTest, TestInitialState) {
TriggerEngine eng;
EXPECT_EQ(kTrigIdle, eng.GetState());
EXPECT_FALSE(eng.GetStopped());
float64 tt, pre, post;
EXPECT_FALSE(eng.GetFiredWindow(tt, pre, post));
}
TEST(TriggerEngineGTest, TestIdleIgnoresSamples) {
TriggerEngine eng;
eng.SetConfig(MakeConfig(kEdgeRising, 0.5, 1.0, 20.0));
eng.CheckSample(1.0, 0.0);
eng.CheckSample(2.0, 1.0); /* would fire if armed */
EXPECT_EQ(kTrigIdle, eng.GetState());
}
TEST(TriggerEngineGTest, TestRisingEdgeFires) {
TriggerEngine eng;
eng.SetConfig(MakeConfig(kEdgeRising, 0.5, 1.0, 20.0));
eng.Arm();
EXPECT_EQ(kTrigArmed, eng.GetState());
eng.CheckSample(1.0, 0.0); /* primes prevValue */
eng.CheckSample(2.0, 1.0); /* 0.0 < 0.5 <= 1.0 → fire */
EXPECT_EQ(kTrigCollecting, eng.GetState());
float64 tt, pre, post;
ASSERT_TRUE(eng.GetFiredWindow(tt, pre, post));
EXPECT_DOUBLE_EQ(2.0, tt);
EXPECT_DOUBLE_EQ(0.2, pre); /* 1.0 s × 20 % */
EXPECT_DOUBLE_EQ(0.8, post);
}
TEST(TriggerEngineGTest, TestFirstSampleNeverFires) {
TriggerEngine eng;
eng.SetConfig(MakeConfig(kEdgeRising, 0.5, 1.0, 20.0));
eng.Arm();
/* First sample above threshold must only prime the detector */
eng.CheckSample(1.0, 2.0);
EXPECT_EQ(kTrigArmed, eng.GetState());
}
TEST(TriggerEngineGTest, TestFallingEdgeFires) {
TriggerEngine eng;
eng.SetConfig(MakeConfig(kEdgeFalling, 0.5, 1.0, 50.0));
eng.Arm();
eng.CheckSample(1.0, 1.0);
eng.CheckSample(2.0, 1.5); /* rising: must not fire */
EXPECT_EQ(kTrigArmed, eng.GetState());
eng.CheckSample(3.0, 0.2); /* 1.5 > 0.5 >= 0.2 → fire */
EXPECT_EQ(kTrigCollecting, eng.GetState());
float64 tt, pre, post;
ASSERT_TRUE(eng.GetFiredWindow(tt, pre, post));
EXPECT_DOUBLE_EQ(3.0, tt);
EXPECT_DOUBLE_EQ(0.5, pre);
EXPECT_DOUBLE_EQ(0.5, post);
}
TEST(TriggerEngineGTest, TestBothEdgesFire) {
TriggerEngine eng;
eng.SetConfig(MakeConfig(kEdgeBoth, 0.0, 1.0, 0.0));
eng.Arm();
eng.CheckSample(1.0, -1.0);
eng.CheckSample(2.0, 1.0); /* rising through 0 */
EXPECT_EQ(kTrigCollecting, eng.GetState());
eng.Arm();
eng.CheckSample(3.0, 1.0);
eng.CheckSample(4.0, -1.0); /* falling through 0 */
EXPECT_EQ(kTrigCollecting, eng.GetState());
}
TEST(TriggerEngineGTest, TestNoFireBelowThreshold) {
TriggerEngine eng;
eng.SetConfig(MakeConfig(kEdgeRising, 5.0, 1.0, 20.0));
eng.Arm();
eng.CheckSample(1.0, 0.0);
eng.CheckSample(2.0, 1.0);
eng.CheckSample(3.0, 4.9);
EXPECT_EQ(kTrigArmed, eng.GetState());
}
TEST(TriggerEngineGTest, TestConfigClamping) {
TriggerEngine eng;
eng.SetConfig(MakeConfig(kEdgeRising, 0.0, 100.0, 150.0));
TriggerConfig cfg = eng.GetConfig();
EXPECT_DOUBLE_EQ(60.0, cfg.windowSec);
EXPECT_DOUBLE_EQ(100.0, cfg.prePercent);
eng.SetConfig(MakeConfig(kEdgeRising, 0.0, 1.0e-6, -5.0));
cfg = eng.GetConfig();
EXPECT_DOUBLE_EQ(1.0e-4, cfg.windowSec);
EXPECT_DOUBLE_EQ(0.0, cfg.prePercent);
}
TEST(TriggerEngineGTest, TestConfigEpochIncrements) {
TriggerEngine eng;
MARTe::uint32 e0 = eng.GetConfigEpoch();
eng.SetConfig(MakeConfig(kEdgeRising, 0.0, 1.0, 20.0));
EXPECT_EQ(e0 + 1u, eng.GetConfigEpoch());
eng.SetConfig(MakeConfig(kEdgeFalling, 1.0, 2.0, 30.0));
EXPECT_EQ(e0 + 2u, eng.GetConfigEpoch());
}
TEST(TriggerEngineGTest, TestMarkTriggeredOnlyFromCollecting) {
TriggerEngine eng;
eng.SetConfig(MakeConfig(kEdgeRising, 0.5, 1.0, 20.0));
/* Not collecting: no-op */
eng.MarkTriggered();
EXPECT_EQ(kTrigIdle, eng.GetState());
eng.Arm();
eng.MarkTriggered();
EXPECT_EQ(kTrigArmed, eng.GetState());
/* Collecting → triggered */
eng.CheckSample(1.0, 0.0);
eng.CheckSample(2.0, 1.0);
ASSERT_EQ(kTrigCollecting, eng.GetState());
eng.MarkTriggered();
EXPECT_EQ(kTrigTriggered, eng.GetState());
}
TEST(TriggerEngineGTest, TestDisarmResetsEverything) {
TriggerEngine eng;
eng.SetConfig(MakeConfig(kEdgeRising, 0.5, 1.0, 20.0));
eng.Arm();
eng.SetStopped(true);
eng.CheckSample(1.0, 0.0);
eng.CheckSample(2.0, 1.0);
ASSERT_EQ(kTrigCollecting, eng.GetState());
eng.Disarm();
EXPECT_EQ(kTrigIdle, eng.GetState());
EXPECT_FALSE(eng.GetStopped());
float64 tt, pre, post;
EXPECT_FALSE(eng.GetFiredWindow(tt, pre, post));
}
TEST(TriggerEngineGTest, TestRearmResetsEdgeDetection) {
TriggerEngine eng;
eng.SetConfig(MakeConfig(kEdgeRising, 0.5, 1.0, 20.0));
eng.Arm();
eng.CheckSample(1.0, 0.0);
eng.CheckSample(2.0, 1.0);
eng.MarkTriggered();
ASSERT_EQ(kTrigTriggered, eng.GetState());
/* Re-arm: first sample only primes even if it crosses the threshold
* relative to the value seen before the re-arm. */
eng.Arm();
EXPECT_EQ(kTrigArmed, eng.GetState());
eng.CheckSample(3.0, 0.0);
EXPECT_EQ(kTrigArmed, eng.GetState());
eng.CheckSample(4.0, 1.0);
EXPECT_EQ(kTrigCollecting, eng.GetState());
float64 tt, pre, post;
ASSERT_TRUE(eng.GetFiredWindow(tt, pre, post));
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());
eng.SetStopped(true);
EXPECT_TRUE(eng.GetStopped());
eng.SetStopped(false);
EXPECT_FALSE(eng.GetStopped());
}
TEST(TriggerEngineGTest, TestWindowLatchedAtFireTime) {
TriggerEngine eng;
eng.SetConfig(MakeConfig(kEdgeRising, 0.5, 1.0, 20.0));
eng.Arm();
eng.CheckSample(1.0, 0.0);
eng.CheckSample(2.0, 1.0);
ASSERT_EQ(kTrigCollecting, eng.GetState());
/* Config edits after the fire must not change the latched window */
eng.SetConfig(MakeConfig(kEdgeRising, 0.5, 10.0, 50.0));
float64 tt, pre, post;
ASSERT_TRUE(eng.GetFiredWindow(tt, pre, post));
EXPECT_DOUBLE_EQ(0.2, pre);
EXPECT_DOUBLE_EQ(0.8, post);
}