Compare commits

...
1 Commits
Author SHA1 Message Date
Martino Ferrari c2c8c596f3 fix(data): the gather origin is the caller's, so a deep zoom draws on the sample
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s
`Series::gather()` stages x as f32 relative to `x0()` -- the start of the
*retained window* -- so the value's ULP was a fraction of data the view need
not even hold: 1.9 us over a 20 s ring. A readout quotes `x_at()` in exact
f64, so at a 10 us view across 700 px that put more than a hundred pixels
between where a sample is drawn and the x reported for it.

The origin is now a parameter, placed before `out_xy` so every call site is a
type error rather than a silent change of behaviour. `Renderer::draw_traces`
passes the visible range's minimum, which makes the error a fixed 2^-24 of the
span on screen -- 4e-5 of a pixel over a 700 px axis -- at every zoom.

`stage_xy()` loses its second origin as a consequence: the gather origin and
the draw origin are now always the same value, so the linear case hands the
gathered buffer straight to `add_polyline_xy()` with no per-point copy.

`Plot::crosshair_point()` names the query itself, the one origin that cannot
lose precision for a caller that might.

Guarded by `a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes`,
which measures the error in pixels both ways: under 0.05 px with the view's
minimum, over 10 px with `x0()`.
2026-10-05 18:43:50 +02:00
15 changed files with 150 additions and 95 deletions

No files matched your search

