diff --git a/Client/udpscope/CMakeLists.txt b/Client/udpscope/CMakeLists.txt index 0b7a988..ba24ee8 100644 --- a/Client/udpscope/CMakeLists.txt +++ b/Client/udpscope/CMakeLists.txt @@ -82,6 +82,7 @@ endif() set(CORE_SOURCES Decimate.cpp PaneTree.cpp + TimeBase.cpp ) add_library(udpscope_core STATIC ${CORE_SOURCES}) diff --git a/Client/udpscope/TimeBase.cpp b/Client/udpscope/TimeBase.cpp new file mode 100644 index 0000000..a1c87aa --- /dev/null +++ b/Client/udpscope/TimeBase.cpp @@ -0,0 +1,72 @@ +#include "TimeBase.h" + +#include + +namespace udpscope { + +/* UDPS_T_UINT64 == 6 in Common/UDP/UDPSProtocol.h. Spelled numerically so this + * translation unit stays free of the C client header. */ +static constexpr uint8_t kTypeUint64 = 6u; + +double TimeSignalScale(uint8_t typeCode) { + return (typeCode == kTypeUint64) ? 1.0e-9 : 1.0e-6; +} + +double ClockOffset::map(double producerSec, double wallSec) { + /* Recalibrate only when wall is significantly ahead of the prediction. + * Early-arriving packets (wall < prediction) are normal network jitter and + * must not reset the offset — doing so would wobble the trace. A large + * positive error means the producer clock jumped back or restarted. */ + if (!valid_ || (wallSec - (offset_ + producerSec)) > kRecalibThresholdS) { + offset_ = wallSec - producerSec; + valid_ = true; + } + return offset_ + producerSec; +} + +void HrtRateFit::reset() { + samples_.clear(); + n_ = 0; + rate_ = 0.0; +} + +void HrtRateFit::add(uint64_t hrt, double wallSec) { + samples_.push_back(Sample{static_cast(hrt), wallSec}); + if (samples_.size() > kWindow) { samples_.pop_front(); } + n_++; + if (n_ >= kMinSamples) { refit(); } +} + +void HrtRateFit::refit() { + const size_t n = samples_.size(); + if (n < 2) { return; } + + /* Least squares slope of hrt against wall time. Both are subtracted from + * their first value first: raw hrt counts and epoch seconds are large + * enough that the naive sums lose precision. */ + const double h0 = samples_.front().hrt; + const double w0 = samples_.front().wall; + + double sw = 0.0, sh = 0.0, sww = 0.0, swh = 0.0; + for (const Sample& s : samples_) { + const double w = s.wall - w0; + const double h = s.hrt - h0; + sw += w; + sh += h; + sww += w * w; + swh += w * h; + } + const double dn = static_cast(n); + const double denom = dn * sww - sw * sw; + if (std::fabs(denom) < 1e-12) { return; } + + const double slope = (dn * swh - sw * sh) / denom; + if (slope > 0.0 && std::isfinite(slope)) { rate_ = slope; } +} + +double HrtRateFit::toSeconds(uint64_t hrt) const { + if (rate_ <= 0.0) { return 0.0; } + return static_cast(hrt) / rate_; +} + +} /* namespace udpscope */ diff --git a/Client/udpscope/TimeBase.h b/Client/udpscope/TimeBase.h new file mode 100644 index 0000000..260618e --- /dev/null +++ b/Client/udpscope/TimeBase.h @@ -0,0 +1,78 @@ +/** + * @file TimeBase.h + * @brief Producer-clock to wall-clock reconstruction. + * + * Framework-free. A UDPS stream's accurate timestamps come from a producer + * clock — either a declared time signal or the packet's embedded hrt — and both + * need mapping onto the client's wall clock before they can be plotted. + */ +#pragma once + +#include +#include +#include + +namespace udpscope { + +/** + * @brief Seconds per count of a time signal, from its type code. + * + * The protocol carries uint64 time signals in nanoseconds and everything else + * in microseconds; this mirrors UDPSourceSession so the two agree on a stream. + */ +double TimeSignalScale(uint8_t typeCode); + +/** + * @brief A latched producer-to-wall offset. + * + * Established from the first sample and then held, so network jitter does not + * wobble the trace. Only a drift beyond kRecalibThresholdS — a producer restart + * or re-phase, not delivery noise — forces a new calibration. + */ +class ClockOffset { +public: + static constexpr double kRecalibThresholdS = 0.5; + + /** @return producerSec mapped onto wall clock. */ + double map(double producerSec, double wallSec); + + bool valid() const { return valid_; } + void reset() { valid_ = false; offset_ = 0.0; } + double offset() const { return offset_; } + +private: + double offset_ = 0.0; + bool valid_ = false; +}; + +/** + * @brief Recovers the producer's hrt tick rate by least squares against arrival + * time. + * + * The protocol does not carry the tick rate, and StreamHub's approach of using + * the local MARTe HighResolutionTimer frequency is only valid when the client + * runs on the producer's host. A remote bench scope cannot assume that, so the + * rate is measured: hrt against recv_time is a straight line whose slope is + * ticks per second. + */ +class HrtRateFit { +public: + static constexpr size_t kMinSamples = 32; + static constexpr size_t kWindow = 256; + + void add(uint64_t hrt, double wallSec); + bool ready() const { return n_ >= kMinSamples && rate_ > 0.0; } + double ticksPerSecond() const { return rate_; } + double toSeconds(uint64_t hrt) const; + void reset(); + +private: + void refit(); + + struct Sample { double hrt; double wall; }; + std::deque samples_; + size_t n_ = 0; + double rate_ = 0.0; +}; + +} /* namespace udpscope */ diff --git a/Client/udpscope/tests/TimeBaseTest.cpp b/Client/udpscope/tests/TimeBaseTest.cpp new file mode 100644 index 0000000..a06b9cc --- /dev/null +++ b/Client/udpscope/tests/TimeBaseTest.cpp @@ -0,0 +1,99 @@ +#include "TimeBase.h" + +#include + +using namespace udpscope; + +TEST(ClockOffset, MapsTheFirstReadingOntoWallClockExactly) { + ClockOffset off; + EXPECT_FALSE(off.valid()); + + const double wall = 1756291200.5; + EXPECT_DOUBLE_EQ(off.map(10.0, wall), wall); + EXPECT_TRUE(off.valid()); +} + +// Network delay jitters the arrival time. If the offset chased every packet +// the whole trace would wobble, so it is latched and only corrected on real +// drift. +TEST(ClockOffset, HoldsTheOffsetThroughSmallArrivalJitter) { + ClockOffset off; + off.map(10.0, 1000.0); // offset = 990 + + EXPECT_DOUBLE_EQ(off.map(11.0, 1001.02), 1001.0); + EXPECT_DOUBLE_EQ(off.map(12.0, 1000.97), 1002.0); +} + +TEST(ClockOffset, RecalibratesWhenDriftExceedsTheThreshold) { + ClockOffset off; + off.map(10.0, 1000.0); // offset = 990 + + /* Producer clock jumped (restart, re-phase): 5 s of error is not jitter. */ + const double mapped = off.map(11.0, 1006.0); + EXPECT_DOUBLE_EQ(mapped, 1006.0); +} + +TEST(ClockOffset, ResetForgetsTheCalibration) { + ClockOffset off; + off.map(10.0, 1000.0); + off.reset(); + EXPECT_FALSE(off.valid()); + EXPECT_DOUBLE_EQ(off.map(50.0, 2000.0), 2000.0); +} + +// The tick rate of the producer's high-resolution timer is not carried by the +// protocol, and StreamHub's trick of using the local MARTe timer frequency only +// works on the producer's own host. Recover it from the data instead. +TEST(HrtRateFit, RecoversAKnownTickRate) { + HrtRateFit fit; + const double ticksPerSec = 2.5e9; + + EXPECT_FALSE(fit.ready()); + for (int i = 0; i < 64; i++) { + const double wall = 1000.0 + i * 0.01; + fit.add(static_cast(wall * ticksPerSec), wall); + } + + ASSERT_TRUE(fit.ready()); + EXPECT_NEAR(fit.ticksPerSecond(), ticksPerSec, ticksPerSec * 1e-6); +} + +TEST(HrtRateFit, IsNotReadyBeforeTheMinimumSampleCount) { + HrtRateFit fit; + for (size_t i = 0; i < HrtRateFit::kMinSamples - 1; i++) { + fit.add(static_cast(i) * 1000000u, 1000.0 + i * 0.001); + } + EXPECT_FALSE(fit.ready()); + + fit.add(static_cast(HrtRateFit::kMinSamples) * 1000000u, + 1000.0 + HrtRateFit::kMinSamples * 0.001); + EXPECT_TRUE(fit.ready()); +} + +TEST(HrtRateFit, ToSecondsUsesTheFittedRate) { + HrtRateFit fit; + const double ticksPerSec = 1.0e9; + for (int i = 0; i < 64; i++) { + const double wall = 500.0 + i * 0.005; + fit.add(static_cast(wall * ticksPerSec), wall); + } + ASSERT_TRUE(fit.ready()); + EXPECT_NEAR(fit.toSeconds(2000000000ull), 2.0, 1e-4); +} + +TEST(HrtRateFit, SurvivesAStalledClock) { + HrtRateFit fit; + for (int i = 0; i < 64; i++) { + fit.add(12345u, 1000.0 + i * 0.01); /* hrt never advances */ + } + /* A degenerate fit must not produce a rate that would divide by zero. */ + if (fit.ready()) { + EXPECT_GT(fit.ticksPerSecond(), 0.0); + } +} + +TEST(TimeSignalScale, UsesNanosecondsForUint64AndMicrosecondsOtherwise) { + EXPECT_DOUBLE_EQ(TimeSignalScale(6 /* UDPS_T_UINT64 */), 1.0e-9); + EXPECT_DOUBLE_EQ(TimeSignalScale(9 /* UDPS_T_FLOAT64 */), 1.0e-6); + EXPECT_DOUBLE_EQ(TimeSignalScale(4 /* UDPS_T_UINT32 */), 1.0e-6); +}