The flicker ("no data" at no constant frequency, worse with the measurements
and cursors panes up) was a torn read of the window's first stamp: acquire()
pins the window to the ring's oldest slot, and reading it at paint time races
the producer lapping it, returning a stamp one ring span too new. x0() is the
origin every trace point is reconstructed from, so one torn read produced a
refused neighbours(), a filter that dropped three quarters of the points, and
a polyline shifted off its axis. It is cached by acquire() under the lock now.
EventSeries::neighbours() answered from the (x - x0)/dx grid, whose drift from
the real arrival stamps arrives as a constant offset in seconds -- a doubling
share of the screen per zoom step, which is the "zooming into a held event
clips it more from the left" report. Both series binary-search the stamps now.
The measurements pane went through the LOD pyramid rather than reading its
window raw (19.7 ms per recompute to 1.2 ms, 0.1% of a 2 M-sample window) and
recomputes on a cadence; the math engine lost a per-slot heap allocation, a
per-sample source lookup by name and a fetch_add that cost more than the
arithmetic it counted.
The cursors pane's mix mode drew a line per *named* slot, so a slot holding a
signal that can never produce a channel -- the stream's timestamp -- got a row
that could only read "--", and such names were re-saved every session. It
counts bound traces now and apply_layout() refuses them.
Four toolbar icons are drawn for this app and compiled in as a GResource: the
stock ones were a record dot for the trigger and three magnifiers for cursors,
measurements and math.
New probes and dump lines for all of it (trace ink, measure cost, math cost,
event zoom, toolbar icons); README and CLAUDE.md record what they cure.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
177 lines
8.6 KiB
C++
177 lines
8.6 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:
|
|
ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>> values,
|
|
std::shared_ptr<const plot::data::RingBuffer<f32>> 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 value is interpolated between the two samples actually either
|
|
/// side of the pointer -- 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 `x0()`** (the library's contract): staging an
|
|
/// absolute epoch time as f32 would quantise it far coarser than a pixel.
|
|
void gather(u32 level, u64 begin, u64 end, 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_`.
|
|
f32 time_of(u64 absolute_index) const noexcept;
|
|
|
|
std::shared_ptr<const plot::data::RingBuffer<f32>> values_;
|
|
std::shared_ptr<const plot::data::RingBuffer<f32>> 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 the origin
|
|
/// the renderer reconstructs every trace point from (`gather[i] + series.x0()`), 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.
|
|
f32 window_x0_ = 0.0f;
|
|
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, 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
|