Correct downsampling and better layout

This commit is contained in:
Martino Ferrari committed 2026-10-02 08:19:10 +02:00
1 parent 0d741c401b
commit 30db44eaa0
13 files changed
+1511 -252

No files matched your search

+88 -9
View File
@@ -2,6 +2,7 @@
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/data/minmax_pyramid.hpp>
#include <plot/data/series.hpp>
#include <algorithm>
@@ -30,6 +31,18 @@ struct EventSnapshot {
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
@@ -70,19 +83,35 @@ class EventSeries final : public plot::data::Series {
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_; }
/// 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, std::vector<f32>& out_xy,
plot::data::GatherMode /*mode*/ =
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;
const u64 b = std::min(begin, n);
const u64 e = std::min(std::max(end, b), n);
if (b >= e) return;
// 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
@@ -94,11 +123,61 @@ class EventSeries final : public plot::data::Series {
// 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)]);
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);
}
bool neighbours(f64 x, u64& i0, u64& i1) const override {