// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #pragma once #include #include #include #include "event_series.hpp" #include #include namespace udpscope { using plot::f32; using plot::f64; using plot::u32; using plot::u64; /// One channel's view of a (value, time) ring pair. /// /// The sample grid the library's `Series` contract assumes -- `x = x0 + i*dx` -- /// does not hold here, because x is a *streamed timestamp*, not an index: a lost /// datagram leaves a gap and the samples either side of it are not one grid step /// apart. `dx()` and `x0()` therefore report a nominal grid (what the LOD level /// and the snap use), while `gather()`, `x_bounds()`, `x_at()` and `neighbours()` /// report the real timestamps. `reduce()`, `sum()` and `sumsq()` are the inherited ones and read /// only y, so they are exact either way. /// /// The time ring is shared by every channel of one buffer (spec: the +1 slice); /// channel sample `j` is stamped by base tick `j * stride`. A stride of 1 is the /// undecimated case and the common one. class ChannelBuffer final : public plot::data::Series { public: /// `values` is f32 and `times` is f64: see `BufferStore::time()` for why the clock /// alone needs the wider type. ChannelBuffer(std::shared_ptr> values, std::shared_ptr> times, u64 stride, f64 epoch, f64 nominal_dt); ChannelBuffer(const ChannelBuffer&) = delete; ChannelBuffer& operator=(const ChannelBuffer&) = delete; /// Consumer thread: publishes everything the producer pushed since the previous /// call into the LOD pyramid and slides the window. O(samples produced). void acquire() noexcept; /// Consumer thread: forgets the published window (after a rebind or a reset). void invalidate() noexcept; /// Consumer thread: drops the published window and re-publishes it from absolute sample /// `absolute` on the next `acquire()`. /// /// For a ring whose contents were *rewritten in place* rather than appended to -- a math /// channel recomputed over its inputs' retained window. `acquire()` is incremental, so /// a rewrite that leaves `produced()` where it was publishes nothing and the pyramid goes /// on describing the samples the edit replaced. `invalidate()` would do it too, but it /// restarts at 0 and `acquire()` would then walk every absolute index ever produced; this /// restarts at the oldest index the ring still holds, so the rebuild is one window. void republish_from(u64 absolute) noexcept; /// Producer thread, under `ScopeModel::mutex()`: records that absolute sample `absolute` /// has been backfilled, for `acquire()` to fold into the pyramid. /// /// The pyramid is mutated *only* on the consumer thread, because a bucket is a 32-byte /// non-atomic aggregate the renderer reads unlocked at paint time: amending it from the /// producer would let a reader see a half-updated bucket whose `sum` did not match its /// `count`, which is a wrong mean out of a structure whose readers trust `count == 0` to /// mean absent. The value ring is amended by the producer, which owns its writes; the /// pyramid waits for here. void queue_amend(u64 absolute, f32 v) { pending_amends_.push_back({absolute, v}); } void set_nominal_dt(f64 dt) noexcept { dt_ = dt; } /// Re-bases the x axis: `x0()`/`x_at()` become `epoch + timestamp`. Used to make /// the axis read seconds from the trigger event instead of from the stream. void set_epoch(f64 e) noexcept { epoch_ = e; } u64 stride() const noexcept { return stride_; } // --- plot::data::Series --------------------------------------------------- u64 size() const override { return pyramid_.size(); } f64 x0() const override; /// The *mean* spacing of the retained window, and a nominal quantity: the renderer /// picks the visible index window through `neighbours()` over the real stamps, not /// through `(x - x0()) / dx()`, precisely because this is only a local average (a /// lost datagram moves the real spacing around it). It still has to describe the /// samples that are actually retained: the LOD level is chosen from the window's /// sample count divided by its pixel width, so reporting the nominal `stride * dt` /// over a decimated window picks a level the pyramid cannot serve and the trace /// draws empty -- which is exactly what a fast channel looked like next to a slow /// one. f64 dx() const override; /// The authoritative x of a sample: the streamed timestamp, not a grid. `dx()` /// is only the mean spacing (the LOD level and a coarse snap use it); a lost /// datagram leaves a gap, so index times must come from the time ring. f64 x_at(u64 i) const override; /// Bracketing samples by binary search over the time ring, which is monotonic, /// so a crosshair reads whichever of the two samples actually either side of the /// pointer is nearer -- a grid walk would land on the wrong pair after a gap. bool neighbours(f64 x, u64& i0, u64& i1) const override; u64 snap_index(f64 x) const override; plot::Range x_bounds() const override; plot::Range y_bounds() const override; const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; } /// x is emitted **relative to `x_origin`** (the library's contract): staging an /// absolute epoch time as f32 would quantise it far coarser than a pixel, and /// staging it relative to the whole *retained window* quantises it coarser than a /// pixel of a zoomed view -- see `plot::data::Series::gather()`. 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; /// `copy_raw`, published so the retargetable trace series can forward the /// protected hook of another object. void raw(u64 begin, u64 end, f32* out) const; protected: void copy_raw(u64 begin, u64 end, f32* out) const override { raw(begin, end, out); } private: /// Absolute (channel) index of retained-window index `i`. u64 absolute(u64 i) const noexcept { return pyramid_.window_base() + i; } /// Timestamp of channel sample `absolute_index`, in seconds since `epoch_`. f64 time_of(u64 absolute_index) const noexcept; struct Amend { u64 absolute; f32 value; }; /// Backfills the producer has written into the value ring and the pyramid has not seen yet. std::vector pending_amends_; std::shared_ptr> values_; std::shared_ptr> times_; u64 stride_ = 1; f64 epoch_ = 0.0; f64 dt_ = 1.0; u64 published_ = 0; /// The stamp of the window's **first** sample, read once by `acquire()` -- which runs on the /// consumer under `ScopeModel::mutex()`, the same lock the producer's pushes hold, so the /// slot cannot be overwritten under the read. Everything else on this object is read at /// *paint* time with no lock, and this one slot is the ring's oldest: the one the producer is /// about to overwrite. Reading it there returned a stamp a whole ring-span too new whenever /// the producer lapped it between the bounds check and the copy -- and `x0()` is what /// `neighbours()` tests the visible range against, as well as the origin the renderer /// reconstructed every trace point from before the caller named one, so one such /// read moved the entire polyline a ring-span off its axis (an invisible trace) or, caught /// inside the renderer's increasing-suffix filter, made it drop most of the points. That is /// the "live plot flickers as if there were no data" defect. The cached value is also the /// *right* answer rather than merely a stable one: it is the stamp sample `window_base` /// actually carries, where the clamp answered with whatever slot happened to be oldest now. f64 window_x0_ = 0.0; plot::data::MinMaxPyramid pyramid_; }; /// A trace's series: one stable object whose channel can be rebound. /// /// `Plot` has no `set_trace_series()` and no `remove_trace()`, so a trace's series /// pointer is fixed for the plot's life. Everything that would otherwise need a /// new pointer -- which signal a trace plots, and the switch between the live ring /// and a frozen event -- is therefore expressed here as a rebind. class SlotSeries final : public plot::data::Series { public: /// Takes shared ownership: a structural change in the model may drop its /// `BufferStore` while a trace still points here, and the renderer reads the /// buffer after this frame's bind has returned. void bind(std::shared_ptr buffer) noexcept { buffer_ = std::move(buffer); event_ = nullptr; } /// Binds the captured event (edge-relative stamps). Binding an event clears /// the ring binding and vice versa: the slot shows exactly one source. void bind_event(std::shared_ptr snap, std::size_t channel) { if (snap == nullptr || channel >= snap->values.size()) { event_ = nullptr; return; } if (event_ == nullptr || !event_->same(snap, channel)) { event_ = std::make_unique(std::move(snap), channel); } buffer_ = nullptr; } ChannelBuffer* bound() const noexcept { return buffer_.get(); } const EventSeries* bound_event() const noexcept { return event_.get(); } bool empty() const noexcept { if (event_ != nullptr) return event_->size() == 0; return buffer_ == nullptr || buffer_->size() == 0; } u64 size() const override { return event_ != nullptr ? event_->size() : buffer_ != nullptr ? buffer_->size() : 0; } f64 x0() const override { return event_ != nullptr ? event_->x0() : buffer_ != nullptr ? buffer_->x0() : 0.0; } f64 dx() const override { return event_ != nullptr ? event_->dx() : buffer_ != nullptr ? buffer_->dx() : 1.0; } f64 x_at(u64 i) const override; bool neighbours(f64 x, u64& i0, u64& i1) const override; u64 snap_index(f64 x) const override; plot::Range x_bounds() const override; plot::Range y_bounds() const override; const plot::data::MinMaxPyramid& pyramid() const override; 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; bool streaming() const noexcept override { return event_ == nullptr && buffer_ != nullptr; } protected: void copy_raw(u64 begin, u64 end, f32* out) const override; private: std::shared_ptr buffer_; std::unique_ptr event_; ///< the captured event, when held }; } // namespace udpscope