Three defects, all of them a window describing data that is not there. A math edit refilled the derived ring with `clear()`, which renumbers it from 0, leaving `produced()` short by the inputs' retained base. Since `live_window_locked()` clamps against every channel's `produced()`, that one lagging channel answered `n = 0` for the live view, every pause view and every trigger capture until a whole ring of fresh data had gone by -- the reported freeze, invisible until the ring first wraps, hence its intermittence. It now restarts with `clear_to(base)`, and `ChannelBuffer::republish_from(base)` puts the published window back: `acquire()` is incremental, so a rewrite in place published nothing and the pyramid went on describing the samples the edit replaced. The trigger's channel is an index, re-resolved by name after a renumbering -- but only in `rebuild_enabled_locked()`, so a math add or delete went through `set_math_channels()` with no restore, the index was clamped to -1, and `process()` returned at once while the panel still named the channel. The restore moves into `rebuild_channels()`, which *is* the renumbering, so no caller can forget it. And `update_measures()` suppressed its "the window moved" test while an event was held -- the one mode where a gesture is the only thing that can move it. Zooming a latched event left the pane describing the whole captured event. A pinned window needs no exemption: it lands on the range it already measured and fails the 2 % test by itself. The checks that missed all three: `UDPSCOPE_PROBE_MATHWRAP_MS` edits a graph whose ring has already wrapped, which `math_probe` structurally cannot reach; `[measure-trace]` now prints the mode, the window on screen and the window the numbers describe; and the dump's `marks_in_window` counts against the window on screen instead of against the one the marks were computed over, where it was a tautology. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
349 lines
14 KiB
C++
349 lines
14 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#include "channel.hpp"
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
|
|
namespace udpscope {
|
|
namespace {
|
|
|
|
/// Raw samples read per chunk on the gather path, so it never allocates.
|
|
constexpr u64 kChunk = 256;
|
|
|
|
/// The pyramid handed out for a slot with nothing bound to it. The renderer never
|
|
/// asks an empty series a bucket question, but the reference must be valid.
|
|
const plot::data::MinMaxPyramid& null_pyramid() {
|
|
static const plot::data::MinMaxPyramid empty;
|
|
return empty;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// --- ChannelBuffer ----------------------------------------------------------
|
|
|
|
ChannelBuffer::ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>> values,
|
|
std::shared_ptr<const plot::data::RingBuffer<f64>> times, u64 stride,
|
|
f64 epoch, f64 nominal_dt)
|
|
: values_(std::move(values)), times_(std::move(times)), stride_(stride < 1 ? 1 : stride),
|
|
epoch_(epoch), dt_(nominal_dt) {}
|
|
|
|
void ChannelBuffer::acquire() noexcept {
|
|
const u64 produced = values_->produced();
|
|
// Nothing new skips the append pass only: the backfill drain at the end of this function
|
|
// runs either way, because a late packet can land in a frame interval that pushed nothing.
|
|
if (produced > published_) {
|
|
// A producer that lapped this ring -- a long trigger wait, or a store that was
|
|
// not being published for a while -- makes the incremental append meaningless:
|
|
// the samples between `published_` and `produced_` are no longer in the ring.
|
|
// Rebuilding from the retained window is O(capacity) once, and it is the only
|
|
// way the published window stays coherent (an empty or garbled window is what
|
|
// "the trace disappeared" looks like from outside).
|
|
if (produced - published_ > values_->capacity()) {
|
|
published_ = 0;
|
|
pyramid_.clear();
|
|
}
|
|
f32 chunk[kChunk];
|
|
for (u64 i = published_; i < produced;) {
|
|
const u64 take = std::min<u64>(kChunk, produced - i);
|
|
values_->copy_from(i, take, chunk);
|
|
for (u64 k = 0; k < take; ++k) pyramid_.append(chunk[k]);
|
|
i += take;
|
|
}
|
|
published_ = produced;
|
|
// The pyramid's window follows the ring's: sample index `base` is the oldest
|
|
// one still retained. drop_before() is monotonic and cheap.
|
|
pyramid_.drop_before(values_->base());
|
|
// And the window's first stamp with it, here and nowhere else (see the member's note): the
|
|
// window is pinned by the line above, so its origin is a fixed sample, and this is the one
|
|
// place in the object's life where that sample's slot is safe to read.
|
|
if (pyramid_.size() > 0) window_x0_ = time_of(absolute(0));
|
|
}
|
|
// Backfills, applied after the append pass above: an amend is only ever queued for a slot
|
|
// the value ring already produced, so the append has reached it and the pyramid's bounds
|
|
// test will accept it. Unconditional, outside the `produced > published_` guard -- a late
|
|
// packet can land in a frame interval that pushed nothing new, and the plan's placement
|
|
// behind the old early return would have left the pyramid stale until the next push.
|
|
if (!pending_amends_.empty()) {
|
|
for (const Amend& a : pending_amends_) pyramid_.amend(a.absolute, a.value);
|
|
pending_amends_.clear();
|
|
}
|
|
}
|
|
|
|
void ChannelBuffer::invalidate() noexcept {
|
|
published_ = 0;
|
|
window_x0_ = 0.0;
|
|
pyramid_.clear();
|
|
pending_amends_.clear();
|
|
}
|
|
|
|
void ChannelBuffer::republish_from(u64 absolute) noexcept {
|
|
published_ = absolute;
|
|
window_x0_ = 0.0;
|
|
pending_amends_.clear();
|
|
// `clear()` leaves the pyramid empty at absolute 0, and `drop_before()` past the end
|
|
// restarts an empty window at the index it is given -- which is the only way to say
|
|
// "nothing is published, and the next append is sample `absolute`". The no-op guard is
|
|
// for `absolute == 0`, where `clear()` has already said exactly that.
|
|
pyramid_.clear();
|
|
if (absolute > 0) pyramid_.drop_before(absolute);
|
|
}
|
|
|
|
f64 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
|
|
// Channel sample `j` is produced on the tick that also pushes time slot
|
|
// `j*stride + stride - 1` (see `Acquisition::on_data`): the producer stamps the
|
|
// time ring every `time_stride` ticks and the channel every `stride` of those,
|
|
// so the last tick of the group is the one that carries the channel's sample.
|
|
const u64 index = absolute_index * stride_ + (stride_ - 1);
|
|
// The time ring is circular, so an index stays readable while it is *retained*
|
|
// and `copy_from()` masks it. Testing it against `capacity()` would reject every
|
|
// index past the first wrap -- which is every sample once the ring has filled,
|
|
// and would silently stamp them all with x = 0.
|
|
//
|
|
// An index the ring has already dropped is **clamped to the oldest readable slot,
|
|
// never answered with a sentinel**. The published window is pinned by `acquire()`
|
|
// to the value ring's base, and both rings keep sliding afterwards (their
|
|
// capacities are equal, so there is no slack at all): once the ring saturates, the
|
|
// few oldest samples of the window routinely point at slots the producer has
|
|
// overwritten since. Returning 0 for those put a stamp of 0 into `x_bounds()`, and
|
|
// from there into `dx()`/`x0()` and every consumer of the window's extent. So
|
|
// *one* unreadable slot at the front inflated `dx()` by the ratio of the whole
|
|
// elapsed stream time to the retained window: the axis in `x_bounds()` read the
|
|
// stream's start rather than the window's, every measurement cut computed from it
|
|
// came out too wide, and the readout disagreed with what the crosshair said
|
|
// (`x_at()`/`neighbours()` go to the ring per sample and never saw the sentinel).
|
|
// That is the "live trace is clipped, only the most recent part is visible, but
|
|
// the crosshair still reads values there" defect.
|
|
//
|
|
// Clamping degrades the way the library's own `StreamSeries` documents for this
|
|
// contract: a snapshot bounds the sample *count*, not the slots, so the oldest few
|
|
// samples of a saturated window may read a newer value than the window names. One
|
|
// frame's worth of samples at the very left edge is then drawn on a short plateau
|
|
// of equal stamps -- monotone, so the binary searches still hold -- instead of the
|
|
// whole trace's geometry collapsing.
|
|
const u64 base = times_->base();
|
|
const u64 produced = times_->produced();
|
|
if (produced == 0) return 0.0;
|
|
u64 slot = index < base ? base : index;
|
|
if (slot >= produced) slot = produced - 1;
|
|
f64 out = 0.0;
|
|
times_->copy_from(slot, 1, &out);
|
|
return out;
|
|
}
|
|
|
|
f64 ChannelBuffer::dx() const {
|
|
const u64 n = pyramid_.size();
|
|
if (n < 2) return dt_ > 0.0 ? dt_ : 1.0;
|
|
const plot::Range b = x_bounds();
|
|
const double span = b.max - b.min;
|
|
if (!(span > 0.0) || !std::isfinite(span)) return dt_ > 0.0 ? dt_ : 1.0;
|
|
return span / f64(n - 1);
|
|
}
|
|
|
|
f64 ChannelBuffer::x0() const {
|
|
if (pyramid_.size() == 0) return epoch_;
|
|
return epoch_ + window_x0_;
|
|
}
|
|
|
|
f64 ChannelBuffer::x_at(u64 i) const {
|
|
if (i >= pyramid_.size()) return x0();
|
|
// Sample 0 is served from the cache, never from the ring: it is the slot the producer is
|
|
// overwriting, and `neighbours()` tests the asked-for x against this value, so a read one
|
|
// ring-span too new made it refuse the whole visible range -- which left the renderer
|
|
// gathering the entire ring at a coarse level and drawing it off the axis.
|
|
if (i == 0) return x0();
|
|
return epoch_ + time_of(absolute(i));
|
|
}
|
|
|
|
bool ChannelBuffer::neighbours(f64 x, u64& i0, u64& i1) const {
|
|
const u64 n = pyramid_.size();
|
|
if (n == 0) return false;
|
|
if (x < x_at(0) || x > x_at(n - 1)) return false;
|
|
// The time ring is monotonic, so the bracketing pair is a binary search rather
|
|
// than a grid walk: after a lost packet the two samples either side of `x` are
|
|
// not `x`/`dx` apart, and a grid would name the wrong pair.
|
|
u64 lo = 0;
|
|
u64 hi = n - 1;
|
|
while (lo < hi) {
|
|
const u64 mid = lo + (hi - lo) / 2;
|
|
if (x_at(mid) < x) {
|
|
lo = mid + 1;
|
|
} else {
|
|
hi = mid;
|
|
}
|
|
}
|
|
i1 = lo;
|
|
i0 = lo;
|
|
if (x_at(lo) > x && lo > 0) i0 = lo - 1;
|
|
return true;
|
|
}
|
|
|
|
u64 ChannelBuffer::snap_index(f64 x) const {
|
|
u64 i0 = 0;
|
|
u64 i1 = 0;
|
|
if (!neighbours(x, i0, i1)) {
|
|
if (pyramid_.size() == 0) return 0;
|
|
return x < x_at(0) ? 0 : pyramid_.size() - 1;
|
|
}
|
|
if (i0 == i1) return i0;
|
|
return (x - x_at(i0)) <= (x_at(i1) - x) ? i0 : i1;
|
|
}
|
|
|
|
plot::Range ChannelBuffer::x_bounds() const {
|
|
const u64 n = pyramid_.size();
|
|
if (n == 0) return plot::Range{epoch_, epoch_};
|
|
return plot::Range{x0(), epoch_ + time_of(absolute(n - 1))};
|
|
}
|
|
|
|
plot::Range ChannelBuffer::y_bounds() const {
|
|
const u64 n = pyramid_.size();
|
|
if (n == 0) return plot::Range{0.0, 0.0};
|
|
return reduce(0, n);
|
|
}
|
|
|
|
void ChannelBuffer::raw(u64 begin, u64 end, f32* out) const {
|
|
const u64 n = pyramid_.size();
|
|
const u64 b = std::min(begin, n);
|
|
const u64 e = std::min(end, n);
|
|
const u64 count = e > b ? e - b : 0;
|
|
if (count > 0) values_->copy_from(absolute(b), count, out);
|
|
for (u64 i = count; i < end - begin; ++i) out[i] = 0.0f;
|
|
}
|
|
|
|
void ChannelBuffer::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
|
plot::data::GatherMode mode) const {
|
|
const u64 n = pyramid_.size();
|
|
const u64 b = std::min(begin, n);
|
|
const u64 e = std::min(std::max(end, b), n);
|
|
if (b >= e) return;
|
|
|
|
const u64 origin = pyramid_.window_base();
|
|
const f64 base = x_origin; // x is emitted relative to this (see the header)
|
|
|
|
const auto emit_raw = [&](u64 from, u64 to) {
|
|
for (u64 i = from; i < to; ++i) {
|
|
f32 y = 0.0f;
|
|
raw(i, i + 1, &y);
|
|
out_xy.push_back(static_cast<f32>(x_at(i) - base));
|
|
out_xy.push_back(y);
|
|
}
|
|
};
|
|
const auto rel = [&](u64 i) { return static_cast<f32>(x_at(i) - base); };
|
|
|
|
if (level == 0) {
|
|
emit_raw(b, e);
|
|
return;
|
|
}
|
|
level = std::min(level, plot::data::kMaxPyramidLevel);
|
|
|
|
// One geometry element per *complete* bucket wholly inside the range. `first` is
|
|
// a ceiling and `last` a floor, so no aggregate can reach outside [b, e) -- a
|
|
// floor on `first` or a `last` that includes the bucket holding `e-1` would emit
|
|
// aggregates covering samples the caller did not ask for (the library's B3 fix,
|
|
// which this mirrors). The spans left at the two edges are drawn from raw
|
|
// samples, so the geometry still covers the range exactly and a partial bucket is
|
|
// drawn once, not twice.
|
|
const u64 span = u64{plot::data::kBucket} << level;
|
|
const u64 lbase = pyramid_.level_base(level);
|
|
const u64 lend = lbase + pyramid_.level_size(level);
|
|
const u64 ceil_div = (origin + b) / span + ((origin + b) % span == 0 ? u64{0} : u64{1});
|
|
const u64 first = std::max(ceil_div, lbase);
|
|
const u64 last = std::min((origin + e) / span, lend);
|
|
if (first >= last) {
|
|
emit_raw(b, e);
|
|
return;
|
|
}
|
|
const u64 lead = first * span - origin;
|
|
if (b < lead) emit_raw(b, std::min(lead, e));
|
|
|
|
for (u64 bi = first; bi < last; ++bi) {
|
|
const u32 count = pyramid_.bucket_count(level, bi);
|
|
const u64 fs = bi * span - origin; // >= 0: a stored bucket starts inside the window
|
|
switch (mode) {
|
|
case plot::data::GatherMode::Nearest: {
|
|
const u64 centre = bi * span + span / 2 - origin;
|
|
f32 y = 0.0f;
|
|
raw(centre, centre + 1, &y);
|
|
out_xy.push_back(rel(centre));
|
|
out_xy.push_back(y);
|
|
break;
|
|
}
|
|
case plot::data::GatherMode::Mean: {
|
|
if (count == 0) break; // no finite sample: no mean to draw
|
|
const u64 centre = bi * span + span / 2 - origin;
|
|
out_xy.push_back(rel(centre));
|
|
out_xy.push_back(static_cast<f32>(pyramid_.bucket_sum(level, bi) / f64(count)));
|
|
break;
|
|
}
|
|
default: { // Envelope
|
|
if (count == 0) break;
|
|
const plot::data::MinMax m = pyramid_.bucket(level, bi);
|
|
const u64 ls = std::min((bi + 1) * span, origin + e) - 1 - origin;
|
|
out_xy.push_back(rel(fs));
|
|
out_xy.push_back(m.min);
|
|
out_xy.push_back(rel(ls));
|
|
out_xy.push_back(m.max);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
const u64 trail = last * span - origin;
|
|
if (trail < e) emit_raw(trail, e);
|
|
}
|
|
|
|
// --- SlotSeries -------------------------------------------------------------
|
|
|
|
plot::Range SlotSeries::x_bounds() const {
|
|
if (event_ != nullptr) return event_->x_bounds();
|
|
return buffer_ != nullptr ? buffer_->x_bounds() : plot::Range{0.0, 1.0};
|
|
}
|
|
|
|
plot::Range SlotSeries::y_bounds() const {
|
|
if (event_ != nullptr) return event_->y_bounds();
|
|
return buffer_ != nullptr ? buffer_->y_bounds() : plot::Range{0.0, 0.0};
|
|
}
|
|
|
|
const plot::data::MinMaxPyramid& SlotSeries::pyramid() const {
|
|
if (event_ != nullptr) return event_->pyramid();
|
|
return buffer_ != nullptr ? buffer_->pyramid() : null_pyramid();
|
|
}
|
|
|
|
f64 SlotSeries::x_at(u64 i) const {
|
|
if (event_ != nullptr) return event_->x_at(i);
|
|
return buffer_ != nullptr ? buffer_->x_at(i) : 0.0;
|
|
}
|
|
|
|
bool SlotSeries::neighbours(f64 x, u64& i0, u64& i1) const {
|
|
if (event_ != nullptr) return event_->neighbours(x, i0, i1);
|
|
return buffer_ != nullptr && buffer_->neighbours(x, i0, i1);
|
|
}
|
|
|
|
u64 SlotSeries::snap_index(f64 x) const {
|
|
if (event_ != nullptr) return event_->snap_index(x);
|
|
return buffer_ != nullptr ? buffer_->snap_index(x) : 0;
|
|
}
|
|
|
|
void SlotSeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
|
plot::data::GatherMode mode) const {
|
|
if (event_ != nullptr) {
|
|
event_->gather(level, begin, end, x_origin, out_xy, mode);
|
|
return;
|
|
}
|
|
if (buffer_ != nullptr) buffer_->gather(level, begin, end, x_origin, out_xy, mode);
|
|
}
|
|
|
|
void SlotSeries::copy_raw(u64 begin, u64 end, f32* out) const {
|
|
if (event_ != nullptr) {
|
|
event_->raw_values(begin, end, out);
|
|
return;
|
|
}
|
|
if (buffer_ != nullptr) {
|
|
buffer_->raw(begin, end, out);
|
|
return;
|
|
}
|
|
for (u64 i = 0; i < end - begin; ++i) out[i] = 0.0f;
|
|
}
|
|
|
|
} // namespace udpscope
|