On the hrt branch the derived period is not just a spacing: it is the burst width ClockOffset latches against, so a wrong one shifts the whole trace by an amount that is usually too small for kRecalibThresholdS to ever heal. Three routes to a wrong period were open. Packet loss. elapsed spans every packet since the last one seen, but it was divided by prevAccCount alone, so a lost datagram scaled the period by the whole counter gap. Since a burst is anchored on its LAST element, too wide means it ends in the FUTURE: +22.5 ms for one loss, +225 ms for ten, at 10 samples per 25 ms packet, mis-spacing 2.7% of all samples at 1% loss. The declared branch already reads the counter for exactly this; the hrt branch now does too. Producer restart and reorder. Both leave elapsed at zero, so no period can be measured -- and the restart packet is also the one that re-latches after offset.reset(). Falling back to kDefaultDt is only right at 1 kHz; measured standing displacement was +13.5 ms at 10 samples per 25 ms and -89 ms at 100 per 10 ms. Remember the last measured period instead. A stray hrt == 0 packet re-enters the warm-up branch, which spans from packetBurst's lastPacketWall -- a field the hrt branch never wrote, so it still held the start of the session. After 153 packets that emitted a burst 3.8 s in the past, worse the longer the scope had run. Also: rule 2 with no declared rate stacked every element of the array on one instant (as UDPSourceSession.cpp:522 does, harmlessly, for a host-local consumer). Spread it from consecutive time-signal anchors, which measure the burst on the producer's own clock. Reverts the previous commit's wallElapsed <= 0 change: it was measurably inert -- the step floor two lines below already yields the same number -- and its comment claimed a divergence it did not stop. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
152 lines
8.1 KiB
C++
152 lines
8.1 KiB
C++
/**
|
|
* @file FrameDecoder.h
|
|
* @brief Per-element timestamp reconstruction for UDPS frames.
|
|
*
|
|
* The C client's udps_frame_element_time() is explicitly an arrival-anchored
|
|
* estimate. It is not sufficient: the kernel frequently delivers several queued
|
|
* datagrams in one burst, so two packets are processed microseconds apart even
|
|
* though each represents ~10 ms of signal, and arrival-time interpolation then
|
|
* crams a packet's samples into that tiny gap — the trace renders as a sawtooth.
|
|
* Source/Applications/StreamHub/UDPSourceSession.cpp documents this failure and
|
|
* solves it; these are the same rules, computed from udps_frame_t's own fields.
|
|
*
|
|
* They are NOT the same code, and the differences are not a short list. Every
|
|
* one of them comes from the same root: StreamHub runs on the producer's host,
|
|
* so its arrival time IS the producer's clock and its local
|
|
* HighResolutionTimer::Frequency() IS the frequency behind the packet's hrt.
|
|
* Neither holds over a network, so anything StreamHub can read directly this
|
|
* decoder has to estimate (HrtRateFit, ClockOffset), and anything it estimates
|
|
* it must also defend — hence the monotonic clamps, the kWallBleedFraction
|
|
* bleed, the reorder and restart guards and the duplicate-datagram drop, none of
|
|
* which exist in UDPSourceSession.cpp. Do not read the three sections below as
|
|
* exhaustive; they are the three that change where a sample LANDS, and so the
|
|
* three worth checking first when a trace looks wrong.
|
|
*
|
|
* First, the anchor. StreamHub anchors every accumulated-scalar burst on the
|
|
* packet's own hrt, converted with the LOCAL MARTe HighResolutionTimer
|
|
* frequency — correct only because StreamHub runs on the producer's host. A
|
|
* bench scope attaches over the network and has no access to that frequency; it
|
|
* can only regress hrt against arrival time, which is exactly what the bursty
|
|
* delivery above corrupts. So when a SamplingRate is declared this decoder
|
|
* chains bursts instead, using the packet counter to account for loss and
|
|
* arrival time only as a backstop. The consequence is that a declared rate
|
|
* measured against the producer's crystal rather than ours makes the
|
|
* reconstructed timeline drift, and drift that only arrival time can observe
|
|
* must be corrected against arrival time — see rule 3.
|
|
*
|
|
* Second, which end of the burst is anchored. StreamHub converts the packet's
|
|
* hrt into the position of sample 0 and steps forward, so the burst STARTS at
|
|
* the anchor. Here the anchor is arrival time, and the samples were acquired
|
|
* before the packet carrying them landed — so the burst must END there instead.
|
|
* Both branches of rule 3 do this, or two accumulated scalars in one scope, one
|
|
* with a declared rate and one without, would sit a whole burst apart on the
|
|
* shared X axis.
|
|
*
|
|
* Third, the entry condition. UDPSourceSession.cpp:554 routes any update
|
|
* carrying nElems <= 1 to plain arrival time. That is safe for a host-local
|
|
* consumer whose arrival time is the producer's own clock, but wrong here:
|
|
* Accumulate mode flushes on a TIMER, so a short RT cycle legitimately delivers
|
|
* a single sample between two full bursts. Dating that one sample from arrival
|
|
* while its neighbours are chained puts it off the chain, and — worse — leaves
|
|
* lastCounter behind, so the next full burst reads the skipped counter as a lost
|
|
* datagram and reinstates a hole that never existed. So a signal that has
|
|
* already burst keeps every later update on rule 3 regardless of its length; a
|
|
* signal that has never burst is a genuine scalar and is left to rule 5.
|
|
*
|
|
* Two divergences OUTSIDE rule 3 are known and deliberately left as they are.
|
|
* Rule 1 keys ClockOffset on the consuming signal, where UDPSourceSession.cpp:516
|
|
* keys it on the time-signal index, so signals sharing a time signal share an
|
|
* offset there and not here — immaterial, since the mapping they compute is the
|
|
* same. And a FIRST_SAMPLE/LAST_SAMPLE signal whose time signal is absent falls
|
|
* through to rule 4 rather than using its declared rate; that is a malformed
|
|
* CONFIG, and spanning arrivals is the more honest answer than trusting a rate
|
|
* whose anchor never arrived.
|
|
*/
|
|
#pragma once
|
|
|
|
#include "TimeBase.h"
|
|
#include "Types.h"
|
|
|
|
#include <vector>
|
|
|
|
namespace udpscope {
|
|
|
|
class FrameDecoder {
|
|
public:
|
|
/** Installs the signal table. Clears all per-signal timing history. */
|
|
void setSignals(const std::vector<SignalMeta>& signals);
|
|
|
|
const std::vector<SignalMeta>& signals() const { return signals_; }
|
|
|
|
/** Call once per frame, before any timestamps() call for that frame. */
|
|
void beginFrame(const FrameView& f);
|
|
|
|
/**
|
|
* @brief Timestamps for every value of signal @p idx in this frame.
|
|
* @return false when the signal produced nothing usable — an empty slot, or
|
|
* the first PACKET burst after connect, which has no previous
|
|
* arrival to span from and would otherwise poison the ring with
|
|
* wrongly spaced timestamps.
|
|
*/
|
|
bool timestamps(const FrameView& f, uint32_t idx, std::vector<double>& tsOut);
|
|
|
|
/** Forgets all timing history; call on reconnect. */
|
|
void reset();
|
|
|
|
private:
|
|
bool packetBurst(uint32_t idx, uint32_t nElems, double wallNow,
|
|
std::vector<double>& tsOut);
|
|
|
|
struct SigState {
|
|
ClockOffset offset;
|
|
double lastPacketWall = 0.0;
|
|
bool lastPacketValid = false;
|
|
/** Raw ticks, not seconds — see the comment in the samplingRate == 0
|
|
* branch for why a tick difference is the only safe way to measure a
|
|
* producer-side interval while the rate is still being re-estimated. */
|
|
uint64_t lastAccHrt = 0u;
|
|
/** Producer seconds since this signal's first usable packet, built by
|
|
* SUMMING short tick deltas. Never recomputed from an absolute tick
|
|
* count; see the samplingRate == 0 branch. */
|
|
double accProdSec = 0.0;
|
|
bool lastAccValid = false;
|
|
uint32_t prevAccCount = 0;
|
|
/** Last inter-element period the hrt branch actually MEASURED, used
|
|
* whenever this packet cannot measure one of its own (no previous tick,
|
|
* or hrt went backwards). The constant kDefaultDt is a poor substitute:
|
|
* it is only right at 1 kHz, and a wrong period here is not merely a
|
|
* wrong spacing for one burst — it is the burst width ClockOffset
|
|
* latches against, and the resulting displacement is usually too small
|
|
* for kRecalibThresholdS to ever heal. Zero until first measured. */
|
|
double lastHrtDt = 0.0;
|
|
/** Rule 2 only: the previous packet's time-signal anchor, in PRODUCER
|
|
* seconds. Consecutive anchors are what lets an array with no declared
|
|
* sampling rate be spread at all. */
|
|
double prevAnchorProdSec = 0.0;
|
|
bool prevAnchorValid = false;
|
|
/** For accumulated scalars (rule 3, either branch): end timestamp of the
|
|
* most recently emitted burst, and the packet counter it came from. The
|
|
* next burst is chained onto that end, with the counter gap reinstating
|
|
* the exact duration of any lost datagrams. */
|
|
double lastEmittedEnd = 0.0;
|
|
uint32_t lastCounter = 0u;
|
|
/** ARRIVAL time of the packet that produced lastEmittedEnd. Valid
|
|
* exactly when lastEmittedValid is, so it needs no flag of its own.
|
|
* Deliberately not lastPacketWall, which belongs to packetBurst() and
|
|
* is updated on frames rule 3 never emits. This is the only reference
|
|
* against which a leading timeline can be pulled back: the correction
|
|
* has to be expressed as a fraction of the wall time that has really
|
|
* elapsed since this signal's previous burst, because within a single
|
|
* timestamps() call the wall clock is frozen and every forward step,
|
|
* however small, increases the lead measured at that instant. */
|
|
double lastEmittedWall = 0.0;
|
|
bool lastEmittedValid = false;
|
|
};
|
|
|
|
std::vector<SignalMeta> signals_;
|
|
std::vector<SigState> state_;
|
|
HrtRateFit hrtFit_;
|
|
};
|
|
|
|
} /* namespace udpscope */
|