Two changes in the trigger pane, landed together. **Persistence.** Trigger mode keeps the last N published acquisitions and draws them fading toward the background, N being a panel control (1..8, default 1) with N=1 meaning today's behaviour exactly, so the knob is the switch. The layers are the accumulate path's own -- one layer per (signal, age) in the row's free trace slots, named with the accumulate vocabulary so `layer_base_name()` strips the suffix and `signal_color()` resolves the base signal -- so a layer is not a signal: the signal list, the colours and the measurements see one row per signal. `ScopeModel` retains the outgoing snapshot in seven atomic `shared_ptr` slots (no lock, no copy; the snapshot already exists) and a layer's channel is resolved **by name inside the snapshot**, because the channel list is renumbered by any structural change since that acquisition was cut. `RowSet::sync_layers()` re-plans from the rows and the depth alone, so a latch costs no rebuild, and `event_data_window()` skips layer slots so an older acquisition can never re-pin the axis. The schedule is 0.5^age -- the acquisition on screen at full brightness and each older one half the previous, independent of N, so raising the depth adds dimmer acquisitions rather than rescaling the ones already on screen. Memory is bounded by the ring's own `--buffer-mb` rather than by the depth, which is the bound that cannot be driven into an OOM, and the panel says when either bound bites. This also wired up `set_trace_alpha()`, which the accumulate path was setting and nothing was reading -- so the documented 0.35 translucency of an accumulation was opaque in fact. **Force.** In the trigger pane beside Arm/Disarm, shown only while trigger mode is on: one acquisition now, with the configured window. It is a synthetic edge rather than a second publisher -- the engine opens one capture at the present moment and everything else is the ordinary latch, so a forced acquisition is cut, pinned, framed, recorded and cached exactly like a latched one, and the button owns no window and no publish. The pre-trigger gate is deliberately skipped (capture now is a request for the history that exists, and the snapshot reports its own shorter pre_s), a capture already filling is refused rather than restarted, and the state afterwards is the mode's: Normal stays armed, Single disarms. The request is one atomic flag written under the model mutex with `armed` -- the producer reads `armed` at the top of a packet and the request at the bottom, so a request written between the two would be dropped -- and consumed at the end of a packet, after the channel loop and before the pending publish. **A defect fixed in passing, in the function Force was modelled on:** `on_arm_clicked()` passed `TriggerConfig::channel` (an index into `channels()`) to `set_signal_enabled()` (an index into `signals()`). With one signal acquired, arming switched on the stream's clock signal and left the watched signal untouched -- and since a signal the panel was not showing is a change, it rebuilt the buffer under the click, dropping the very pre-trigger window the arming, and now the Force, was about to capture. Measured before: `0.0100 s of history behind it`; after: `2.5299 s`. `trigger_signal_index()` is now the one resolution, used by both buttons.
277 lines
13 KiB
C++
277 lines
13 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 <string>
|
|
#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;
|
|
/// 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<f64> abs_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;
|
|
/// 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<std::string> 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<f32>& 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<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
|