The repository had no licence at all, so every source was all-rights-reserved by default. Add the verbatim MIT text at LICENSE (Copyright (c) 2026 Martino Ferrari) and a two-line SPDX header to each of the 113 first-party sources -- 48 include/plot, 26 src, 10 adapters, 5 examples, 21 tests, 3 tools -- so the grant travels with each file rather than relying on the root document alone. Scripts keep their shebang on line 1; the SPDX lines follow it. Genuinely vendored code is untouched: third_party/stb_truetype.h carries no header of ours. src/render/embedded_mono_font.cpp is generated, so tools/fonts/gen_atlas.py emit() now writes the two lines into its header comment and the file was regenerated -- gen_atlas.py --check passes and the regeneration adds only those comment lines to the previously committed file. README's provenance section now states MIT and links LICENSE. No code, test or public API change.
426 lines
17 KiB
C++
426 lines
17 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#include "harness.hpp"
|
|
#include <plot/data/array_series.hpp>
|
|
#include <plot/data/detail/merge_count.hpp>
|
|
#include <plot/data/stream_series.hpp>
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <random>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
using plot::data::kBucket;
|
|
|
|
PLOT_TEST(array_series_bounds_and_gather) {
|
|
std::vector<f32> y{1, 5, -2, 3};
|
|
plot::data::ArraySeries s(std::move(y), /*x0=*/10.0, /*dx=*/0.5);
|
|
PLOT_CHECK(s.size() == 4);
|
|
PLOT_CHECK_NEAR(s.x_bounds().min, 10.0, 1e-9);
|
|
PLOT_CHECK_NEAR(s.x_bounds().max, 11.5, 1e-9);
|
|
PLOT_CHECK_NEAR(s.y_bounds().min, -2.0, 1e-6);
|
|
std::vector<f32> out;
|
|
s.gather(0, 0, 4, out);
|
|
PLOT_CHECK(out.size() == 8); // interleaved x,y
|
|
PLOT_CHECK_NEAR(out[0], 10.0, 1e-6);
|
|
PLOT_CHECK_NEAR(out[1], 1.0, 1e-6);
|
|
PLOT_CHECK_NEAR(out[6], 11.5, 1e-6);
|
|
}
|
|
|
|
PLOT_TEST(stream_series_acquire_publishes_snapshot) {
|
|
plot::data::StreamSeries s(1 << 12, /*x0=*/0.0, /*dx=*/1e-6);
|
|
for (int i = 0; i < 100; ++i) s.push(f32(i));
|
|
PLOT_CHECK(s.produced() == 100);
|
|
s.acquire();
|
|
PLOT_CHECK(s.size() == 100);
|
|
// Pyramid must track the published samples.
|
|
PLOT_CHECK_NEAR(s.reduce(0, 100).max, 99.0, 1e-4);
|
|
for (int i = 100; i < 250; ++i) s.push(f32(i));
|
|
s.acquire();
|
|
PLOT_CHECK(s.size() == 250);
|
|
}
|
|
|
|
PLOT_TEST(stream_series_drops_oldest_when_full) {
|
|
plot::data::StreamSeries s(256, 0.0, 1.0);
|
|
for (int i = 0; i < 1000; ++i) s.push(f32(i));
|
|
s.acquire();
|
|
PLOT_CHECK(s.size() <= 256);
|
|
PLOT_CHECK_NEAR(s.y_bounds().max, 999.0, 1e-4);
|
|
}
|
|
|
|
PLOT_TEST(array_series_exact_range_queries_match_bruteforce) {
|
|
// The strong test for the exactness split: arbitrary, deliberately
|
|
// unaligned ranges must match a brute-force scan of the raw samples.
|
|
std::mt19937 rng(7);
|
|
std::uniform_real_distribution<float> d(-5.0f, 5.0f);
|
|
std::vector<f32> y(4000);
|
|
for (auto& v : y) v = d(rng);
|
|
plot::data::ArraySeries s(y, 0.0, 1.0);
|
|
for (u64 a = 0; a < 4000; a += 173) {
|
|
for (u64 b = a + 1; b <= 4000; b += 331) {
|
|
float lo = y[a], hi = y[a];
|
|
double sum = 0.0, sq = 0.0;
|
|
for (u64 i = a; i < b; ++i) {
|
|
lo = std::min(lo, y[i]); hi = std::max(hi, y[i]);
|
|
sum += y[i]; sq += double(y[i]) * double(y[i]);
|
|
}
|
|
const plot::Range r = s.reduce(a, b);
|
|
PLOT_CHECK_NEAR(r.min, lo, 1e-4);
|
|
PLOT_CHECK_NEAR(r.max, hi, 1e-4);
|
|
PLOT_CHECK_NEAR(s.sum(a, b), sum, 1e-2);
|
|
PLOT_CHECK_NEAR(s.sumsq(a, b), sq, 1e-1);
|
|
}
|
|
}
|
|
}
|
|
|
|
PLOT_TEST(stream_series_exact_queries_survive_ring_wrap) {
|
|
plot::data::StreamSeries s(1u << 10, 0.0, 1.0);
|
|
constexpr u64 kPushes = 3000;
|
|
constexpr u64 kFirstRetained = kPushes - (1u << 10); // 1976
|
|
for (u64 i = 0; i < kPushes; ++i) s.push(f32(i % 97)); // wraps the ring twice
|
|
s.acquire();
|
|
const u64 n = s.size();
|
|
PLOT_CHECK(n == 1u << 10);
|
|
PLOT_CHECK_NEAR(s.x0(), 1976.0, 1e-9);
|
|
PLOT_CHECK(s.x_bounds().min == s.x0());
|
|
PLOT_CHECK(s.pyramid().size() == n);
|
|
|
|
const plot::Range r = s.reduce(0, n); // retained window, wrapped
|
|
PLOT_CHECK_NEAR(r.max, 96.0, 1e-4);
|
|
PLOT_CHECK_NEAR(r.min, 0.0, 1e-4);
|
|
|
|
double sum = 0.0, sumsq = 0.0;
|
|
for (u64 i = kFirstRetained; i < kPushes; ++i) {
|
|
const double v = static_cast<double>(i % 97);
|
|
sum += v;
|
|
sumsq += v * v;
|
|
}
|
|
PLOT_CHECK_NEAR(s.sum(0, n), sum, 1e-2);
|
|
PLOT_CHECK_NEAR(s.sumsq(0, n), sumsq, 1e-1);
|
|
|
|
std::vector<f32> out;
|
|
s.gather(0, 0, n, out); // level 0: raw samples
|
|
PLOT_CHECK(out.size() == 2 * n);
|
|
PLOT_CHECK_NEAR(out[0], s.x0(), 1e-6);
|
|
PLOT_CHECK_NEAR(out[1], f32(kFirstRetained % 97), 1e-6);
|
|
PLOT_CHECK_NEAR(out[2 * n - 2], s.x0() + f64(n - 1), 1e-6);
|
|
}
|
|
|
|
PLOT_TEST(stream_series_push_and_read_are_race_free) {
|
|
// The ring overwrites, so the producer reuses the slot of the snapshot's oldest
|
|
// retained sample while the consumer may still be reading it. Relaxed atomic
|
|
// slots make that defined; nothing else in the suite has two threads, so this is
|
|
// the case ThreadSanitizer actually flags when the slots are plain.
|
|
//
|
|
// The two flags make the overlap structural, not scheduling luck: the reader
|
|
// waits until the producer is already pushing, acknowledges, and only then does
|
|
// the producer push the rest -- so the reader's window spans pushes that are in
|
|
// flight by construction. The reader then loops until the producer reports done,
|
|
// which also makes "at least one read" true by construction instead of by
|
|
// winning a race with a fast producer.
|
|
plot::data::StreamSeries s(1u << 8, 0.0, 1.0); // one read spans reused slots
|
|
constexpr u64 kStride = 512; // pushes before the reader starts
|
|
constexpr u64 kPushes = 4'096; // total: a few thousand only
|
|
std::atomic<bool> started{false};
|
|
std::atomic<bool> reading{false};
|
|
std::atomic<bool> done{false};
|
|
|
|
std::thread producer([&] {
|
|
for (u64 i = 0; i < kStride; ++i) s.push(f32(i % 971));
|
|
started.store(true, std::memory_order_release);
|
|
while (!reading.load(std::memory_order_acquire)) {
|
|
}
|
|
for (u64 i = kStride; i < kPushes; ++i) s.push(f32(i % 971));
|
|
done.store(true, std::memory_order_release);
|
|
});
|
|
|
|
while (!started.load(std::memory_order_acquire)) {
|
|
}
|
|
reading.store(true, std::memory_order_release);
|
|
|
|
u64 reads = 0;
|
|
bool sizes_ok = true;
|
|
bool finished = false;
|
|
do {
|
|
finished = done.load(std::memory_order_acquire);
|
|
s.acquire();
|
|
const u64 n = s.size();
|
|
if (n > 0) {
|
|
(void)s.reduce(0, n);
|
|
(void)s.sum(0, n);
|
|
(void)s.sumsq(0, n);
|
|
std::vector<f32> out;
|
|
s.gather(0, 0, n, out); // reads the oldest edge
|
|
if (out.size() != 2 * n) sizes_ok = false;
|
|
}
|
|
++reads;
|
|
} while (!finished);
|
|
|
|
producer.join();
|
|
// Checked only after the join: a throw while the producer is joinable would
|
|
// std::terminate instead of reporting the failure.
|
|
PLOT_CHECK(s.produced() == kPushes);
|
|
PLOT_CHECK(reads >= 1);
|
|
PLOT_CHECK(sizes_ok);
|
|
}
|
|
|
|
namespace {
|
|
|
|
/// Buckets the pyramid holds across every level: its footprint, not its window.
|
|
u64 pyramid_entries(const plot::data::MinMaxPyramid& p) {
|
|
u64 total = 0;
|
|
for (u32 l = 0; l < p.level_count(); ++l) total += p.level_size(l);
|
|
return total;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
PLOT_TEST(stream_series_sliding_window_queries_match_bruteforce) {
|
|
// After the base has moved many times, an exact query must still match a scan
|
|
// of exactly the retained samples. The pyramid's buckets live in absolute
|
|
// sample space while the window's base is arbitrary, and that meeting point is
|
|
// where an off-by-one hides -- so test the sliding case, not just the static
|
|
// ArraySeries one.
|
|
constexpr u64 kCapacity = 1u << 14;
|
|
constexpr u64 kPushes = 60'000;
|
|
const auto value = [](u64 i) { return f32((i * 29u) % 173u) - 86.0f; };
|
|
plot::data::StreamSeries s(kCapacity, 0.0, 1e-6);
|
|
for (u64 i = 0; i < kPushes; i += 337) {
|
|
const u64 n = std::min<u64>(337, kPushes - i);
|
|
for (u64 k = 0; k < n; ++k) s.push(value(i + k));
|
|
s.acquire(); // the window's base moves on every one of these
|
|
}
|
|
|
|
const u64 n = s.size();
|
|
const u64 base = s.produced() - n;
|
|
PLOT_CHECK(n == kCapacity);
|
|
PLOT_CHECK(base > 2 * kCapacity); // the window really did slide
|
|
PLOT_CHECK(s.pyramid().size() == n); // pyramid() indexes the window
|
|
PLOT_CHECK(s.pyramid().window_base() == base);
|
|
PLOT_CHECK(s.pyramid().window_end() == s.produced());
|
|
PLOT_CHECK_NEAR(s.x0(), f64(base) * 1e-6, 1e-12);
|
|
PLOT_CHECK(s.x_bounds().min == s.x0());
|
|
PLOT_CHECK_NEAR(s.x_bounds().max, f64(base + n - 1) * 1e-6, 1e-9);
|
|
|
|
// Brute force in window coordinates over the retained samples only.
|
|
const auto brute = [&](u64 a, u64 b) {
|
|
f64 lo = f64(value(base + a)), hi = lo;
|
|
f64 sum = 0.0, sq = 0.0;
|
|
for (u64 i = a; i < b; ++i) {
|
|
const f64 v = f64(value(base + i));
|
|
lo = std::min(lo, v);
|
|
hi = std::max(hi, v);
|
|
sum += v;
|
|
sq += v * v;
|
|
}
|
|
return std::array<f64, 4>{lo, hi, sum, sq};
|
|
};
|
|
|
|
const u64 starts[] = {0, 1, 63, 64, 65, 1000, 7919, n - 200};
|
|
for (const u64 a : starts) {
|
|
for (u64 b = a + 1; b <= n; b += 1301) {
|
|
const std::array<f64, 4> want = brute(a, b);
|
|
const plot::Range r = s.reduce(a, b);
|
|
PLOT_CHECK_NEAR(r.min, want[0], 1e-4);
|
|
PLOT_CHECK_NEAR(r.max, want[1], 1e-4);
|
|
PLOT_CHECK_NEAR(s.sum(a, b), want[2], 1e-2);
|
|
PLOT_CHECK_NEAR(s.sumsq(a, b), want[3], 1e-1);
|
|
}
|
|
}
|
|
const std::array<f64, 4> whole = brute(0, n);
|
|
PLOT_CHECK_NEAR(s.y_bounds().min, whole[0], 1e-6);
|
|
PLOT_CHECK_NEAR(s.y_bounds().max, whole[1], 1e-6);
|
|
|
|
// The level accessors are in absolute bucket coordinates: a stored bucket must
|
|
// aggregate its own absolute span, the sliding window included.
|
|
const u64 span = u64{kBucket} << 1;
|
|
const u64 lbase = s.pyramid().level_base(1);
|
|
const u64 lsize = s.pyramid().level_size(1);
|
|
PLOT_CHECK(lsize >= 2);
|
|
for (u64 b = lbase; b < lbase + std::min<u64>(lsize, 4); ++b) {
|
|
const std::array<f64, 4> want = brute(b * span - base, (b + 1) * span - base);
|
|
const plot::data::MinMax m = s.pyramid().bucket(1, b);
|
|
PLOT_CHECK_NEAR(m.min, want[0], 1e-6);
|
|
PLOT_CHECK_NEAR(m.max, want[1], 1e-6);
|
|
}
|
|
|
|
// 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);
|
|
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 u64 first_sample = bi * span - base;
|
|
const u64 last_sample = std::min((bi + 1) * span, base + n) - 1 - base;
|
|
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);
|
|
}
|
|
}
|
|
|
|
PLOT_TEST(stream_series_steady_state_acquire_is_proportional_to_produced) {
|
|
// The defect this pins: a saturated ring whose base moved every frame used to
|
|
// rebuild the whole window, costing O(capacity * live_levels) merges per
|
|
// acquire. It must cost O(samples produced since the last acquire) instead, so
|
|
// the same production through a 16x larger window must cost the same.
|
|
const auto merges_for = [](u64 capacity) {
|
|
plot::data::StreamSeries s(capacity, 0.0, 1.0);
|
|
constexpr u64 kSaturate = 1u << 20;
|
|
for (u64 i = 0; i < kSaturate; ++i) s.push(f32(i % 977));
|
|
s.acquire();
|
|
PLOT_CHECK(s.size() == capacity);
|
|
|
|
constexpr u64 kFrames = 64;
|
|
constexpr u64 kFrame = 1u << 13; // 8192 samples: 1 MS/s at ~122 fps
|
|
plot::data::detail::merge_count().store(0, std::memory_order_relaxed);
|
|
for (u64 f = 0; f < kFrames; ++f) {
|
|
for (u64 k = 0; k < kFrame; ++k) s.push(f32((kSaturate + f * kFrame + k) % 977));
|
|
s.acquire();
|
|
}
|
|
return plot::data::detail::merge_count().load(std::memory_order_relaxed);
|
|
};
|
|
|
|
constexpr u64 kProduced = 64 * (1u << 13);
|
|
const u64 small = merges_for(1u << 16); // 64k-sample window
|
|
const u64 big = merges_for(1u << 20); // 1M-sample window: 16x to rebuild
|
|
// ~kProduced/32 in practice; rebuilding the 1M window alone would merge
|
|
// kCapacity/64 * live_levels ~ 245'000 buckets per acquire.
|
|
PLOT_CHECK(small <= kProduced / 16);
|
|
PLOT_CHECK(big <= kProduced / 16);
|
|
PLOT_CHECK(big <= 2 * small + kBucket);
|
|
}
|
|
|
|
PLOT_TEST(stream_series_steady_state_acquire_is_fast) {
|
|
// Wall-clock backstop for the merge count above: rebuilding the 1M-sample
|
|
// window measured ~44 ms, so 128 such frames would need ~5.6 s. The sliding
|
|
// path does the same work in a few milliseconds; the bound is two orders of
|
|
// magnitude clear of both, so it is not a thin timing margin.
|
|
plot::data::StreamSeries s(1u << 20, 0.0, 1.0);
|
|
constexpr u64 kSaturate = 1u << 20;
|
|
for (u64 i = 0; i < kSaturate; ++i) s.push(f32(i % 977));
|
|
s.acquire();
|
|
|
|
constexpr u64 kFrames = 128;
|
|
constexpr u64 kFrame = 1u << 13;
|
|
const auto t0 = std::chrono::steady_clock::now();
|
|
for (u64 f = 0; f < kFrames; ++f) {
|
|
for (u64 k = 0; k < kFrame; ++k) s.push(f32((kSaturate + f * kFrame + k) % 977));
|
|
s.acquire();
|
|
}
|
|
const f64 ms =
|
|
std::chrono::duration<f64>(std::chrono::steady_clock::now() - t0).count() * 1.0e3;
|
|
PLOT_CHECK(s.size() == 1u << 20);
|
|
PLOT_CHECK(ms < 250.0);
|
|
}
|
|
|
|
PLOT_TEST(stream_series_pyramid_memory_is_bounded_by_the_window) {
|
|
// 50M samples through a 1M-sample ring: the pyramid's footprint must track the
|
|
// retained window (n/64 level-0 buckets plus the levels over them), not the
|
|
// samples ever pushed.
|
|
constexpr u64 kCapacity = 1u << 20;
|
|
constexpr u64 kTotal = 50u * (1u << 20);
|
|
constexpr u64 kChunk = 1u << 19;
|
|
plot::data::StreamSeries s(kCapacity, 0.0, 1.0);
|
|
u64 entries_after_10m = 0;
|
|
for (u64 p = 0; p < kTotal; p += kChunk) {
|
|
for (u64 i = 0; i < kChunk; ++i) s.push(f32((p + i) % 251));
|
|
s.acquire();
|
|
if (p + kChunk == 10u * (1u << 20)) entries_after_10m = pyramid_entries(s.pyramid());
|
|
}
|
|
const u64 entries = pyramid_entries(s.pyramid());
|
|
const u64 bound = 2 * (kCapacity / kBucket) + kBucket;
|
|
PLOT_CHECK(s.produced() == kTotal);
|
|
PLOT_CHECK(s.size() == kCapacity);
|
|
PLOT_CHECK(entries <= bound);
|
|
// 5x the samples pushed, same footprint: levels deeper than one chunk can
|
|
// 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);
|
|
}
|