Files
udpscope/src/channel.cpp
T
Martino FerrariandClaude Opus 4.6 a92a6b5bf0 fixed plot flicker, measurement cost and toolbar icons
The flicker ("no data" at no constant frequency, worse with the measurements
and cursors panes up) was a torn read of the window's first stamp: acquire()
pins the window to the ring's oldest slot, and reading it at paint time races
the producer lapping it, returning a stamp one ring span too new. x0() is the
origin every trace point is reconstructed from, so one torn read produced a
refused neighbours(), a filter that dropped three quarters of the points, and
a polyline shifted off its axis. It is cached by acquire() under the lock now.

EventSeries::neighbours() answered from the (x - x0)/dx grid, whose drift from
the real arrival stamps arrives as a constant offset in seconds -- a doubling
share of the screen per zoom step, which is the "zooming into a held event
clips it more from the left" report. Both series binary-search the stamps now.

The measurements pane went through the LOD pyramid rather than reading its
window raw (19.7 ms per recompute to 1.2 ms, 0.1% of a 2 M-sample window) and
recomputes on a cadence; the math engine lost a per-slot heap allocation, a
per-sample source lookup by name and a fetch_add that cost more than the
arithmetic it counted.

The cursors pane's mix mode drew a line per *named* slot, so a slot holding a
signal that can never produce a channel -- the stream's timestamp -- got a row
that could only read "--", and such names were re-saved every session. It
counts bound traces now and apply_layout() refuses them.

Four toolbar icons are drawn for this app and compiled in as a GResource: the
stock ones were a record dot for the trigger and three magnifiers for cursors,
measurements and math.

New probes and dump lines for all of it (trace ink, measure cost, math cost,
event zoom, toolbar icons); README and CLAUDE.md record what they cure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-03 23:36:07 +02:00

327 lines
12 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<f32>> 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();
if (produced <= published_) {
// Nothing new; still allow the window to slide if another reader dropped it.
return;
}
// 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));
}
void ChannelBuffer::invalidate() noexcept {
published_ = 0;
window_x0_ = 0.0f;
pyramid_.clear();
}
f32 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.0f;
u64 slot = index < base ? base : index;
if (slot >= produced) slot = produced - 1;
f32 out = 0.0f;
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_ + f64(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_ + f64(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_ + f64(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, 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 = x0(); // 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, std::vector<f32>& out_xy,
plot::data::GatherMode mode) const {
if (event_ != nullptr) {
event_->gather(level, begin, end, out_xy, mode);
return;
}
if (buffer_ != nullptr) buffer_->gather(level, begin, end, 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