The counter is the denominator of the very period lastAccHrt is the numerator of, so any packet that cannot move the tick reference must not move the counter either. Two paths were violating that: a reordered datagram rolled the counter back while the reference correctly held (next burst drawn 0.048x too narrow at distance 20, 83.3% worst spacing error under 2% sustained reordering), and a stray hrt == 0 packet advanced the counter from the warm-up branch without a tick to match (+22.5 ms of future-dating per stray packet). Rules 1 and 2 now record a counter too. The duplicate-datagram guard is keyed on one, so an array rule that recorded none was exempt and plotted every doubly-delivered update twice. Also: rule 2 divides by the count the anchor actually spans and falls back to the last period it derived; the counter-gap test is wrap-safe so 2^32 rollover reads as no information rather than 2e9 lost packets. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
162 lines
8.8 KiB
C++
162 lines
8.8 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.
|
|
*
|
|
* Divergences OUTSIDE rule 3 exist as well; two are named here only because they
|
|
* are the ones easily mistaken for bugs. Rules 1 and 2 key ClockOffset on the
|
|
* CONSUMING signal, where UDPSourceSession.cpp:538 and :516 key 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. Neither this list nor the three above is closed: any other
|
|
* difference you find is unexamined, not sanctioned.
|
|
*/
|
|
#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, the element count that anchor spanned, and the last period
|
|
* actually derived from a pair of them. Consecutive anchors are what
|
|
* lets an array with no declared sampling rate be spread at all. */
|
|
double prevAnchorProdSec = 0.0;
|
|
uint32_t prevAnchorCount = 0u;
|
|
double prevAnchorDt = 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;
|
|
/** Whether lastCounter holds a counter this signal has actually seen.
|
|
* Distinct from lastEmittedValid, which is about the emitted TIMELINE:
|
|
* rules 1 and 2 keep a counter (so the duplicate-datagram guard can
|
|
* fire for them too) without ever joining rule 3's chain. */
|
|
bool counterValid = false;
|
|
/** 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 */
|