Findings from the review of the sliding-window change: - A level >= 1 gather emitted only complete, wholly-retained buckets, so the geometry stopped at the last bucket boundary inside the range: the newest samples of a live window -- up to `span - 1` of them, about a pixel at the level level_for() picks -- were not drawn, on the right edge, which is the part of a scrolling trace that gets watched, and the oldest retained samples fell outside the geometry too (the level-1 gather of a sliding window started 51 samples in). The two partial edge buckets are now gathered as raw samples through copy_raw() and bracketed around the envelope -- the same exactness split reduce/sum/sumsq already use -- so the emitted geometry spans the requested range exactly. The pyramid still stores no partial bucket, so append stays amortised O(1); at 1 MS/s the demo stages 511 extra sub-pixel segments and still draws at 1.05 ms/frame, and the screenshot's trace now reaches one column further at each end. - `drop_before`'s doc claimed a no-op "for an empty pyramid": false, and backwards -- the restart branch is exactly what an empty pyramid hits, which is what StreamSeries::acquire() depends on when the ring saturated before the first acquire. The precondition is `absolute_sample <= window_base()`, and the restart is documented as what it is. - The pyramid's memory figure was 3x optimistic: an entry is 24 bytes (MinMax plus the exact sum/sumsq doubles), so level 0 costs 0.375n bytes and the whole pyramid ~0.75n -- about 19% of 4-byte raw samples, not the 6% a min/max-only index would cost. Corrected in the header, spec 4.2 and 4.5, with the trade stated (the doubles buy O(log n) sum/mean/RMS/integral from whole buckets), and the plan's Task 3 sketch no longer describes per-level prefix sums, a level() span or an unaligned-end widening that does not exist. Tests: `gather_at_a_lod_level_reaches_both_ends_of_the_window` pins that the first and last emitted points are the oldest and newest retained samples -- with spike values no bucket envelope can produce -- at level 1, at the level a dense view picks, and for a static series whose end is unaligned. It fails against the previous commit (first point at the first whole bucket), and so does the sliding gather mapping check, which now accounts for the raw edge pairs.
150 lines
7.2 KiB
C++
150 lines
7.2 KiB
C++
#pragma once
|
|
#include <deque>
|
|
#include <vector>
|
|
|
|
#include <plot/core/types.hpp>
|
|
|
|
namespace plot::data {
|
|
|
|
/// Aggregate of one bucket: the extremes of the samples it covers.
|
|
struct MinMax {
|
|
f32 min = 0.0f;
|
|
f32 max = 0.0f;
|
|
};
|
|
|
|
/// Samples per level-0 bucket. Fixed so the LOD geometry is identical for every
|
|
/// series and the level maths is a shift.
|
|
inline constexpr u32 kBucket = 64;
|
|
|
|
/// Incremental min/max LOD pyramid, indexed in absolute bucket space.
|
|
///
|
|
/// Level `l` bucket `k` covers the absolute samples
|
|
/// `[k * (kBucket << l), (k + 1) * (kBucket << l))`. Bucket boundaries are
|
|
/// therefore fixed for the pyramid's whole life: appending never re-buckets an
|
|
/// existing aggregate, and the window a caller reads can slide over the same
|
|
/// buckets instead of being rebuilt from the raw samples.
|
|
///
|
|
/// Each level holds its complete buckets in a deque (O(1) push_back *and*
|
|
/// pop_front) plus a `base`, the absolute level-`l` bucket index of its first
|
|
/// entry. `append()` folds a sample into the in-progress level-0 bucket and, when
|
|
/// that bucket completes, pushes it and walks up -- stopping at the first level
|
|
/// that gains no new entry. Level `l` gains one entry per `kBucket << l` samples,
|
|
/// so an append costs amortised O(1) and never walks every live level.
|
|
///
|
|
/// `drop_before(u)` slides the window's leading edge to absolute sample `u`: each
|
|
/// level discards the buckets whose span starts before `u`, so the memory is
|
|
/// bounded by the retained window and not by the number of samples ever appended.
|
|
/// A bucket entry is 24 bytes -- `MinMax` plus the exact `sum`/`sumsq` the exact
|
|
/// range queries fold over -- so level 0 costs 0.375n bytes and the geometric tail
|
|
/// brings the whole pyramid to about 0.75n bytes, ~19% of the 4n bytes of raw
|
|
/// samples. (A min/max-only index would be 0.25n, ~6%: the two doubles are the
|
|
/// price of answering mean/RMS/integral from whole buckets instead of raw
|
|
/// samples.) Every stored entry is the complete aggregate of its own span and
|
|
/// every stored span lies inside the retained window, so no aggregate is ever
|
|
/// recomputed or invalidated.
|
|
///
|
|
/// Queries are in *window* coordinates: index 0 is the oldest retained sample,
|
|
/// `size()` is the retained sample count, and `window_base()` is the absolute
|
|
/// index window index 0 refers to. That is the same window `Series` publishes,
|
|
/// which is why a `StreamSeries` can hand the pyramid a moving base and keep
|
|
/// answering in its own coordinates.
|
|
///
|
|
/// A pyramid stores *aggregates*, so it can only answer exactly for whole
|
|
/// buckets; a range with partial edge buckets would silently pull in samples
|
|
/// outside the range. Every query here is therefore bucket-grained and named
|
|
/// *_buckets, and exact range queries live on Series, which owns the raw samples.
|
|
class MinMaxPyramid {
|
|
public:
|
|
MinMaxPyramid() = default;
|
|
|
|
/// Sizes the level table for a window of `n` samples so a following run of
|
|
/// appends never grows the level vector. The bucket deques themselves grow in
|
|
/// chunks: a sliding window cannot know their final length up front.
|
|
void reserve(u64 n);
|
|
void append(f32 v);
|
|
/// Slides the retained window's leading edge to absolute sample index
|
|
/// `absolute_sample`, dropping every aggregate that covered a sample before it.
|
|
/// Monotonic, and a no-op only when `absolute_sample <= window_base()`: an empty
|
|
/// pyramid is not exempt, because a caller whose window already starts past
|
|
/// everything appended so far has to be able to say so (that is exactly what a
|
|
/// ring that saturated before the first acquire() passes here).
|
|
///
|
|
/// An argument past `window_end()` -- a producer that outran the consumer by
|
|
/// more than the ring held -- drops every aggregate and restarts the window at
|
|
/// `absolute_sample`: the samples in between were never appended, so they are
|
|
/// not representable, and the window the caller reads is the new one.
|
|
void drop_before(u64 absolute_sample);
|
|
void clear();
|
|
|
|
/// Samples in the retained window.
|
|
u64 size() const noexcept { return count_ - base_; }
|
|
/// Absolute index of the sample window index 0 refers to.
|
|
u64 window_base() const noexcept { return base_; }
|
|
/// Absolute index one past the last appended sample: the first sample index a
|
|
/// following append() will occupy.
|
|
u64 window_end() const noexcept { return count_; }
|
|
/// At least 1, even when empty, so level_count() - 1 is always valid.
|
|
u32 level_count() const noexcept { return live_levels_; }
|
|
|
|
/// Absolute level-`l` bucket index of level `l`'s first entry, and how many
|
|
/// entries it holds. They cover `[level_base(l), level_base(l) + level_size(l))`:
|
|
/// entry `i` is the aggregate of the absolute samples
|
|
/// `[i * (kBucket << l), (i + 1) * (kBucket << l))`. A level that exists but
|
|
/// holds nothing, or a level past level_count(), reports size 0 (its base is
|
|
/// then meaningless).
|
|
u64 level_base(u32 l) const noexcept;
|
|
u64 level_size(u32 l) const noexcept;
|
|
/// Aggregate of the absolute level-`l` bucket `i`. Precondition:
|
|
/// level_base(l) <= i < level_base(l) + level_size(l).
|
|
MinMax bucket(u32 l, u64 i) const;
|
|
|
|
/// Min/max over the whole level-0 buckets fully contained in the window range
|
|
/// [begin, end). Partial edge buckets are excluded, never approximated; an
|
|
/// empty result is a degenerate Range with span() == 0.
|
|
Range reduce_buckets(u64 begin, u64 end) const;
|
|
/// Sum of the samples in those same whole buckets, accumulated in double.
|
|
f64 sum_buckets(u64 begin, u64 end) const;
|
|
/// Sum of squares of the samples in those same whole buckets.
|
|
f64 sumsq_buckets(u64 begin, u64 end) const;
|
|
/// Pyramid level whose buckets hold about `samples_per_pixel` samples, for a
|
|
/// window of `n` samples: 0 when a pixel covers at most one bucket. Clamped to
|
|
/// the levels that exist, to levels with more than one bucket, and to levels
|
|
/// that give the window at least two buckets.
|
|
u32 level_for(u64 n, f64 samples_per_pixel) const;
|
|
/// Index of the sample nearest x on the uniform grid (x0 + i*dx), clamped to
|
|
/// [0, size() - 1]. Returns 0 for an empty pyramid or a non-positive dx.
|
|
u64 snap(f64 x, f64 x0, f64 dx) const;
|
|
|
|
private:
|
|
/// One bucket: its extremes plus its own exact sum and sum of squares. The two
|
|
/// doubles take a bucket from 8 bytes to 24, which is what lets mean/RMS/
|
|
/// integral come from whole buckets instead of raw samples.
|
|
struct Entry {
|
|
MinMax mm;
|
|
f64 sum = 0.0;
|
|
f64 sumsq = 0.0;
|
|
};
|
|
/// One level's complete buckets, in absolute bucket-index order.
|
|
struct Level {
|
|
std::deque<Entry> entries;
|
|
u64 base = 0; ///< absolute level-l bucket index of entries.front()
|
|
};
|
|
|
|
void add_level();
|
|
/// Appends `e` at absolute level-`l` bucket `k`, which must be one past the
|
|
/// level's last entry.
|
|
void push(u32 l, u64 k, const Entry& e);
|
|
/// Entry for absolute level-`l` bucket `i`; the caller has checked the range.
|
|
const Entry& at(u32 l, u64 i) const {
|
|
return levels_[l].entries[static_cast<usize>(i - levels_[l].base)];
|
|
}
|
|
|
|
u64 count_ = 0; ///< absolute index one past the last appended sample
|
|
u64 base_ = 0; ///< absolute index of the window's oldest sample
|
|
u32 live_levels_ = 1;
|
|
std::vector<Level> levels_{1};
|
|
Entry pending_; ///< the in-progress level-0 bucket, not yet an entry
|
|
};
|
|
|
|
} // namespace plot::data
|