+1 -1
View File
@@ -33,7 +33,7 @@ class ArraySeries final : public Series {
Range x_bounds() const override; Range x_bounds() const override;
Range y_bounds() const override; Range y_bounds() const override;
const MinMaxPyramid& pyramid() const override; const MinMaxPyramid& pyramid() const override;
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const override; GatherMode mode = GatherMode::Envelope) const override;
protected: protected:
+17 -9
View File
@@ -59,12 +59,20 @@ class Series {
/// Appends interleaved (x, y) pairs for level `level`. /// Appends interleaved (x, y) pairs for level `level`.
/// ///
/// **x is relative to `x0()`**: the emitted value is `x_at(i) - x0()` as /// **x is relative to `x_origin`**: the emitted value is `x_at(i) - x_origin` as f32, never
/// f32, never the absolute coordinate. A consumer reconstructs the absolute /// the absolute coordinate. A consumer reconstructs the absolute x in f64
/// x in f64 (`x0() + emitted`) before mapping it to the screen; staging an /// (`x_origin + emitted`) before mapping it to the screen; staging an absolute epoch-time x
/// absolute epoch-time x as f32 would quantise it to a grid coarser than /// as f32 would quantise it to a grid coarser than the pixel (f32 carries 24 bits -- 1 s at
/// the pixel (f32 carries 24 bits -- 1 s at 1.7e9), while the relative /// 1.7e9). y is unchanged.
/// value `i*dx` is small and exact. y is unchanged. ///
/// **The caller names the origin because only the caller knows the span the f32 has to be
/// exact against.** The origin was `x0()` -- the start of the series' *retained window* --
/// and a relative value's ULP is then a fraction of the whole window however little of it is
/// on screen: 1.9 us over 20 s of retained data, which at a view 10 us wide is more than a
/// hundred pixels between where a sample is *drawn* and the x a readout quotes for the same
/// sample (`x_at()`, exact f64). Passing the visible range's minimum instead makes the error
/// 2^-24 of the *visible span* -- a fixed 4e-5 of a pixel over a 700 px axis -- at every
/// zoom. A caller with no view in mind may pass `x0()` and get the old behaviour.
/// ///
/// Level 0 emits the raw samples of [begin, end). Level >= 1 emits one /// Level 0 emits the raw samples of [begin, end). Level >= 1 emits one
/// geometry element per *complete* level bucket wholly inside the retained /// geometry element per *complete* level bucket wholly inside the retained
@@ -85,7 +93,7 @@ class Series {
/// level_for() picks), and never reaches outside the retained window -- nor /// level_for() picks), and never reaches outside the retained window -- nor
/// outside [begin, end). Pairs are appended to out_xy, which is never /// outside [begin, end). Pairs are appended to out_xy, which is never
/// cleared. /// cleared.
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, virtual void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const = 0; GatherMode mode = GatherMode::Envelope) const = 0;
/// Absolute x of sample `i` (`x0() + i*dx()` on a uniform-grid series). /// Absolute x of sample `i` (`x0() + i*dx()` on a uniform-grid series).
@@ -121,7 +129,7 @@ class Series {
/// The shared body of the concrete gather() overrides: raw samples at level 0, /// The shared body of the concrete gather() overrides: raw samples at level 0,
/// bucket geometry above it (per `mode`). /// bucket geometry above it (per `mode`).
void gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, void gather_impl(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode) const; GatherMode mode) const;
private: private:
@@ -143,7 +151,7 @@ class Series {
/// 0 is entirely this, and a level >= 1 gather uses it for the two partial edge /// 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: /// buckets a pyramid of complete buckets cannot answer for. Precondition:
/// begin < end, end <= size(). /// begin < end, end <= size().
void gather_raw(u64 begin, u64 end, std::vector<f32>& out_xy) const; void gather_raw(u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy) const;
}; };
} // namespace plot::data } // namespace plot::data
+1 -1
View File
@@ -92,7 +92,7 @@ class StreamSeries final : public Series {
Range x_bounds() const override; Range x_bounds() const override;
Range y_bounds() const override; Range y_bounds() const override;
const MinMaxPyramid& pyramid() const override; const MinMaxPyramid& pyramid() const override;
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const override; GatherMode mode = GatherMode::Envelope) const override;
/// Always true: this series' `x_bounds()` is the retained ring window, which /// Always true: this series' `x_bounds()` is the retained ring window, which
/// slides with every `acquire()` -- the property `XFollow::Live` follows. /// slides with every `acquire()` -- the property `XFollow::Live` follows.
+2 -2
View File
@@ -53,8 +53,8 @@ class XYSeries final : public Series {
Range x_bounds() const override; Range x_bounds() const override;
Range y_bounds() const override; Range y_bounds() const override;
const MinMaxPyramid& pyramid() const override; const MinMaxPyramid& pyramid() const override;
/// Same contract as `Series::gather`, with x emitted relative to `x0()`. /// Same contract as `Series::gather`, with x emitted relative to `x_origin`.
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const override; GatherMode mode = GatherMode::Envelope) const override;
/// The sample's own x. /// The sample's own x.
+5 -4
View File
@@ -445,11 +445,12 @@ class Renderer {
/// `origin_x` back in, in f64, before the single f32 narrowing (B1), so a /// `origin_x` back in, in f64, before the single f32 narrowing (B1), so a
/// large-magnitude x -- an epoch axis -- never loses sub-pixel precision. /// large-magnitude x -- an epoch axis -- never loses sub-pixel precision.
void stage_segment(f64 origin_x, f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y); void stage_segment(f64 origin_x, f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y);
/// Stages `n` interleaved `x,y` points of a gathered window (see /// Stages `n` interleaved `x,y` points of a gathered window (see `Series::gather`): x
/// `Series::gather`): x **relative to the series' `x0()`**, y absolute. The /// **relative to `origin_x`**, which the caller asked the gather for, y absolute. On linear
/// absolute data x is reconstructed in f64 (`series_x0 + rel`) and staged as /// axes that is already what the batch wants and the buffer is handed over as it is; a log
/// axis reconstructs the absolute x in f64 per segment and stages
/// `to_space(x_abs) - to_space(origin_x)` -- O(visible pixels), not O(epoch). /// `to_space(x_abs) - to_space(origin_x)` -- O(visible pixels), not O(epoch).
void stage_xy(const f32* xy, u32 n, f64 series_x0, f64 origin_x, bool log_x, bool log_y); void stage_xy(const f32* xy, u32 n, f64 origin_x, bool log_x, bool log_y);
/// Lays one run of text out at a framebuffer position and stages it into /// Lays one run of text out at a framebuffer position and stages it into
/// `batch`, shifting it into viewport space and tinting it `color` (the /// `batch`, shifting it into viewport space and tinting it `color` (the
/// per-instance `a_colour`). Returns the pen past the run. /// per-instance `a_colour`). Returns the pen past the run.
+2 -2
View File
@@ -75,9 +75,9 @@ Range ArraySeries::y_bounds() const { return y_bounds_; }
const MinMaxPyramid& ArraySeries::pyramid() const { return pyramid_; } const MinMaxPyramid& ArraySeries::pyramid() const { return pyramid_; }
void ArraySeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, void ArraySeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode) const { GatherMode mode) const {
gather_impl(level, begin, end, out_xy, mode); gather_impl(level, begin, end, x_origin, out_xy, mode);
} }
void ArraySeries::copy_raw(u64 begin, u64 end, f32* out) const { void ArraySeries::copy_raw(u64 begin, u64 end, f32* out) const {
+15 -11
View File
@@ -176,21 +176,24 @@ u64 Series::snap_index(f64 x) const {
return static_cast<u64>(k); return static_cast<u64>(k);
} }
void Series::gather_raw(u64 begin, u64 end, std::vector<f32>& out_xy) const { void Series::gather_raw(u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy) const {
const f64 step = dx(); const f64 step = dx();
// Sample i sits at `x0() + i*step`, so this is `x_at(i) - x_origin` with the subtraction done
// once in f64 rather than per sample: see gather() for why the caller owns the origin.
const f64 shift = x0() - x_origin;
f32 raw[kGatherChunk]; f32 raw[kGatherChunk];
for (u64 i = begin; i < end;) { for (u64 i = begin; i < end;) {
const u64 take = std::min<u64>(kGatherChunk, end - i); const u64 take = std::min<u64>(kGatherChunk, end - i);
copy_raw(i, i + take, raw); copy_raw(i, i + take, raw);
for (u64 k = 0; k < take; ++k) { for (u64 k = 0; k < take; ++k) {
out_xy.push_back(f32(f64(i + k) * step)); // x relative to x0(): see gather() out_xy.push_back(f32(shift + f64(i + k) * step)); // x relative to x_origin: see gather()
out_xy.push_back(raw[k]); out_xy.push_back(raw[k]);
} }
i += take; i += take;
} }
} }
void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, void Series::gather_impl(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode) const { GatherMode mode) const {
const u64 n = size(); const u64 n = size();
const u64 b = std::min(begin, n); const u64 b = std::min(begin, n);
@@ -198,7 +201,7 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
if (b >= e) return; if (b >= e) return;
if (level == 0) { if (level == 0) {
gather_raw(b, e, out_xy); gather_raw(b, e, x_origin, out_xy);
return; return;
} }
// Public levels clamp to the deepest representable one (R3): past // Public levels clamp to the deepest representable one (R3): past
@@ -226,6 +229,7 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
// picks, so the envelope path stays O(buckets) and the geometry still spans // picks, so the envelope path stays O(buckets) and the geometry still spans
// the requested range exactly, for every mode. // the requested range exactly, for every mode.
const f64 step = dx(); const f64 step = dx();
const f64 shift = x0() - x_origin; // as in gather_raw(): x_at(i) - x_origin = shift + i*step
const u64 span = u64{kBucket} << level; const u64 span = u64{kBucket} << level;
const u64 origin = pyramid().window_base(); const u64 origin = pyramid().window_base();
const u64 lbase = pyramid().level_base(level); const u64 lbase = pyramid().level_base(level);
@@ -235,11 +239,11 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
if (first >= last) { if (first >= last) {
// No complete bucket of this level is inside the range -- a window squeezed // 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. // into one bucket -- so the raw samples are the only exact geometry there is.
gather_raw(b, e, out_xy); gather_raw(b, e, x_origin, out_xy);
return; return;
} }
const u64 lead = first * span - origin; // window index the first emitted bucket starts at 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); if (b < lead) gather_raw(b, std::min(lead, e), x_origin, out_xy);
switch (mode) { switch (mode) {
case GatherMode::Envelope: { case GatherMode::Envelope: {
@@ -252,9 +256,9 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
const MinMax m = pyramid().bucket(level, bi); const MinMax m = pyramid().bucket(level, bi);
const u64 first_sample = bi * span - origin; // >= 0: stored buckets start inside the window const u64 first_sample = bi * span - origin; // >= 0: stored buckets start inside the window
const u64 last_sample = std::min((bi + 1) * span, origin + e) - 1 - origin; const u64 last_sample = std::min((bi + 1) * span, origin + e) - 1 - origin;
out_xy.push_back(f32(f64(first_sample) * step)); // x relative to x0() out_xy.push_back(f32(shift + f64(first_sample) * step)); // x relative to x_origin
out_xy.push_back(m.min); out_xy.push_back(m.min);
out_xy.push_back(f32(f64(last_sample) * step)); out_xy.push_back(f32(shift + f64(last_sample) * step));
out_xy.push_back(m.max); out_xy.push_back(m.max);
} }
break; break;
@@ -269,7 +273,7 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
const u64 centre = bi * span + span / 2 - origin; // inside [b, e): the bucket is wholly inside const u64 centre = bi * span + span / 2 - origin; // inside [b, e): the bucket is wholly inside
f32 v = 0.0f; f32 v = 0.0f;
copy_raw(centre, centre + 1, &v); copy_raw(centre, centre + 1, &v);
out_xy.push_back(f32(f64(centre) * step)); // x relative to x0() out_xy.push_back(f32(shift + f64(centre) * step)); // x relative to x_origin
out_xy.push_back(v); out_xy.push_back(v);
} }
break; break;
@@ -283,7 +287,7 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
const u32 count = pyramid().bucket_count(level, bi); const u32 count = pyramid().bucket_count(level, bi);
if (count == 0) continue; if (count == 0) continue;
const u64 centre = bi * span + span / 2 - origin; const u64 centre = bi * span + span / 2 - origin;
out_xy.push_back(f32(f64(centre) * step)); // x relative to x0() out_xy.push_back(f32(shift + f64(centre) * step)); // x relative to x_origin
out_xy.push_back(f32(pyramid().bucket_sum(level, bi) / f64(count))); out_xy.push_back(f32(pyramid().bucket_sum(level, bi) / f64(count)));
} }
break; break;
@@ -291,7 +295,7 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
} }
const u64 trail = last * span - origin; // one past the last emitted bucket's span const u64 trail = last * span - origin; // one past the last emitted bucket's span
if (trail < e) gather_raw(trail, e, out_xy); if (trail < e) gather_raw(trail, e, x_origin, out_xy);
} }
} // namespace plot::data } // namespace plot::data
+2 -2
View File
@@ -87,9 +87,9 @@ Range StreamSeries::y_bounds() const { return y_bounds_; }
const MinMaxPyramid& StreamSeries::pyramid() const { return pyramid_; } const MinMaxPyramid& StreamSeries::pyramid() const { return pyramid_; }
void StreamSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, void StreamSeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode) const { GatherMode mode) const {
gather_impl(level, begin, end, out_xy, mode); gather_impl(level, begin, end, x_origin, out_xy, mode);
} }
void StreamSeries::copy_raw(u64 begin, u64 end, f32* out) const { void StreamSeries::copy_raw(u64 begin, u64 end, f32* out) const {
+4 -4
View File
@@ -100,16 +100,16 @@ u64 XYSeries::snap_index(f64 x) const {
: hi; // ties go to the lower index : hi; // ties go to the lower index
} }
void XYSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, void XYSeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode) const { GatherMode mode) const {
const u64 n = size(); const u64 n = size();
const u64 b = std::min(begin, n); const u64 b = std::min(begin, n);
const u64 e = std::min(std::max(end, b), n); const u64 e = std::min(std::max(end, b), n);
if (b >= e) return; if (b >= e) return;
// x emitted relative to x0(): see Series::gather(). The x array is f64, so // x emitted relative to the caller's origin: see Series::gather(). The x array is f64, so
// the subtraction happens wide and only the small, exact offset narrows. // the subtraction happens wide and only the small, exact offset narrows.
const f64 origin = x0(); const f64 origin = x_origin;
if (level == 0) { if (level == 0) {
for (u64 i = b; i < e; ++i) { for (u64 i = b; i < e; ++i) {
@@ -127,7 +127,7 @@ void XYSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
// `begin`, `last` one past the last bucket wholly before `end`, and the two // `begin`, `last` one past the last bucket wholly before `end`, and the two
// edge spans around them are read from the raw arrays -- so no emitted // edge spans around them are read from the raw arrays -- so no emitted
// geometry comes from outside [begin, end), and the emitted x values are the // geometry comes from outside [begin, end), and the emitted x values are the
// array's own coordinates relative to x0(). // array's own coordinates relative to the caller's origin.
const u64 span = u64{kBucket} << level; const u64 span = u64{kBucket} << level;
const u64 lend = pyramid().level_size(level); const u64 lend = pyramid().level_size(level);
const u64 first = std::min(ceil_div(b, span), lend); const u64 first = std::min(ceil_div(b, span), lend);
+7 -4
View File
@@ -1464,7 +1464,10 @@ bool Plot::crosshair_point(u32 trace, f64 x, f64& out_x, f64& out_y) const {
// when it fell between them. The scratch is cleared and refilled rather than reallocated, // when it fell between them. The scratch is cleared and refilled rather than reallocated,
// and sized for two pairs from construction, so the pointer path allocates nothing. // and sized for two pairs from construction, so the pointer path allocates nothing.
crosshair_scratch_.clear(); crosshair_scratch_.clear();
series->gather(0u, span.begin, span.end + 1u, crosshair_scratch_); // The origin is the query itself: only y is read out of the pairs below -- the x comes back
// through `x_at()` -- so this names the one origin that cannot lose precision for a caller
// that might.
series->gather(0u, span.begin, span.end + 1u, x, crosshair_scratch_);
const usize pairs = static_cast<usize>(span.end - span.begin + 1u); const usize pairs = static_cast<usize>(span.end - span.begin + 1u);
if (crosshair_scratch_.size() != 2u * pairs) return false; // a series that produced nothing if (crosshair_scratch_.size() != 2u * pairs) return false; // a series that produced nothing
const f64 y_begin = static_cast<f64>(crosshair_scratch_[1u]); const f64 y_begin = static_cast<f64>(crosshair_scratch_[1u]);
@@ -1480,9 +1483,9 @@ bool Plot::crosshair_point(u32 trace, f64 x, f64& out_x, f64& out_y) const {
// `NearestSample`: the nearer of the two, and **its own x with it**. `span.t` is measured on // `NearestSample`: the nearer of the two, and **its own x with it**. `span.t` is measured on
// the samples' own coordinates, so "nearer" is exact on any grid; a tie reads the earlier // the samples' own coordinates, so "nearer" is exact on any grid; a tie reads the earlier
// sample, which is the bias `crosshair_span()` already takes for a degenerate pair. The x // sample, which is the bias `crosshair_span()` already takes for a degenerate pair. The x
// comes back through `x_at()` rather than from the gathered pairs, which are relative to // comes back through `x_at()` rather than from the gathered pairs, which are relative to a
// `x0()` by the `Series::gather()` contract -- adding it back here would be a second // caller-named origin by the `Series::gather()` contract -- adding it back here would be a
// reconstruction of a coordinate the series will hand over directly. // second reconstruction of a coordinate the series will hand over directly.
const bool take_begin = span.t <= 0.5; const bool take_begin = span.t <= 0.5;
out_x = series->x_at(take_begin ? span.begin : span.end); out_x = series->x_at(take_begin ? span.begin : span.end);
out_y = take_begin ? y_begin : y_end; out_y = take_begin ? y_begin : y_end;
+21 -24
View File
@@ -736,24 +736,16 @@ void Renderer::stage_segment(f64 origin_x, f64 x0, f64 y0, f64 x1, f64 y1, bool
static_cast<f32>(space(y1, log_y))}); static_cast<f32>(space(y1, log_y))});
} }
void Renderer::stage_xy(const f32* xy, u32 n, f64 series_x0, f64 origin_x, bool log_x, void Renderer::stage_xy(const f32* xy, u32 n, f64 origin_x, bool log_x, bool log_y) {
bool log_y) {
if (!log_x && !log_y) { if (!log_x && !log_y) {
// The common case: reconstruct the absolute data x in f64 (`x0 + rel` -- // The common case costs nothing at all: the gather emitted x relative to this very origin
// the gather contract's own reconstruction), stage it relative to the // (`draw_traces` asks it for `x_range.min`, which is the draw's origin too) and a linear y
// draw's origin, and the f32 is O(visible pixels), not O(epoch). // needs no mapping, so the gathered buffer *is* the staged one.
staged_.clear(); lines_.add_polyline_xy(xy, n);
staged_.reserve(usize(n) * 2u);
const f64 origin_s = space(origin_x, log_x);
for (u32 i = 0; i < n; ++i) {
staged_.push_back(static_cast<f32>(xy[2u * i] + series_x0 - origin_s));
staged_.push_back(xy[2u * i + 1u]);
}
lines_.add_polyline_xy(staged_.data(), n);
return; return;
} }
for (u32 i = 0; i + 1u < n; ++i) { for (u32 i = 0; i + 1u < n; ++i) {
stage_segment(origin_x, xy[2u * i] + series_x0, xy[2u * i + 1u], xy[2u * i + 2u] + series_x0, stage_segment(origin_x, xy[2u * i] + origin_x, xy[2u * i + 1u], xy[2u * i + 2u] + origin_x,
xy[2u * i + 3u], log_x, log_y); xy[2u * i + 3u], log_x, log_y);
} }
} }
@@ -1026,7 +1018,12 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
: 0u; : 0u;
gather_.clear(); gather_.clear();
series.gather(level, first, last, gather_, trace.mode); // The visible range's minimum is the gather origin: the emitted f32 is then O(the span on
// screen), so its ULP is a fixed 2^-24 of the axis at every zoom. `series.x0()` -- the origin
// before -- is the start of the *retained window*, so the quantisation was a fraction of data
// the view need not even hold, and a deep zoom drew a sample visibly off the x a readout
// quotes for it. See `Series::gather()`.
series.gather(level, first, last, x_range.min, gather_, trace.mode);
if (gather_.size() < 4u) continue; // fewer than two points is no segment if (gather_.size() < 4u) continue; // fewer than two points is no segment
stats_.visible_samples += visible; stats_.visible_samples += visible;
@@ -1041,7 +1038,7 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
const auto& yt = plot.y_transform(trace.axis_y); const auto& yt = plot.y_transform(trace.axis_y);
const f32 half = 0.5f * style_.device(style_.marker_px); const f32 half = 0.5f * style_.device(style_.marker_px);
for (u32 k = 0; k + 1 < gather_.size(); k += 2) { for (u32 k = 0; k + 1 < gather_.size(); k += 2) {
const f64 x_abs = series.x0() + static_cast<f64>(gather_[k]); const f64 x_abs = x_range.min + static_cast<f64>(gather_[k]);
const f64 y_abs = static_cast<f64>(gather_[k + 1]); const f64 y_abs = static_cast<f64>(gather_[k + 1]);
if (!std::isfinite(x_abs) || !std::isfinite(y_abs)) continue; if (!std::isfinite(x_abs) || !std::isfinite(y_abs)) continue;
const f32 sx = static_cast<f32>(xt.to_screen_x(x_abs)); const f32 sx = static_cast<f32>(xt.to_screen_x(x_abs));
@@ -1059,11 +1056,11 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
++stats_.lod_traces; ++stats_.lod_traces;
} }
// Gathered x is relative to `series.x0()` (the gather contract); the staging // Gathered x is relative to `x_range.min` (the gather contract, with the origin this
// reconstructs the absolute data x in f64 and emits `to_space(x_abs) - // function asked for); the staging reconstructs the absolute data x in f64 and emits
// to_space(origin)` -- the visible x range's minimum is the natural origin // `to_space(x_abs) - to_space(origin)` about that same origin, so the f32 is O(visible
// for a trace -- so the f32 is O(visible pixels), not O(epoch) (B1). The // pixels), not O(epoch) (B1). The mapping folds the origin back into the offset uniform,
// mapping folds the origin back into the offset uniform, in f64. // in f64.
const bool log_x = plot.x_axis(trace.axis_x).log; const bool log_x = plot.x_axis(trace.axis_x).log;
const bool log_y = plot.y_axis(trace.axis_y).log; const bool log_y = plot.y_axis(trace.axis_y).log;
const Mapping map = mapping(plot, trace.axis_x, trace.axis_y, x_range.min); const Mapping map = mapping(plot, trace.axis_x, trace.axis_y, x_range.min);
@@ -1087,7 +1084,7 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
f64 next = std::numeric_limits<f64>::infinity(); f64 next = std::numeric_limits<f64>::infinity();
for (usize k = count; k > 0; --k) { for (usize k = count; k > 0; --k) {
const usize p = k - 1u; // the point index; `i` above is the trace's const usize p = k - 1u; // the point index; `i` above is the trace's
const f64 x = static_cast<f64>(gather_[2u * p]) + series.x0(); const f64 x = static_cast<f64>(gather_[2u * p]) + x_range.min;
if (!(x < next)) continue; // out of order with what follows: unreadable, and a NaN too if (!(x < next)) continue; // out of order with what follows: unreadable, and a NaN too
next = x; next = x;
gather_[2u * (count - 1u - kept)] = gather_[2u * p]; gather_[2u * (count - 1u - kept)] = gather_[2u * p];
@@ -1114,7 +1111,7 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
// trace/axis hierarchy on a high-DPI panel. `width == 0` is scatter: the // trace/axis hierarchy on a high-DPI panel. `width == 0` is scatter: the
// markers below are the whole trace. // markers below are the whole trace.
if (trace.width > 0.0f) { if (trace.width > 0.0f) {
stage_xy(gather_.data(), points, series.x0(), x_range.min, log_x, log_y); stage_xy(gather_.data(), points, x_range.min, log_x, log_y);
flush_lines(map, trace.color, style_.device(trace.width), dash); flush_lines(map, trace.color, style_.device(trace.width), dash);
} }
@@ -1132,7 +1129,7 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
const f64 origin_s = space(x_range.min, log_x); const f64 origin_s = space(x_range.min, log_x);
u32 staged = 0; u32 staged = 0;
for (u32 p = 0; p < points; p += stride) { for (u32 p = 0; p < points; p += stride) {
const f64 x_abs = static_cast<f64>(gather_[2u * p]) + series.x0(); const f64 x_abs = static_cast<f64>(gather_[2u * p]) + x_range.min;
const f32 rx = static_cast<f32>(space(x_abs, log_x) - origin_s); const f32 rx = static_cast<f32>(space(x_abs, log_x) - origin_s);
const f32 ry = static_cast<f32>(space(gather_[2u * p + 1u], log_y)); const f32 ry = static_cast<f32>(space(gather_[2u * p + 1u], log_y));
const Vec2 centre{rx * map.scale.x + map.offset.x, ry * map.scale.y + map.offset.y}; const Vec2 centre{rx * map.scale.x + map.offset.x, ry * map.scale.y + map.offset.y};
+4 -4
View File
@@ -1939,11 +1939,11 @@ class CountingSeries final : public plot::data::Series {
} }
plot::Range y_bounds() const override { return {0.0, 99.0}; } plot::Range y_bounds() const override { return {0.0, 99.0}; }
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; } const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out, void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out,
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override { plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override {
++gather_calls_; ++gather_calls_;
samples_read_ += end - begin; samples_read_ += end - begin;
gather_impl(level, begin, end, out, mode); gather_impl(level, begin, end, x_origin, out, mode);
} }
u64 gather_calls() const noexcept { return gather_calls_; } u64 gather_calls() const noexcept { return gather_calls_; }
@@ -2471,9 +2471,9 @@ class XyFixture final : public plot::data::Series {
} }
plot::Range y_bounds() const override { return {0.0, 20.0}; } plot::Range y_bounds() const override { return {0.0, 20.0}; }
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; } const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out, void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out,
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override { plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override {
gather_impl(level, begin, end, out, mode); gather_impl(level, begin, end, x_origin, out, mode);
} }
f64 x_at(u64 i) const override { return x_[static_cast<std::size_t>(i)]; } f64 x_at(u64 i) const override { return x_[static_cast<std::size_t>(i)]; }
+5 -4
View File
@@ -723,11 +723,11 @@ class LyingGridSeries final : public plot::data::Series {
if (x_at(i0) != x && i0 + 1u < kN) i1 = i0 + 1u; if (x_at(i0) != x && i0 + 1u < kN) i1 = i0 + 1u;
return true; return true;
} }
void gather(u32, u64 begin, u64 end, std::vector<f32>& out, void gather(u32, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out,
plot::data::GatherMode) const override { plot::data::GatherMode) const override {
++gather_calls_; ++gather_calls_;
for (u64 i = begin; i < end; ++i) { for (u64 i = begin; i < end; ++i) {
out.push_back(static_cast<f32>(x_at(i) - x0())); out.push_back(static_cast<f32>(x_at(i) - x_origin));
out.push_back(kY); out.push_back(kY);
} }
} }
@@ -1512,9 +1512,10 @@ PLOT_TEST(a_clamped_front_does_not_draw_a_segment_across_the_panel) {
if (x_at(k) != x && k + 1u < 8u) i1 = k + 1u; if (x_at(k) != x && k + 1u < 8u) i1 = k + 1u;
return true; return true;
} }
void gather(u32, u64 begin, u64 end, std::vector<f32>& out, plot::data::GatherMode) const override { void gather(u32, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out,
plot::data::GatherMode) const override {
for (u64 i = begin; i < end; ++i) { for (u64 i = begin; i < end; ++i) {
out.push_back(static_cast<f32>(x_at(i) - x0())); out.push_back(static_cast<f32>(x_at(i) - x_origin));
out.push_back(i < 2u ? -1.0f : 1.0f); out.push_back(i < 2u ? -1.0f : 1.0f);
} }
} }
+60 -20
View File
@@ -24,7 +24,7 @@ PLOT_TEST(array_series_bounds_and_gather) {
PLOT_CHECK_NEAR(s.x_bounds().max, 11.5, 1e-9); PLOT_CHECK_NEAR(s.x_bounds().max, 11.5, 1e-9);
PLOT_CHECK_NEAR(s.y_bounds().min, -2.0, 1e-6); PLOT_CHECK_NEAR(s.y_bounds().min, -2.0, 1e-6);
std::vector<f32> out; std::vector<f32> out;
s.gather(0, 0, 4, out); s.gather(0, 0, 4, s.x0(), out);
PLOT_CHECK(out.size() == 8); // interleaved x,y PLOT_CHECK(out.size() == 8); // interleaved x,y
// x is relative to x0(): sample 0 emits 0, not 10 (the relative-x contract). // x is relative to x0(): sample 0 emits 0, not 10 (the relative-x contract).
PLOT_CHECK_NEAR(out[0], 0.0, 1e-6); PLOT_CHECK_NEAR(out[0], 0.0, 1e-6);
@@ -104,7 +104,7 @@ PLOT_TEST(stream_series_exact_queries_survive_ring_wrap) {
PLOT_CHECK_NEAR(s.sumsq(0, n), sumsq, 1e-1); PLOT_CHECK_NEAR(s.sumsq(0, n), sumsq, 1e-1);
std::vector<f32> out; std::vector<f32> out;
s.gather(0, 0, n, out); // level 0: raw samples s.gather(0, 0, n, s.x0(), out); // level 0: raw samples
PLOT_CHECK(out.size() == 2 * n); PLOT_CHECK(out.size() == 2 * n);
// x is relative to x0(): the oldest retained sample emits 0, the newest // x is relative to x0(): the oldest retained sample emits 0, the newest
// (n - 1) * dx, never the absolute data coordinates (relative-x contract). // (n - 1) * dx, never the absolute data coordinates (relative-x contract).
@@ -157,7 +157,7 @@ PLOT_TEST(stream_series_push_and_read_are_race_free) {
(void)s.sum(0, n); (void)s.sum(0, n);
(void)s.sumsq(0, n); (void)s.sumsq(0, n);
std::vector<f32> out; std::vector<f32> out;
s.gather(0, 0, n, out); // reads the oldest edge s.gather(0, 0, n, s.x0(), out); // reads the oldest edge
if (out.size() != 2 * n) sizes_ok = false; if (out.size() != 2 * n) sizes_ok = false;
} }
++reads; ++reads;
@@ -258,7 +258,7 @@ PLOT_TEST(stream_series_sliding_window_queries_match_bruteforce) {
// wholly at or after window index 0, `last` one past the last wholly inside // wholly at or after window index 0, `last` one past the last wholly inside
// the window -- so every emitted bucket's samples lie inside the window. // the window -- so every emitted bucket's samples lie inside the window.
std::vector<f32> out; std::vector<f32> out;
s.gather(1, 0, n, out); s.gather(1, 0, n, s.x0(), out);
const u64 first = const u64 first =
std::max(base / span + (base % span == 0 ? u64{0} : u64{1}), lbase); // ceil, then clamp std::max(base / span + (base % span == 0 ? u64{0} : u64{1}), lbase); // ceil, then clamp
const u64 last = std::min((base + n) / span, lbase + lsize); const u64 last = std::min((base + n) / span, lbase + lsize);
@@ -374,7 +374,7 @@ PLOT_TEST(gather_at_a_lod_level_reaches_both_ends_of_the_window) {
const auto check_ends = [&](u32 level) { const auto check_ends = [&](u32 level) {
std::vector<f32> out; std::vector<f32> out;
s.gather(level, 0, n, out); s.gather(level, 0, n, s.x0(), out);
PLOT_CHECK(out.size() >= 8u); PLOT_CHECK(out.size() >= 8u);
// Oldest: the first emitted pair is the retained sample at index 0, not the // Oldest: the first emitted pair is the retained sample at index 0, not the
// first whole bucket (which starts `lead` samples later). x is relative to // first whole bucket (which starts `lead` samples later). x is relative to
@@ -403,7 +403,7 @@ PLOT_TEST(gather_at_a_lod_level_reaches_both_ends_of_the_window) {
PLOT_CHECK((ny % 256u) != 0u); // 5000 = 19 * 256 + 136 at level 2 PLOT_CHECK((ny % 256u) != 0u); // 5000 = 19 * 256 + 136 at level 2
plot::data::ArraySeries a(y, 0.0, 1e-6); plot::data::ArraySeries a(y, 0.0, 1e-6);
std::vector<f32> out; std::vector<f32> out;
a.gather(2, 0, ny, out); a.gather(2, 0, ny, a.x0(), out);
PLOT_CHECK(out.size() >= 8u); PLOT_CHECK(out.size() >= 8u);
PLOT_CHECK_NEAR(out[0], 0.0, 1e-9); // level 2 starts on the grid 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() - 2], f32(f64(ny - 1) * 1e-6), 1e-9);
@@ -469,7 +469,7 @@ PLOT_TEST(gather_envelope_stays_inside_an_unaligned_range) {
const auto check_range = [&](u32 level) { const auto check_range = [&](u32 level) {
std::vector<f32> out; std::vector<f32> out;
s.gather(level, b, e, out); s.gather(level, b, e, s.x0(), out);
PLOT_CHECK(out.size() >= 4u); PLOT_CHECK(out.size() >= 4u);
for (plot::usize i = 0; i + 1 < out.size(); i += 2) { for (plot::usize i = 0; i + 1 < out.size(); i += 2) {
PLOT_CHECK(f64(out[i]) >= f64(b)); // emitted x inside [begin, end) PLOT_CHECK(f64(out[i]) >= f64(b)); // emitted x inside [begin, end)
@@ -499,7 +499,7 @@ PLOT_TEST(gather_envelope_stays_inside_an_unaligned_range) {
const u64 buckets = last - first; const u64 buckets = last - first;
PLOT_CHECK(lead > b && last * span < e); // both edge spans really are partial PLOT_CHECK(lead > b && last * span < e); // both edge spans really are partial
std::vector<f32> out; std::vector<f32> out;
s.gather(level, b, e, out); s.gather(level, b, e, s.x0(), out);
PLOT_CHECK(out.size() == 2 * ((lead - b) + 2 * buckets + (e - last * span))); PLOT_CHECK(out.size() == 2 * ((lead - b) + 2 * buckets + (e - last * span)));
plot::usize o = 0; plot::usize o = 0;
for (u64 i = b; i < lead; ++i, o += 2) { // the partial leading bucket, raw for (u64 i = b; i < lead; ++i, o += 2) { // the partial leading bucket, raw
@@ -544,7 +544,7 @@ PLOT_TEST(stream_gather_envelope_stays_inside_an_unaligned_range) {
const plot::Range exact = s.reduce(b, e); const plot::Range exact = s.reduce(b, e);
PLOT_CHECK(exact.max < 1.0 && exact.min > -1.0); PLOT_CHECK(exact.max < 1.0 && exact.min > -1.0);
std::vector<f32> out; std::vector<f32> out;
s.gather(1, b, e, out); s.gather(1, b, e, s.x0(), out);
PLOT_CHECK(out.size() >= 4u); PLOT_CHECK(out.size() >= 4u);
for (plot::usize i = 0; i + 1 < out.size(); i += 2) { for (plot::usize i = 0; i + 1 < out.size(); i += 2) {
PLOT_CHECK(f64(out[i]) >= f64(b)); // relative x inside [begin, end) PLOT_CHECK(f64(out[i]) >= f64(b)); // relative x inside [begin, end)
@@ -614,11 +614,11 @@ PLOT_TEST(gather_level_beyond_the_pyramid_clamps) {
for (u64 i = 0; i < y.size(); ++i) y[i] = f32(i % 13) - 6.0f; for (u64 i = 0; i < y.size(); ++i) y[i] = f32(i % 13) - 6.0f;
plot::data::ArraySeries s(std::move(y), 0.0, 1.0); plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
std::vector<f32> deepest; std::vector<f32> deepest;
s.gather(plot::data::kMaxPyramidLevel, 0, s.size(), deepest); s.gather(plot::data::kMaxPyramidLevel, 0, s.size(), s.x0(), deepest);
PLOT_CHECK(!deepest.empty()); PLOT_CHECK(!deepest.empty());
for (const u32 level : {u32{58}, u32{200}, u32{1000}, ~u32{0}}) { for (const u32 level : {u32{58}, u32{200}, u32{1000}, ~u32{0}}) {
std::vector<f32> out; std::vector<f32> out;
s.gather(level, 0, s.size(), out); s.gather(level, 0, s.size(), s.x0(), out);
PLOT_CHECK(out == deepest); // clamped to the same deepest geometry PLOT_CHECK(out == deepest); // clamped to the same deepest geometry
} }
// The cap is where the span shift still fits: level 57 spans exactly 2^63. // The cap is where the span shift still fits: level 57 spans exactly 2^63.
@@ -657,9 +657,9 @@ PLOT_TEST(gather_modes_envelope_nearest_mean) {
PLOT_CHECK(buckets == 16); PLOT_CHECK(buckets == 16);
std::vector<f32> env, near, mean; std::vector<f32> env, near, mean;
s.gather(level, 0, kN, env, plot::data::GatherMode::Envelope); s.gather(level, 0, kN, s.x0(), env, plot::data::GatherMode::Envelope);
s.gather(level, 0, kN, near, plot::data::GatherMode::Nearest); s.gather(level, 0, kN, s.x0(), near, plot::data::GatherMode::Nearest);
s.gather(level, 0, kN, mean, plot::data::GatherMode::Mean); s.gather(level, 0, kN, s.x0(), mean, plot::data::GatherMode::Mean);
// Envelope: two points per bucket, min at the bucket's first sample, max at // Envelope: two points per bucket, min at the bucket's first sample, max at
// its last (this sawtooth's per-bucket min is its first sample's value and // its last (this sawtooth's per-bucket min is its first sample's value and
@@ -692,7 +692,7 @@ PLOT_TEST(gather_modes_envelope_nearest_mean) {
// Level 0 and sub-bucket ranges are the raw samples for every mode. // Level 0 and sub-bucket ranges are the raw samples for every mode.
std::vector<f32> raw; std::vector<f32> raw;
s.gather(0, 5, 300, raw, plot::data::GatherMode::Mean); s.gather(0, 5, 300, s.x0(), raw, plot::data::GatherMode::Mean);
PLOT_CHECK(raw.size() == 2 * (300 - 5)); PLOT_CHECK(raw.size() == 2 * (300 - 5));
PLOT_CHECK_NEAR(raw[0], 5.0, 1e-9); PLOT_CHECK_NEAR(raw[0], 5.0, 1e-9);
} }
@@ -712,17 +712,17 @@ PLOT_TEST(gather_skips_all_nan_buckets) {
// first = 0, last = 256/128 = 2: buckets 0 and 1, no lead/trail raw spans. // first = 0, last = 256/128 = 2: buckets 0 and 1, no lead/trail raw spans.
std::vector<f32> env, mean, near; std::vector<f32> env, mean, near;
s.gather(1, 0, 256, env, plot::data::GatherMode::Envelope); s.gather(1, 0, 256, s.x0(), env, plot::data::GatherMode::Envelope);
PLOT_CHECK(env.size() == 4); // bucket 0's two pairs only PLOT_CHECK(env.size() == 4); // bucket 0's two pairs only
PLOT_CHECK_NEAR(env[0], 0.0, 1e-9); PLOT_CHECK_NEAR(env[0], 0.0, 1e-9);
PLOT_CHECK_NEAR(env[1], 1.0, 1e-9); PLOT_CHECK_NEAR(env[1], 1.0, 1e-9);
PLOT_CHECK_NEAR(env[2], 127.0, 1e-9); PLOT_CHECK_NEAR(env[2], 127.0, 1e-9);
PLOT_CHECK_NEAR(env[3], 1.0, 1e-9); PLOT_CHECK_NEAR(env[3], 1.0, 1e-9);
s.gather(1, 0, 256, mean, plot::data::GatherMode::Mean); s.gather(1, 0, 256, s.x0(), mean, plot::data::GatherMode::Mean);
PLOT_CHECK(mean.size() == 2); // bucket 1 has no finite mean: skipped PLOT_CHECK(mean.size() == 2); // bucket 1 has no finite mean: skipped
PLOT_CHECK_NEAR(mean[0], 64.0, 1e-9); PLOT_CHECK_NEAR(mean[0], 64.0, 1e-9);
PLOT_CHECK_NEAR(mean[1], 1.0, 1e-9); PLOT_CHECK_NEAR(mean[1], 1.0, 1e-9);
s.gather(1, 0, 256, near, plot::data::GatherMode::Nearest); s.gather(1, 0, 256, s.x0(), near, plot::data::GatherMode::Nearest);
PLOT_CHECK(near.size() == 4); // every bucket still has a centre sample PLOT_CHECK(near.size() == 4); // every bucket still has a centre sample
PLOT_CHECK_NEAR(near[0], 64.0, 1e-9); PLOT_CHECK_NEAR(near[0], 64.0, 1e-9);
PLOT_CHECK_NEAR(near[1], 1.0, 1e-9); PLOT_CHECK_NEAR(near[1], 1.0, 1e-9);
@@ -739,7 +739,7 @@ PLOT_TEST(stream_series_freeze_copies_the_last_acquire_window) {
const u64 n = s.size(); const u64 n = s.size();
const plot::Range bounds = s.y_bounds(); const plot::Range bounds = s.y_bounds();
std::vector<f32> gathered; std::vector<f32> gathered;
s.gather(1, 0, n, gathered); s.gather(1, 0, n, s.x0(), gathered);
const plot::Range reduced = s.reduce(0, n); const plot::Range reduced = s.reduce(0, n);
// Freeze now, while the ring still holds exactly the published window. // Freeze now, while the ring still holds exactly the published window.
@@ -754,7 +754,7 @@ PLOT_TEST(stream_series_freeze_copies_the_last_acquire_window) {
PLOT_CHECK_NEAR(fr.max, reduced.max, 1e-6); PLOT_CHECK_NEAR(fr.max, reduced.max, 1e-6);
PLOT_CHECK_NEAR(frozen.sum(0, n), s.sum(0, n), 1e-3); PLOT_CHECK_NEAR(frozen.sum(0, n), s.sum(0, n), 1e-3);
std::vector<f32> frozen_gathered; std::vector<f32> frozen_gathered;
frozen.gather(1, 0, n, frozen_gathered); frozen.gather(1, 0, n, frozen.x0(), frozen_gathered);
PLOT_CHECK(frozen_gathered == gathered); // same geometry, byte for byte PLOT_CHECK(frozen_gathered == gathered); // same geometry, byte for byte
// The producer now laps the ring (700 more pushes overwrite the oldest // The producer now laps the ring (700 more pushes overwrite the oldest
@@ -769,6 +769,46 @@ PLOT_TEST(stream_series_freeze_copies_the_last_acquire_window) {
PLOT_CHECK(frozen.reduce(0, n).max == reduced.max); PLOT_CHECK(frozen.reduce(0, n).max == reduced.max);
} }
// --- The gather origin: f32 precision against the view, not the window -------
PLOT_TEST(a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes) {
// 20 s of retained data at 1 kS/s examined through a 10 us window -- the shape of
// a scope zoomed in on a long ring. gather() stages x as f32, so what the origin
// decides is the span that f32 has to resolve: the whole retained window (which
// is what `x0()`, the old implicit origin, names) or the part of it on screen. A
// readout quotes `x_at()` in exact f64, so the difference between the two is the
// distance between where a sample is *drawn* and the x reported for it.
constexpr u64 kN = 20001;
std::vector<f32> y(static_cast<plot::usize>(kN));
for (u64 i = 0; i < kN; ++i) y[static_cast<plot::usize>(i)] = f32(i % 71);
const plot::data::ArraySeries s(std::move(y), /*x0=*/0.0, /*dx=*/1e-3);
PLOT_CHECK_NEAR(s.x_bounds().max, 20.0, 1e-9);
const f64 centre = s.x_at(kN - 2u); // 19.999 s, near the far end
const plot::Range view{centre - 5e-6, centre + 5e-6}; // 10 us across
const f64 px_per_x = 700.0 / (view.max - view.min); // a typical axis width
const auto worst_error_px = [&](f64 origin) {
std::vector<f32> out;
s.gather(0, kN - 3u, kN, origin, out);
PLOT_CHECK(out.size() == 6u);
f64 worst = 0.0;
for (u64 k = 0; k < 3u; ++k) {
// Exactly the renderer's reconstruction: the origin added back in f64.
const f64 drawn = origin + f64(out[static_cast<plot::usize>(2u * k)]);
worst = std::max(worst, std::abs(drawn - s.x_at(kN - 3u + k)) * px_per_x);
}
return worst;
};
// The visible range's minimum: the f32 resolves 2^-24 of what is on screen, so
// the error is a fixed fraction of a pixel at every zoom.
PLOT_CHECK(worst_error_px(view.min) < 0.05);
// The start of the retained window -- what every caller used to get -- resolves
// 2^-24 of 20 s instead, which at this zoom is most of the axis.
PLOT_CHECK(worst_error_px(s.x0()) > 10.0);
}
// --- Incremental append: ArraySeries::push() ---------------------------------- // --- Incremental append: ArraySeries::push() ----------------------------------
PLOT_TEST(array_series_push_matches_commit) { PLOT_TEST(array_series_push_matches_commit) {
+4 -3
View File
@@ -52,7 +52,8 @@ PLOT_TEST(xy_series_reports_its_own_grid) {
PLOT_CHECK_NEAR(s.y_bounds().max, 48.0, 1e-6); PLOT_CHECK_NEAR(s.y_bounds().max, 48.0, 1e-6);
// Epoch-style offset: the same shape shifted by 1.7e9 keeps exact bounds, and // Epoch-style offset: the same shape shifted by 1.7e9 keeps exact bounds, and
// gather() emits x relative to x0() -- small numbers, not quantised epochs. // gather() emits x relative to the origin the caller names -- x0() here, so
// small numbers, not quantised epochs.
std::vector<f64> epoch = spread_x(500); std::vector<f64> epoch = spread_x(500);
const std::vector<f64> es = epoch; // un-moved copy for the expectations const std::vector<f64> es = epoch; // un-moved copy for the expectations
for (auto& v : epoch) v += 1.7e9; for (auto& v : epoch) v += 1.7e9;
@@ -60,7 +61,7 @@ PLOT_TEST(xy_series_reports_its_own_grid) {
PLOT_CHECK_NEAR(e.x_bounds().min, 1.7e9, 1e-3); PLOT_CHECK_NEAR(e.x_bounds().min, 1.7e9, 1e-3);
PLOT_CHECK_NEAR(e.x_bounds().max, 1.7e9 + (xs.back() - xs.front()), 1e-3); PLOT_CHECK_NEAR(e.x_bounds().max, 1.7e9 + (xs.back() - xs.front()), 1e-3);
std::vector<f32> out; std::vector<f32> out;
e.gather(0, 0, 500, out); e.gather(0, 0, 500, e.x0(), out);
PLOT_CHECK(out.size() == 1000); PLOT_CHECK(out.size() == 1000);
// x is relative to x0(): x_at(i) - x0(), the array's own offsets -- small // x is relative to x0(): x_at(i) - x0(), the array's own offsets -- small
// values even though the absolute epoch is 1.7e9. The expectations come from // values even though the absolute epoch is 1.7e9. The expectations come from
@@ -186,7 +187,7 @@ PLOT_TEST(xy_series_gather_envelope_matches_exact_reduces) {
const u64 span = u64{kBucket} << level; const u64 span = u64{kBucket} << level;
const u64 b = 500 + 33, e = 3500 - 57; // unaligned on the bucket grid const u64 b = 500 + 33, e = 3500 - 57; // unaligned on the bucket grid
std::vector<f32> out; std::vector<f32> out;
s.gather(level, b, e, out, plot::data::GatherMode::Envelope); s.gather(level, b, e, s.x0(), out, plot::data::GatherMode::Envelope);
PLOT_CHECK(out.size() >= 8u); PLOT_CHECK(out.size() >= 8u);
// Emitted x stays inside [x_at(begin), x_at(end)) and the geometry reaches // Emitted x stays inside [x_at(begin), x_at(end)) and the geometry reaches
// both the range's extremes (the lead/trail raw spans carry them). // both the range's extremes (the lead/trail raw spans carry them).