Files
udpscope/src/event_series.hpp
T
Martino FerrariandClaude Opus 4.6 b313cac46e feat: the x axis is relative to now, and an export carries both clocks
Three asked-for changes, one commit because they are one coordinate system.

The live axis is a fixed [-window, 0] with the newest sample at 0, so x reads
as an age and the labels stand still while the trace rolls under them. That
makes the display clock distinct from the stream clock for the first time, so
`ScopeModel::stream_at_x0()` becomes the single conversion between them
(`display_x = stream_t - stream_at_x0()` in all four modes) and a snapshot
carries its own origin in `EventSnapshot::x_offset`. The pause view is now
rebased like an event's, reversing an earlier deliberate choice: a frozen
absolute-stamped picture would sit a whole uptime right of a newest-relative
axis. That also fixed a latent bug, the export adding `trigger_time()` to a
*paused* view's stamps.

The status bar says how long you have been paused -- wall time, since the
producer never stops, through `format_elapsed()` so the field cannot widen.

The export writes two time columns: `time`, the axis as displayed, and
`abs_seconds`, the stream's own clock for the same sample. f64 seconds and
not the wire's integer, because `udps_frame_t::values` is a `const double*`
and the integer never exists in this process.

Cursors and measurement marks move in opposite directions under the rolling
axis, deliberately: a cursor is a position on screen and stays put, a mark
names a sample and travels with it. `App::measure_window` is stream-clocked
for a sharper reason -- a display-coordinate live window never moves, so the
"the window moved" recompute would never fire again.

Moves the deps/plotter pin to 58473db, which stops a time label's mantissa
reaching its own radix: a one-second tick differenced off a live axis read
`1e+3ms`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 21:49:12 +02:00

250 lines
12 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/data/minmax_pyramid.hpp>
#include <plot/data/series.hpp>
#include <algorithm>
#include <memory>
#include <vector>
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<f64> stamps;
std::vector<std::vector<f32>> 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<plot::Range> 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<plot::data::MinMaxPyramid> pyramids;
};
/// 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<const EventSnapshot> 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<const EventSnapshot>& 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<u64>(
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<f64>(s.size() - 1) : 1.0;
}
f64 x_at(u64 i) const override {
const auto& s = snap_->stamps;
return i < s.size() ? s[static_cast<std::size_t>(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<f32>& 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<f32>(stamps[static_cast<std::size_t>(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<std::size_t>(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<std::size_t>(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<f32>(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<std::ptrdiff_t>(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<f32>(x));
const u64 above = static_cast<u64>(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<u64>(vals.size());
if (end > n) end = n;
for (u64 i = begin; i < end; ++i)
out[static_cast<std::size_t>(i - begin)] = vals[static_cast<std::size_t>(i)];
}
protected:
void copy_raw(u64 begin, u64 end, f32* out) const override { raw_values(begin, end, out); }
private:
std::shared_ptr<const EventSnapshot> snap_;
std::size_t channel_;
plot::data::MinMaxPyramid empty_pyramid_{};
};
} // namespace udpscope