Files
udpscope/src/event_series.hpp
T
2026-10-01 15:42:48 +02:00

142 lines
5.8 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/data/series.hpp>
#include <algorithm>
#include <memory>
#include <vector>
namespace udpscope {
using plot::f32;
using plot::f64;
using plot::u32;
using plot::u64;
/// One captured trigger event, immutable once published: per-channel values over
/// a shared, edge-relative time stamp array (the edge sits at exactly x = 0, the
/// window spans [-pre, +post]).
///
/// The plot binds to a snapshot while the trigger holds an event. Because the
/// snapshot can never change after publication, nothing can drag, re-base or
/// refresh the view while it is held -- the failures the previous design (two
/// live ring buffers switched by a pending flag, plus a display-epoch shift)
/// could not stop.
struct EventSnapshot {
std::vector<f32> stamps; ///< edge-relative seconds, monotonic
std::vector<std::vector<f32>> values; ///< one array per channel, same length
double pre_s = 0.0; ///< seconds of pre-trigger data kept
double post_s = 0.0; ///< seconds of post-trigger data kept
std::vector<plot::Range> y; ///< per-channel value bounds, precomputed
};
/// A plot::data::Series view of one channel of an EventSnapshot. The stamps are
/// near-uniform (the arrival clock), so dx is the mean spacing and x_at returns
/// the exact stamp; gather decimates by 2^level like the ring series does.
class EventSeries final : public plot::data::Series {
public:
explicit EventSeries(std::shared_ptr<const EventSnapshot> snap, std::size_t channel)
: snap_(std::move(snap)), channel_(channel) {}
/// Identity for rebind: same snapshot object and channel means the existing
/// EventSeries already shows this event.
bool same(const std::shared_ptr<const EventSnapshot>& snap, std::size_t channel) const {
return snap_ == snap && channel_ == channel;
}
/// The shorter of the two arrays this series reads. `gather()` indexes `stamps`
/// and `values[channel]` at the same i, so reporting the stamp count alone would
/// be an out-of-bounds read the moment they differ.
u64 size() const override {
if (channel_ >= snap_->values.size()) return 0;
return static_cast<u64>(
std::min(snap_->stamps.size(), snap_->values[channel_].size()));
}
f64 x0() const override { return snap_->stamps.empty() ? 0.0 : snap_->stamps.front(); }
f64 dx() const override {
const auto& s = snap_->stamps;
return s.size() > 1 ? (s.back() - s.front()) / static_cast<f64>(s.size() - 1) : 1.0;
}
f64 x_at(u64 i) const override {
const auto& s = snap_->stamps;
return i < s.size() ? s[static_cast<std::size_t>(i)] : 0.0;
}
plot::Range x_bounds() const override {
const auto& s = snap_->stamps;
return s.empty() ? plot::Range{0.0, 0.0} : plot::Range{s.front(), s.back()};
}
plot::Range y_bounds() const override {
return channel_ < snap_->y.size() ? snap_->y[channel_] : plot::Range{0.0, 0.0};
}
const plot::data::MinMaxPyramid& pyramid() const override { return empty_pyramid_; }
bool streaming() const noexcept override { return false; }
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
plot::data::GatherMode /*mode*/ =
plot::data::GatherMode::Envelope) const override {
if (channel_ >= snap_->values.size()) return;
const auto& vals = snap_->values[channel_];
const auto& stamps = snap_->stamps;
const u64 n = size();
const u64 stride = u64{1} << level;
if (stride == 0 || begin >= n) return;
if (end > n) end = n;
// x **relative to x0()**, which is the library's gather contract: the renderer
// adds `x0()` back before mapping to the screen. Emitting the absolute stamp made
// it add x0 twice, so the whole capture was drawn one `x0()` -- a full
// pre-trigger window -- to the left of the axis it had just been measured
// against. The event's own stamps are small (edge-relative, so [-pre, +post]),
// which is why that shift looked like a plausible picture: with a 50/50 split the
// right half of the plot was simply empty, zooming out showed the trace ending at
// the x = 0 marker, and zooming in past the shift showed nothing at all while the
// crosshair -- which reads `x_at()`, never `gather()` -- went on reporting the
// right values.
const f64 base = x0();
out_xy.reserve(out_xy.size() + static_cast<std::size_t>((end - begin) / stride * 2 + 4));
for (u64 i = (begin / stride) * stride; i < end; i += stride) {
out_xy.push_back(static_cast<f32>(stamps[static_cast<std::size_t>(i)] - base));
out_xy.push_back(vals[static_cast<std::size_t>(i)]);
}
}
bool neighbours(f64 x, u64& i0, u64& i1) const override {
const u64 n = size();
if (n < 2) return false;
const f64 step = dx();
if (!(step > 0.0)) return false;
f64 idx = (x - x0()) / step;
if (idx < 0.0) idx = 0.0;
if (idx > static_cast<f64>(n - 1)) idx = static_cast<f64>(n - 1);
i0 = static_cast<u64>(idx);
i1 = i0 + 1 < n ? i0 + 1 : i0;
return true;
}
u64 snap_index(f64 x) const override {
u64 i0 = 0, i1 = 0;
if (!neighbours(x, i0, i1)) return 0;
return i1;
}
/// SlotSeries::copy_raw forwards here (public: the base's hook is protected,
/// and SlotSeries is a different class).
void raw_values(u64 begin, u64 end, f32* out) const {
const auto& vals = snap_->values[channel_];
const u64 n = static_cast<u64>(vals.size());
if (end > n) end = n;
for (u64 i = begin; i < end; ++i)
out[static_cast<std::size_t>(i - begin)] = vals[static_cast<std::size_t>(i)];
}
protected:
void copy_raw(u64 begin, u64 end, f32* out) const override { raw_values(begin, end, out); }
private:
std::shared_ptr<const EventSnapshot> snap_;
std::size_t channel_;
plot::data::MinMaxPyramid empty_pyramid_{};
};
} // namespace udpscope