feat(udpscope): producer-clock calibration and hrt tick-rate fit
Adds TimeBase.h/cpp with ClockOffset (latched wall-clock offset with one-sided recalibration on positive drift only, so early-arriving packets do not wobble the trace) and HrtRateFit (sliding-window OLS that recovers an unknown hrt tick rate from receive timestamps). Also adds TimeSignalScale() which maps UDPS type codes to seconds-per-count. 9 new tests, all 28 pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
ea9689591d
commit
41ab151a2f
@@ -82,6 +82,7 @@ endif()
|
||||
set(CORE_SOURCES
|
||||
Decimate.cpp
|
||||
PaneTree.cpp
|
||||
TimeBase.cpp
|
||||
)
|
||||
|
||||
add_library(udpscope_core STATIC ${CORE_SOURCES})
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
#include "TimeBase.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
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<double>(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<double>(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<double>(hrt) / rate_;
|
||||
}
|
||||
|
||||
} /* namespace udpscope */
|
||||
@@ -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 <cstddef>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
|
||||
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<Sample> samples_;
|
||||
size_t n_ = 0;
|
||||
double rate_ = 0.0;
|
||||
};
|
||||
|
||||
} /* namespace udpscope */
|
||||
@@ -0,0 +1,99 @@
|
||||
#include "TimeBase.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
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<uint64_t>(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<uint64_t>(i) * 1000000u, 1000.0 + i * 0.001);
|
||||
}
|
||||
EXPECT_FALSE(fit.ready());
|
||||
|
||||
fit.add(static_cast<uint64_t>(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<uint64_t>(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);
|
||||
}
|
||||
Reference in New Issue
Block a user