254 lines
12 KiB
C++
254 lines
12 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
#pragma once
|
|
|
|
#include <plot/data/minmax_pyramid.hpp>
|
|
#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 {
|
|
/// Seconds, monotonic, and **in the coordinates the axis reads**: a trigger event's
|
|
/// stamps are edge-relative and a pause view's are relative to the instant it froze,
|
|
/// which is what `x_offset` below records. **f64, like the live ring it is cut from**
|
|
/// (see `BufferStore::time()`), which also makes the cut in `publish_event()`/
|
|
/// `publish_pause_view()` a straight copy.
|
|
std::vector<f64> stamps;
|
|
/// Absolute stream seconds on the same indices as `stamps`, or empty when the store had no
|
|
/// absolute ring. **Not** rebased by `x_offset` or by the trigger edge: a snapshot outlives
|
|
/// the epoch that produced it, so it stores the absolute value outright.
|
|
std::vector<f64> abs_stamps;
|
|
std::vector<std::vector<f32>> values; ///< one array per channel, same length
|
|
/// The stream time of this snapshot's x = 0, so `x_offset + stamps[i]` is the clock the
|
|
/// data arrived on: the edge for a trigger event, the newest sample at the moment of the
|
|
/// freeze for a pause view.
|
|
///
|
|
/// It exists because a snapshot is the one thing on screen whose coordinates outlive the
|
|
/// `display_epoch()` that produced them, and `ScopeModel::stream_at_x0()` is what reads
|
|
/// it: an export has to name the stream's own clock whatever is on screen, and the pause
|
|
/// handler has to put a *display* window back on the ring to cut it. Before it, the
|
|
/// export added `trigger_time()` to a pause view's stamps -- an unrelated edge, or none
|
|
/// at all -- for want of anywhere to record which clock a snapshot was on.
|
|
double x_offset = 0.0;
|
|
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
|
|
/// One LOD pyramid per channel, built once here rather than by the series.
|
|
///
|
|
/// It is the *renderer's* performance contract that needs it: `level_for()` descends
|
|
/// to level 0 on a pyramid with no levels, so a snapshot without one is drawn from
|
|
/// every raw sample it holds, every frame, for as long as the event is held -- a
|
|
/// 2 s capture at 1 MS/s staged 6 million segments across three traces and took
|
|
/// ~100 ms a frame, which is the "large data is laggy" report. It belongs to the
|
|
/// snapshot and not to `EventSeries` because the snapshot is immutable and shared:
|
|
/// built here it costs one O(n) pass per channel inside the copy loop that is
|
|
/// already O(n), and the same channel on two rows shares it instead of building and
|
|
/// storing it twice.
|
|
std::vector<plot::data::MinMaxPyramid> pyramids;
|
|
};
|
|
|
|
/// 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};
|
|
}
|
|
/// The snapshot's pyramid for this channel (see `EventSnapshot::pyramids`). The
|
|
/// empty one only stands in for a channel the snapshot does not carry, where the
|
|
/// series has no samples either.
|
|
const plot::data::MinMaxPyramid& pyramid() const override {
|
|
return channel_ < snap_->pyramids.size() ? snap_->pyramids[channel_] : empty_pyramid_;
|
|
}
|
|
bool streaming() const noexcept override { return false; }
|
|
|
|
/// Envelope geometry per whole bucket, raw samples at the two partial edges --
|
|
/// the same shape as `ChannelBuffer::gather()`, and for the same two reasons: the
|
|
/// emitted geometry must span `[begin, end)` exactly and never reach outside it,
|
|
/// and a *bucket's extremes* are what a scope has to draw. The decimation this
|
|
/// replaced took every `2^level`-th sample, which hides exactly the spike a
|
|
/// capture was triggered on -- and, since the level was always 0 before the
|
|
/// snapshot carried a pyramid, it never even ran.
|
|
///
|
|
/// Indices need no rebasing: the pyramid is built from sample 0 and nothing is ever
|
|
/// dropped from it, so `window_base()` is 0 and a window index *is* the absolute one
|
|
/// (unlike the ring, whose window slides).
|
|
void gather(u32 level, u64 begin, u64 end, f64 x_origin, 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 b = std::min(begin, n);
|
|
const u64 e = std::min(std::max(end, b), n);
|
|
if (b >= e) return;
|
|
// x **relative to `x_origin`**, which is the library's gather contract: the
|
|
// renderer adds that same origin back before mapping to the screen. Emitting the
|
|
// absolute stamp made it add an origin 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 = x_origin;
|
|
const auto rel = [&](u64 i) {
|
|
return static_cast<f32>(stamps[static_cast<std::size_t>(i)] - base);
|
|
};
|
|
const auto emit_raw = [&](u64 from, u64 to) {
|
|
for (u64 i = from; i < to; ++i) {
|
|
out_xy.push_back(rel(i));
|
|
out_xy.push_back(vals[static_cast<std::size_t>(i)]);
|
|
}
|
|
};
|
|
|
|
if (level == 0) {
|
|
emit_raw(b, e);
|
|
return;
|
|
}
|
|
level = std::min(level, plot::data::kMaxPyramidLevel);
|
|
const plot::data::MinMaxPyramid& pyr = pyramid();
|
|
const u64 span = u64{plot::data::kBucket} << level;
|
|
const u64 lbase = pyr.level_base(level);
|
|
const u64 lend = lbase + pyr.level_size(level);
|
|
const u64 first = std::max(b / span + (b % span == 0 ? u64{0} : u64{1}), lbase);
|
|
const u64 last = std::min(e / span, lend);
|
|
if (first >= last) {
|
|
emit_raw(b, e);
|
|
return;
|
|
}
|
|
if (b < first * span) emit_raw(b, std::min(first * span, e));
|
|
|
|
for (u64 bi = first; bi < last; ++bi) {
|
|
const u32 count = pyr.bucket_count(level, bi);
|
|
switch (mode) {
|
|
case plot::data::GatherMode::Nearest: {
|
|
const u64 centre = bi * span + span / 2;
|
|
out_xy.push_back(rel(centre));
|
|
out_xy.push_back(vals[static_cast<std::size_t>(centre)]);
|
|
break;
|
|
}
|
|
case plot::data::GatherMode::Mean: {
|
|
if (count == 0) break; // no finite sample: no mean to draw
|
|
const u64 centre = bi * span + span / 2;
|
|
out_xy.push_back(rel(centre));
|
|
out_xy.push_back(static_cast<f32>(pyr.bucket_sum(level, bi) / f64(count)));
|
|
break;
|
|
}
|
|
default: { // Envelope: min at the bucket's first sample, max at its last
|
|
if (count == 0) break;
|
|
const plot::data::MinMax m = pyr.bucket(level, bi);
|
|
out_xy.push_back(rel(bi * span));
|
|
out_xy.push_back(m.min);
|
|
out_xy.push_back(rel(std::min((bi + 1) * span, e) - 1));
|
|
out_xy.push_back(m.max);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (last * span < e) emit_raw(last * span, e);
|
|
}
|
|
|
|
/// Bracketing samples by **binary search over the stamps**, which are monotonic -- never
|
|
/// through the `(x - x0()) / dx()` grid, for the same reason `ChannelBuffer` does not: `dx()`
|
|
/// is the window's *mean* spacing and the stamps are an arrival clock, so the grid and the
|
|
/// real stamps drift apart by however much the clock wandered. The renderer picks the visible
|
|
/// index window from this call, so that drift came out as a **constant offset in data
|
|
/// seconds** between the axis and the gathered geometry: a blank left margin whose *pixel*
|
|
/// width doubles with every zoom step (374 px of a 716 px axis after seven), with the trace's
|
|
/// ink pushed off the right-hand edge by the same amount and the crosshair -- which reads
|
|
/// `x_at()`, the real stamp -- still correct everywhere. That is the "zooming into a held
|
|
/// event clips it more and more from the left" report. A perfectly uniform source hides it
|
|
/// completely, which is why the mock can jitter its clock (`--clock-jitter`).
|
|
bool neighbours(f64 x, u64& i0, u64& i1) const override {
|
|
const u64 n = size();
|
|
if (n < 2) return false;
|
|
const auto& s = snap_->stamps;
|
|
const auto begin = s.begin();
|
|
const auto end = begin + static_cast<std::ptrdiff_t>(n);
|
|
// The first stamp *greater* than x, so x itself brackets as [that - 1, that]: a pointer
|
|
// exactly on a sample must not put the sample behind it.
|
|
const auto it = std::upper_bound(begin, end, static_cast<f32>(x));
|
|
const u64 above = static_cast<u64>(it - begin);
|
|
i0 = above > 0 ? above - 1 : 0;
|
|
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
|