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:
Martino Ferrari
2026-08-27 19:50:24 +02:00
co-authored by Claude Sonnet 4.6
parent ea9689591d
commit 41ab151a2f
4 changed files with 250 additions and 0 deletions
+72
View File
@@ -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 */