1237 lines
66 KiB
C++
1237 lines
66 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#include "acquisition.hpp"
|
|
|
|
#include <limits>
|
|
|
|
#include "layout_store.hpp"
|
|
#include "source/registry.hpp"
|
|
|
|
#include <glib/gstdio.h>
|
|
|
|
#include <arpa/inet.h>
|
|
#include <netinet/in.h>
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <cstring>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
|
|
namespace udpscope {
|
|
namespace {
|
|
|
|
/// How far a packet's stamps may run ahead before the jump is read as a clock fault rather
|
|
/// than a stream gap. Also the cap for hole reservation: 256 packet spans in one go is an
|
|
/// outage, not a loss, and reserving one would push the entire retained history out of the
|
|
/// ring to make room for samples that are not coming.
|
|
constexpr double kGlitchPackets = 256.0;
|
|
|
|
} // namespace
|
|
|
|
Acquisition::Acquisition(ScopeModel& model, const Config& cfg) : model_(model), cfg_(cfg) {}
|
|
|
|
Acquisition::~Acquisition() { stop(); }
|
|
|
|
void Acquisition::start() {
|
|
// A started -- or *restarted* -- source is a new stream. Going live from a file view is the restart that
|
|
// matters: the store still holds the event's samples, and the new stream's clock can sit behind their
|
|
// stamps, so the guard below has to re-base rather than clamp. `ring=[2.5200..2.5200]` with `n` climbing
|
|
// was the symptom -- every packet pinned flat at the file view's last stamp.
|
|
model_.reset_stream();
|
|
last_stamp_ = -std::numeric_limits<double>::infinity();
|
|
if (!source_) {
|
|
SourceKind* kind = find_kind(cfg_.source_kind);
|
|
if (kind == nullptr) throw std::runtime_error("unknown source kind: " + cfg_.source_kind);
|
|
source_ = kind->make(cfg_.source_config());
|
|
}
|
|
source_->set_datagram_tap([this](const std::uint8_t* bytes, std::size_t len, double recv_time) {
|
|
on_datagram(bytes, len, recv_time);
|
|
});
|
|
source_->open(*this);
|
|
// `--capture-packets`: armed before the first datagram, so the first CONNECT and
|
|
// its CONFIG -- the hardest strange behaviours to reproduce -- are in the ring.
|
|
// The open itself still happens on the producer thread, at the first datagram.
|
|
if (cfg_.capture_packets > 0) {
|
|
set_capture_want(true, cfg_.capture_packets);
|
|
}
|
|
|
|
started_ = std::chrono::steady_clock::now();
|
|
running_.store(true, std::memory_order_release);
|
|
thread_ = std::thread([this] { run(); });
|
|
}
|
|
|
|
void Acquisition::stop() {
|
|
if (running_.exchange(false, std::memory_order_acq_rel) && thread_.joinable()) thread_.join();
|
|
if (source_) source_->close();
|
|
}
|
|
|
|
void Acquisition::run() {
|
|
while (running_.load(std::memory_order_acquire)) {
|
|
source_->poll(50);
|
|
const SourceStats s = source_->stats();
|
|
StreamStats& out = model_.stats();
|
|
out.packets.store(s.packets, std::memory_order_relaxed);
|
|
out.frames.store(s.frames, std::memory_order_relaxed);
|
|
out.gaps.store(s.gaps, std::memory_order_relaxed);
|
|
out.resequenced.store(s.resequenced, std::memory_order_relaxed);
|
|
out.late.store(s.late, std::memory_order_relaxed);
|
|
out.stale.store(s.stale, std::memory_order_relaxed);
|
|
out.overflows.store(s.overflows, std::memory_order_relaxed);
|
|
out.reconnects.store(s.reconnects, std::memory_order_relaxed);
|
|
out.connected.store(s.connected, std::memory_order_relaxed);
|
|
}
|
|
}
|
|
|
|
void Acquisition::set_capture_want(bool want, std::uint32_t slots) {
|
|
if (want) {
|
|
if (slots > 0) capture_slots_ = slots;
|
|
if (capture_path_.empty()) {
|
|
// Keyed on the source: one path per source, the previous capture kept one deep.
|
|
std::string host = cfg_.session_host();
|
|
for (char& ch : host) {
|
|
if (ch == ':' || ch == '/' || ch == ' ') ch = '_';
|
|
}
|
|
std::string dir = cache_dir() + "/" + host + "-" + std::to_string(cfg_.session_port());
|
|
g_mkdir_with_parents(dir.c_str(), 0700);
|
|
capture_path_ = dir + "/capture.bin";
|
|
std::uint32_t addr = 0;
|
|
std::uint16_t peer_port = 0;
|
|
if (cfg_.source_kind == "udps") {
|
|
// The source config, not cfg_.server/port: --source-opt overrides only the former.
|
|
const SourceConfig sc = cfg_.source_config();
|
|
struct ::in_addr a;
|
|
if (::inet_pton(AF_INET, sc.get("server", "127.0.0.1").c_str(), &a) == 1) {
|
|
addr = a.s_addr; // already network order, which is what the header wants
|
|
}
|
|
peer_port = static_cast<std::uint16_t>(sc.get_int("port", 8000));
|
|
}
|
|
capture_.set_peer(addr, peer_port);
|
|
}
|
|
}
|
|
capture_want_.store(want, std::memory_order_release);
|
|
}
|
|
|
|
void Acquisition::on_datagram(const std::uint8_t* bytes, std::size_t len, double recv_time) {
|
|
const bool want = capture_want_.load(std::memory_order_acquire);
|
|
if (want && !capture_.enabled()) {
|
|
// A user-initiated act that happens once per toggle, on this thread: a rename, a
|
|
// create, a 62.6 MiB reserve and an mmap. It can cost the packets in flight, and
|
|
// the measured cost is `capture_.open_ms()`.
|
|
if (!capture_.open(capture_path_, capture_slots_)) {
|
|
char err[128];
|
|
capture_.error(err, sizeof(err));
|
|
std::fprintf(stderr, "[udpscope] packet capture failed to open: %s\n", err);
|
|
// Stay off rather than retrying every datagram against a full disk; the GTK
|
|
// thread reads the flag back and returns the button to off.
|
|
capture_want_.store(false, std::memory_order_release);
|
|
return;
|
|
}
|
|
std::fprintf(stderr, "[udpscope] packet capture on: %s (%u slots, %.1f ms to open)\n",
|
|
capture_.path().c_str(), capture_.slot_count(), capture_.open_ms());
|
|
} else if (!want && capture_.enabled()) {
|
|
capture_.close();
|
|
std::fprintf(stderr, "[udpscope] packet capture off\n");
|
|
}
|
|
if (capture_.enabled()) {
|
|
capture_.store(bytes, len, recv_time);
|
|
}
|
|
}
|
|
|
|
void Acquisition::on_config(const std::vector<SignalInfo>& info, std::uint8_t publish_mode) {
|
|
// One line per CONFIG on stderr: what the stream says it is. This is the first
|
|
// thing to look at when a signal refuses to plot.
|
|
static const char* const kModes[] = {"strict", "accumulate", "decimate"};
|
|
std::fprintf(stderr, "[udpscope] CONFIG: %u signals, publish=%s\n",
|
|
static_cast<unsigned>(info.size()),
|
|
publish_mode < 3 ? kModes[publish_mode] : "?");
|
|
for (const SignalInfo& sig : info) {
|
|
std::fprintf(stderr,
|
|
"[udpscope] '%s' %ux%u (%u elem) rate=%g Hz time=%s idx=%u unit='%s'\n",
|
|
sig.name.c_str(), sig.rows, sig.cols, sig.elements(), sig.sampling_rate,
|
|
sig.time_mode == kTimePacket ? "packet"
|
|
: sig.time_mode == kTimeFullArray ? "full-array"
|
|
: sig.time_mode == kTimeFirstSample ? "first-sample"
|
|
: "last-sample",
|
|
sig.time_signal_idx, sig.unit.c_str());
|
|
}
|
|
std::fflush(stderr);
|
|
// The warn-once latches are per *connection*, not per process. A reconnect re-enters here, and
|
|
// a diagnostic that goes quiet after the first occurrence of a condition that recurs on the
|
|
// next session is the failure mode worth avoiding in a feature whose whole job is diagnosis.
|
|
warned_batch_ = false;
|
|
warned_outage_ = false;
|
|
warned_phantom_gap_ = false;
|
|
warned_bridge_ = false;
|
|
warned_time_source_ = false;
|
|
warned_clock_stall_ = false;
|
|
// The clock choice is per connection too: a reconnect re-derives it from the frames
|
|
// the new session actually delivers, rather than trusting a decision made about a
|
|
// session that dropped.
|
|
time_source_ = 0;
|
|
reset_session_state();
|
|
model_.stats().have_config.store(true, std::memory_order_relaxed);
|
|
model_.set_signals(info);
|
|
}
|
|
|
|
void Acquisition::on_event(SourceEvent event, const char* detail) {
|
|
(void)detail;
|
|
if (event == SourceEvent::Connected) {
|
|
model_.stats().connected.store(true, std::memory_order_relaxed);
|
|
} else if (event == SourceEvent::Disconnected) {
|
|
model_.stats().connected.store(false, std::memory_order_relaxed);
|
|
// The session dropped mid-flight: everything the producer knows about *that*
|
|
// session's numbering and clocks is now a lie about the next one. Clearing it here
|
|
// is what makes the next frame re-anchor instead of reading as late, as a glitch or
|
|
// as an outage -- and nothing here touches the buffer, whose history survives the
|
|
// drop (see `ScopeModel::set_signals`'s unchanged-CONFIG path).
|
|
reset_session_state();
|
|
}
|
|
}
|
|
|
|
void Acquisition::reset_session_state() noexcept {
|
|
have_counter_ = false;
|
|
last_counter_ = 0;
|
|
have_stream_anchor_ = false;
|
|
last_stream_anchor_ = 0.0;
|
|
stamp_failures_ = 0;
|
|
// The arrival clock's *anchor* stays -- stamps are small seconds the ring's timeline
|
|
// is already scaled against, and re-anchoring mid-stream would move x -- but the
|
|
// smoothed interval and the previous arrival are per-connection measurements, and
|
|
// carrying them across a multi-second outage makes the first new frame read as a
|
|
// clock fault rather than as the resumption it is.
|
|
have_arrival_interval_ = false;
|
|
arrival_interval_ = 0.0;
|
|
arrival_prev_ = 0.0;
|
|
have_prev_frame_recv_ = false;
|
|
prev_frame_recv_ = 0.0;
|
|
// The clock origin is adopted again: the next session's first stamp is that session's epoch.
|
|
epoch_adopted_ = false;
|
|
}
|
|
|
|
void Acquisition::note_frame_arrival(double recv_time) noexcept {
|
|
const bool first = !have_prev_frame_recv_;
|
|
const double elapsed = first ? 0.0 : recv_time - prev_frame_recv_;
|
|
prev_frame_recv_ = recv_time;
|
|
have_prev_frame_recv_ = true;
|
|
// The arrival *cadence* belongs to the arrival clock, and `build_stamps()`'s arrival branch keeps
|
|
// it whenever that clock is the one in play (`time_source_ == 2`, decided once per connection and
|
|
// sticky). Every other case needs it here: relative mode returns before that branch, and a stream
|
|
// whose declared time signal checks out never reaches it either -- while the reservation test
|
|
// below weighs the counter's claim against a measured cadence whatever clock the display uses,
|
|
// because a counter that does not count packets lies in all of them. The two writers are therefore
|
|
// exclusive by `time_source_`, and this is the branch's own update (0.8/0.2) so the estimate does
|
|
// not jump at the one frame where the clock changes hands.
|
|
if (first || time_source_ == 2) return;
|
|
if (elapsed > 0.0 && std::isfinite(elapsed)) {
|
|
arrival_interval_ =
|
|
have_arrival_interval_ ? 0.8 * arrival_interval_ + 0.2 * elapsed : elapsed;
|
|
have_arrival_interval_ = true;
|
|
}
|
|
}
|
|
|
|
bool Acquisition::gap_plausible(std::uint32_t missing, double elapsed, const Partition& part,
|
|
u64 g) const {
|
|
if (missing == 0) return true;
|
|
// The ring cannot hold a hole bigger than itself, and this test needs no measurement: the
|
|
// invariant's own rule is that the reservation's *sample* cost -- `missing * batch_`, not the
|
|
// packet cap -- is what has to be weighed against the ring. A hole that laps the ring erases the
|
|
// history it is meant to annotate, can never be backfilled (its slots are already overwritten),
|
|
// and is never forgotten (`forget_holes_before()` waits for slots the ring has passed), so it
|
|
// leaves a band over a buffer that is all NaN.
|
|
if (g == 0 || part.time_capacity == 0) return false;
|
|
if (u64(missing) * u64(batch_) / g >= part.time_capacity) return false;
|
|
// And the counter has to agree with the wire about how long the loss took. The counter is a claim
|
|
// about the stream; the arrival clock is a *measurement* of it: `missing` packets would have taken
|
|
// `missing` frame intervals to arrive, and the frame before this one is the witness of how long
|
|
// actually passed. A device whose counter counts samples, or frames of an inner layer, reports a
|
|
// forward jump on *every* datagram while the wire delivers on time -- and believing that booked a
|
|
// NaN hole per packet, i.e. a band over the whole window filled with samples that were never
|
|
// missing, which no backfill can repair because no packet was ever lost. The factor is a half and
|
|
// not one: the socket clock jitters and a drain can compress two frames' arrival times, so only a
|
|
// claim the wire flatly contradicts (half the time it needs, or less) is refused. A refused gap
|
|
// costs a shading, never data.
|
|
if (have_arrival_interval_ && arrival_interval_ > 0.0 && elapsed > 0.0 &&
|
|
elapsed < 0.5 * double(missing) * arrival_interval_) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
std::uint32_t Acquisition::build_stamps(const Frame& frame, int time_signal,
|
|
bool counter_gap_refused) {
|
|
// The frame's span in base ticks: a scalar signal carries `num_samples` values
|
|
// (one per batched cycle), a block signal (rows*cols > 1) carries its whole
|
|
// element block. The largest of those is how many base ticks this frame spans --
|
|
// in `strict` mode `num_samples` is 1 while a 1x1000 block carries 1000 samples.
|
|
std::uint32_t n = frame.num_samples;
|
|
for (std::uint32_t i = 0; i < frame.num_signals; ++i) {
|
|
n = std::max(n, frame.signals[i].elements());
|
|
}
|
|
stamps_.assign(n, 0.0);
|
|
abs_stamps_.assign(n, 0.0);
|
|
|
|
// Samples per packet, needed in both modes: relative stamping must advance its own clock
|
|
// past the reserved hole before placing this frame's own stamps, and `on_data` must size
|
|
// the hole before calling this function -- both operations require knowing how many samples
|
|
// a missing packet represents, which is only knowable from the first frame that arrives.
|
|
if (batch_ == 0) {
|
|
batch_ = n;
|
|
if (cfg_.relative_time) {
|
|
std::fprintf(stderr,
|
|
"[udpscope] relative time: batch=%u samples/packet, rate=%.6g S/s declared\n",
|
|
batch_, model_.partition().base_dt > 0.0 ? 1.0 / model_.partition().base_dt
|
|
: 0.0);
|
|
std::fflush(stderr);
|
|
}
|
|
} else if (n != batch_) {
|
|
// A frame whose length disagrees with the first one is an inconsistency in the
|
|
// producer, not a loss: the counter still names one packet. Warn once and keep the
|
|
// established batch, which is only used to size holes -- the timeline itself is built
|
|
// from the lengths actually seen, so it stays exact for this frame either way.
|
|
if (!warned_batch_) {
|
|
warned_batch_ = true;
|
|
std::fprintf(stderr,
|
|
"[udpscope] warning: packet carries %u samples but the stream's first "
|
|
"carried %u; keeping %u as the packet length a lost packet is worth.\n",
|
|
n, batch_, batch_);
|
|
std::fflush(stderr);
|
|
}
|
|
}
|
|
|
|
// Relative mode: the stamp is the *declared* interval counted from the stream's own origin.
|
|
// Nothing below this block runs -- not the arrival-cadence override, not the degeneracy guard,
|
|
// not the glitch bridge, not the monotonic clamp's reason for existing -- because a counted
|
|
// clock cannot jitter, cannot fail to advance and cannot jump.
|
|
//
|
|
// The clock is **accumulated in seconds**, never recomputed as `position * interval`: see
|
|
// `Acquisition::rel_base_s_` for the defect that distinction cost. The interval is read from the
|
|
// plan, which is the stream's declared rate as soon as a streamed channel exists -- and
|
|
// `on_data` refuses a frame until one does, so the fallback interval can never be the origin.
|
|
if (cfg_.relative_time) {
|
|
double dt = model_.partition().base_dt;
|
|
if (!(dt > 0.0)) dt = 1.0e-3;
|
|
// The reserved hole for the missing packets advances the clock so this frame's own stamps land
|
|
// *after* the hole rather than on top of it. The same conditions the reservation call uses: not
|
|
// late, gap present, batch known, within the cap -- and not one the arrival clock or the ring
|
|
// refused, because the advance and the reservation are the *same* claim about the wire:
|
|
// believing a phantom gap here would stretch the timeline by exactly the loss that did not
|
|
// happen.
|
|
if (!frame.late && frame.lost > 0 && batch_ > 0 && !counter_gap_refused &&
|
|
double(frame.lost) <= kGlitchPackets) {
|
|
rel_base_s_ += double(frame.lost) * double(batch_) * dt;
|
|
}
|
|
for (std::uint32_t k = 0; k < n; ++k) {
|
|
stamps_[k] = rel_base_s_ + double(k) * dt;
|
|
}
|
|
rel_base_s_ += double(n) * dt;
|
|
// The absolute clock is still read, for export only. When the stream carries no usable
|
|
// time signal there is no absolute clock to record and the relative stamp is the honest
|
|
// answer -- an export then says what the counter says, which is all anyone had.
|
|
if (time_signal >= 0 && time_signal < static_cast<int>(frame.num_signals)) {
|
|
const SignalInfo& ts = frame.signals[time_signal];
|
|
const bool scalar_time = ts.elements() == 1;
|
|
for (std::uint32_t k = 0; k < n; ++k) {
|
|
abs_stamps_[k] =
|
|
scalar_time
|
|
? frame.value(static_cast<std::uint32_t>(time_signal), k, 0)
|
|
: frame.value(static_cast<std::uint32_t>(time_signal), 0, k);
|
|
}
|
|
} else {
|
|
for (std::uint32_t k = 0; k < n; ++k) abs_stamps_[k] = stamps_[k];
|
|
}
|
|
return n;
|
|
}
|
|
|
|
double period = model_.partition().base_dt;
|
|
if (!(period > 0.0)) period = 1.0e-3;
|
|
// The cadence of the samples actually stored: the partition's slot interval (base
|
|
// tick times the clock's decimation), as *declared* by the CONFIG.
|
|
const double slot = model_.partition().slot_dt();
|
|
double cadence = slot > 0.0 ? slot : period;
|
|
// A declared rate is a claim the stream may not keep (a CONFIG declaring 1 MS/s
|
|
// while delivering a hundred samples every fifth of a second is the case that
|
|
// exposed this): stamps spaced by the declaration then span microseconds inside a
|
|
// frame and jump by the frame interval between frames, which reads as a staircase
|
|
// in the x column and, in an export, as data that is "downsampled" while the
|
|
// values are fine. When the arrivals disagree with the declaration, the arrivals
|
|
// are the truth -- the mean spacing of the samples that actually arrived.
|
|
if (have_arrival_interval_ && n > 0) {
|
|
const double measured = arrival_interval_ / double(n);
|
|
const bool agrees = cadence > 0.0 && measured >= 0.75 * cadence && measured <= 1.25 * cadence;
|
|
if (measured > 0.0 && std::isfinite(measured) && !agrees) {
|
|
cadence = measured;
|
|
}
|
|
}
|
|
|
|
bool used_stream_time = false;
|
|
// Once the stream has been judged to have no usable time base, it keeps the
|
|
// arrival clock for the rest of its life (until a CONFIG says otherwise): letting
|
|
// the decision flip per frame is what interleaved two timelines in one ring.
|
|
if (time_source_ != 2 && time_signal >= 0 &&
|
|
time_signal < static_cast<int>(frame.num_signals)) {
|
|
const SignalInfo& ts = frame.signals[time_signal];
|
|
// How a frame indexes a signal's values depends on its shape
|
|
// (`Frame::value`): a scalar carries one value per batched cycle, so the
|
|
// stamp of sample k is (sample=k, elem=0); a multi-element signal carries one
|
|
// *sample* of `elements` values per packet, so the stamp of sample k is
|
|
// (sample=0, elem=k). Passing the scalar form for both is why FULL_ARRAY time
|
|
// signals -- the only mode that carries a stamp per element -- read as
|
|
// element 0 for every sample and were then judged degenerate.
|
|
const bool scalar_time = ts.elements() == 1;
|
|
auto value_of = [&](std::uint32_t k) {
|
|
return scalar_time
|
|
? frame.value(static_cast<std::uint32_t>(time_signal), k, 0)
|
|
: frame.value(static_cast<std::uint32_t>(time_signal), 0, k);
|
|
};
|
|
switch (ts.time_mode) {
|
|
case kTimeFullArray:
|
|
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = value_of(k);
|
|
used_stream_time = true;
|
|
break;
|
|
case kTimeFirstSample: {
|
|
// Only element 0 is meaningful here: the producer stamps the batch's first
|
|
// sample and says nothing about the rest, so the cadence has to space them --
|
|
// exactly as LAST_SAMPLE below dates its batch backwards from its anchor.
|
|
// Deriving a step from `value_of(n - 1)` instead cannot work: a scalar signal
|
|
// in Accumulate mode still occupies `num_samples` slots on the wire, and a
|
|
// producer fills them by repeating the anchor, so the step came out as zero
|
|
// and every sample of the frame landed on one x. The degeneracy guard below
|
|
// then threw the whole stream onto the arrival clock.
|
|
const double anchor = value_of(0);
|
|
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = anchor + double(k) * cadence;
|
|
used_stream_time = true;
|
|
break;
|
|
}
|
|
case kTimeLastSample: {
|
|
const double anchor = value_of(n > 0 ? n - 1 : 0);
|
|
for (std::uint32_t k = 0; k < n; ++k)
|
|
stamps_[k] = anchor - double(n - 1 - k) * cadence;
|
|
used_stream_time = true;
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
// A stream-time signal may be declared in a time unit rather than seconds ('ns' is
|
|
// the common one). The scale has to be known *here*, before the checks below, because
|
|
// they weigh the stamps' own spacing against the declared cadence, which is in
|
|
// seconds: on a 'ns' stream an unscaled comparison is off by a factor of 1e9, so
|
|
// every packet would read as an implausible jump.
|
|
double unit_scale = 1.0;
|
|
if (time_signal >= 0 && time_signal < static_cast<int>(model_.signals().size())) {
|
|
const std::string& unit = model_.signals()[static_cast<std::size_t>(time_signal)].unit;
|
|
if (unit == "ns") unit_scale = 1e-9;
|
|
else if (unit == "us" || unit == "\u00b5s" || unit == "\xce\xbc" "s") unit_scale = 1e-6;
|
|
else if (unit == "ms") unit_scale = 1e-3;
|
|
}
|
|
|
|
if (used_stream_time && n > 0) {
|
|
// One sample's worth of the time signal's *own* unit, which is what a synthesised
|
|
// continuation has to be spaced by and what a jump is measured against.
|
|
const double step = cadence / unit_scale;
|
|
// How many packets in a row must fail before the declared time signal is abandoned
|
|
// for the arrival clock. A glitch is one packet; a signal that is not a time base
|
|
// at all fails every one of them.
|
|
constexpr unsigned kFailureLimit = 4;
|
|
|
|
// A time signal that does not actually vary -- a vector time signal, whose
|
|
// values the client reads one element at a time, or a producer that leaves it
|
|
// zero -- would put every sample at one x and collapse the axis to a point.
|
|
// The arrival estimate is a worse clock but a usable one; a degenerate one is
|
|
// not a time base at all.
|
|
const char* fault = nullptr;
|
|
double lo = stamps_[0];
|
|
double hi = stamps_[0];
|
|
if (!std::isfinite(stamps_[0])) fault = "carries a stamp that is not finite";
|
|
for (std::uint32_t k = 1; fault == nullptr && k < n; ++k) {
|
|
if (!std::isfinite(stamps_[k])) {
|
|
fault = "carries a stamp that is not finite";
|
|
break;
|
|
}
|
|
lo = std::min(lo, stamps_[k]);
|
|
hi = std::max(hi, stamps_[k]);
|
|
}
|
|
if (fault == nullptr && !(hi > lo)) fault = "carries no span (every stamp is equal)";
|
|
if (fault == nullptr && have_stream_anchor_) {
|
|
// The stamps must also ADVANCE across packets, and by a plausible amount. A
|
|
// producer that restarts its stamps every packet (or a signal mislabelled as
|
|
// time) keeps handing back the same values; the max-clamp would then pin every
|
|
// sample at one x, and the trigger's hold-off clock with it -- the engine waited
|
|
// forever and no second event ever fired.
|
|
if (!(stamps_[n - 1] > last_stream_anchor_)) {
|
|
fault = "does not advance across packets";
|
|
} else if (stamps_[0] - last_stream_anchor_ > kGlitchPackets * double(n) * step) {
|
|
fault = "jumped implausibly far ahead of the previous packet";
|
|
}
|
|
}
|
|
if (fault == nullptr) {
|
|
stamp_failures_ = 0;
|
|
last_stream_anchor_ = stamps_[n - 1];
|
|
have_stream_anchor_ = true;
|
|
} else if (have_stream_anchor_ && ++stamp_failures_ < kFailureLimit) {
|
|
// A short run of bad packets is bridged rather than believed: the stamps are
|
|
// replaced by a continuation of the accepted timeline at the declared cadence,
|
|
// so neither the monotonic mark nor the anchor is poisoned, and the next good
|
|
// packet -- which carries on from where the true timeline was -- is accepted
|
|
// normally. Believing one glitching packet instead is what condemned the *next*,
|
|
// perfectly good one and threw the whole stream onto the arrival clock.
|
|
const double from = last_stream_anchor_;
|
|
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = from + double(k + 1) * step;
|
|
last_stream_anchor_ = stamps_[n - 1];
|
|
if (!warned_bridge_) {
|
|
warned_bridge_ = true;
|
|
std::fprintf(stderr,
|
|
"[udpscope] warning: the stream's time signal %s (%g..%g against a "
|
|
"previous %g); bridging that packet at the declared cadence and "
|
|
"keeping the stream clock.\n",
|
|
fault, lo, hi, from);
|
|
}
|
|
} else {
|
|
used_stream_time = false; // not a time base; fall back to arrival
|
|
if (!warned_time_source_) {
|
|
warned_time_source_ = true;
|
|
std::fprintf(stderr,
|
|
"[udpscope] warning: the stream's time signal %s (%g..%g); timing "
|
|
"samples by arrival instead. Check the time signal's mode and index "
|
|
"in the CONFIG dump above.\n",
|
|
fault, lo, hi);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!used_stream_time) {
|
|
// Arrival-anchored: the packet is assumed to have arrived as its last element
|
|
// was produced, and the earlier ones are dated backwards. A gap (`lost`)
|
|
// shows up as extra elapsed time between arrivals, which is exactly the
|
|
// spacing the samples had.
|
|
const double end = frame.recv_time;
|
|
if (!have_arrival_epoch_) {
|
|
arrival_epoch_ = end;
|
|
have_arrival_epoch_ = true;
|
|
} else {
|
|
const double elapsed = end - arrival_prev_;
|
|
if (elapsed > 0.0 && std::isfinite(elapsed)) {
|
|
// Smoothed: the socket clock jitters by a millisecond or so per frame, and
|
|
// an unsmoothed step would make every frame's span disagree slightly.
|
|
arrival_interval_ = have_arrival_interval_ ? 0.8 * arrival_interval_ + 0.2 * elapsed
|
|
: elapsed;
|
|
have_arrival_interval_ = true;
|
|
}
|
|
}
|
|
arrival_prev_ = end;
|
|
const double base = end - arrival_epoch_;
|
|
// The spacing between two *stored* samples is the partition's own cadence --
|
|
// the exact interval the ingest filter decimates to, which is `slot_dt()` and
|
|
// not the base tick. Deriving it from the arrival gap divided by
|
|
// `n * (lost + 1)` collapsed the step to ~zero whenever the client reported
|
|
// losses, and a whole frame then shared one f32 instant: the x column became a
|
|
// staircase (many rows at one time, then a jump) while the values were fine --
|
|
// which is what an export of it looks like, and what the plot's crosshair and
|
|
// trigger times were reading. The arrival clock still anchors the frame's LAST
|
|
// sample, so a lost packet or a stalled producer still shows up as a jump
|
|
// *between* frames.
|
|
// The frame's stamps start at the later of two anchors: where the arrival clock
|
|
// puts the frame's first sample, and one cadence past the previous frame's last.
|
|
// The second is what keeps the timeline strictly increasing -- anchoring the
|
|
// frame's *end* at the arrival let a cadence estimate even slightly long reach
|
|
// back past the previous end, and the monotonic clamp then flattened the overlap
|
|
// into equal timestamps (a consumer plotting against them draws a vertical line
|
|
// there, and an export reads as if sampled twice at one instant). A gap still
|
|
// shows: when the arrivals run ahead, the first anchor wins and the frame jumps.
|
|
const double base_rel = base - model_.epoch();
|
|
const double start = std::max(base_rel - double(n - 1) * cadence, last_stamp_ + cadence);
|
|
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = start + double(k) * cadence + model_.epoch();
|
|
}
|
|
|
|
// A change of time source invalidates the running mark: the stream-time branch
|
|
// stamps in the time signal's own values and the arrival branch in seconds since
|
|
// the first packet, and the two timelines share no zero. Leaving the mark at the
|
|
// old source's last value made every arriving frame clamp flat onto it -- which is
|
|
// the 2.5 s plateau of one timestamp that a run reports when its declared time
|
|
// signal turns out unusable and the app falls back mid-stream.
|
|
if (time_source_ == 0) {
|
|
time_source_ = used_stream_time ? 1 : 2;
|
|
} else if (time_source_ == 1 && !used_stream_time) {
|
|
// The declared time signal failed its checks for good: keep the arrival clock from
|
|
// here on, anchored so that it continues from the monotonic mark. The two timelines
|
|
// then share a zero *by construction*, which is the whole point of deriving the
|
|
// epoch from `last_stamp_` -- so the recorded history is still on one timeline and
|
|
// is kept. It used to be wiped here, on the reasoning that the old stamps belonged
|
|
// to a dead clock; they do not, and the wipe is what made a single glitching packet
|
|
// look like the stream restarting from zero.
|
|
time_source_ = 2;
|
|
const double end = frame.recv_time;
|
|
arrival_epoch_ = end - (last_stamp_ + model_.epoch());
|
|
arrival_prev_ = end;
|
|
}
|
|
|
|
// The declared unit, applied last so the x axis is in seconds either way. It applies
|
|
// ONLY to stream time: the arrival fallback already produces seconds off the socket
|
|
// clock -- scaling those by the time signal's unit shrank the whole time base by 1e9
|
|
// on 'ns' streams, which froze the trigger's hold-off clock and pinned the axis at ~0.
|
|
if (used_stream_time && unit_scale != 1.0) {
|
|
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] *= unit_scale;
|
|
}
|
|
|
|
// Stream mode: `time()` already holds the absolute clock, so the parallel ring repeats it
|
|
// rather than carrying a second timeline. One rule for the export either way.
|
|
for (std::uint32_t k = 0; k < n; ++k) abs_stamps_[k] = stamps_[k];
|
|
|
|
return n;
|
|
}
|
|
|
|
void Acquisition::on_data(const Frame& frame) {
|
|
|
|
std::lock_guard<std::mutex> lock(model_.mutex());
|
|
if (!running_.load(std::memory_order_relaxed)) return;
|
|
|
|
// The counter is this frame's identity and the hole's address. A *late* frame's counter is
|
|
// behind the sequence, so it must not move the mark -- the hole it belongs in was addressed
|
|
// from the counter that revealed it.
|
|
if (!have_counter_ || static_cast<std::int32_t>(frame.counter - last_counter_) > 0) {
|
|
last_counter_ = frame.counter;
|
|
have_counter_ = true;
|
|
}
|
|
|
|
const Partition& part = model_.partition();
|
|
const std::vector<ChannelInfo>& channels = model_.channels();
|
|
// A frame is only stored into a model that has a **streamed** channel. A model holding only
|
|
// *derived* ones -- a session's math channels, applied before the stream's own have arrived, or
|
|
// a file's channels while the math is re-applied -- has nowhere to put a streamed sample, and its
|
|
// plan is built from the 1 kHz fallback interval, which is **not the stream's rate**. Accepting
|
|
// such a frame (which the `channels.empty()` test below did) stamps the timeline at that fallback,
|
|
// and the frame after the stream's own channels arrive makes every later stamp *behind* the one
|
|
// already written -- the monotonic clamp holds the clock there for the rest of the run, the
|
|
// trigger's `history` never fills and the plot draws nothing. The reading is the `trig engine:`
|
|
// line: `n` climbing while `ring=[a..a]` stands still.
|
|
bool streamed = false;
|
|
for (const ChannelInfo& c : channels) {
|
|
if (!c.math) {
|
|
streamed = true;
|
|
break;
|
|
}
|
|
}
|
|
if (part.time_capacity == 0 || channels.empty() || !streamed) return;
|
|
|
|
// A frame delivered behind the sequence: the slots it owns were reserved as NaN by
|
|
// `reserve_hole()`, so it writes values into them and does nothing else. It is deliberately
|
|
// not stamped, does not advance `tick_`, and never reaches the trigger or the math channels:
|
|
// the time ring was pre-stamped at reservation and those consumers are stateful and have
|
|
// already run past this region. That is the trade the design takes -- the raw trace repairs,
|
|
// a derived trace keeps the gap, and nothing that was computed from the gap is retrofitted.
|
|
//
|
|
// Placed here and not earlier because `part`/`channels` are not in scope above, and not
|
|
// later because `build_stamps()` is stateful: a late frame's stamps would walk the clock
|
|
// backwards and can throw the whole stream onto the arrival clock.
|
|
if (frame.late) {
|
|
backfill_from(frame, part, channels, model_.live(), part.time_stride);
|
|
return;
|
|
}
|
|
|
|
// The time reference, in order of preference: the signal the first channel's
|
|
// CONFIG entry names via `time_signal_idx`, or -- when the stream instead
|
|
// carries its time base as an explicit full-array signal -- that signal.
|
|
int time_signal = -1;
|
|
{
|
|
const SignalInfo& sig = model_.signals()[channels[0].signal];
|
|
if (sig.time_signal_idx != 0xFFFFFFFFu && sig.time_signal_idx < model_.signals().size()) {
|
|
time_signal = static_cast<int>(sig.time_signal_idx);
|
|
}
|
|
}
|
|
if (time_signal < 0) {
|
|
// No channel names a time signal: look for the standalone timestamp signal
|
|
// (full-array, more than one element, no declared rate).
|
|
for (std::size_t i = 0; i < model_.signals().size(); ++i) {
|
|
const SignalInfo& sig = model_.signals()[i];
|
|
if (sig.time_mode == kTimeFullArray && sig.elements() > 1 &&
|
|
sig.sampling_rate == 0.0) {
|
|
time_signal = static_cast<int>(i);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// The wire's own cadence, and the verdict on the counter's claim, both taken *before* the
|
|
// stamps: relative mode's clock advance and the reservation below are the same claim
|
|
// about the wire (see `build_stamps`), so they have to agree about it. The verdict is taken
|
|
// before the cadence is updated, too: folding this frame's own arrival into the estimate would
|
|
// let one burst raise the very number that is supposed to corroborate it -- a 200-packet outage
|
|
// smooths the interval to 40 ms and then refuses itself.
|
|
const bool first_frame = !have_prev_frame_recv_;
|
|
const double since_prev = first_frame ? 0.0 : frame.recv_time - prev_frame_recv_;
|
|
const std::uint32_t missing = frame.lost;
|
|
const bool gap_refused =
|
|
missing > 0 && !gap_plausible(missing, since_prev, part, part.time_stride);
|
|
note_frame_arrival(frame.recv_time);
|
|
|
|
const std::uint32_t n = build_stamps(frame, time_signal, gap_refused);
|
|
if (n == 0) return;
|
|
|
|
// The epoch -- the stream time the display's x = 0 means -- is the first frame's stamp, adopted
|
|
// **once per connection**. The test is a state and not a value: relative mode's first frame is
|
|
// stamped exactly 0, so an `epoch() == 0.0` test reads that as "no epoch yet" and lets the
|
|
// *second* frame move the origin, which stalls the timeline through that frame (the samples of
|
|
// one frame all land on one stamp) and shifts every later x by a frame's span. The flag is
|
|
// re-armed by `reset_session_state()`, because a new session's clock is a new clock.
|
|
if (!epoch_adopted_) {
|
|
epoch_adopted_ = true;
|
|
model_.set_epoch_if_unset(stamps_[0]);
|
|
std::fprintf(stderr, "[udpscope] time base: epoch=%.3f, first frame recv=%.3f, n=%u\n",
|
|
model_.epoch(), frame.recv_time, n);
|
|
std::fflush(stderr);
|
|
}
|
|
const double epoch = model_.epoch();
|
|
|
|
TriggerConfig& tc = model_.trigger();
|
|
// One ring, always filling, whatever the trigger is doing: the pre-trigger
|
|
// history has to already be there when an edge arrives, and the first latch after
|
|
// arming would otherwise open on however little had accumulated since. What
|
|
// freezes in trigger mode is the *view*, not acquisition -- the latch copies the
|
|
// event window out into an immutable `EventSnapshot`.
|
|
BufferStore& store = model_.live();
|
|
const u64 g = part.time_stride;
|
|
// `tick % g == g - 1` as a mask. `plan_partition()` doubles `time_stride` from 1, so `g` is
|
|
// a power of two -- but it is a *runtime* value, so the modulo compiled to a real 64-bit
|
|
// division rather than the cheap test its comment there claims. See `acc_mask_` for the
|
|
// per-channel form and for what that cost on the hot loop.
|
|
const u64 g_mask = g - 1;
|
|
|
|
// A re-plan replaced the store, so every ring's `produced()` restarted at 0 while `tick_`
|
|
// kept counting. `backfill_from()` derives a slot index from a hole's `tick_begin`, which is
|
|
// only an absolute slot index while `tick_ / period == produced()`, so the tick counter has
|
|
// to restart with the buffer -- otherwise the amend lands outside the ring, repairs nothing,
|
|
// and the hole is still marked repaired, which is the band saying the opposite of the truth.
|
|
// The generation is the signal rather than the stride/channel-count comparison below: only
|
|
// a CONFIG that *changes* the stream re-plans the store -- a reconnect's identical CONFIG
|
|
// keeps it, history and all (see `ScopeModel::set_signals`).
|
|
// `rel_base_s_` is deliberately *not* reset -- it is relative mode's clock origin, seconds
|
|
// since the stream began, and relative time must not jump backwards when the user retypes
|
|
// the window.
|
|
if (const unsigned gen = model_.plan_generation(); gen != seen_plan_generation_) {
|
|
seen_plan_generation_ = gen;
|
|
tick_ = 0;
|
|
last_tick_ = ~std::uint64_t{0};
|
|
// Zeroing the stride makes the guard below fire too: the accumulators belong to the group
|
|
// `tick_` was counting through, so they are stale for the same reason.
|
|
acc_stride_ = 0;
|
|
}
|
|
|
|
// The filter's groups are `g` long, so a re-plan has to start a fresh group rather than
|
|
// finish the one it was in with the wrong length.
|
|
if (acc_stride_ != g || acc_channels_ != channels.size()) {
|
|
acc_stride_ = g;
|
|
acc_channels_ = channels.size();
|
|
value_acc_.assign(channels.size(), 0.0);
|
|
value_acc_n_.assign(channels.size(), 0);
|
|
math_sample_.assign(channels.size(), std::nanf(""));
|
|
// The group boundaries, as masks, for the per-sample loops below. Here rather than in
|
|
// them: `g` and the strides are properties of the partition, and a partition change is
|
|
// exactly what this block already reacts to.
|
|
acc_mask_.resize(channels.size());
|
|
for (std::size_t i = 0; i < channels.size(); ++i) {
|
|
acc_mask_[i] = g * std::max<u64>(1, part.stride[i]) - 1;
|
|
}
|
|
time_acc_ = 0.0;
|
|
abs_time_acc_ = 0.0;
|
|
time_acc_n_ = 0;
|
|
}
|
|
|
|
// The gap the counter reports, reserved as NaN before this frame's own samples are
|
|
// stamped: the hole belongs *ahead* of the data that revealed it. Running the missing
|
|
// samples through the same per-sample loop below is the whole trick -- `tick_` advances
|
|
// so the decimation phase does not shift, the group mean lands on the mean stamp, and
|
|
// `MinMaxPyramid::append` sees NaN, which its non-finite contract already stores as an
|
|
// empty aggregate every reader treats as absent.
|
|
//
|
|
// Capped at `kGlitchPackets`: 256 packet spans in one go is an outage, not loss, and
|
|
// reserving one would push the entire retained history out of the ring to make room for
|
|
// samples that are not coming.
|
|
//
|
|
// This block must follow the re-plan above: reserve_hole() runs the missing samples
|
|
// through the same decimation accumulators (value_acc_, value_acc_n_, math_sample_),
|
|
// which must already be sized to the current channel count and stride before any
|
|
// per-sample write.
|
|
if (missing > 0 && gap_refused) {
|
|
// A claim the wire contradicted, or a hole the ring cannot hold: framing it as NaN is what
|
|
// turns one phantom gap into a band over the whole window (and, at a batch big enough, into
|
|
// minutes of the producer's time under this lock). The samples are kept, no hole is booked,
|
|
// and the timeline above was not advanced either -- so the picture shows the data and the gap
|
|
// shows as the x discontinuity it is. Said once per connection with the numbers, because it is
|
|
// otherwise invisible: the status bar's `gaps` climbs either way.
|
|
model_.stats().gaps_refused.fetch_add(1, std::memory_order_relaxed);
|
|
if (!warned_phantom_gap_) {
|
|
warned_phantom_gap_ = true;
|
|
std::fprintf(stderr,
|
|
"[udpscope] warning: the counter reports %u packet(s) missing (%llu samples, "
|
|
"%.3f s at the measured cadence of %.6f s/frame) but only %.3f s passed since "
|
|
"the previous frame, or the hole is larger than the ring (%llu slots); "
|
|
"refusing the reservation and keeping the samples. A counter that does not "
|
|
"count packets -- samples, or frames of an inner layer -- reports a jump on "
|
|
"every datagram and reads exactly like this.\n",
|
|
missing, static_cast<unsigned long long>(std::uint64_t(missing) * batch_),
|
|
double(missing) * arrival_interval_, arrival_interval_, since_prev,
|
|
static_cast<unsigned long long>(u64(missing) * u64(batch_) / part.time_stride));
|
|
std::fflush(stderr);
|
|
}
|
|
} else if (missing > 0 && batch_ > 0 && double(missing) <= kGlitchPackets) {
|
|
reserve_hole(missing, part, channels, store, g);
|
|
} else if (missing > 0 && !warned_outage_) {
|
|
warned_outage_ = true;
|
|
std::fprintf(stderr,
|
|
"[udpscope] warning: %u packets missing in one go, past the %g-packet "
|
|
"reservation cap; treating it as an outage and carrying on rather than "
|
|
"reserving %llu samples of hole.\n",
|
|
missing, kGlitchPackets,
|
|
static_cast<unsigned long long>(std::uint64_t(missing) * batch_));
|
|
std::fflush(stderr);
|
|
}
|
|
|
|
// Calibration is read per frame; it only changes under the same lock.
|
|
TriggerEngine& engine = engine_;
|
|
const int trig_channel = tc.channel;
|
|
// The post-trigger window straight through in seconds. There used to be a measured,
|
|
// smoothed samples-per-second estimate here, converting the window into a sample
|
|
// count for the engine; the estimate is what sized captures wrong. Its floor
|
|
// (`inst >= 1.0`) turned any stream whose time signal advances in large steps into
|
|
// 1 S/s, the count came out as 1, and the engine latched on the edge sample itself
|
|
// -- an event with no post-trigger data, which is exactly the "post window is
|
|
// empty" defect. The engine is fed stream time per sample anyway, so it can count
|
|
// the window itself and no conversion has to exist.
|
|
//
|
|
// The oldest buffered stamp goes with it, so the engine can refuse an edge the
|
|
// history does not reach back far enough behind. Read from the ring rather than
|
|
// timed from the arm: the ring fills with the trigger off too, so the pre window is
|
|
// usually already there and a timer would delay the first event for nothing.
|
|
const double event_span = model_.event_span();
|
|
const double post_want = tc.post_span_s(event_span);
|
|
const double oldest_s =
|
|
store.time().size() > 0 ? double(store.time()[0]) : last_stamp_;
|
|
// The channel's *name* goes with it, because that is what identifies it across a
|
|
// rebuild of the channel list (see `TriggerEngine::configure`).
|
|
static const std::string kNoTriggerChannel;
|
|
const std::string& trig_name =
|
|
trig_channel >= 0 && trig_channel < static_cast<int>(channels.size())
|
|
? channels[static_cast<std::size_t>(trig_channel)].name
|
|
: kNoTriggerChannel;
|
|
engine.configure(tc, trig_name, post_want, event_span - post_want, oldest_s,
|
|
model_.partition().slot_dt());
|
|
// The engine's armed state follows the panel's, both ways: without the disarm
|
|
// half, Disarm left the engine firing (its own `armed_` was only ever set), and
|
|
// re-entering trigger mode kept the stale prev/edge/hold-off state.
|
|
if (tc.enabled && tc.armed) {
|
|
if (!engine.armed()) {
|
|
engine.arm(last_stamp_);
|
|
model_.trigger_armed().store(true, std::memory_order_relaxed);
|
|
}
|
|
} else if (engine.armed()) {
|
|
// Disarm cuts a capture in progress where it got to and keeps it, instead of
|
|
// discarding it. `engine.disarm()` clears `pending_`, which drops the display out of
|
|
// `Capturing`; with nothing published the view fell back to the *previous* event, or
|
|
// to blank if this was the first -- the capture the user was watching simply
|
|
// vanished. Publishing first means the same snapshot path freezes it, so what is on
|
|
// screen at the moment of the Disarm stays on screen.
|
|
//
|
|
// The full post window is requested and `live_window_locked()` clips it to what the
|
|
// rings hold, so the snapshot ends at the newest sample and reports its own shorter
|
|
// `post_s` -- the same way a first latch whose pre window was short reports its own
|
|
// `pre_s`. No event fired, so `trigger_latches()` is deliberately *not* bumped; the
|
|
// UI notices the new picture through `event_seq()`.
|
|
if (engine.pending()) {
|
|
const double edge = engine.last_trigger_time();
|
|
model_.publish_event(event_span - post_want, post_want, edge);
|
|
model_.trigger_time().store(edge, std::memory_order_relaxed);
|
|
model_.trigger_time_valid().store(true, std::memory_order_relaxed);
|
|
auto snap = model_.event();
|
|
std::fprintf(stderr,
|
|
"[udpscope] trigger: disarmed mid-capture, edge at %.3f s kept as "
|
|
"[-%.4f..%.4f] n=%llu (post window asked %.4f s, filled %.4f s)\n",
|
|
edge, snap ? snap->pre_s : 0.0, snap ? snap->post_s : 0.0,
|
|
static_cast<unsigned long long>(snap ? snap->stamps.size() : 0), post_want,
|
|
engine.post_elapsed());
|
|
std::fflush(stderr);
|
|
}
|
|
engine.disarm();
|
|
model_.trigger_armed().store(false, std::memory_order_relaxed);
|
|
}
|
|
|
|
double last_stamp_used = last_stamp_;
|
|
|
|
// The latch is handled on the sample it happens on, and with the edge the engine
|
|
// latched *on* -- after that sample's channels have all been pushed (see the loop).
|
|
//
|
|
// It used to set a flag and publish once after the loop, reading the edge back from
|
|
// the engine. But the loop keeps feeding the engine after a latch, so any further
|
|
// edge in the rest of the packet -- the hold-off is 10 ms and a packet can be a long
|
|
// block of samples -- overwrote that edge before it was read. The capture was then
|
|
// cut around an edge whose post window had not been collected yet, so the event came
|
|
// out all pre-trigger with the marker and x = 0 at the right-hand end of the data.
|
|
// Publishing here also means the ring provably holds the post window: the latch
|
|
// condition is that stream time has passed `edge + post`, and every sample up to
|
|
// this one has already been pushed.
|
|
const double span = std::max(1.0e-9, model_.event_span());
|
|
const double post_s = tc.post_span_s(span);
|
|
const auto latch = [&](double edge) {
|
|
model_.publish_event(span - post_s, post_s, edge);
|
|
model_.trigger_latches().fetch_add(1, std::memory_order_relaxed);
|
|
model_.trigger_time().store(edge, std::memory_order_relaxed);
|
|
model_.trigger_time_valid().store(true, std::memory_order_relaxed);
|
|
// The captured window is reported from the snapshot itself, not from the request:
|
|
// `want` versus `got` is what distinguishes "the engine did not wait" from "the
|
|
// cut lost the data", which is otherwise guesswork from the picture alone.
|
|
auto snap = model_.event();
|
|
std::fprintf(stderr,
|
|
"[udpscope] trigger: edge at %.3f s, latched at %.3f s of stream time "
|
|
"(want [-%.4f..%.4f] got [-%.4f..%.4f] n=%llu, frame recv=%.3f, "
|
|
"ticks_so_far=%llu, wall=%.3f s)\n",
|
|
edge, last_stamp_, span - post_s, post_s, snap ? snap->pre_s : 0.0,
|
|
snap ? snap->post_s : 0.0,
|
|
static_cast<unsigned long long>(snap ? snap->stamps.size() : 0),
|
|
frame.recv_time, static_cast<unsigned long long>(tick_),
|
|
std::chrono::duration<double>(std::chrono::steady_clock::now() - started_)
|
|
.count());
|
|
std::fflush(stderr);
|
|
if (tc.mode == TriggerMode::Single) tc.armed = false;
|
|
};
|
|
|
|
// The monotonic clamp, and the one state it must not be allowed to sit in. `max()` is for a
|
|
// stream clock that glitches, fails to advance for a sample or arrives out of order -- one
|
|
// sample, or a few, held back. A **whole frame** whose stamps are all behind the clock is not
|
|
// that: the timeline has stopped while the wire has not, and nothing downstream can recover. It
|
|
// is staged as a reading because the picture it produces is a *blank* plot beside a healthy
|
|
// status bar, which names no cause at all: the trigger's `history` never reaches its pre-window,
|
|
// so it never latches and the display sits in `Waiting`; the renderer's bound window is empty, so
|
|
// nothing is drawn even with the trigger off. `relative_time` with a session's math channel was
|
|
// how this was reached (see `rel_base_s_`); the guard is `build_stamps`' own accumulator.
|
|
if (!warned_clock_stall_ && n > 0 && stamps_[0] - epoch < last_stamp_ &&
|
|
stamps_[n - 1] - epoch < last_stamp_) {
|
|
warned_clock_stall_ = true;
|
|
const u64 held = model_.live().time().size();
|
|
std::fprintf(stderr,
|
|
"[udpscope] warning: the clock stalled at %.6f s: %u samples stamped [%.6f..%.6f] s "
|
|
"are all behind it, so the timeline stands still while frames keep arriving -- "
|
|
"nothing can be drawn and no trigger can latch. The plan's interval is %.9g s now; "
|
|
"ring n=%llu\n",
|
|
last_stamp_, n, stamps_[0] - epoch, stamps_[n - 1] - epoch, part.base_dt,
|
|
static_cast<unsigned long long>(held));
|
|
std::fflush(stderr);
|
|
}
|
|
|
|
for (std::uint32_t k = 0; k < n; ++k) {
|
|
const u64 tick = tick_++;
|
|
// The shared clock's group boundary, read once and reused by the time ring, the channel
|
|
// loop's own per-channel test and the math push: three tests of the same thing.
|
|
const bool group_end = (tick & g_mask) == g_mask;
|
|
const double rel = std::max(stamps_[k] - epoch, last_stamp_);
|
|
last_stamp_used = rel;
|
|
last_stamp_ = rel;
|
|
|
|
// The stored stamp is the *mean* of the group, not its last member: that is where the
|
|
// boxcar's group delay puts the averaged value, and stamping it at the end of the
|
|
// group would shift every trace half a stored sample late.
|
|
time_acc_ += rel;
|
|
last_abs_stamp_ = abs_stamps_[k] - epoch;
|
|
abs_time_acc_ += last_abs_stamp_;
|
|
++time_acc_n_;
|
|
if (group_end) {
|
|
store.time().push(time_acc_ / static_cast<double>(time_acc_n_));
|
|
time_acc_ = 0.0;
|
|
// The absolute clock on the same slot and under the same group mean, so an export can
|
|
// pair value, relative stamp and absolute stamp by index with no interpolation.
|
|
if (plot::data::RingBuffer<f64>* abs = store.abs_time(); abs != nullptr) {
|
|
abs->push(abs_time_acc_ / static_cast<double>(time_acc_n_));
|
|
}
|
|
abs_time_acc_ = 0.0;
|
|
time_acc_n_ = 0;
|
|
}
|
|
|
|
// The latch belongs to this sample, but it cannot be published from inside the
|
|
// channel loop: `publish_event()` clamps the slice to every value ring's
|
|
// `produced()`, and the channels *after* the trigger channel have not pushed this
|
|
// sample yet, so the newest sample -- the one the post window was waiting for --
|
|
// would be clamped away. One sample is invisible in a 20 000-sample capture (it is
|
|
// the `n=19999` and the `0.9999` instead of `1.0000` in the probe's output), and at
|
|
// one sample per second it is the entire post side: `got [-0.9997..0.0000]`, which
|
|
// reads exactly like the "post window is empty" defect.
|
|
bool latch_here = false;
|
|
double latch_edge = 0.0;
|
|
|
|
for (std::size_t i = 0; i < channels.size(); ++i) {
|
|
const ChannelInfo& ch = channels[i];
|
|
// A derived channel is filled by `push_math_sample()` below, once per stored slot, from the
|
|
// values the streamed ones just pushed. Reading it from the packet would give zero (it has no
|
|
// signal) and pushing that would advance its value ring twice per time slot -- which is an
|
|
// alternation between the value and 0 while the buffer is filling, and a complete loss of the
|
|
// pairing once both rings wrap.
|
|
if (ch.math) continue;
|
|
// A signal's columns are consecutive samples of the same channel: a scalar
|
|
// (one element) carries one value per batched cycle, a block signal carries
|
|
// the cycle's sample at element `k`. Either way element 0 is the channel.
|
|
const double raw =
|
|
ch.elements > 1
|
|
? frame.value(static_cast<std::uint32_t>(ch.signal), 0,
|
|
std::min<std::uint32_t>(k, ch.elements - 1))
|
|
: frame.value(static_cast<std::uint32_t>(ch.signal), k, 0);
|
|
// Every sample is read and accumulated even when only one in `period` is stored:
|
|
// that is the whole point of the filter. Skipping the read -- which is what the
|
|
// old stride did -- is what made decimation alias.
|
|
value_acc_[i] += raw * ch.gain + ch.offset;
|
|
++value_acc_n_[i];
|
|
|
|
const u64 mask = acc_mask_[i];
|
|
if ((tick & mask) != mask) continue;
|
|
const f32 value =
|
|
static_cast<f32>(value_acc_[i] / static_cast<double>(value_acc_n_[i]));
|
|
value_acc_[i] = 0.0;
|
|
value_acc_n_[i] = 0;
|
|
store.values(i).push(value);
|
|
// This slot's value, for the math channels below. Every channel is pushed on the same slot
|
|
// (per-channel strides are 1 by design), so the vector is complete by the time the loop ends.
|
|
if (i < math_sample_.size()) math_sample_[i] = value;
|
|
// The trigger sees the *stored* signal, not the wire signal. An edge found in a
|
|
// sample that is not in the buffer would be cut from data that does not contain it,
|
|
// and with the filter in front of it a spike above the stored Nyquist is exactly
|
|
// the thing that must not latch -- it is not in the record the user would get.
|
|
if (static_cast<int>(i) == trig_channel) {
|
|
if (engine.process(value, rel)) {
|
|
latch_here = true;
|
|
latch_edge = engine.last_trigger_time();
|
|
}
|
|
}
|
|
}
|
|
// The derived channels: once per *stored* slot, and **before** the latch publishes.
|
|
// `publish_event()` clamps its snapshot to every value ring's `produced()`, so a derived
|
|
// channel pushed after it would be missing its newest sample from the capture -- the same
|
|
// one-sample clamp the deferred latch above exists for.
|
|
if (group_end) model_.push_math_sample(math_sample_);
|
|
if (latch_here) latch(latch_edge);
|
|
}
|
|
last_stamp_ = last_stamp_used;
|
|
|
|
// A Force pressed since the last packet: one acquisition, at the present moment, without waiting
|
|
// for the condition to hold. Read *here* -- the end of the packet, past the channel loop -- for
|
|
// the reason the latch itself is deferred out of that loop: `last_stamp_` is the newest stored
|
|
// sample by now, so the capture opens on the moment the user pressed, and every channel has
|
|
// pushed that sample, so the cut the latch goes on to make cannot clamp it away
|
|
// (`publish_event()` clamps to each value ring's `produced()`).
|
|
//
|
|
// It opens the capture and nothing else. The latch below -- reached through `engine.process()`
|
|
// by an edge, through `engine.force()` here -- is what cuts the window, bumps the latch count,
|
|
// sets the trigger time and applies the mode's re-arm rule, so a forced acquisition is framed,
|
|
// pinned and recorded exactly like one the condition produced. A capture already in flight is
|
|
// not cancelled: it *is* the acquisition being asked for, and the engine refuses the request
|
|
// rather than restarting it.
|
|
if (force_want_.exchange(false, std::memory_order_acq_rel)) {
|
|
if (engine.force(last_stamp_)) {
|
|
std::fprintf(stderr,
|
|
"[udpscope] trigger: forced acquisition, edge at %.3f s -- want "
|
|
"[-%.4f..%.4f] s, %.4f s of history behind it, mode=%s\n",
|
|
last_stamp_, event_span - post_want, post_want, last_stamp_ - oldest_s,
|
|
tc.mode == TriggerMode::Single ? "single (disarms at the latch)" : "normal");
|
|
std::fflush(stderr);
|
|
} else {
|
|
// Nothing to force. Every cause is a state the user can see from the panel -- a trigger that
|
|
// is off, a signal that is not acquired, the engine disarmed, a capture already filling --
|
|
// so it is named on stderr rather than swallowed.
|
|
std::fprintf(stderr,
|
|
"[udpscope] trigger: force ignored (enabled=%d armed=%d ch=%d pending=%d)\n",
|
|
int(tc.enabled), int(engine.armed()), trig_channel, int(engine.pending()));
|
|
std::fflush(stderr);
|
|
}
|
|
}
|
|
|
|
model_.trigger_armed().store(engine.armed(), std::memory_order_relaxed);
|
|
// The edge goes out before the flag that makes the UI read it (release/acquire on
|
|
// `trigger_pending`), so a tick that sees a capture open sees which edge it is for. A
|
|
// capture that opens and latches inside this one frame never reports pending, and that
|
|
// is right: the display goes straight to the frozen event.
|
|
if (engine.pending()) model_.set_capture_edge(engine.last_trigger_time());
|
|
model_.set_trigger_pending(engine.pending());
|
|
static std::int64_t next_engine_log = 0;
|
|
const std::int64_t now_e = static_cast<std::int64_t>(frame.recv_time * 1.0e6);
|
|
if (now_e >= next_engine_log) {
|
|
next_engine_log = now_e + 1000000;
|
|
// `elapsed/post_want` is the capture's progress in the only unit that matters now
|
|
// (stream seconds); `accum` is how many samples that window has collected, which
|
|
// is what says whether a capture is thin because the window is short or because
|
|
// the stream is slow.
|
|
std::fprintf(stderr,
|
|
"[udpscope] trig engine: ch=%d armed=%d pending=%d elapsed=%.4f/%.4f s "
|
|
"accum=%llu history=%.4f/%.4f s pre_pct=%.0f v=%.4g now_t=%.3f "
|
|
"holdoff_until=%.3f ring=[%.4f..%.4f] n=%zu\n",
|
|
trig_channel, int(engine.armed()), int(engine.pending()),
|
|
engine.post_elapsed(), engine.post_seconds(),
|
|
static_cast<unsigned long long>(engine.post_accum()), engine.history(),
|
|
engine.pre_seconds(), tc.pretrigger_pct, engine.last_value(),
|
|
engine.last_t(), engine.holdoff_until(),
|
|
// The store's own retained span, beside the engine's view of the clock. Going live
|
|
// restarts the client, and the question a stalled display raises is whether the *time
|
|
// ring* is moving at all: `n` climbing while `ring` stands still is a clock problem, and
|
|
// `ring` moving while `now_t` does not is a feed problem. Neither can be told from the
|
|
// engine's own fields.
|
|
store.time().size() > 0 ? double(store.time()[0]) : 0.0,
|
|
store.time().size() > 0 ? double(store.time()[store.time().size() - 1]) : 0.0,
|
|
store.time().size());
|
|
std::fflush(stderr);
|
|
}
|
|
}
|
|
|
|
void Acquisition::reserve_hole(std::uint32_t missing, const Partition& part,
|
|
const std::vector<ChannelInfo>& channels, BufferStore& store,
|
|
u64 g) {
|
|
const u64 raw = u64(missing) * u64(batch_);
|
|
Hole h;
|
|
h.tick_begin = tick_;
|
|
h.slot_begin = store.time().produced();
|
|
// `lost` counts the packets missing *immediately before* the frame that revealed them
|
|
// (see `udps_frame_t::lost`), and the counter mark at the top of `on_data` has already
|
|
// moved `last_counter_` to that frame's own counter -- so the hole spans
|
|
// [counter - missing, counter). This read `last_counter_ + 1u`, which addressed a span
|
|
// one past the revealing frame and `missing + 1` too high: no late packet's counter could
|
|
// fall in it, so every backfill lookup missed and `backfilled` read 0 against 154 holes.
|
|
h.counter_first = last_counter_ - missing;
|
|
h.packets = missing;
|
|
|
|
double dt = part.base_dt;
|
|
if (!(dt > 0.0)) dt = 1.0e-3;
|
|
// The same group tests as the live loop, written the same way: a hole's samples go through
|
|
// the same accumulators, so the two phases have to agree sample for sample.
|
|
const u64 g_mask = g - 1;
|
|
for (u64 j = 0; j < raw; ++j) {
|
|
const u64 tick = tick_++;
|
|
const bool group_end = (tick & g_mask) == g_mask;
|
|
// The hole is stamped at the declared cadence from the last good stamp -- exact in
|
|
// relative mode, and in stream mode the same continuation the glitch bridge makes.
|
|
// It must be *stamped* rather than left short: the time ring is binary-searched by
|
|
// `ChannelBuffer::neighbours()`, so it has to stay monotone and dense, and backfill
|
|
// then changes values only and never a stamp.
|
|
const double rel = last_stamp_ + dt;
|
|
last_stamp_ = rel;
|
|
last_abs_stamp_ += dt;
|
|
if (j == 0) h.x_begin = rel;
|
|
h.x_end = rel;
|
|
|
|
time_acc_ += rel;
|
|
abs_time_acc_ += last_abs_stamp_;
|
|
++time_acc_n_;
|
|
if (group_end) {
|
|
store.time().push(time_acc_ / double(time_acc_n_));
|
|
// Null in stream mode, where `time()` already is the absolute clock.
|
|
if (plot::data::RingBuffer<f64>* abs = store.abs_time(); abs != nullptr) {
|
|
abs->push(abs_time_acc_ / double(time_acc_n_));
|
|
}
|
|
time_acc_ = 0.0;
|
|
abs_time_acc_ = 0.0;
|
|
time_acc_n_ = 0;
|
|
}
|
|
for (std::size_t i = 0; i < channels.size(); ++i) {
|
|
if (channels[i].math) continue;
|
|
// A NaN in the accumulator makes the whole group NaN, which is why a slot straddling
|
|
// a hole edge is NaN too: its real members were averaged into a sum that is now
|
|
// non-finite and were never retained, so the backfill cannot recover it and must not
|
|
// pretend to. One slot per edge, against a hole thousands of slots wide.
|
|
value_acc_[i] += std::numeric_limits<double>::quiet_NaN();
|
|
++value_acc_n_[i];
|
|
const u64 mask = acc_mask_[i];
|
|
if ((tick & mask) != mask) continue;
|
|
store.values(i).push(static_cast<f32>(value_acc_[i] / double(value_acc_n_[i])));
|
|
value_acc_[i] = 0.0;
|
|
value_acc_n_[i] = 0;
|
|
}
|
|
// The math channels are fed NaN like everything else, so a derived trace keeps the gap
|
|
// its source had. The trigger is *not* fed: a NaN comparison is false, so the engine
|
|
// could not latch here anyway, and feeding it would advance its hold-off clock over
|
|
// time nothing was sampled in.
|
|
if (group_end) {
|
|
std::fill(math_sample_.begin(), math_sample_.end(), std::nanf(""));
|
|
model_.push_math_sample(math_sample_);
|
|
}
|
|
}
|
|
h.slot_count = store.time().produced() - h.slot_begin;
|
|
model_.note_hole(h);
|
|
model_.stats().holes.fetch_add(1, std::memory_order_relaxed);
|
|
model_.stats().hole_slots.fetch_add(h.slot_count, std::memory_order_relaxed);
|
|
}
|
|
|
|
void Acquisition::backfill_from(const Frame& frame, const Partition& part,
|
|
const std::vector<ChannelInfo>& channels, BufferStore& store,
|
|
u64 g) {
|
|
// `batch_` is learned in `build_stamps()`, which a late frame never reaches. A late frame
|
|
// before any good one therefore has no packet length to size its span with, and no hole
|
|
// could have been reserved for it either.
|
|
if (batch_ == 0) return;
|
|
|
|
// Which hole this packet belongs to, by counter: that is the whole reason relative timing
|
|
// and resequencing are one design. A stamp could not answer it -- the packet's own stamps
|
|
// are a claim about a timeline the ring has moved past -- but the counter indexes the
|
|
// reservation table directly.
|
|
// By signed delta, the idiom the client uses throughout: the counter is a wrapping uint32, so
|
|
// a raw `>=`/`<` pair matches nothing for a hole straddling the 2^32 wrap and every one of its
|
|
// late packets would be dropped on the next line.
|
|
Hole* hole = nullptr;
|
|
for (Hole& h : model_.holes()) {
|
|
const std::int32_t into = static_cast<std::int32_t>(frame.counter - h.counter_first);
|
|
if (into >= 0 && into < static_cast<std::int32_t>(h.packets)) {
|
|
hole = &h;
|
|
break;
|
|
}
|
|
}
|
|
if (hole == nullptr) return; // its hole has already slid out of the ring, or never existed
|
|
|
|
// The packet's own span in absolute base ticks. `tick_begin` is the hole's first raw sample
|
|
// and the packets inside a hole are consecutive, so the counter's distance from
|
|
// `counter_first` places this one exactly.
|
|
const u64 lo = hole->tick_begin + u64(frame.counter - hole->counter_first) * u64(batch_);
|
|
const u64 hi = lo + u64(batch_);
|
|
|
|
u64 filled = 0;
|
|
for (std::size_t i = 0; i < channels.size(); ++i) {
|
|
// A derived channel has no packet signal to read, and the design does not retrofit it:
|
|
// its gap stays.
|
|
if (channels[i].math) continue;
|
|
const u64 period = g * part.stride[i];
|
|
// Only slots lying *wholly* inside this packet's span are repaired. A slot straddling the
|
|
// span's edge mixes this packet's samples with ones still missing, and its real members
|
|
// were never retained -- `reserve_hole()` averaged a NaN into them -- so it stays NaN, and
|
|
// a displayed hole is at most one slot wider per edge than the true loss.
|
|
const u64 first_slot = (lo + period - 1) / period;
|
|
const u64 last_slot = hi / period;
|
|
for (u64 s = first_slot; s < last_slot; ++s) {
|
|
// The same boxcar mean the live path stores, over the same group of raw samples: the
|
|
// whole group is inside this packet, so the average is exact and not an approximation
|
|
// of what the live path would have written.
|
|
double acc = 0.0;
|
|
for (u64 j = 0; j < period; ++j) {
|
|
const u64 k = s * period + j - lo; // this packet's own sample index, 0..batch_-1
|
|
const double raw =
|
|
channels[i].elements > 1
|
|
? frame.value(static_cast<std::uint32_t>(channels[i].signal), 0,
|
|
std::min<std::uint32_t>(static_cast<std::uint32_t>(k),
|
|
channels[i].elements - 1))
|
|
: frame.value(static_cast<std::uint32_t>(channels[i].signal),
|
|
static_cast<std::uint32_t>(k), 0);
|
|
acc += raw * channels[i].gain + channels[i].offset;
|
|
}
|
|
const f32 value = static_cast<f32>(acc / double(period));
|
|
// The ring first, by the producer that owns its writes; the pyramid is queued for the
|
|
// consumer (see `ChannelBuffer::queue_amend`). A slot the ring has already discarded
|
|
// returns false, and then there is no aggregate to amend either.
|
|
if (store.values(i).amend(s, value)) {
|
|
store.channel(i).queue_amend(s, value);
|
|
++filled;
|
|
}
|
|
}
|
|
}
|
|
// This packet's share of the hole has been delivered, whatever the ring still retains of it.
|
|
// Marked on *arrival* and not on `filled`: a decimation coarse enough that no whole slot lies
|
|
// inside one packet repairs no slot at all, and keying off `filled` would leave such a hole
|
|
// permanently unrepairable -- a solid band over data that is all there, which is the exact
|
|
// defect this task closes.
|
|
++hole->filled_packets;
|
|
model_.stats().backfilled.fetch_add(filled, std::memory_order_relaxed);
|
|
}
|
|
|
|
} // namespace udpscope
|