#pragma once #include #include #include 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 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(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 levels_{1}; Entry pending_; ///< the in-progress level-0 bucket, not yet an entry }; } // namespace plot::data