Initial commit

This commit is contained in:
Martino Ferrari committed 2026-10-01 15:42:48 +02:00
commit 8059b90de7
29 files changed
+7893

No files matched your search

+317
View File
@@ -0,0 +1,317 @@
// 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());
}
void ChannelBuffer::invalidate() noexcept {
published_ = 0;
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()` and `x0()` -- which is the renderer's entire index
// mapping, `(x - x0()) / dx()`. So *one* unreadable slot at the front inflated
// `dx()` by the ratio of the whole elapsed stream time to the retained window and
// pushed every index up with it: `first` landed well past the first visible
// sample, and the samples in between were in the buffer, readable by the crosshair
// (`x_at()`/`neighbours()` go to the ring per sample and never saw the sentinel),
// and never drawn. 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(time_of(absolute(0)));
}
f64 ChannelBuffer::x_at(u64 i) const {
if (i >= pyramid_.size()) 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{epoch_ + f64(time_of(absolute(0))),
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