// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #pragma once #include #include #include #include #include #include namespace udpscope { using plot::f32; using plot::f64; using plot::u32; using plot::u64; /// One captured trigger event, immutable once published: per-channel values over /// a shared, edge-relative time stamp array (the edge sits at exactly x = 0, the /// window spans [-pre, +post]). /// /// The plot binds to a snapshot while the trigger holds an event. Because the /// snapshot can never change after publication, nothing can drag, re-base or /// refresh the view while it is held -- the failures the previous design (two /// live ring buffers switched by a pending flag, plus a display-epoch shift) /// could not stop. struct EventSnapshot { /// Seconds, monotonic, and **in the coordinates the axis reads**: a trigger event's /// stamps are edge-relative and a pause view's are relative to the instant it froze, /// which is what `x_offset` below records. **f64, like the live ring it is cut from** /// (see `BufferStore::time()`), which also makes the cut in `publish_event()`/ /// `publish_pause_view()` a straight copy. std::vector stamps; /// Absolute stream seconds on the same indices as `stamps`, or empty when the store had no /// absolute ring. **Not** rebased by `x_offset` or by the trigger edge: a snapshot outlives /// the epoch that produced it, so it stores the absolute value outright. std::vector abs_stamps; std::vector> values; ///< one array per channel, same length /// The stream time of this snapshot's x = 0, so `x_offset + stamps[i]` is the clock the /// data arrived on: the edge for a trigger event, the newest sample at the moment of the /// freeze for a pause view. /// /// It exists because a snapshot is the one thing on screen whose coordinates outlive the /// `display_epoch()` that produced them, and `ScopeModel::stream_at_x0()` is what reads /// it: an export has to name the stream's own clock whatever is on screen, and the pause /// handler has to put a *display* window back on the ring to cut it. Before it, the /// export added `trigger_time()` to a pause view's stamps -- an unrelated edge, or none /// at all -- for want of anywhere to record which clock a snapshot was on. double x_offset = 0.0; double pre_s = 0.0; ///< seconds of pre-trigger data kept double post_s = 0.0; ///< seconds of post-trigger data kept std::vector y; ///< per-channel value bounds, precomputed /// One LOD pyramid per channel, built once here rather than by the series. /// /// It is the *renderer's* performance contract that needs it: `level_for()` descends /// to level 0 on a pyramid with no levels, so a snapshot without one is drawn from /// every raw sample it holds, every frame, for as long as the event is held -- a /// 2 s capture at 1 MS/s staged 6 million segments across three traces and took /// ~100 ms a frame, which is the "large data is laggy" report. It belongs to the /// snapshot and not to `EventSeries` because the snapshot is immutable and shared: /// built here it costs one O(n) pass per channel inside the copy loop that is /// already O(n), and the same channel on two rows shares it instead of building and /// storing it twice. std::vector pyramids; /// The channel *names* `values` is indexed by, so a snapshot can be re-bound after the channel /// list was rebuilt -- and renumbered -- under it: a persistence layer names its signal and /// resolves that name here, exactly as the trigger engine names its channel and for the same /// reason (an index is a position in a list that any enable, disable or CONFIG renumbers). /// Empty on a snapshot nobody re-binds later (a pause view). std::vector channel_names; }; /// Bytes one snapshot holds: both stamp arrays, every channel's samples, the pyramids built for /// them and the names above. The number the persistence retention is measured in -- a retained /// snapshot is a whole window copied out of the ring, so what bounds it is bytes, not a count. inline std::size_t snapshot_bytes(const EventSnapshot& s) { std::size_t bytes = s.stamps.size() * sizeof(f64) + s.abs_stamps.size() * sizeof(f64) + s.y.size() * sizeof(plot::Range) + s.channel_names.size() * sizeof(std::string); for (const std::vector& v : s.values) bytes += v.size() * sizeof(f32); // A pyramid entry is 32 bytes (`MinMaxPyramid::Entry`), one per level-`l` bucket of // `kBucket << l` samples -- the whole index is about one byte per sample. for (const plot::data::MinMaxPyramid& p : s.pyramids) { for (u32 l = 0; l < p.level_count(); ++l) bytes += p.level_size(l) * 32u; } return bytes; } /// A plot::data::Series view of one channel of an EventSnapshot. The stamps are /// near-uniform (the arrival clock), so dx is the mean spacing and x_at returns /// the exact stamp; gather decimates by 2^level like the ring series does. class EventSeries final : public plot::data::Series { public: explicit EventSeries(std::shared_ptr snap, std::size_t channel) : snap_(std::move(snap)), channel_(channel) {} /// Identity for rebind: same snapshot object and channel means the existing /// EventSeries already shows this event. bool same(const std::shared_ptr& snap, std::size_t channel) const { return snap_ == snap && channel_ == channel; } /// The shorter of the two arrays this series reads. `gather()` indexes `stamps` /// and `values[channel]` at the same i, so reporting the stamp count alone would /// be an out-of-bounds read the moment they differ. u64 size() const override { if (channel_ >= snap_->values.size()) return 0; return static_cast( std::min(snap_->stamps.size(), snap_->values[channel_].size())); } f64 x0() const override { return snap_->stamps.empty() ? 0.0 : snap_->stamps.front(); } f64 dx() const override { const auto& s = snap_->stamps; return s.size() > 1 ? (s.back() - s.front()) / static_cast(s.size() - 1) : 1.0; } f64 x_at(u64 i) const override { const auto& s = snap_->stamps; return i < s.size() ? s[static_cast(i)] : 0.0; } plot::Range x_bounds() const override { const auto& s = snap_->stamps; return s.empty() ? plot::Range{0.0, 0.0} : plot::Range{s.front(), s.back()}; } plot::Range y_bounds() const override { return channel_ < snap_->y.size() ? snap_->y[channel_] : plot::Range{0.0, 0.0}; } /// The snapshot's pyramid for this channel (see `EventSnapshot::pyramids`). The /// empty one only stands in for a channel the snapshot does not carry, where the /// series has no samples either. const plot::data::MinMaxPyramid& pyramid() const override { return channel_ < snap_->pyramids.size() ? snap_->pyramids[channel_] : empty_pyramid_; } bool streaming() const noexcept override { return false; } /// Envelope geometry per whole bucket, raw samples at the two partial edges -- /// the same shape as `ChannelBuffer::gather()`, and for the same two reasons: the /// emitted geometry must span `[begin, end)` exactly and never reach outside it, /// and a *bucket's extremes* are what a scope has to draw. The decimation this /// replaced took every `2^level`-th sample, which hides exactly the spike a /// capture was triggered on -- and, since the level was always 0 before the /// snapshot carried a pyramid, it never even ran. /// /// Indices need no rebasing: the pyramid is built from sample 0 and nothing is ever /// dropped from it, so `window_base()` is 0 and a window index *is* the absolute one /// (unlike the ring, whose window slides). void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector& out_xy, plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override { if (channel_ >= snap_->values.size()) return; const auto& vals = snap_->values[channel_]; const auto& stamps = snap_->stamps; 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; // x **relative to `x_origin`**, which is the library's gather contract: the // renderer adds that same origin back before mapping to the screen. Emitting the // absolute stamp made it add an origin twice, so the whole capture was drawn one // `x0()` -- a full pre-trigger window -- to the left of the axis it had just been // measured against. The event's own stamps are small (edge-relative, so // [-pre, +post]), which is why that shift looked like a plausible picture: with a // 50/50 split the right half of the plot was simply empty, zooming out showed the // trace ending at the x = 0 marker, and zooming in past the shift showed nothing // at all while the crosshair -- which reads `x_at()`, never `gather()` -- went on // reporting the right values. const f64 base = x_origin; const auto rel = [&](u64 i) { return static_cast(stamps[static_cast(i)] - base); }; const auto emit_raw = [&](u64 from, u64 to) { for (u64 i = from; i < to; ++i) { out_xy.push_back(rel(i)); out_xy.push_back(vals[static_cast(i)]); } }; if (level == 0) { emit_raw(b, e); return; } level = std::min(level, plot::data::kMaxPyramidLevel); const plot::data::MinMaxPyramid& pyr = pyramid(); const u64 span = u64{plot::data::kBucket} << level; const u64 lbase = pyr.level_base(level); const u64 lend = lbase + pyr.level_size(level); const u64 first = std::max(b / span + (b % span == 0 ? u64{0} : u64{1}), lbase); const u64 last = std::min(e / span, lend); if (first >= last) { emit_raw(b, e); return; } if (b < first * span) emit_raw(b, std::min(first * span, e)); for (u64 bi = first; bi < last; ++bi) { const u32 count = pyr.bucket_count(level, bi); switch (mode) { case plot::data::GatherMode::Nearest: { const u64 centre = bi * span + span / 2; out_xy.push_back(rel(centre)); out_xy.push_back(vals[static_cast(centre)]); break; } case plot::data::GatherMode::Mean: { if (count == 0) break; // no finite sample: no mean to draw const u64 centre = bi * span + span / 2; out_xy.push_back(rel(centre)); out_xy.push_back(static_cast(pyr.bucket_sum(level, bi) / f64(count))); break; } default: { // Envelope: min at the bucket's first sample, max at its last if (count == 0) break; const plot::data::MinMax m = pyr.bucket(level, bi); out_xy.push_back(rel(bi * span)); out_xy.push_back(m.min); out_xy.push_back(rel(std::min((bi + 1) * span, e) - 1)); out_xy.push_back(m.max); break; } } } if (last * span < e) emit_raw(last * span, e); } /// Bracketing samples by **binary search over the stamps**, which are monotonic -- never /// through the `(x - x0()) / dx()` grid, for the same reason `ChannelBuffer` does not: `dx()` /// is the window's *mean* spacing and the stamps are an arrival clock, so the grid and the /// real stamps drift apart by however much the clock wandered. The renderer picks the visible /// index window from this call, so that drift came out as a **constant offset in data /// seconds** between the axis and the gathered geometry: a blank left margin whose *pixel* /// width doubles with every zoom step (374 px of a 716 px axis after seven), with the trace's /// ink pushed off the right-hand edge by the same amount and the crosshair -- which reads /// `x_at()`, the real stamp -- still correct everywhere. That is the "zooming into a held /// event clips it more and more from the left" report. A perfectly uniform source hides it /// completely, which is why the mock can jitter its clock (`--clock-jitter`). bool neighbours(f64 x, u64& i0, u64& i1) const override { const u64 n = size(); if (n < 2) return false; const auto& s = snap_->stamps; const auto begin = s.begin(); const auto end = begin + static_cast(n); // The first stamp *greater* than x, so x itself brackets as [that - 1, that]: a pointer // exactly on a sample must not put the sample behind it. const auto it = std::upper_bound(begin, end, static_cast(x)); const u64 above = static_cast(it - begin); i0 = above > 0 ? above - 1 : 0; i1 = i0 + 1 < n ? i0 + 1 : i0; return true; } u64 snap_index(f64 x) const override { u64 i0 = 0, i1 = 0; if (!neighbours(x, i0, i1)) return 0; return i1; } /// SlotSeries::copy_raw forwards here (public: the base's hook is protected, /// and SlotSeries is a different class). void raw_values(u64 begin, u64 end, f32* out) const { const auto& vals = snap_->values[channel_]; const u64 n = static_cast(vals.size()); if (end > n) end = n; for (u64 i = begin; i < end; ++i) out[static_cast(i - begin)] = vals[static_cast(i)]; } protected: void copy_raw(u64 begin, u64 end, f32* out) const override { raw_values(begin, end, out); } private: std::shared_ptr snap_; std::size_t channel_; plot::data::MinMaxPyramid empty_pyramid_{}; }; } // namespace udpscope