fix(data): draw the partial edge buckets at LOD levels, correct the pyramid's memory and precondition docs
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.
This commit is contained in:
1 parent
c8a97e4a1b
commit
fb2c2f5e4d
6 files changed
+199
-56
No files matched your search
@@ -718,12 +718,19 @@ class MinMaxPyramid {
|
||||
public:
|
||||
void reserve(u64 n);
|
||||
void append(f32 v);
|
||||
void drop_before(u64 absolute_sample); // slide the retained window to an absolute sample
|
||||
void clear();
|
||||
u64 size() const noexcept { return size_; }
|
||||
u32 level_count() const noexcept { return u32(levels_.size()); }
|
||||
std::span<const MinMax> level(u32 l) const;
|
||||
u64 size() const noexcept; // retained-window samples
|
||||
u64 window_base() const noexcept; // absolute index of window index 0
|
||||
u64 window_end() const noexcept; // absolute index one past the last append
|
||||
u32 level_count() const noexcept;
|
||||
u64 level_base(u32 l) const noexcept; // absolute level-l bucket index of entry 0
|
||||
u64 level_size(u32 l) const noexcept;
|
||||
MinMax bucket(u32 l, u64 i) const; // absolute level-l bucket i
|
||||
// Whole-bucket only: [begin, end) is intersected with whole level-0 buckets and
|
||||
// partial edges are excluded, never approximated. An empty result has span()==0.
|
||||
// Window coordinates: the grid is absolute, so the intersection runs through
|
||||
// window_base().
|
||||
Range reduce_buckets(u64 begin, u64 end) const;
|
||||
f64 sum_buckets(u64 begin, u64 end) const;
|
||||
f64 sumsq_buckets(u64 begin, u64 end) const;
|
||||
@@ -731,23 +738,33 @@ class MinMaxPyramid {
|
||||
u64 snap(f64 x, f64 x0, f64 dx) const;
|
||||
|
||||
private:
|
||||
u64 size_ = 0;
|
||||
std::vector<std::vector<MinMax>> levels_; // levels_[0] = kBucket granularity
|
||||
std::vector<std::vector<f64>> sums_, sumsqs_; // inclusive prefix sums per level
|
||||
// One bucket: its extremes plus its own exact sum and sum of squares. The two
|
||||
// doubles take a bucket from 8 bytes to 24 -- level 0 costs 0.375n bytes, the
|
||||
// whole pyramid ≈0.75n, ≈19% of 4-byte raw samples -- and are what let
|
||||
// mean/RMS/integral come from whole buckets. Absolute prefix sums cannot: they
|
||||
// grow without bound and cannot survive a sliding front.
|
||||
struct Entry { MinMax mm; f64 sum = 0.0; f64 sumsq = 0.0; };
|
||||
struct Level { std::deque<Entry> entries; u64 base = 0; }; // absolute bucket space
|
||||
u64 count_ = 0; // samples ever appended
|
||||
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
|
||||
};
|
||||
} // namespace plot::data
|
||||
```
|
||||
|
||||
Implementation rules for `src/data/minmax_pyramid.cpp`:
|
||||
- `append(v)`: write/merge into the last bucket of `levels_[0]`, then for each level `l`, if level `l` gained a complete pair, merge the last two entries of level `l` into a new entry of level `l+1`; otherwise stop early. Amortised O(1). The same walk maintains `sums_`/`sumsqs_` (inclusive prefix sums per level).
|
||||
- `reduce_buckets(begin, end)`: `if (end <= begin) return {0,0};` intersect `[begin, end)` with whole level-0 buckets, then cover the result by descending to the coarsest level whose buckets lie entirely inside it, finishing the remainder at level 0. It must NEVER scan raw samples — it stores none — and a partial edge bucket is excluded, not approximated. `sum_buckets`/`sumsq_buckets` use the same decomposition over `sums_`/`sumsqs_`.
|
||||
- `append(v)`: fold the sample into `pending_`, the in-progress level-0 bucket; when that bucket completes, push it as an entry and merge upward while `kBucket << l` divides the sample count, stopping at the first level that gained no entry. Amortised O(1) -- `1/kBucket + 1/(2·kBucket) + … = 2/kBucket` merges per sample, independent of the number of live levels -- and no entry is ever rewritten, which is what lets a window slide over the pyramid instead of forcing a rebuild.
|
||||
- `drop_before(u)`: set the window base and discard, per level, the leading entries whose span starts before `u`; level 0 also drops its in-progress bucket. Memory then tracks the window, not the samples ever pushed.
|
||||
- `reduce_buckets(begin, end)`: `if (end <= begin) return {0,0};` intersect `[begin, end)` with whole level-0 buckets, then cover the result by descending to the coarsest level whose buckets lie entirely inside it, finishing the remainder at level 0. It must NEVER scan raw samples — it stores none — and a partial edge bucket is excluded, not approximated. `sum_buckets`/`sumsq_buckets` use the same decomposition, folding each covered entry's own `sum`/`sumsq`.
|
||||
- `Series::reduce`/`sum`/`sumsq` in `src/data/series.cpp` are the exact entry points every other module uses: they take the bucket-aligned interior from `reduce_buckets`/`sum_buckets`/`sumsq_buckets` and scan the at-most-two partial edge buckets obtained via `copy_raw` (a stack buffer of at most 2*kBucket floats, so no allocation on the query path).
|
||||
- `level_for(n, spp)`: `if (spp <= f64(kBucket)) return 0;` else `u32 l = u32(std::floor(std::log2(spp / f64(kBucket))));` clamp to `level_count() - 1`, and additionally clamp so that `level(l).size() >= 2`.
|
||||
- `level_for(n, spp)`: `if (spp <= f64(kBucket)) return 0;` else `u32 l = u32(std::floor(std::log2(spp / f64(kBucket))));` clamp to `level_count() - 1`, and additionally clamp so that `level_size(l) >= 2`.
|
||||
- `snap(x, x0, dx)`: `if (dx <= 0.0 || size_ == 0) return 0;` compute `i = llround((x - x0) / dx)` and clamp to `[0, size_ - 1]`.
|
||||
|
||||
`include/plot/data/ring_buffer.hpp`: `RingBuffer<T>` with power-of-two capacity, atomic producer index, `push(T)` overwriting the oldest entry when full, `size()`, `operator[](u64)` indexing oldest→newest, `clear()`.
|
||||
|
||||
`include/plot/data/series.hpp`: the abstract `Series` from spec §6. `virtual f64 x0() const` and `virtual f64 dx() const` expose the uniform x grid (measurement and `snap` need them). `virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const = 0;` is documented as "appends interleaved (x, y) pairs in data coordinates for bucket-aligned level `level`; level 0 emits raw samples". `reduce`/`sum`/`sumsq` are non-virtual, implemented once in `src/data/series.cpp` over the protected pure virtual `copy_raw(u64 begin, u64 end, f32* out) const`, which copies `end - begin` raw y values and handles any wraparound.
|
||||
`include/plot/data/series.hpp`: the abstract `Series` from spec §6. `virtual f64 x0() const` and `virtual f64 dx() const` expose the uniform x grid (measurement and `snap` need them). `virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const = 0;` is documented as "level 0 emits the raw samples of [begin, end); level >= 1 emits one (min, max) envelope pair per complete retained bucket, bracketed by the partial edge buckets drawn as raw samples", so the geometry spans the requested range exactly without the pyramid storing a partial bucket. `reduce`/`sum`/`sumsq` are non-virtual, implemented once in `src/data/series.cpp` over the protected pure virtual `copy_raw(u64 begin, u64 end, f32* out) const`, which copies `end - begin` raw y values and handles any wraparound.
|
||||
|
||||
`ArraySeries`: holds `std::vector<f32> y_`, `f64 x0_`, `f64 dx_`, a `MinMaxPyramid`, and cached `Range y_bounds_`/`Range x_bounds_`. Constructor and `reset()` fill the pyramid and bounds; `commit()` rebuilds them (for when the caller mutated the vector out of band — document that the vector is owned and mutated only via `reset`). `copy_raw` is a bounds-checked `std::copy` over `y_`.
|
||||
|
||||
|
||||
@@ -102,8 +102,14 @@ separate code path or pre-transformed buffer is needed.
|
||||
### 4.2 Min/max LOD pyramid
|
||||
|
||||
Every series owns a mip chain: level 0 buckets `kBucket = 64` consecutive samples
|
||||
and stores one `(min, max)` pair per bucket; each subsequent level halves the
|
||||
bucket count. Memory cost is `n/8` bytes at level 0 (2 floats per 64 samples) times the geometric sum `1 + ½ + ¼ + …`, giving `n/4` bytes total — ≈6% of the 4n bytes of raw samples, not 2×.
|
||||
and stores one entry per bucket; each subsequent level halves the bucket count.
|
||||
Each entry is 24 bytes — a `MinMax` pair (8 bytes) plus the exact `sum` and `sumsq`
|
||||
doubles the range queries fold over (16 bytes) — so level 0 costs
|
||||
`24n/64 = 0.375n` bytes and the geometric sum `1 + ½ + ¼ + …` brings the pyramid to
|
||||
≈`0.75n` bytes, about 19% of the `4n` bytes of raw samples, not 2×. A min/max-only
|
||||
index would be `n/4`, ≈6%: the two doubles are the trade that makes an interior
|
||||
sum/mean/RMS/integral O(log n) whole-bucket additions instead of a scan of raw
|
||||
samples, at every level, for every series that uses the measurement index.
|
||||
|
||||
- Built incrementally, in **absolute bucket space**: level `l` bucket `k` covers
|
||||
samples `[k · (kBucket << l), (k + 1) · (kBucket << l))`. A bucket's span
|
||||
@@ -158,7 +164,7 @@ is `reduce(0, size())` over the window rather than a scan of it.
|
||||
|
||||
| | Approach | Verdict |
|
||||
|---|---|---|
|
||||
| **A** | **CPU min/max pyramid, incremental** | **chosen** — works on GL 3.3, deterministic, unit-testable with no GPU, O(1) amortised per sample, and it doubles as the measurement index. Cost: one CPU pass per sample, plus ≈6% memory. |
|
||||
| **A** | **CPU min/max pyramid, incremental** | **chosen** — works on GL 3.3, deterministic, unit-testable with no GPU, O(1) amortised per sample, and it doubles as the measurement index. Cost: one CPU pass per sample, plus ≈19% memory (24-byte buckets: extremes plus exact per-bucket sums). |
|
||||
| B | GPU compute decimation every frame | rejected — re-reads the whole series each frame (real bandwidth at 1M samples/frame), no 3.3 fallback, untestable headlessly. |
|
||||
| C | no decimation | rejected — dies above ~500k samples. |
|
||||
|
||||
|
||||
@@ -33,10 +33,15 @@ inline constexpr u32 kBucket = 64;
|
||||
///
|
||||
/// `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 (about n/4 bytes, ~6% of 4-byte raw samples)
|
||||
/// and not by the number of samples ever appended. 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.
|
||||
/// 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
|
||||
@@ -59,12 +64,15 @@ class MinMaxPyramid {
|
||||
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 for arguments ahead of the base; a no-op for arguments at or
|
||||
/// behind it, and for an empty pyramid.
|
||||
/// 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 everything and restarts the window there: the
|
||||
/// samples in between were never appended, so they are not representable.
|
||||
/// 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();
|
||||
|
||||
@@ -108,7 +116,9 @@ class MinMaxPyramid {
|
||||
u64 snap(f64 x, f64 x0, f64 dx) const;
|
||||
|
||||
private:
|
||||
/// One bucket: its extremes plus the raw-sum aggregates Series folds over.
|
||||
/// 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;
|
||||
|
||||
@@ -31,11 +31,20 @@ class Series {
|
||||
virtual Range y_bounds() const = 0;
|
||||
virtual const MinMaxPyramid& pyramid() const = 0;
|
||||
|
||||
/// Appends interleaved (x, y) pairs in data coordinates for bucket-aligned
|
||||
/// level `level`; level 0 emits raw samples. begin/end are expected to be
|
||||
/// bucket-aligned at that level; an unaligned end is widened to the bucket that
|
||||
/// holds it (so passing end == size() includes the trailing partial bucket).
|
||||
/// Pairs are appended to out_xy, which is never cleared.
|
||||
/// Appends interleaved (x, y) pairs in data coordinates for level `level`.
|
||||
///
|
||||
/// Level 0 emits the raw samples of [begin, end). Level >= 1 emits one (min, max)
|
||||
/// envelope pair per *complete* level bucket wholly inside the retained window,
|
||||
/// bounded by the two partial buckets at the edges of the requested range — the
|
||||
/// bucket the window starts inside and the bucket the newest samples are in —
|
||||
/// which it emits as raw samples through copy_raw(). A bucket that is only partly
|
||||
/// retained holds aggregates of samples the window no longer has and a partial
|
||||
/// trailing bucket is not stored at all, so neither may be drawn as an envelope.
|
||||
///
|
||||
/// The emitted geometry therefore always spans [begin, end) exactly, for at most
|
||||
/// two buckets of extra raw work (about two pixels at the level level_for()
|
||||
/// picks), and never reaches outside the retained window. Pairs are appended to
|
||||
/// out_xy, which is never cleared.
|
||||
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const = 0;
|
||||
|
||||
/// Exact min/max, sum and sum-of-squares over [begin, end).
|
||||
@@ -71,6 +80,12 @@ class Series {
|
||||
/// Reads both edge spans through copy_raw() into `out` (which must hold at
|
||||
/// least 2*kBucket samples) and returns how many were written.
|
||||
u64 copy_edges(const QueryParts& parts, f32* out) const;
|
||||
/// Appends the raw samples of [begin, end) as (x, y) pairs, in copy_raw()'s
|
||||
/// bounded chunks. The one path that reads samples rather than aggregates: level
|
||||
/// 0 is entirely this, and a level >= 1 gather uses it for the two partial edge
|
||||
/// buckets a pyramid of complete buckets cannot answer for. Precondition:
|
||||
/// begin < end, end <= size().
|
||||
void gather_raw(u64 begin, u64 end, std::vector<f32>& out_xy) const;
|
||||
};
|
||||
|
||||
} // namespace plot::data
|
||||
+36
-16
@@ -117,25 +117,29 @@ f64 Series::sumsq(u64 begin, u64 end) const {
|
||||
return total;
|
||||
}
|
||||
|
||||
void Series::gather_raw(u64 begin, u64 end, std::vector<f32>& out_xy) const {
|
||||
const f64 x_0 = x0();
|
||||
const f64 step = dx();
|
||||
f32 raw[kGatherChunk];
|
||||
for (u64 i = begin; i < end;) {
|
||||
const u64 take = std::min<u64>(kGatherChunk, end - i);
|
||||
copy_raw(i, i + take, raw);
|
||||
for (u64 k = 0; k < take; ++k) {
|
||||
out_xy.push_back(f32(x_0 + f64(i + k) * step));
|
||||
out_xy.push_back(raw[k]);
|
||||
}
|
||||
i += take;
|
||||
}
|
||||
}
|
||||
|
||||
void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const {
|
||||
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;
|
||||
|
||||
const f64 x_0 = x0();
|
||||
const f64 step = dx();
|
||||
if (level == 0) {
|
||||
f32 raw[kGatherChunk];
|
||||
for (u64 i = b; i < e;) {
|
||||
const u64 take = std::min<u64>(kGatherChunk, e - i);
|
||||
copy_raw(i, i + take, raw);
|
||||
for (u64 k = 0; k < take; ++k) {
|
||||
out_xy.push_back(f32(x_0 + f64(i + k) * step));
|
||||
out_xy.push_back(raw[k]);
|
||||
}
|
||||
i += take;
|
||||
}
|
||||
gather_raw(b, e, out_xy);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -146,23 +150,39 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
|
||||
// are both complete and wholly inside the window: a bucket that is only partly
|
||||
// retained aggregates samples the window no longer has, and a partial trailing
|
||||
// bucket is not stored at all.
|
||||
//
|
||||
// Those two excluded buckets are still part of the range, and one of them holds
|
||||
// the newest samples -- for a live trace, the part being watched. They are drawn
|
||||
// from the raw samples instead: at most one bucket at each end, about a pixel
|
||||
// each at the level level_for() picks, so the envelope path stays O(buckets)
|
||||
// and the geometry still spans the requested range exactly.
|
||||
const f64 x_0 = x0();
|
||||
const f64 step = dx();
|
||||
const u64 span = u64{kBucket} << level;
|
||||
const u64 origin = pyramid().window_base();
|
||||
const u64 lbase = pyramid().level_base(level);
|
||||
const u64 lend = lbase + pyramid().level_size(level);
|
||||
if (lbase >= lend) return;
|
||||
const u64 a_end = origin + e;
|
||||
const u64 first = std::max((origin + b) / span, lbase);
|
||||
const u64 last = std::min((a_end - 1) / span + 1, lend);
|
||||
const u64 last = std::min((origin + e - 1) / span + 1, lend);
|
||||
if (first >= last) {
|
||||
// No complete bucket of this level is inside the range -- a window squeezed
|
||||
// into one bucket -- so the raw samples are the only exact geometry there is.
|
||||
gather_raw(b, e, out_xy);
|
||||
return;
|
||||
}
|
||||
const u64 lead = first * span - origin; // window index the first emitted bucket starts at
|
||||
if (b < lead) gather_raw(b, std::min(lead, e), out_xy);
|
||||
for (u64 bi = first; bi < last; ++bi) {
|
||||
const MinMax m = pyramid().bucket(level, bi);
|
||||
const u64 first_sample = bi * span - origin; // >= 0: stored buckets start inside the window
|
||||
const u64 last_sample = std::min((bi + 1) * span, a_end) - 1 - origin;
|
||||
const u64 last_sample = std::min((bi + 1) * span, origin + e) - 1 - origin;
|
||||
out_xy.push_back(f32(x_0 + f64(first_sample) * step));
|
||||
out_xy.push_back(m.min);
|
||||
out_xy.push_back(f32(x_0 + f64(last_sample) * step));
|
||||
out_xy.push_back(m.max);
|
||||
}
|
||||
const u64 trail = last * span - origin; // one past the last emitted bucket's span
|
||||
if (trail < e) gather_raw(trail, e, out_xy);
|
||||
}
|
||||
|
||||
} // namespace plot::data
|
||||
+87
-12
@@ -245,25 +245,41 @@ PLOT_TEST(stream_series_sliding_window_queries_match_bruteforce) {
|
||||
PLOT_CHECK_NEAR(m.max, want[1], 1e-6);
|
||||
}
|
||||
|
||||
// level-1 gather: one envelope pair per retained bucket the range overlaps, x
|
||||
// at the bucket's own first and last sample. This is the mapping the renderer
|
||||
// draws from.
|
||||
// level-1 gather: the envelope pairs of the retained buckets, bracketed by the
|
||||
// two partial edge buckets as raw samples, so the geometry spans the range
|
||||
// exactly. This is the mapping the renderer draws from.
|
||||
std::vector<f32> out;
|
||||
s.gather(1, 0, n, out);
|
||||
const u64 first = std::max(base / span, lbase);
|
||||
const u64 last = std::min((base + n - 1) / span + 1, lbase + lsize);
|
||||
PLOT_CHECK(out.size() == 4 * (last - first));
|
||||
for (u64 bi = first; bi < last; ++bi) {
|
||||
const u64 lead = first * span - base; // raw pairs before the first whole bucket
|
||||
const u64 trail = last * span - base; // first window index after the last one
|
||||
const u64 tail = trail < n ? n - trail : 0;
|
||||
const u64 env = last - first;
|
||||
PLOT_CHECK(out.size() == 2 * (lead + 2 * env + tail)); // each bucket is two pairs
|
||||
for (u64 i = 0; i < lead; ++i) { // the partial leading bucket, raw
|
||||
PLOT_CHECK_NEAR(out[2 * i], f32(f64(base + i) * 1e-6), 1e-9);
|
||||
PLOT_CHECK_NEAR(out[2 * i + 1], value(base + i), 1e-6);
|
||||
}
|
||||
for (u64 j = 0; j < env; ++j) {
|
||||
// Each whole bucket contributes two pairs: (first sample, min) and (last
|
||||
// sample, max). x is in data coordinates -- x0() is the retained window's
|
||||
// first sample -- so window index i plots at (base + i) * dx.
|
||||
const u64 bi = first + j;
|
||||
const std::array<f64, 4> want = brute(bi * span - base, (bi + 1) * span - base);
|
||||
const plot::usize k = static_cast<plot::usize>(bi - first);
|
||||
const u64 first_sample = bi * span - base;
|
||||
const u64 last_sample = std::min((bi + 1) * span, base + n) - 1 - base;
|
||||
// x is in data coordinates: x0() is the retained window's first sample, so
|
||||
// window index j plots at (base + j) * dx.
|
||||
PLOT_CHECK_NEAR(out[4 * k], f32(f64(base + first_sample) * 1e-6), 1e-9);
|
||||
PLOT_CHECK_NEAR(out[4 * k + 1], want[0], 1e-6);
|
||||
PLOT_CHECK_NEAR(out[4 * k + 2], f32(f64(base + last_sample) * 1e-6), 1e-9);
|
||||
PLOT_CHECK_NEAR(out[4 * k + 3], want[1], 1e-6);
|
||||
const plot::usize lo = static_cast<plot::usize>(2 * (lead + 2 * j));
|
||||
const plot::usize hi = lo + 2;
|
||||
PLOT_CHECK_NEAR(out[lo], f32(f64(base + first_sample) * 1e-6), 1e-9);
|
||||
PLOT_CHECK_NEAR(out[lo + 1], want[0], 1e-6);
|
||||
PLOT_CHECK_NEAR(out[hi], f32(f64(base + last_sample) * 1e-6), 1e-9);
|
||||
PLOT_CHECK_NEAR(out[hi + 1], want[1], 1e-6);
|
||||
}
|
||||
for (u64 i = 0; i < tail; ++i) { // the partial trailing bucket: the newest samples
|
||||
const plot::usize k = static_cast<plot::usize>(2 * (lead + 2 * env + i));
|
||||
PLOT_CHECK_NEAR(out[k], f32(f64(base + trail + i) * 1e-6), 1e-9);
|
||||
PLOT_CHECK_NEAR(out[k + 1], value(base + trail + i), 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,3 +361,62 @@ PLOT_TEST(stream_series_pyramid_memory_is_bounded_by_the_window) {
|
||||
// differ by a bucket each, hence the slack.
|
||||
PLOT_CHECK(entries <= entries_after_10m + kBucket);
|
||||
}
|
||||
|
||||
PLOT_TEST(gather_at_a_lod_level_reaches_both_ends_of_the_window) {
|
||||
// The pyramid stores complete buckets only, so its envelope stops at bucket
|
||||
// boundaries. Without the two partial edge buckets drawn from raw samples the
|
||||
// geometry would stop short of the newest samples -- the ones a live trace is
|
||||
// watched for -- and of the oldest retained ones.
|
||||
constexpr u64 kCapacity = 1u << 13;
|
||||
constexpr u64 kPushes = 40013;
|
||||
constexpr u64 kBase = kPushes - kCapacity; // 31821: not a multiple of kBucket
|
||||
const auto value = [](u64 i) {
|
||||
if (i == kBase) return 25.0f; // the oldest retained sample
|
||||
if (i == kPushes - 1) return -25.0f; // the newest retained sample
|
||||
return f32(i % 97) * 0.25f - 12.0f; // inside [-12, 12], so a bucket's
|
||||
}; // envelope can never equal either spike
|
||||
|
||||
plot::data::StreamSeries s(kCapacity, 0.0, 1e-6);
|
||||
for (u64 i = 0; i < kPushes; ++i) s.push(value(i));
|
||||
s.acquire();
|
||||
const u64 n = s.size();
|
||||
PLOT_CHECK(n == kCapacity);
|
||||
PLOT_CHECK((kBase % kBucket) != 0); // both edges off the grid
|
||||
PLOT_CHECK((kPushes % (u64{kBucket} << 1)) != 0);
|
||||
|
||||
const auto check_ends = [&](u32 level) {
|
||||
std::vector<f32> out;
|
||||
s.gather(level, 0, n, out);
|
||||
PLOT_CHECK(out.size() >= 8u);
|
||||
// Oldest: the first emitted pair is the retained sample at index 0, not the
|
||||
// first whole bucket (which starts `lead` samples later).
|
||||
PLOT_CHECK_NEAR(out[0], f32(s.x0()), 1e-9);
|
||||
PLOT_CHECK_NEAR(out[1], 25.0, 1e-6);
|
||||
// Newest: the last emitted pair is the sample at index n-1, not the last
|
||||
// complete bucket.
|
||||
PLOT_CHECK_NEAR(out[out.size() - 2], f32(s.x0() + f64(n - 1) * 1e-6), 1e-9);
|
||||
PLOT_CHECK_NEAR(out[out.size() - 1], -25.0, 1e-6);
|
||||
};
|
||||
PLOT_CHECK(s.pyramid().level_size(1) >= 2);
|
||||
check_ends(1);
|
||||
const u32 dense = s.pyramid().level_for(n, 1024.0);
|
||||
PLOT_CHECK(dense > 0u);
|
||||
check_ends(dense);
|
||||
|
||||
// A static series whose window starts on the grid: only the trailing partial
|
||||
// bucket is at stake, and that is exactly what level_for() picks when a pixel
|
||||
// covers hundreds of samples.
|
||||
std::vector<f32> y(5000);
|
||||
for (u64 i = 0; i < y.size(); ++i) y[i] = value(i);
|
||||
y[0] = 25.0f;
|
||||
y[y.size() - 1] = -25.0f;
|
||||
const u64 ny = static_cast<u64>(y.size());
|
||||
PLOT_CHECK((ny % 256u) != 0u); // 5000 = 19 * 256 + 136 at level 2
|
||||
plot::data::ArraySeries a(y, 0.0, 1e-6);
|
||||
std::vector<f32> out;
|
||||
a.gather(2, 0, ny, out);
|
||||
PLOT_CHECK(out.size() >= 8u);
|
||||
PLOT_CHECK_NEAR(out[0], 0.0, 1e-9); // level 2 starts on the grid
|
||||
PLOT_CHECK_NEAR(out[out.size() - 2], f32(f64(ny - 1) * 1e-6), 1e-9);
|
||||
PLOT_CHECK_NEAR(out[out.size() - 1], -25.0, 1e-6);
|
||||
}
|
||||
Reference in new issue
Block a user