Correct downsampling and better layout
This commit is contained in:
1 parent
0d741c401b
commit
30db44eaa0
13 files changed
+1511
-252
No files matched your search
+37
-4
@@ -319,6 +319,17 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
||||
BufferStore& store = model_.live();
|
||||
const u64 g = part.time_stride;
|
||||
|
||||
// The filter's groups are `g` long, so a re-plan has to start a fresh group rather than
|
||||
// finish the one it was in with the wrong length.
|
||||
if (acc_stride_ != g || acc_channels_ != channels.size()) {
|
||||
acc_stride_ = g;
|
||||
acc_channels_ = channels.size();
|
||||
value_acc_.assign(channels.size(), 0.0);
|
||||
value_acc_n_.assign(channels.size(), 0);
|
||||
time_acc_ = 0.0;
|
||||
time_acc_n_ = 0;
|
||||
}
|
||||
|
||||
// Calibration is read per frame; it only changes under the same lock.
|
||||
TriggerEngine& engine = engine_;
|
||||
const int trig_channel = tc.channel;
|
||||
@@ -424,7 +435,16 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
||||
last_stamp_used = rel;
|
||||
last_stamp_ = rel;
|
||||
|
||||
if (tick % g == g - 1) store.time().push(static_cast<f32>(rel));
|
||||
// The stored stamp is the *mean* of the group, not its last member: that is where the
|
||||
// boxcar's group delay puts the averaged value, and stamping it at the end of the
|
||||
// group would shift every trace half a stored sample late.
|
||||
time_acc_ += rel;
|
||||
++time_acc_n_;
|
||||
if (tick % g == g - 1) {
|
||||
store.time().push(static_cast<f32>(time_acc_ / static_cast<double>(time_acc_n_)));
|
||||
time_acc_ = 0.0;
|
||||
time_acc_n_ = 0;
|
||||
}
|
||||
|
||||
// The latch belongs to this sample, but it cannot be published from inside the
|
||||
// channel loop: `publish_event()` clamps the slice to every value ring's
|
||||
@@ -438,8 +458,6 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
||||
double latch_edge = 0.0;
|
||||
|
||||
for (std::size_t i = 0; i < channels.size(); ++i) {
|
||||
const u64 period = g * part.stride[i];
|
||||
if (tick % period != period - 1) continue;
|
||||
const ChannelInfo& ch = channels[i];
|
||||
// A signal's columns are consecutive samples of the same channel: a scalar
|
||||
// (one element) carries one value per batched cycle, a block signal carries
|
||||
@@ -449,8 +467,23 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
||||
? udps_frame_value(frame, static_cast<std::uint32_t>(ch.signal), 0,
|
||||
std::min<std::uint32_t>(k, ch.elements - 1))
|
||||
: udps_frame_value(frame, static_cast<std::uint32_t>(ch.signal), k, 0);
|
||||
const f32 value = static_cast<f32>(raw * ch.gain + ch.offset);
|
||||
// Every sample is read and accumulated even when only one in `period` is stored:
|
||||
// that is the whole point of the filter. Skipping the read -- which is what the
|
||||
// old stride did -- is what made decimation alias.
|
||||
value_acc_[i] += raw * ch.gain + ch.offset;
|
||||
++value_acc_n_[i];
|
||||
|
||||
const u64 period = g * part.stride[i];
|
||||
if (tick % period != period - 1) continue;
|
||||
const f32 value =
|
||||
static_cast<f32>(value_acc_[i] / static_cast<double>(value_acc_n_[i]));
|
||||
value_acc_[i] = 0.0;
|
||||
value_acc_n_[i] = 0;
|
||||
store.values(i).push(value);
|
||||
// The trigger sees the *stored* signal, not the wire signal. An edge found in a
|
||||
// sample that is not in the buffer would be cut from data that does not contain it,
|
||||
// and with the filter in front of it a spike above the stored Nyquist is exactly
|
||||
// the thing that must not latch -- it is not in the record the user would get.
|
||||
if (static_cast<int>(i) == trig_channel) {
|
||||
if (engine.process(value, rel)) {
|
||||
latch_here = true;
|
||||
|
||||
@@ -77,6 +77,20 @@ class Acquisition {
|
||||
double arrival_epoch_ = 0.0;
|
||||
bool have_arrival_epoch_ = false;
|
||||
std::vector<double> stamps_;
|
||||
/// The decimation filter's state: a running sum per channel (and one for the shared
|
||||
/// clock) over the group of base samples that becomes one stored sample. A boxcar
|
||||
/// average, which is what turns decimation into a *resampling* rather than a stride:
|
||||
/// its first null sits at the stored rate, so what would fold back is attenuated
|
||||
/// instead of reappearing as a false low frequency. Cleared whenever the partition
|
||||
/// changes, since the group length is `time_stride`.
|
||||
std::vector<double> value_acc_;
|
||||
std::vector<std::uint64_t> value_acc_n_;
|
||||
double time_acc_ = 0.0;
|
||||
std::uint64_t time_acc_n_ = 0;
|
||||
/// The partition the accumulators were sized for, so a re-plan resets them rather than
|
||||
/// averaging across the boundary.
|
||||
std::uint64_t acc_stride_ = 0;
|
||||
std::size_t acc_channels_ = 0;
|
||||
std::chrono::steady_clock::time_point started_{};
|
||||
};
|
||||
|
||||
|
||||
+9
-2
@@ -3,6 +3,10 @@
|
||||
|
||||
#include "config.hpp"
|
||||
|
||||
// For `RowSet::kMaxRows`: `--plots` names a row count, and one constant for the ceiling
|
||||
// is what stops the message and the clamp from drifting apart.
|
||||
#include "plot_host.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <stdexcept>
|
||||
@@ -48,7 +52,8 @@ void print_usage(std::FILE* out, const char* argv0) {
|
||||
" --max-window SEC largest time window the toolbar offers (default 600)\n"
|
||||
" --holdoff SEC default trigger hold-off (default 0.010)\n"
|
||||
" --recv-buffer BYTES SO_RCVBUF for the receiver socket (default 8388608)\n"
|
||||
" --plots N initial number of stacked plots, 1..4 (default 1)\n"
|
||||
" --plots N stacked plots to start with, 1..8 (default 1); rows are\n"
|
||||
" added and removed from the layout editor\n"
|
||||
" -h, --help this text\n",
|
||||
argv0);
|
||||
}
|
||||
@@ -88,7 +93,9 @@ Config parse_args(int argc, char** argv) {
|
||||
} else if (a == "--plots" || a.rfind("--plots=", 0) == 0) {
|
||||
const std::string v = value_of(argc, argv, i, "--plots");
|
||||
const long n = std::strtol(v.c_str(), nullptr, 10);
|
||||
if (n < 1 || n > 4) fail("--plots must be 1..4: " + v);
|
||||
if (n < 1 || n > RowSet::kMaxRows) {
|
||||
fail("--plots must be 1.." + std::to_string(RowSet::kMaxRows) + ": " + v);
|
||||
}
|
||||
c.plots = static_cast<int>(n);
|
||||
} else {
|
||||
fail("unknown argument: " + a);
|
||||
|
||||
+2
-1
@@ -27,7 +27,8 @@ struct Config {
|
||||
double trigger_holdoff_s = 0.010;
|
||||
int recv_buffer_bytes = 8 * 1024 * 1024;
|
||||
|
||||
/// Number of stacked plots (rows) to start with, 1..4.
|
||||
/// Number of stacked plots (rows) to start with, 1..`RowSet::kMaxRows`. Only the
|
||||
/// starting point: rows are added and removed from the layout editor.
|
||||
int plots = 1;
|
||||
};
|
||||
|
||||
|
||||
+88
-9
@@ -2,6 +2,7 @@
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
#pragma once
|
||||
|
||||
#include <plot/data/minmax_pyramid.hpp>
|
||||
#include <plot/data/series.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
@@ -30,6 +31,18 @@ struct EventSnapshot {
|
||||
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
|
||||
@@ -70,19 +83,35 @@ class EventSeries final : public plot::data::Series {
|
||||
plot::Range y_bounds() const override {
|
||||
return channel_ < snap_->y.size() ? snap_->y[channel_] : plot::Range{0.0, 0.0};
|
||||
}
|
||||
const plot::data::MinMaxPyramid& pyramid() const override { return empty_pyramid_; }
|
||||
/// 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, std::vector<f32>& out_xy,
|
||||
plot::data::GatherMode /*mode*/ =
|
||||
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 stride = u64{1} << level;
|
||||
if (stride == 0 || begin >= n) return;
|
||||
if (end > n) end = n;
|
||||
const u64 b = std::min(begin, n);
|
||||
const u64 e = std::min(std::max(end, b), n);
|
||||
if (b >= e) return;
|
||||
// x **relative to x0()**, which is the library's gather contract: the renderer
|
||||
// adds `x0()` back before mapping to the screen. Emitting the absolute stamp made
|
||||
// it add x0 twice, so the whole capture was drawn one `x0()` -- a full
|
||||
@@ -94,11 +123,61 @@ class EventSeries final : public plot::data::Series {
|
||||
// crosshair -- which reads `x_at()`, never `gather()` -- went on reporting the
|
||||
// right values.
|
||||
const f64 base = x0();
|
||||
out_xy.reserve(out_xy.size() + static_cast<std::size_t>((end - begin) / stride * 2 + 4));
|
||||
for (u64 i = (begin / stride) * stride; i < end; i += stride) {
|
||||
out_xy.push_back(static_cast<f32>(stamps[static_cast<std::size_t>(i)] - base));
|
||||
out_xy.push_back(vals[static_cast<std::size_t>(i)]);
|
||||
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);
|
||||
}
|
||||
|
||||
bool neighbours(f64 x, u64& i0, u64& i1) const override {
|
||||
|
||||
+59
-11
@@ -7,6 +7,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
@@ -29,7 +30,8 @@ u64 Partition::total_samples() const noexcept {
|
||||
return total;
|
||||
}
|
||||
|
||||
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes) {
|
||||
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
|
||||
double target_span_s) {
|
||||
Partition p;
|
||||
const std::size_t n = channel_dt.size();
|
||||
p.stride.assign(n, 1);
|
||||
@@ -48,16 +50,12 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
|
||||
if (!(base_dt > 0.0)) base_dt = 1.0e-3;
|
||||
p.base_dt = base_dt;
|
||||
|
||||
// No per-channel decimation, and none on the shared clock either. A stride of
|
||||
// `round(dt_i / dt_fastest)` throws away all but one sample per stride, and when
|
||||
// that stride equals the signal's own period -- a 2 kHz sine on a 1 MHz clock,
|
||||
// stride 500 -- every kept sample sits at the same phase: the channel plots as a
|
||||
// flat line. Decimating the shared clock to make a 600 s window fit did the same
|
||||
// to *every* channel. The LOD min/max pyramid is what preserves the extremes at
|
||||
// draw time, and it needs the real samples; the buffer simply holds as many as
|
||||
// it holds, and the window dropdown is capped to that span.
|
||||
// No *per-channel* decimation. A stride of `round(dt_i / dt_fastest)` keeps one sample
|
||||
// in every stride, and when that stride equals the signal's own period -- a 2 kHz sine
|
||||
// on a 1 MHz clock, stride 500 -- every kept sample sits at the same phase and the
|
||||
// channel plots as a flat line. The shared clock is decimated below instead, which
|
||||
// affects every channel equally and goes through the ingest filter.
|
||||
for (std::size_t i = 0; i < n; ++i) p.stride[i] = 1;
|
||||
p.time_stride = 1;
|
||||
|
||||
const u64 budget_bytes = static_cast<u64>(std::max(1.0, buffer_bytes));
|
||||
const u64 budget = budget_bytes / sizeof(f32);
|
||||
@@ -97,6 +95,23 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
p.value_capacity[i] = capacity_for(p.time_capacity, p.stride[i]);
|
||||
}
|
||||
|
||||
// Only now, with the ring as long as the budget can buy, decimate the shared clock
|
||||
// enough for the requested window to fit in it: deciding this earlier would over-
|
||||
// decimate by whatever factor the growth loop was about to add. A power of two keeps
|
||||
// the ring capacities (themselves powers of two) in step and keeps `tick % period`
|
||||
// cheap. The cap stops a nonsense request -- a window far past `--max-window` -- from
|
||||
// spinning here; at 2^20 the stored rate is six decades below the wire rate and the
|
||||
// picture is averages of averages anyway.
|
||||
u64 m = 1;
|
||||
if (target_span_s > 0.0) {
|
||||
while (m < (u64(1) << 20) &&
|
||||
static_cast<double>(time_capacity) * static_cast<double>(m) * base_dt <
|
||||
target_span_s) {
|
||||
m *= 2;
|
||||
}
|
||||
}
|
||||
p.time_stride = m;
|
||||
p.span_s = static_cast<double>(p.time_capacity) * static_cast<double>(p.time_stride) * base_dt;
|
||||
return p;
|
||||
}
|
||||
@@ -184,12 +199,37 @@ void ScopeModel::rebuild_locked() {
|
||||
return;
|
||||
}
|
||||
|
||||
part_ = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0);
|
||||
part_ = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0,
|
||||
target_span_.load(std::memory_order_relaxed));
|
||||
store_ = std::make_unique<BufferStore>(part_, part_.span_s);
|
||||
|
||||
if (trigger_.channel >= static_cast<int>(channels_.size())) trigger_.channel = -1;
|
||||
}
|
||||
|
||||
bool ScopeModel::set_target_span(double seconds) {
|
||||
if (!(seconds > 0.0)) return false;
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
const double previous = target_span_.exchange(seconds, std::memory_order_relaxed);
|
||||
if (channels_.empty()) return false;
|
||||
// Re-plan only if the factor moves. The planner is cheap, so ask it rather than
|
||||
// reimplementing its arithmetic here -- two expressions for the same decision is how
|
||||
// the buffer and the view end up disagreeing about the stored rate.
|
||||
std::vector<double> dts;
|
||||
dts.reserve(channels_.size());
|
||||
for (const ChannelInfo& c : channels_) dts.push_back(c.dt);
|
||||
const Partition want = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0, seconds);
|
||||
if (want.time_stride == part_.time_stride) return false;
|
||||
std::fprintf(stderr,
|
||||
"[udpscope] window %.4f s -> %.4f s: decimation %llu -> %llu, stored rate "
|
||||
"%.4g -> %.4g S/s (anti-alias %.4g Hz); buffer rebuilt\n",
|
||||
previous, seconds, static_cast<unsigned long long>(part_.time_stride),
|
||||
static_cast<unsigned long long>(want.time_stride), part_.stored_rate(),
|
||||
want.stored_rate(), want.antialias_hz());
|
||||
std::fflush(stderr);
|
||||
rebuild_locked();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ScopeModel::is_time_signal(const std::vector<SignalInfo>& signals,
|
||||
std::size_t signal) {
|
||||
if (signal >= signals.size()) return false;
|
||||
@@ -331,15 +371,23 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
|
||||
|
||||
snap->values.resize(channels_.size());
|
||||
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0});
|
||||
snap->pyramids.resize(channels_.size());
|
||||
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
||||
auto& vals = snap->values[c];
|
||||
vals.resize(static_cast<std::size_t>(n));
|
||||
if (n > 0) src.values(c).copy_from(first, n, vals.data());
|
||||
f32 lo = vals.empty() ? 0.0f : vals.front();
|
||||
f32 hi = lo;
|
||||
// The LOD index is folded into the bounds pass that was already here, so the
|
||||
// snapshot costs one O(n) walk per channel rather than two. Without a pyramid
|
||||
// the renderer's `level_for()` descends to level 0 and the whole capture is
|
||||
// drawn raw on every frame the event is held: see `EventSnapshot::pyramids`.
|
||||
auto& pyr = snap->pyramids[c];
|
||||
pyr.reserve(n);
|
||||
for (f32 v : vals) {
|
||||
lo = std::min(lo, v);
|
||||
hi = std::max(hi, v);
|
||||
pyr.append(v);
|
||||
}
|
||||
snap->y[c] = plot::Range{lo, hi};
|
||||
}
|
||||
|
||||
+33
-5
@@ -69,7 +69,12 @@ struct ChannelInfo {
|
||||
/// coincide and every slice is the same size.
|
||||
struct Partition {
|
||||
u64 time_capacity = 0; ///< time-base slots retained
|
||||
u64 time_stride = 1; ///< base ticks per time-base slot (reserved; 1)
|
||||
/// Base ticks per stored slot: the decimation factor the requested window forced. 1
|
||||
/// until the window needs more samples than the budget can hold. The ingest path
|
||||
/// *averages* each group of this many samples rather than keeping one of them (see
|
||||
/// `Acquisition::on_data()`), so this is a sample rate reduction with an anti-alias
|
||||
/// filter in front of it, not a stride.
|
||||
u64 time_stride = 1;
|
||||
std::vector<u64> stride; ///< time slots per channel sample (>= 1)
|
||||
std::vector<u64> value_capacity; ///< samples retained per channel
|
||||
double base_dt = 1.0; ///< seconds per base tick
|
||||
@@ -79,12 +84,23 @@ struct Partition {
|
||||
u64 total_bytes() const noexcept { return total_samples() * sizeof(f32); }
|
||||
/// Seconds between two time-base slots (the sample interval of every channel).
|
||||
double slot_dt() const noexcept { return base_dt * static_cast<double>(time_stride); }
|
||||
/// The rate samples are actually stored at, in samples per second.
|
||||
double stored_rate() const noexcept {
|
||||
const double dt = slot_dt();
|
||||
return dt > 0.0 ? 1.0 / dt : 0.0;
|
||||
}
|
||||
/// The anti-alias filter's corner, in Hz: half the stored rate, which is the new
|
||||
/// Nyquist. The boxcar the ingest path averages with has its first null at the stored
|
||||
/// rate itself, so everything that would fold back is attenuated.
|
||||
double antialias_hz() const noexcept { return 0.5 * stored_rate(); }
|
||||
};
|
||||
|
||||
/// Plans a partition for `channel_dt` cadences within `buffer_bytes`, targeting a
|
||||
/// span of `target_span_s` seconds. Throws std::runtime_error when the budget
|
||||
/// cannot hold even one sample per channel.
|
||||
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes);
|
||||
/// Plans a partition for `channel_dt` cadences within `buffer_bytes`, covering at least
|
||||
/// `target_span_s` seconds of stream -- decimating (with the ingest filter) by whatever
|
||||
/// power of two that takes. Throws std::runtime_error when the budget cannot hold even
|
||||
/// one sample per channel.
|
||||
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
|
||||
double target_span_s);
|
||||
|
||||
/// One of the two identical sample buffers: a shared time-base ring plus one
|
||||
/// value ring and one `ChannelBuffer` per channel.
|
||||
@@ -287,6 +303,14 @@ class ScopeModel {
|
||||
}
|
||||
double event_span() const noexcept { return event_span_.load(std::memory_order_relaxed); }
|
||||
|
||||
/// The window the buffer has to cover, which is the display window. Re-plans the
|
||||
/// partition -- and so drops the history -- only when the decimation factor it implies
|
||||
/// actually changes, so walking the window inside one decade costs nothing. Takes
|
||||
/// `mutex()` itself; do not call it holding the lock.
|
||||
/// Returns true when the buffer was rebuilt.
|
||||
bool set_target_span(double seconds);
|
||||
double target_span() const noexcept { return target_span_.load(std::memory_order_relaxed); }
|
||||
|
||||
/// Trigger state for the UI, published by the acquisition thread. Atomics
|
||||
/// rather than fields of `TriggerConfig` because the producer writes the arm
|
||||
/// state when a single-shot trigger disarms itself, and the frame tick reads it
|
||||
@@ -321,6 +345,10 @@ class ScopeModel {
|
||||
StreamStats stats_{};
|
||||
f64 epoch_ = 0.0;
|
||||
std::atomic<double> event_span_{1.0};
|
||||
/// The window the buffer is planned to cover. Read by `rebuild_locked()`, which a
|
||||
/// CONFIG can reach from the producer thread, so it is an atomic rather than a plain
|
||||
/// field.
|
||||
std::atomic<double> target_span_{0.0};
|
||||
std::atomic<bool> trigger_pending_{false};
|
||||
std::atomic<double> capture_edge_{0.0};
|
||||
std::atomic<std::shared_ptr<const EventSnapshot>> event_; ///< the event on screen
|
||||
|
||||
@@ -54,6 +54,53 @@ void RowSet::set_row_count(int rows) {
|
||||
rebuild_all();
|
||||
}
|
||||
|
||||
void RowSet::carry_row(Row& dst, const Row& src) {
|
||||
dst.signal_name = src.signal_name;
|
||||
dst.name = src.name;
|
||||
dst.channel = src.channel;
|
||||
dst.color_index = src.color_index;
|
||||
dst.cursors_visible = src.cursors_visible;
|
||||
dst.cursor_a_x = src.cursor_a_x;
|
||||
dst.cursor_b_x = src.cursor_b_x;
|
||||
dst.ruler_y = src.ruler_y;
|
||||
// The slot's series stays where it is, but what it is bound to belonged to the row
|
||||
// this state came *from*. `bind()` re-resolves every slot next frame; clearing it here
|
||||
// is what stops the frame in between from drawing the moved row's data under the new
|
||||
// row's legend.
|
||||
for (SlotSeries& s : dst.slots) s.bind(nullptr);
|
||||
}
|
||||
|
||||
void RowSet::clear_row(Row& r) {
|
||||
r.signal_name = {};
|
||||
r.name = {};
|
||||
r.channel = {-1, -1, -1, -1};
|
||||
r.color_index = {0, 1, 2, 3};
|
||||
r.cursors_visible = false;
|
||||
r.cursor_a_x = 0.0;
|
||||
r.cursor_b_x = 0.0;
|
||||
r.ruler_y = 0.0;
|
||||
for (SlotSeries& s : r.slots) s.bind(nullptr);
|
||||
}
|
||||
|
||||
bool RowSet::insert_row(int at) {
|
||||
if (rows_ >= kMaxRows) return false;
|
||||
const int pos = std::clamp(at, 0, rows_);
|
||||
for (int i = rows_; i > pos; --i) carry_row(row(i), row(i - 1));
|
||||
clear_row(row(pos));
|
||||
++rows_;
|
||||
rebuild_all();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool RowSet::remove_row(int at) {
|
||||
if (rows_ <= 1 || at < 0 || at >= rows_) return false;
|
||||
for (int i = at; i + 1 < rows_; ++i) carry_row(row(i), row(i + 1));
|
||||
--rows_;
|
||||
clear_row(row(rows_));
|
||||
rebuild_all();
|
||||
return true;
|
||||
}
|
||||
|
||||
void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
|
||||
r.plot = &p;
|
||||
r.axis_x = r.plot->add_x_axis(
|
||||
|
||||
+26
-1
@@ -41,7 +41,12 @@ namespace udpscope {
|
||||
/// context and is simply handed the new figure.
|
||||
class RowSet {
|
||||
public:
|
||||
static constexpr int kMaxRows = 4;
|
||||
/// The ceiling on rows, not the count: rows are added and removed one at a time
|
||||
/// (`insert_row()`, `remove_row()`) rather than picked off a list of fixed layouts.
|
||||
/// A cap still exists because `storage_` is the rows' persistent state and has to be
|
||||
/// allocated somewhere, and because the figure keeps a floor height per row -- past
|
||||
/// about this many a row is chrome and no plot.
|
||||
static constexpr int kMaxRows = 8;
|
||||
static constexpr int kSlotsPerRow = 4;
|
||||
|
||||
RowSet();
|
||||
@@ -93,6 +98,17 @@ class RowSet {
|
||||
/// Shows `rows` rows. A changed count rebuilds the figure, since a `Figure`'s row
|
||||
/// count is fixed at construction.
|
||||
void set_row_count(int rows);
|
||||
/// Inserts an empty row at `at` (0 = above the first row, `row_count()` = below the
|
||||
/// last), pushing the rows at and below it down. Returns false at `kMaxRows`.
|
||||
///
|
||||
/// The rows below move as *rows*: their trace assignments, colours and cursors follow
|
||||
/// them, so inserting above row 1 does not renumber what row 1 was plotting. The
|
||||
/// figure's `Plot`s are not what moves -- they are rebuilt and re-dealt -- which is
|
||||
/// the same mechanism a row-count change already used.
|
||||
bool insert_row(int at);
|
||||
/// Removes row `at` and pulls the rows below it up. Returns false when only one row
|
||||
/// is left: a figure with no rows has nothing to draw and no way back.
|
||||
bool remove_row(int at);
|
||||
Row& row(int i) { return storage_[static_cast<std::size_t>(i)]; }
|
||||
const Row& row(int i) const { return storage_[static_cast<std::size_t>(i)]; }
|
||||
|
||||
@@ -192,6 +208,15 @@ class RowSet {
|
||||
static plot::Color unplotted_color();
|
||||
|
||||
private:
|
||||
/// Moves one row's *persistent* state -- what it plots, in which colours, where its
|
||||
/// cursors are -- onto another row. Deliberately not a `Row` assignment: a `Row` also
|
||||
/// carries the figure handles (`plot`, the axis and cursor ids) and the `SlotSeries`
|
||||
/// objects the traces point at, and those belong to the row's *position*, not to its
|
||||
/// contents. `rebuild_all()` re-deals them straight after.
|
||||
static void carry_row(Row& dst, const Row& src);
|
||||
/// Back to an empty row: nothing plotted, the default palette entries, no cursors.
|
||||
static void clear_row(Row& r);
|
||||
|
||||
void install(Row& r, int row_index, plot::Plot& p);
|
||||
void place_cursors(Row& r);
|
||||
/// The newest x any row's live traces hold, which is what the group follows.
|
||||
|
||||
+795
-186
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user