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
+77 -25
View File
@@ -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_);