gather() staged x as f32 relative to the series' own x0() -- the start of the whole retained window -- so the quantisation was a fraction of the ring rather than of the view: 1.9 us at 20 s of retained data, which at a 10 us view across a 716 px axis is over 100 px between where a sample is drawn and the x a readout quotes for it (crosshair_point() reads x_at() in exact f64). It halved on every zoom out, which is what made it read as "zooming in makes the crosshair select a point beside the data". The origin is now the caller's (plotter c2c8c59): the renderer passes the visible range's minimum, so the error is a fixed 2^-24 of what is on screen -- about 4e-5 px over a 700 px axis -- at any zoom. The parameter sits before out_xy so every call site is a compile error rather than a silent change of behaviour. The renderer no longer reads series.x0() at paint time at all, which removes two of the three symptoms of the torn window_x0_ read by construction; the ink probe's now-vacuous kept_fixed_x0 walk goes with them, replaced by a drawn-vs-quoted pixel figure. neighbours() still tests the visible range against x_at(0), so the cache stays load-bearing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
182 lines
8.9 KiB
C++
182 lines
8.9 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#pragma once
|
|
|
|
#include <plot/data/minmax_pyramid.hpp>
|
|
#include <plot/data/ring_buffer.hpp>
|
|
#include <plot/data/series.hpp>
|
|
|
|
#include "event_series.hpp"
|
|
|
|
#include <memory>
|
|
#include <vector>
|
|
|
|
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<const plot::data::RingBuffer<f32>> values,
|
|
std::shared_ptr<const plot::data::RingBuffer<f64>> 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;
|
|
|
|
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<f32>& 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;
|
|
|
|
std::shared_ptr<const plot::data::RingBuffer<f32>> values_;
|
|
std::shared_ptr<const plot::data::RingBuffer<f64>> 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<ChannelBuffer> 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<const EventSnapshot> 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<EventSeries>(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<f32>& 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<ChannelBuffer> buffer_;
|
|
std::unique_ptr<EventSeries> event_; ///< the captured event, when held
|
|
};
|
|
|
|
} // namespace udpscope
|