2 Commits
Author SHA1 Message Date
Martino FerrariandClaude Opus 4.6 5562877c99 fix(udps): publish the producer's HRT frequency so timestamps survive the hop
DATA packets timestamp with the raw value of the producer's high-resolution
counter, and the wire never said how fast that counter runs. The hub divided by
its own timer's frequency instead, which is only the same number while producer
and hub share a machine — on x86 it is the TSC frequency and differs from model
to model. Off-box, every accumulated batch was therefore laid out over the wrong
span of time: the samples in it drift away from where they belong and start
colliding with the next packet's, which is the "same" symptom as a stale time
base even though nothing is out of order.

CONFIG now carries the rate as a trailing uint64, alongside the publish-mode
byte and read the same tolerant way: absent or zero means the producer did not
say, and the hub falls back to its own timer as before. Anything below 1 kHz is
not a high-resolution timer and is refused, so a mis-parsed payload cannot
stretch a millisecond batch across seconds.

The Accumulate DATA payload is unchanged, so this costs nothing per packet and
the period *within* a batch is still estimated from the gap between packets.

The Go, C and browser parsers already ignore trailer bytes they do not know,
so they read the new CONFIG unchanged; none of them uses the HRT timestamp.

Also corrects the Accumulate DATA layout in all three protocol documents: they
described it as one snapshot per array signal, where it has always been one per
accumulated cycle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-02 02:49:50 +02:00
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
15 changed files with 614 additions and 51 deletions
+11 -4
View File
@@ -117,9 +117,16 @@ Sent when the signal set changes or a client connects:
```
[uint32 numSigs]
numSigs × UDPSSignalDescriptor (136 bytes each, packed)
[uint8 publishMode] 0=Strict/Decimate, 1=Accumulate
[uint8 publishMode] 0=Strict, 1=Accumulate, 2=Decimate
[uint64 hrtFrequency] producer's HRT ticks per second; 0 = unknown
```
Everything after the descriptors is an optional trailer: a receiver accepts a
payload that stops early and ignores bytes it does not know. `hrtFrequency` is
what lets a receiver on another host turn the raw counter in DATA into seconds
— without it the only option is the receiver's own timer, which agrees with the
producer only when the two share a machine.
### DATA Payload (Strict / Decimate modes)
```
@@ -130,9 +137,9 @@ per-signal data in CONFIG order (quantised or raw, no inter-signal padding)
### DATA Payload (Accumulate mode)
```
[uint64 HRT timestamp]
[uint32 numSamples]
for each signal: if scalar → numSamples elements; else → NumElements once
[uint64 HRT timestamp of the first slot in the batch]
[uint32 numSamples] RT cycles accumulated into this packet
for each signal, in CONFIG order: numSamples × NumElements values
```
### Quantization / Dequantization
+24 -3
View File
@@ -23,15 +23,22 @@
* [uint32 numSigs]
* numSigs × UDPSSignalDescriptor (136 bytes each, packed)
* [uint8 publishMode] (PublishModeStrict / Accumulate / Decimate)
* [uint64 hrtFrequency] ticks per second of the producer's HRT
*
* Everything after the descriptors is an optional trailer: a receiver must
* accept a payload that stops early and must ignore bytes it does not know.
* publishMode defaults to Strict when absent, hrtFrequency to
* UDPS_HRT_FREQUENCY_UNKNOWN.
*
* DATA payload (Strict / Decimate):
* [uint64 HRT timestamp]
* per-signal data in CONFIG order (quantised or raw, no padding)
*
* DATA payload (Accumulate):
* [uint64 HRT timestamp]
* [uint32 numSamples]
* for each signal: if scalar → numSamples elements; else → NumElements once
* [uint64 HRT timestamp of the first slot in the batch]
* [uint32 numSamples] RT cycles accumulated into this packet
* for each signal, in CONFIG order: numSamples × NumElements values
* (signal-major, one full snapshot per accumulated cycle)
*/
#ifndef UDPS_PROTOCOL_H_
@@ -123,6 +130,20 @@ static const uint8 UDPS_PUBLISH_STRICT = 0u; ///< One packet per Synchronise
static const uint8 UDPS_PUBLISH_ACCUMULATE = 1u; ///< Variable batch; flush on size or time
static const uint8 UDPS_PUBLISH_DECIMATE = 2u; ///< One packet per Ratio calls
/*---------------------------------------------------------------------------*/
/* HRT frequency (CONFIG trailing uint64) */
/*---------------------------------------------------------------------------*/
/**
* Sentinel for a CONFIG that carries no HRT frequency, either because the
* trailer is absent (producer older than this field) or because the producer
* could not determine it. DATA timestamps are raw ticks of the producer's
* high-resolution timer, so without this a receiver on another host has no
* way to turn them into seconds and can only fall back to its own timer's
* frequency — which is right only while the two happen to agree.
*/
static const uint64 UDPS_HRT_FREQUENCY_UNKNOWN = 0u;
/*---------------------------------------------------------------------------*/
/* CONFIG payload — per-signal descriptor */
/*---------------------------------------------------------------------------*/
+23 -1
View File
@@ -84,8 +84,28 @@ Offset Size Type Field
0xFFFFFFFF = PacketTime (no reference)
104 32 char[32] unit null-terminated physical unit string
── (total per signal: 136 bytes) ────────────────────────────
── trailer, immediately after the last descriptor ───────────
0 1 uint8 publishMode 0 = Strict, 1 = Accumulate, 2 = Decimate
1 8 uint64 hrtFrequency producer's HRT ticks per second;
0 = unknown
```
### CONFIG trailer
Everything after the descriptors is a trailer that grew field by field, so a
receiver must accept a payload that stops early and must ignore bytes it does
not recognise. An absent `publishMode` means Strict; an absent or zero
`hrtFrequency` means the producer did not publish its tick rate.
`hrtFrequency` is what makes DATA timestamps interpretable off-box. DATA
carries the raw value of the producer's high-resolution counter, and on x86
that counter runs at the TSC frequency — a different number on every model. A
receiver that divides by its own timer's frequency instead is right only while
producer and consumer sit on the same host; anywhere else every batch is laid
out over the wrong span of time. Fall back to the local frequency only when the
field is missing, and reject implausible values (nothing below 1 kHz is a
high-resolution timer).
### Type Codes
| Code | C type | Bytes/element |
@@ -129,7 +149,9 @@ After reassembly, the DATA payload layout is:
```
Offset Size Type Field
────── ──── ────── ────────────────────────────────────────────────────
0 8 uint64 hrtTimestamp hardware reference timer count at Synchronise()
0 8 uint64 hrtTimestamp producer's high-resolution counter at
Synchronise(); divide by the CONFIG
hrtFrequency to get seconds
── for each signal (in config order) ────────────────────────────────────
varies N×sz — signal data N = numRows×numCols, sz = element size
(wire size if quantized, raw size otherwise)
+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
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 `[trigTimepreSec, 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.
+5 -1
View File
@@ -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();
}
}
+64 -12
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();
}
@@ -62,6 +75,26 @@ void TriggerEngine::Arm() {
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();
}
@@ -72,6 +105,7 @@ void TriggerEngine::Disarm() {
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,17 +158,36 @@ void TriggerEngine::CheckSample(float64 t, float64 v) {
}
if (fired) {
state_ = kTrigCollecting;
trigTime_ = t;
if (!inFlight) {
/* 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;
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_);
+35 -3
View File
@@ -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()
@@ -228,6 +228,9 @@ void UDPSourceSession::ParseConfigPayload(const uint8 *payload, uint32 size) {
sigDescs_[i].unit[sizeof(sigDescs_[i].unit) - 1u] = '\0';
}
publishMode_ = payload[4u + numSigs * UDPS_SIGNAL_DESC_SIZE];
hrtFreq_ = UDPSConfigHrtFrequency(
payload, size, numSigs,
static_cast<float64>(MARTe::HighResolutionTimer::Frequency()));
numSignals_ = numSigs;
configured_ = true;
@@ -276,8 +279,9 @@ void UDPSourceSession::ParseConfigPayload(const uint8 *payload, uint32 size) {
}
REPORT_ERROR_STATIC(MARTe::ErrorManagement::Information,
"UDPSourceSession[%s]: CONFIG received — %u signals.",
id_.Buffer(), numSigs);
"UDPSourceSession[%s]: CONFIG received — %u signals, "
"producer HRT %.0f Hz.",
id_.Buffer(), numSigs, hrtFreq_);
}
void UDPSourceSession::AllocateRingBuffers() {
@@ -572,9 +576,9 @@ void UDPSourceSession::ParseDataPayload(const uint8 *payload, uint32 size,
* immune to this because it is sampled at acquisition.
*
* hrtTimestamp is the HRT counter of sample 0; hrtFreq_ (the
* local HRT frequency, identical to the sender on the same
* host) converts it to seconds, then a one-time calibration
* maps the sender clock onto wall-clock. */
* producer's tick rate, taken from the CONFIG trailer) converts
* it to seconds, then a one-time calibration maps the sender
* clock onto wall-clock. */
const float64 hrt0Sec = static_cast<float64>(hrtTimestamp) /
hrtFreq_;
if ((!timeSigCalibValid_[s]) ||
@@ -102,6 +102,41 @@ inline float64 UDPSEstimateAccumDt(const float64 gap, const uint32 prevN,
return dtEMA;
}
/**
* @brief Pick the tick rate to divide a producer's DATA timestamps by.
*
* DATA packets carry the raw value of the producer's high-resolution counter,
* which is meaningless without the rate it runs at. The rate is published in
* the CONFIG trailer, after the descriptors and the publish-mode byte. When it
* is missing — an older producer, or one that could not determine it — the
* only remaining option is this host's own timer, which is right only while
* the two machines agree; on x86 that is the TSC frequency, so it is a
* different number on every model.
*
* @param payload Reassembled CONFIG payload.
* @param size Bytes in @p payload.
* @param numSigs Signal count already read from the payload, capped to what
* the receiver will store.
* @param localFreq This host's HRT frequency, used as the fallback.
* @return Ticks per second to convert DATA timestamps with; never 0.
*/
inline float64 UDPSConfigHrtFrequency(const uint8 *payload, const uint32 size,
const uint32 numSigs,
const float64 localFreq) {
const uint32 offset = 4u + (numSigs * MARTe::UDPS_SIGNAL_DESC_SIZE) + 1u;
if ((payload != NULL_PTR(const uint8 *)) && (size >= (offset + 8u))) {
uint64 wireFreq = 0u;
memcpy(&wireFreq, payload + offset, 8u);
/* Anything below 1 kHz is not a high-resolution timer; the field is
* either absent, unset, or the payload was mis-parsed, and adopting it
* would stretch every timestamp far enough to make the trace useless. */
if (wireFreq >= 1000u) {
return static_cast<float64>(wireFreq);
}
}
return localFreq;
}
/**
* @brief One connected UDPStreamer source.
*
@@ -463,10 +498,11 @@ private:
float64 lastPktWallS_[UDPSS_MAX_SIGNALS];
bool lastPktWallValid_[UDPSS_MAX_SIGNALS];
/* Accumulated-scalar timing: HRT counter frequency (local == sender on the
* same host) and the previous packet's sample count, used to reconstruct
* per-sample timestamps from the embedded sender HRT instead of the (UDP
* burst-sensitive) packet arrival time. */
/* Accumulated-scalar timing: the producer's HRT counter frequency and the
* previous packet's sample count, used to reconstruct per-sample timestamps
* from the embedded sender HRT instead of the (UDP burst-sensitive) packet
* arrival time. Seeded from this host's timer and replaced by the rate the
* producer publishes in CONFIG; see UDPSConfigHrtFrequency. */
float64 hrtFreq_;
uint32 accScalarPrevN_[UDPSS_MAX_SIGNALS];
@@ -810,7 +810,9 @@ bool UDPStreamer::PrepareNextState(const char8 *const currentStateName,
* receives it immediately. The config is static for the lifetime of this
* state. */
if (ok) {
uint32 configBufSize = 4u + (numSigs * UDPS_SIGNAL_DESC_SIZE) + 32u + 1u;
/* numSigs + descriptors + publishMode + hrtFrequency (+ slack). */
uint32 configBufSize =
4u + (numSigs * UDPS_SIGNAL_DESC_SIZE) + 1u + 8u + 32u;
HeapI *heap = GlobalObjectsDatabase::Instance()->GetStandardHeap();
uint8 *cfgBuf = reinterpret_cast<uint8 *>(heap->Malloc(configBufSize));
if (cfgBuf != NULL_PTR(uint8 *)) {
@@ -1244,6 +1246,16 @@ bool UDPStreamer::BuildConfigPayload(uint8 *buf, uint32 bufSize,
buf[payloadSize] = static_cast<uint8>(publishMode);
payloadSize += 1u;
/* 8 bytes: this host's HRT tick rate. DATA packets carry raw counter
* values, so a receiver on another machine cannot turn them into seconds
* without it. */
if ((payloadSize + 8u) > bufSize) {
return false;
}
uint64 hrtFrequency = HighResolutionTimer::Frequency();
(void)MemoryOperationsHelper::Copy(buf + payloadSize, &hrtFrequency, 8u);
payloadSize += 8u;
return true;
}
@@ -674,6 +674,14 @@ bool DebugService::SendUDPSConfig() {
payloadOffset++;
}
// Write this host's HRT tick rate: DATA packets carry raw counter values,
// which a receiver on another machine cannot convert to seconds without it.
if ((payloadOffset + 8u) <= CFG_BUF_SIZE) {
uint64 hrtFrequency = HighResolutionTimer::Frequency();
memcpy(payload + payloadOffset, &hrtFrequency, 8u);
payloadOffset += 8u;
}
udpsNumSlots = newNumSlots;
mutex.FastUnLock();
@@ -0,0 +1,151 @@
/**
* @file ConfigHrtFreqGTest.cpp
* @brief Tests UDPSConfigHrtFrequency, which picks the tick rate used to turn
* a producer's DATA timestamps into seconds.
*
* DATA packets carry the raw value of the producer's high-resolution counter.
* Until the rate was published in CONFIG the hub divided by its own timer's
* frequency, which is only right while the producer runs on the same host
* on x86 that number is the TSC frequency and differs from model to model, so
* off-box every accumulated batch was laid out over the wrong span of time.
*
* The field is a trailer, so these tests pin both directions: a payload that
* carries it must be believed, and one that stops early an older producer
* must still decode against the local fallback rather than against zero.
*
* @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 <gtest/gtest.h>
#include <string.h>
#include "UDPSourceSession.h"
using MARTe::uint8;
using MARTe::uint32;
using MARTe::uint64;
using MARTe::float64;
using MARTe::UDPS_SIGNAL_DESC_SIZE;
using StreamHub::UDPSConfigHrtFrequency;
namespace {
/** This hub's own timer rate, i.e. what the code must fall back to. */
const float64 kLocalFreq = 1.0e9;
/** A plausible producer rate that is deliberately not kLocalFreq. */
const uint64 kWireFreq = 2400000000ULL;
const uint32 kNumSigs = 2u;
/** Offset of the CONFIG trailer that follows the publish-mode byte. */
uint32 TrailerOffset(uint32 numSigs) {
return 4u + (numSigs * UDPS_SIGNAL_DESC_SIZE) + 1u;
}
/**
* Builds a CONFIG payload for kNumSigs signals.
* @param withFreq Append the 8-byte HRT frequency trailer.
* @param freq Value to append when @p withFreq.
* @param[out] size Bytes written.
*/
const uint8 *BuildConfig(bool withFreq, uint64 freq, uint32 &size) {
static uint8 buf[4u + (kNumSigs * UDPS_SIGNAL_DESC_SIZE) + 1u + 8u];
(void) memset(buf, 0, sizeof(buf));
(void) memcpy(buf, &kNumSigs, 4u);
size = TrailerOffset(kNumSigs);
if (withFreq) {
(void) memcpy(buf + size, &freq, 8u);
size += 8u;
}
return buf;
}
} // namespace
/* The whole point of the field: a producer that publishes its rate is believed
* even when the hub's own timer runs at a different one. */
TEST(ConfigHrtFreqGTest, AdoptsThePublishedRate) {
uint32 size = 0u;
const uint8 *cfg = BuildConfig(true, kWireFreq, size);
EXPECT_DOUBLE_EQ(static_cast<float64>(kWireFreq),
UDPSConfigHrtFrequency(cfg, size, kNumSigs, kLocalFreq));
}
/* A producer older than the field stops after the publish-mode byte. Reading
* past it would take whatever follows in the receive buffer as a frequency. */
TEST(ConfigHrtFreqGTest, FallsBackWhenTheTrailerIsAbsent) {
uint32 size = 0u;
const uint8 *cfg = BuildConfig(false, 0u, size);
EXPECT_DOUBLE_EQ(kLocalFreq,
UDPSConfigHrtFrequency(cfg, size, kNumSigs, kLocalFreq));
}
/* A trailer cut short mid-field is not a frequency either; taking the bytes
* that are there would assemble one out of whatever the rest of the buffer
* holds. */
TEST(ConfigHrtFreqGTest, FallsBackOnATruncatedTrailer) {
uint32 size = 0u;
const uint8 *cfg = BuildConfig(true, kWireFreq, size);
EXPECT_DOUBLE_EQ(kLocalFreq,
UDPSConfigHrtFrequency(cfg, size - 1u, kNumSigs,
kLocalFreq));
}
/* Zero is the protocol's "I do not know my own rate". Dividing by it yields
* infinities that propagate into every timestamp. */
TEST(ConfigHrtFreqGTest, FallsBackOnTheUnknownSentinel) {
uint32 size = 0u;
const uint8 *cfg = BuildConfig(true, MARTe::UDPS_HRT_FREQUENCY_UNKNOWN,
size);
EXPECT_DOUBLE_EQ(kLocalFreq,
UDPSConfigHrtFrequency(cfg, size, kNumSigs, kLocalFreq));
}
/* No high-resolution timer ticks slower than 1 kHz, so a value that low means
* the payload was misread. Adopting it would stretch a millisecond batch
* across whole seconds. */
TEST(ConfigHrtFreqGTest, FallsBackOnAnImplausiblyLowRate) {
uint32 size = 0u;
const uint8 *cfg = BuildConfig(true, 999u, size);
EXPECT_DOUBLE_EQ(kLocalFreq,
UDPSConfigHrtFrequency(cfg, size, kNumSigs, kLocalFreq));
}
/* The trailer sits after the descriptors, so its offset moves with the signal
* count; a fixed offset would read descriptor bytes on any other config. */
TEST(ConfigHrtFreqGTest, LocatesTheTrailerAfterTheDescriptors) {
const uint32 numSigs = 7u;
const uint32 size = TrailerOffset(numSigs) + 8u;
uint8 buf[4u + (7u * UDPS_SIGNAL_DESC_SIZE) + 1u + 8u];
/* Fill the descriptor area with a byte pattern that would decode as a
* plausible frequency if the offset were wrong. */
(void) memset(buf, 0x11, sizeof(buf));
(void) memcpy(buf, &numSigs, 4u);
(void) memcpy(buf + TrailerOffset(numSigs), &kWireFreq, 8u);
EXPECT_DOUBLE_EQ(static_cast<float64>(kWireFreq),
UDPSConfigHrtFrequency(buf, size, numSigs, kLocalFreq));
}
/* A null payload must not be dereferenced: CONFIG arrives from the network. */
TEST(ConfigHrtFreqGTest, FallsBackOnANullPayload) {
EXPECT_DOUBLE_EQ(kLocalFreq,
UDPSConfigHrtFrequency(NULL_PTR(const uint8 *), 1024u,
kNumSigs, kLocalFreq));
}
+1 -1
View File
@@ -22,7 +22,7 @@
#
#############################################################
OBJSX = TriggerEngineSrc.x BinaryRecorderSrc.x SignalRingBufferGTest.x TriggerEngineGTest.x LTTBGTest.x BinaryRecorderGTest.x BoundsCheckTest.x WSServerBufferTest.x AccumDtGTest.x
OBJSX = TriggerEngineSrc.x BinaryRecorderSrc.x SignalRingBufferGTest.x TriggerEngineGTest.x LTTBGTest.x BinaryRecorderGTest.x BoundsCheckTest.x WSServerBufferTest.x AccumDtGTest.x ConfigHrtFreqGTest.x
PACKAGE=Applications
ROOT_DIR=../../..
@@ -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());
@@ -36,6 +36,7 @@
#include "ConfigurationDatabase.h"
#include "GAM.h"
#include "GAMScheduler.h"
#include "HighResolutionTimer.h"
#include "MemoryOperationsHelper.h"
#include "ObjectRegistryDatabase.h"
#include "RealTimeApplication.h"
@@ -907,6 +908,23 @@ bool UDPStreamerTest::TestExecute_ConnectDataDisconnect() {
reinterpret_cast<const UDPSPacketHeader *>(recvBuf);
ok &= (hdr->magic == UDPS_MAGIC);
ok &= (hdr->type == UDPS_TYPE_CONFIG);
/* The CONFIG trailer must carry this host's HRT tick rate: DATA
* packets timestamp with the raw counter, so a receiver on another
* machine has nothing to convert it with otherwise. */
const uint8 *payload = recvBuf + UDPS_HEADER_SIZE;
uint32 numSigs = 0u;
if (ok && (hdr->payloadBytes >= 4u)) {
(void) memcpy(&numSigs, payload, 4u);
}
const uint32 freqOff =
4u + (numSigs * UDPS_SIGNAL_DESC_SIZE) + 1u;
ok &= (hdr->payloadBytes >= (freqOff + 8u));
if (ok) {
uint64 wireFreq = 0u;
(void) memcpy(&wireFreq, payload + freqOff, 8u);
ok &= (wireFreq == HighResolutionTimer::Frequency());
}
}
}