Initial commit
This commit is contained in:
commit
8059b90de7
29 files changed
+7893
No files matched your search
@@ -0,0 +1,476 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include "acquisition.hpp"
|
||||
|
||||
#include <udps_client.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace udpscope {
|
||||
namespace {
|
||||
|
||||
SignalInfo to_info(const udps_signal_t& s) {
|
||||
SignalInfo info;
|
||||
info.name = s.name;
|
||||
info.unit = s.unit;
|
||||
info.type_code = s.type_code;
|
||||
info.quant_type = s.quant_type;
|
||||
info.rows = s.num_rows;
|
||||
info.cols = s.num_cols;
|
||||
info.range_min = s.range_min;
|
||||
info.range_max = s.range_max;
|
||||
info.time_mode = s.time_mode;
|
||||
info.sampling_rate = s.sampling_rate;
|
||||
info.time_signal_idx = s.time_signal_idx;
|
||||
return info;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Acquisition::Acquisition(ScopeModel& model, const Config& cfg) : model_(model), cfg_(cfg) {}
|
||||
|
||||
Acquisition::~Acquisition() { stop(); }
|
||||
|
||||
void Acquisition::start() {
|
||||
udps_client_config_t c;
|
||||
udps_client_config_init(&c);
|
||||
c.server_addr = cfg_.server.c_str();
|
||||
c.server_port = cfg_.port;
|
||||
c.multicast_group = cfg_.multicast.empty() ? nullptr : cfg_.multicast.c_str();
|
||||
c.interface_addr = cfg_.interface_addr.empty() ? nullptr : cfg_.interface_addr.c_str();
|
||||
c.recv_buffer_bytes = static_cast<std::uint32_t>(cfg_.recv_buffer_bytes);
|
||||
c.silence_timeout_s = 2.0;
|
||||
c.reconnect_delay_s = 0.5;
|
||||
c.keepalive_interval_s = 1.0;
|
||||
|
||||
client_ = udps_client_create(&c);
|
||||
if (client_ == nullptr) {
|
||||
throw std::runtime_error("could not create the UDPS client for " + cfg_.server + ":" +
|
||||
std::to_string(cfg_.port));
|
||||
}
|
||||
udps_client_set_callbacks(client_, &Acquisition::config_trampoline, &Acquisition::data_trampoline,
|
||||
&Acquisition::event_trampoline, this);
|
||||
|
||||
started_ = std::chrono::steady_clock::now();
|
||||
running_.store(true, std::memory_order_release);
|
||||
thread_ = std::thread([this] { run(); });
|
||||
}
|
||||
|
||||
void Acquisition::stop() {
|
||||
if (!running_.exchange(false, std::memory_order_acq_rel)) {
|
||||
if (client_ != nullptr) {
|
||||
udps_client_destroy(client_);
|
||||
client_ = nullptr;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (thread_.joinable()) thread_.join();
|
||||
if (client_ != nullptr) {
|
||||
udps_client_destroy(client_);
|
||||
client_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void Acquisition::run() {
|
||||
while (running_.load(std::memory_order_acquire)) {
|
||||
udps_client_poll(client_, 50);
|
||||
udps_stats_t s;
|
||||
udps_client_stats(client_, &s);
|
||||
StreamStats& out = model_.stats();
|
||||
out.packets.store(s.packets_received, std::memory_order_relaxed);
|
||||
out.frames.store(s.frames_delivered, std::memory_order_relaxed);
|
||||
out.gaps.store(s.counter_gaps, std::memory_order_relaxed);
|
||||
out.reconnects.store(s.reconnects, std::memory_order_relaxed);
|
||||
out.connected.store(udps_client_is_connected(client_) != 0, std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
void Acquisition::on_config(const udps_signal_t* signals, std::uint32_t count,
|
||||
std::uint8_t publish_mode) {
|
||||
std::vector<SignalInfo> info;
|
||||
info.reserve(count);
|
||||
for (std::uint32_t i = 0; i < count; ++i) info.push_back(to_info(signals[i]));
|
||||
|
||||
// One line per CONFIG on stderr: what the stream says it is. This is the first
|
||||
// thing to look at when a signal refuses to plot.
|
||||
static const char* const kModes[] = {"strict", "accumulate", "decimate"};
|
||||
std::fprintf(stderr, "[udpscope] CONFIG: %u signals, publish=%s\n", count,
|
||||
publish_mode < 3 ? kModes[publish_mode] : "?");
|
||||
for (const SignalInfo& sig : info) {
|
||||
std::fprintf(stderr,
|
||||
"[udpscope] '%s' %ux%u (%u elem) rate=%g Hz time=%s idx=%u unit='%s'\n",
|
||||
sig.name.c_str(), sig.rows, sig.cols, sig.elements(), sig.sampling_rate,
|
||||
sig.time_mode == UDPS_TIME_PACKET ? "packet"
|
||||
: sig.time_mode == UDPS_TIME_FULL_ARRAY ? "full-array"
|
||||
: sig.time_mode == UDPS_TIME_FIRST_SAMPLE ? "first-sample"
|
||||
: "last-sample",
|
||||
sig.time_signal_idx, sig.unit.c_str());
|
||||
}
|
||||
std::fflush(stderr);
|
||||
model_.stats().have_config.store(true, std::memory_order_relaxed);
|
||||
model_.set_signals(std::move(info));
|
||||
}
|
||||
|
||||
void Acquisition::on_event(int event, const char* detail) {
|
||||
(void)detail;
|
||||
if (event == UDPS_EVENT_CONNECTED) {
|
||||
model_.stats().connected.store(true, std::memory_order_relaxed);
|
||||
} else if (event == UDPS_EVENT_DISCONNECTED) {
|
||||
model_.stats().connected.store(false, std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_signal) {
|
||||
// The frame's span in base ticks: a scalar signal carries `num_samples` values
|
||||
// (one per batched cycle), a block signal (rows*cols > 1) carries its whole
|
||||
// element block. The largest of those is how many base ticks this frame spans --
|
||||
// in `strict` mode `num_samples` is 1 while a 1x1000 block carries 1000 samples.
|
||||
std::uint32_t n = frame->num_samples;
|
||||
for (std::uint32_t i = 0; i < frame->num_signals; ++i) {
|
||||
n = std::max(n, udps_signal_num_elements(&frame->signals[i]));
|
||||
}
|
||||
stamps_.assign(n, 0.0);
|
||||
|
||||
double period = model_.partition().base_dt;
|
||||
if (!(period > 0.0)) period = 1.0e-3;
|
||||
|
||||
bool used_stream_time = false;
|
||||
if (time_signal >= 0 && time_signal < static_cast<int>(frame->num_signals)) {
|
||||
const udps_signal_t& ts = frame->signals[time_signal];
|
||||
// How the client indexes a signal's values depends on its shape
|
||||
// (`udps_frame_value`): a scalar carries one value per batched cycle, so the
|
||||
// stamp of sample k is (sample=k, elem=0); a multi-element signal carries one
|
||||
// *sample* of `elements` values per packet, so the stamp of sample k is
|
||||
// (sample=0, elem=k). Passing the scalar form for both is why FULL_ARRAY time
|
||||
// signals -- the only mode that carries a stamp per element -- read as
|
||||
// element 0 for every sample and were then judged degenerate.
|
||||
const bool scalar_time = udps_signal_num_elements(&ts) == 1;
|
||||
auto value_of = [&](std::uint32_t k) {
|
||||
return scalar_time
|
||||
? udps_frame_value(frame, static_cast<std::uint32_t>(time_signal), k, 0)
|
||||
: udps_frame_value(frame, static_cast<std::uint32_t>(time_signal), 0, k);
|
||||
};
|
||||
switch (ts.time_mode) {
|
||||
case UDPS_TIME_FULL_ARRAY:
|
||||
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = value_of(k);
|
||||
used_stream_time = true;
|
||||
break;
|
||||
case UDPS_TIME_FIRST_SAMPLE: {
|
||||
const double anchor = value_of(0);
|
||||
const double step = n > 1 ? (value_of(n - 1) - anchor) / double(n - 1) : period;
|
||||
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = anchor + double(k) * step;
|
||||
used_stream_time = true;
|
||||
break;
|
||||
}
|
||||
case UDPS_TIME_LAST_SAMPLE: {
|
||||
const double anchor = value_of(n > 0 ? n - 1 : 0);
|
||||
for (std::uint32_t k = 0; k < n; ++k)
|
||||
stamps_[k] = anchor - double(n - 1 - k) * period;
|
||||
used_stream_time = true;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (used_stream_time && n > 0) {
|
||||
// A time signal that does not actually vary -- a vector time signal, whose
|
||||
// values the client reads one element at a time, or a producer that leaves it
|
||||
// zero -- would put every sample at one x and collapse the axis to a point.
|
||||
// The arrival estimate is a worse clock but a usable one; a degenerate one is
|
||||
// not a time base at all.
|
||||
bool usable = std::isfinite(stamps_[0]);
|
||||
double lo = stamps_[0];
|
||||
double hi = stamps_[0];
|
||||
for (std::uint32_t k = 1; usable && k < n; ++k) {
|
||||
if (!std::isfinite(stamps_[k])) {
|
||||
usable = false;
|
||||
break;
|
||||
}
|
||||
lo = std::min(lo, stamps_[k]);
|
||||
hi = std::max(hi, stamps_[k]);
|
||||
}
|
||||
if (usable && hi > lo) {
|
||||
// The stamps must also ADVANCE across packets. A producer that restarts its
|
||||
// stamps every packet (or a signal mislabelled as time) keeps handing back
|
||||
// the same values; the max-clamp would then pin every sample at one x, and
|
||||
// the trigger's hold-off clock with it -- the engine waited forever and no
|
||||
// second event ever fired.
|
||||
if (have_stream_anchor_ && !(stamps_[n - 1] > last_stream_anchor_)) {
|
||||
usable = false;
|
||||
} else {
|
||||
last_stream_anchor_ = stamps_[n - 1];
|
||||
have_stream_anchor_ = true;
|
||||
}
|
||||
}
|
||||
if (!usable || !(hi > lo)) {
|
||||
used_stream_time = false; // no span to time with; fall back to arrival
|
||||
static bool warned = false;
|
||||
if (!warned) {
|
||||
warned = true;
|
||||
std::fprintf(stderr,
|
||||
"[udpscope] warning: the stream's time signal carries no usable span "
|
||||
"(%g..%g) or does not advance across packets; timing samples by "
|
||||
"arrival instead. Check the time signal's mode and index in the "
|
||||
"CONFIG dump above.\n",
|
||||
lo, hi);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!used_stream_time) {
|
||||
// Arrival-anchored: the packet is assumed to have arrived as its last element
|
||||
// was produced, and the earlier ones are dated backwards. A gap (`lost`)
|
||||
// shows up as extra elapsed time between arrivals, which is exactly the
|
||||
// spacing the samples had.
|
||||
const double end = frame->recv_time;
|
||||
if (!have_arrival_epoch_) {
|
||||
arrival_epoch_ = end;
|
||||
have_arrival_epoch_ = true;
|
||||
}
|
||||
const double base = end - arrival_epoch_;
|
||||
double step = period;
|
||||
if (have_recv_ && n > 0) {
|
||||
const double elapsed = end - last_recv_;
|
||||
const double cycles = double(n) * double(frame->lost + 1);
|
||||
if (elapsed > 0.0 && cycles > 0.0) step = elapsed / cycles;
|
||||
}
|
||||
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = base - double(n - 1 - k) * step;
|
||||
}
|
||||
|
||||
// A stream-time signal may be declared in a time unit rather than seconds
|
||||
// ('ns' is the common one): scale it to seconds so the x axis is in seconds
|
||||
// either way. The scale applies ONLY to stream time: the arrival fallback
|
||||
// already produces seconds off the socket clock -- scaling those by the time
|
||||
// signal's unit shrank the whole time base by 1e9 on 'ns' streams, which
|
||||
// froze the trigger's hold-off clock and pinned the axis at ~0.
|
||||
if (used_stream_time && time_signal >= 0 &&
|
||||
time_signal < static_cast<int>(model_.signals().size())) {
|
||||
double unit_scale = 1.0;
|
||||
const std::string& unit = model_.signals()[static_cast<std::size_t>(time_signal)].unit;
|
||||
if (unit == "ns") unit_scale = 1e-9;
|
||||
else if (unit == "us" || unit == "\u00b5s" || unit == "\xce\xbc" "s") unit_scale = 1e-6;
|
||||
else if (unit == "ms") unit_scale = 1e-3;
|
||||
if (unit_scale != 1.0) {
|
||||
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] *= unit_scale;
|
||||
}
|
||||
}
|
||||
|
||||
last_recv_ = frame->recv_time;
|
||||
have_recv_ = true;
|
||||
return n;
|
||||
}
|
||||
|
||||
void Acquisition::on_data(const udps_frame_t* frame) {
|
||||
if (paused_.load(std::memory_order_relaxed)) return;
|
||||
|
||||
std::lock_guard<std::mutex> lock(model_.mutex());
|
||||
if (!running_.load(std::memory_order_relaxed)) return;
|
||||
|
||||
const Partition& part = model_.partition();
|
||||
const std::vector<ChannelInfo>& channels = model_.channels();
|
||||
if (part.time_capacity == 0 || channels.empty()) return;
|
||||
|
||||
// The time reference, in order of preference: the signal the first channel's
|
||||
// CONFIG entry names via `time_signal_idx`, or -- when the stream instead
|
||||
// carries its time base as an explicit full-array signal -- that signal.
|
||||
int time_signal = -1;
|
||||
{
|
||||
const SignalInfo& sig = model_.signals()[channels[0].signal];
|
||||
if (sig.time_signal_idx != 0xFFFFFFFFu && sig.time_signal_idx < model_.signals().size()) {
|
||||
time_signal = static_cast<int>(sig.time_signal_idx);
|
||||
}
|
||||
}
|
||||
if (time_signal < 0) {
|
||||
// No channel names a time signal: look for the standalone timestamp signal
|
||||
// (full-array, more than one element, no declared rate).
|
||||
for (std::size_t i = 0; i < model_.signals().size(); ++i) {
|
||||
const SignalInfo& sig = model_.signals()[i];
|
||||
if (sig.time_mode == UDPS_TIME_FULL_ARRAY && sig.elements() > 1 &&
|
||||
sig.sampling_rate == 0.0) {
|
||||
time_signal = static_cast<int>(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const std::uint32_t n = build_stamps(frame, time_signal);
|
||||
if (n == 0) return;
|
||||
|
||||
if (model_.epoch() == 0.0) {
|
||||
model_.set_epoch_if_unset(stamps_[0]);
|
||||
std::fprintf(stderr, "[udpscope] time base: epoch=%.3f, first frame recv=%.3f, n=%u\n",
|
||||
model_.epoch(), frame->recv_time, n);
|
||||
std::fflush(stderr);
|
||||
}
|
||||
const double epoch = model_.epoch();
|
||||
|
||||
TriggerConfig& tc = model_.trigger();
|
||||
// One ring, always filling, whatever the trigger is doing: the pre-trigger
|
||||
// history has to already be there when an edge arrives, and the first latch after
|
||||
// arming would otherwise open on however little had accumulated since. What
|
||||
// freezes in trigger mode is the *view*, not acquisition -- the latch copies the
|
||||
// event window out into an immutable `EventSnapshot`.
|
||||
BufferStore& store = model_.live();
|
||||
const u64 g = part.time_stride;
|
||||
|
||||
// Calibration is read per frame; it only changes under the same lock.
|
||||
TriggerEngine& engine = engine_;
|
||||
const int trig_channel = tc.channel;
|
||||
// The post-trigger window straight through in seconds. There used to be a measured,
|
||||
// smoothed samples-per-second estimate here, converting the window into a sample
|
||||
// count for the engine; the estimate is what sized captures wrong. Its floor
|
||||
// (`inst >= 1.0`) turned any stream whose time signal advances in large steps into
|
||||
// 1 S/s, the count came out as 1, and the engine latched on the edge sample itself
|
||||
// -- an event with no post-trigger data, which is exactly the "post window is
|
||||
// empty" defect. The engine is fed stream time per sample anyway, so it can count
|
||||
// the window itself and no conversion has to exist.
|
||||
//
|
||||
// The oldest buffered stamp goes with it, so the engine can refuse an edge the
|
||||
// history does not reach back far enough behind. Read from the ring rather than
|
||||
// timed from the arm: the ring fills with the trigger off too, so the pre window is
|
||||
// usually already there and a timer would delay the first event for nothing.
|
||||
const double event_span = model_.event_span();
|
||||
const double post_want = tc.post_span_s(event_span);
|
||||
const double oldest_s =
|
||||
store.time().size() > 0 ? double(store.time()[0]) : last_stamp_;
|
||||
engine.configure(tc, post_want, event_span - post_want, oldest_s);
|
||||
// The engine's armed state follows the panel's, both ways: without the disarm
|
||||
// half, Disarm left the engine firing (its own `armed_` was only ever set), and
|
||||
// re-entering trigger mode kept the stale prev/edge/hold-off state.
|
||||
if (tc.enabled && tc.armed) {
|
||||
if (!engine.armed()) {
|
||||
engine.arm(last_stamp_);
|
||||
model_.trigger_armed().store(true, std::memory_order_relaxed);
|
||||
}
|
||||
} else if (engine.armed()) {
|
||||
engine.disarm();
|
||||
model_.trigger_armed().store(false, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
double last_stamp_used = last_stamp_;
|
||||
|
||||
// The latch is handled on the sample it happens on, and with the edge the engine
|
||||
// latched *on* -- after that sample's channels have all been pushed (see the loop).
|
||||
//
|
||||
// It used to set a flag and publish once after the loop, reading the edge back from
|
||||
// the engine. But the loop keeps feeding the engine after a latch, so any further
|
||||
// edge in the rest of the packet -- the hold-off is 10 ms and a packet can be a long
|
||||
// block of samples -- overwrote that edge before it was read. The capture was then
|
||||
// cut around an edge whose post window had not been collected yet, so the event came
|
||||
// out all pre-trigger with the marker and x = 0 at the right-hand end of the data.
|
||||
// Publishing here also means the ring provably holds the post window: the latch
|
||||
// condition is that stream time has passed `edge + post`, and every sample up to
|
||||
// this one has already been pushed.
|
||||
const double span = std::max(1.0e-9, model_.event_span());
|
||||
const double post_s = tc.post_span_s(span);
|
||||
const auto latch = [&](double edge) {
|
||||
model_.publish_event(span - post_s, post_s, edge);
|
||||
model_.trigger_latches().fetch_add(1, std::memory_order_relaxed);
|
||||
model_.trigger_time().store(edge, std::memory_order_relaxed);
|
||||
model_.trigger_time_valid().store(true, std::memory_order_relaxed);
|
||||
// The captured window is reported from the snapshot itself, not from the request:
|
||||
// `want` versus `got` is what distinguishes "the engine did not wait" from "the
|
||||
// cut lost the data", which is otherwise guesswork from the picture alone.
|
||||
auto snap = model_.event();
|
||||
std::fprintf(stderr,
|
||||
"[udpscope] trigger: edge at %.3f s, latched at %.3f s of stream time "
|
||||
"(want [-%.4f..%.4f] got [-%.4f..%.4f] n=%llu, frame recv=%.3f, "
|
||||
"ticks_so_far=%llu, wall=%.3f s)\n",
|
||||
edge, last_stamp_, span - post_s, post_s, snap ? snap->pre_s : 0.0,
|
||||
snap ? snap->post_s : 0.0,
|
||||
static_cast<unsigned long long>(snap ? snap->stamps.size() : 0),
|
||||
frame->recv_time, static_cast<unsigned long long>(tick_),
|
||||
std::chrono::duration<double>(std::chrono::steady_clock::now() - started_)
|
||||
.count());
|
||||
std::fflush(stderr);
|
||||
if (tc.mode == TriggerMode::Single) tc.armed = false;
|
||||
};
|
||||
|
||||
for (std::uint32_t k = 0; k < n; ++k) {
|
||||
const u64 tick = tick_++;
|
||||
const double rel = std::max(stamps_[k] - epoch, last_stamp_);
|
||||
last_stamp_used = rel;
|
||||
last_stamp_ = rel;
|
||||
|
||||
if (tick % g == g - 1) store.time().push(static_cast<f32>(rel));
|
||||
|
||||
// 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
|
||||
// `produced()`, and the channels *after* the trigger channel have not pushed this
|
||||
// sample yet, so the newest sample -- the one the post window was waiting for --
|
||||
// would be clamped away. One sample is invisible in a 20 000-sample capture (it is
|
||||
// the `n=19999` and the `0.9999` instead of `1.0000` in the probe's output), and at
|
||||
// one sample per second it is the entire post side: `got [-0.9997..0.0000]`, which
|
||||
// reads exactly like the "post window is empty" defect.
|
||||
bool latch_here = false;
|
||||
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
|
||||
// the cycle's sample at element `k`. Either way element 0 is the channel.
|
||||
const double raw =
|
||||
ch.elements > 1
|
||||
? 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);
|
||||
store.values(i).push(value);
|
||||
if (static_cast<int>(i) == trig_channel) {
|
||||
if (engine.process(value, rel)) {
|
||||
latch_here = true;
|
||||
latch_edge = engine.last_trigger_time();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (latch_here) latch(latch_edge);
|
||||
}
|
||||
last_stamp_ = last_stamp_used;
|
||||
|
||||
model_.trigger_armed().store(engine.armed(), std::memory_order_relaxed);
|
||||
model_.set_trigger_pending(engine.pending());
|
||||
static std::int64_t next_engine_log = 0;
|
||||
const std::int64_t now_e = static_cast<std::int64_t>(frame->recv_time * 1.0e6);
|
||||
if (now_e >= next_engine_log) {
|
||||
next_engine_log = now_e + 1000000;
|
||||
// `elapsed/post_want` is the capture's progress in the only unit that matters now
|
||||
// (stream seconds); `accum` is how many samples that window has collected, which
|
||||
// is what says whether a capture is thin because the window is short or because
|
||||
// the stream is slow.
|
||||
std::fprintf(stderr,
|
||||
"[udpscope] trig engine: ch=%d armed=%d pending=%d elapsed=%.4f/%.4f s "
|
||||
"accum=%llu history=%.4f/%.4f s pre_pct=%.0f v=%.4g now_t=%.3f "
|
||||
"holdoff_until=%.3f\n",
|
||||
trig_channel, int(engine.armed()), int(engine.pending()),
|
||||
engine.post_elapsed(), engine.post_seconds(),
|
||||
static_cast<unsigned long long>(engine.post_accum()), engine.history(),
|
||||
engine.pre_seconds(), tc.pretrigger_pct, engine.last_value(),
|
||||
engine.last_t(), engine.holdoff_until());
|
||||
std::fflush(stderr);
|
||||
}
|
||||
}
|
||||
|
||||
// Static trampolines: the client's C ABI cannot call a member function directly.
|
||||
void Acquisition::config_trampoline(const udps_signal_t* signals, std::uint32_t count,
|
||||
std::uint8_t publish_mode, void* user) {
|
||||
static_cast<Acquisition*>(user)->on_config(signals, count, publish_mode);
|
||||
}
|
||||
|
||||
void Acquisition::data_trampoline(const udps_frame_t* frame, void* user) {
|
||||
static_cast<Acquisition*>(user)->on_data(frame);
|
||||
}
|
||||
|
||||
void Acquisition::event_trampoline(udps_event_t event, const char* detail, void* user) {
|
||||
static_cast<Acquisition*>(user)->on_event(static_cast<int>(event), detail);
|
||||
}
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -0,0 +1,83 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "config.hpp"
|
||||
#include "model.hpp"
|
||||
#include "trigger.hpp"
|
||||
|
||||
#include <udps_client.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace udpscope {
|
||||
|
||||
/// Owns the UDPStreamer client and the producer thread that feeds the model's
|
||||
/// acquisition buffer. Everything it touches from the thread is serialised on
|
||||
/// `ScopeModel::mutex()`, so a UI-side structural change cannot race a frame.
|
||||
class Acquisition {
|
||||
public:
|
||||
Acquisition(ScopeModel& model, const Config& cfg);
|
||||
~Acquisition();
|
||||
|
||||
Acquisition(const Acquisition&) = delete;
|
||||
Acquisition& operator=(const Acquisition&) = delete;
|
||||
|
||||
/// Opens the client (no socket until the first poll) and starts the thread.
|
||||
/// Throws std::runtime_error when the client cannot be created.
|
||||
void start();
|
||||
/// Stops the thread and destroys the client. Idempotent.
|
||||
void stop();
|
||||
|
||||
void set_paused(bool paused) noexcept { paused_.store(paused, std::memory_order_relaxed); }
|
||||
bool paused() const noexcept { return paused_.load(std::memory_order_relaxed); }
|
||||
|
||||
private:
|
||||
void run();
|
||||
void on_config(const udps_signal_t* signals, std::uint32_t count, std::uint8_t publish_mode);
|
||||
void on_data(const udps_frame_t* frame);
|
||||
void on_event(int event, const char* detail);
|
||||
|
||||
static void config_trampoline(const udps_signal_t* signals, std::uint32_t count,
|
||||
std::uint8_t publish_mode, void* user);
|
||||
static void data_trampoline(const udps_frame_t* frame, void* user);
|
||||
static void event_trampoline(udps_event_t event, const char* detail, void* user);
|
||||
|
||||
/// Fills `stamps_` with this frame's base-tick timestamps, in stream seconds,
|
||||
/// and returns how many were written (`frame->num_samples` unless the frame is
|
||||
/// malformed).
|
||||
std::uint32_t build_stamps(const udps_frame_t* frame, int time_signal);
|
||||
|
||||
ScopeModel& model_;
|
||||
Config cfg_;
|
||||
udps_client* client_ = nullptr;
|
||||
std::thread thread_;
|
||||
std::atomic<bool> running_{false};
|
||||
std::atomic<bool> paused_{false};
|
||||
TriggerEngine engine_;
|
||||
|
||||
std::uint64_t tick_ = 0; ///< base ticks consumed since the epoch
|
||||
std::uint64_t last_tick_ = ~std::uint64_t{0};
|
||||
double last_stamp_ = 0.0; ///< last relative time written (monotonic guard)
|
||||
double last_recv_ = 0.0;
|
||||
bool have_recv_ = false;
|
||||
/// Last accepted stream-time stamp: a "time signal" whose values do not advance
|
||||
/// across packets (a producer that restarts them per packet, or a mislabelled
|
||||
/// signal) would freeze the whole time base -- the trigger's hold-off clock
|
||||
/// included -- so the stream source is dropped in favour of arrival time.
|
||||
double last_stream_anchor_ = 0.0;
|
||||
bool have_stream_anchor_ = false;
|
||||
/// Arrival-clock origin: the first packet's receive time, so arrival stamps are
|
||||
/// small seconds (the time ring stores f32, which cannot hold Unix epochs).
|
||||
double arrival_epoch_ = 0.0;
|
||||
bool have_arrival_epoch_ = false;
|
||||
std::vector<double> stamps_;
|
||||
std::chrono::steady_clock::time_point started_{};
|
||||
};
|
||||
|
||||
} // namespace udpscope
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include "channel.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace udpscope {
|
||||
namespace {
|
||||
|
||||
/// Raw samples read per chunk on the gather path, so it never allocates.
|
||||
constexpr u64 kChunk = 256;
|
||||
|
||||
/// The pyramid handed out for a slot with nothing bound to it. The renderer never
|
||||
/// asks an empty series a bucket question, but the reference must be valid.
|
||||
const plot::data::MinMaxPyramid& null_pyramid() {
|
||||
static const plot::data::MinMaxPyramid empty;
|
||||
return empty;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// --- ChannelBuffer ----------------------------------------------------------
|
||||
|
||||
ChannelBuffer::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)
|
||||
: values_(std::move(values)), times_(std::move(times)), stride_(stride < 1 ? 1 : stride),
|
||||
epoch_(epoch), dt_(nominal_dt) {}
|
||||
|
||||
void ChannelBuffer::acquire() noexcept {
|
||||
const u64 produced = values_->produced();
|
||||
if (produced <= published_) {
|
||||
// Nothing new; still allow the window to slide if another reader dropped it.
|
||||
return;
|
||||
}
|
||||
// A producer that lapped this ring -- a long trigger wait, or a store that was
|
||||
// not being published for a while -- makes the incremental append meaningless:
|
||||
// the samples between `published_` and `produced_` are no longer in the ring.
|
||||
// Rebuilding from the retained window is O(capacity) once, and it is the only
|
||||
// way the published window stays coherent (an empty or garbled window is what
|
||||
// "the trace disappeared" looks like from outside).
|
||||
if (produced - published_ > values_->capacity()) {
|
||||
published_ = 0;
|
||||
pyramid_.clear();
|
||||
}
|
||||
f32 chunk[kChunk];
|
||||
for (u64 i = published_; i < produced;) {
|
||||
const u64 take = std::min<u64>(kChunk, produced - i);
|
||||
values_->copy_from(i, take, chunk);
|
||||
for (u64 k = 0; k < take; ++k) pyramid_.append(chunk[k]);
|
||||
i += take;
|
||||
}
|
||||
published_ = produced;
|
||||
// The pyramid's window follows the ring's: sample index `base` is the oldest
|
||||
// one still retained. drop_before() is monotonic and cheap.
|
||||
pyramid_.drop_before(values_->base());
|
||||
}
|
||||
|
||||
void ChannelBuffer::invalidate() noexcept {
|
||||
published_ = 0;
|
||||
pyramid_.clear();
|
||||
}
|
||||
|
||||
f32 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
|
||||
// Channel sample `j` is produced on the tick that also pushes time slot
|
||||
// `j*stride + stride - 1` (see `Acquisition::on_data`): the producer stamps the
|
||||
// time ring every `time_stride` ticks and the channel every `stride` of those,
|
||||
// so the last tick of the group is the one that carries the channel's sample.
|
||||
const u64 index = absolute_index * stride_ + (stride_ - 1);
|
||||
// The time ring is circular, so an index stays readable while it is *retained*
|
||||
// and `copy_from()` masks it. Testing it against `capacity()` would reject every
|
||||
// index past the first wrap -- which is every sample once the ring has filled,
|
||||
// and would silently stamp them all with x = 0.
|
||||
//
|
||||
// An index the ring has already dropped is **clamped to the oldest readable slot,
|
||||
// never answered with a sentinel**. The published window is pinned by `acquire()`
|
||||
// to the value ring's base, and both rings keep sliding afterwards (their
|
||||
// capacities are equal, so there is no slack at all): once the ring saturates, the
|
||||
// few oldest samples of the window routinely point at slots the producer has
|
||||
// overwritten since. Returning 0 for those put a stamp of 0 into `x_bounds()`, and
|
||||
// from there into `dx()` and `x0()` -- which is the renderer's entire index
|
||||
// mapping, `(x - x0()) / dx()`. So *one* unreadable slot at the front inflated
|
||||
// `dx()` by the ratio of the whole elapsed stream time to the retained window and
|
||||
// pushed every index up with it: `first` landed well past the first visible
|
||||
// sample, and the samples in between were in the buffer, readable by the crosshair
|
||||
// (`x_at()`/`neighbours()` go to the ring per sample and never saw the sentinel),
|
||||
// and never drawn. That is the "live trace is clipped, only the most recent part
|
||||
// is visible, but the crosshair still reads values there" defect.
|
||||
//
|
||||
// Clamping degrades the way the library's own `StreamSeries` documents for this
|
||||
// contract: a snapshot bounds the sample *count*, not the slots, so the oldest few
|
||||
// samples of a saturated window may read a newer value than the window names. One
|
||||
// frame's worth of samples at the very left edge is then drawn on a short plateau
|
||||
// of equal stamps -- monotone, so the binary searches still hold -- instead of the
|
||||
// whole trace's geometry collapsing.
|
||||
const u64 base = times_->base();
|
||||
const u64 produced = times_->produced();
|
||||
if (produced == 0) return 0.0f;
|
||||
u64 slot = index < base ? base : index;
|
||||
if (slot >= produced) slot = produced - 1;
|
||||
f32 out = 0.0f;
|
||||
times_->copy_from(slot, 1, &out);
|
||||
return out;
|
||||
}
|
||||
|
||||
f64 ChannelBuffer::dx() const {
|
||||
const u64 n = pyramid_.size();
|
||||
if (n < 2) return dt_ > 0.0 ? dt_ : 1.0;
|
||||
const plot::Range b = x_bounds();
|
||||
const double span = b.max - b.min;
|
||||
if (!(span > 0.0) || !std::isfinite(span)) return dt_ > 0.0 ? dt_ : 1.0;
|
||||
return span / f64(n - 1);
|
||||
}
|
||||
|
||||
f64 ChannelBuffer::x0() const {
|
||||
if (pyramid_.size() == 0) return epoch_;
|
||||
return epoch_ + f64(time_of(absolute(0)));
|
||||
}
|
||||
|
||||
f64 ChannelBuffer::x_at(u64 i) const {
|
||||
if (i >= pyramid_.size()) return x0();
|
||||
return epoch_ + f64(time_of(absolute(i)));
|
||||
}
|
||||
|
||||
bool ChannelBuffer::neighbours(f64 x, u64& i0, u64& i1) const {
|
||||
const u64 n = pyramid_.size();
|
||||
if (n == 0) return false;
|
||||
if (x < x_at(0) || x > x_at(n - 1)) return false;
|
||||
// The time ring is monotonic, so the bracketing pair is a binary search rather
|
||||
// than a grid walk: after a lost packet the two samples either side of `x` are
|
||||
// not `x`/`dx` apart, and a grid would name the wrong pair.
|
||||
u64 lo = 0;
|
||||
u64 hi = n - 1;
|
||||
while (lo < hi) {
|
||||
const u64 mid = lo + (hi - lo) / 2;
|
||||
if (x_at(mid) < x) {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
i1 = lo;
|
||||
i0 = lo;
|
||||
if (x_at(lo) > x && lo > 0) i0 = lo - 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
u64 ChannelBuffer::snap_index(f64 x) const {
|
||||
u64 i0 = 0;
|
||||
u64 i1 = 0;
|
||||
if (!neighbours(x, i0, i1)) {
|
||||
if (pyramid_.size() == 0) return 0;
|
||||
return x < x_at(0) ? 0 : pyramid_.size() - 1;
|
||||
}
|
||||
if (i0 == i1) return i0;
|
||||
return (x - x_at(i0)) <= (x_at(i1) - x) ? i0 : i1;
|
||||
}
|
||||
|
||||
plot::Range ChannelBuffer::x_bounds() const {
|
||||
const u64 n = pyramid_.size();
|
||||
if (n == 0) return plot::Range{epoch_, epoch_};
|
||||
return plot::Range{epoch_ + f64(time_of(absolute(0))),
|
||||
epoch_ + f64(time_of(absolute(n - 1)))};
|
||||
}
|
||||
|
||||
plot::Range ChannelBuffer::y_bounds() const {
|
||||
const u64 n = pyramid_.size();
|
||||
if (n == 0) return plot::Range{0.0, 0.0};
|
||||
return reduce(0, n);
|
||||
}
|
||||
|
||||
void ChannelBuffer::raw(u64 begin, u64 end, f32* out) const {
|
||||
const u64 n = pyramid_.size();
|
||||
const u64 b = std::min(begin, n);
|
||||
const u64 e = std::min(end, n);
|
||||
const u64 count = e > b ? e - b : 0;
|
||||
if (count > 0) values_->copy_from(absolute(b), count, out);
|
||||
for (u64 i = count; i < end - begin; ++i) out[i] = 0.0f;
|
||||
}
|
||||
|
||||
void ChannelBuffer::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
plot::data::GatherMode mode) const {
|
||||
const u64 n = pyramid_.size();
|
||||
const u64 b = std::min(begin, n);
|
||||
const u64 e = std::min(std::max(end, b), n);
|
||||
if (b >= e) return;
|
||||
|
||||
const u64 origin = pyramid_.window_base();
|
||||
const f64 base = x0(); // x is emitted relative to this (see the header)
|
||||
|
||||
const auto emit_raw = [&](u64 from, u64 to) {
|
||||
for (u64 i = from; i < to; ++i) {
|
||||
f32 y = 0.0f;
|
||||
raw(i, i + 1, &y);
|
||||
out_xy.push_back(static_cast<f32>(x_at(i) - base));
|
||||
out_xy.push_back(y);
|
||||
}
|
||||
};
|
||||
const auto rel = [&](u64 i) { return static_cast<f32>(x_at(i) - base); };
|
||||
|
||||
if (level == 0) {
|
||||
emit_raw(b, e);
|
||||
return;
|
||||
}
|
||||
level = std::min(level, plot::data::kMaxPyramidLevel);
|
||||
|
||||
// One geometry element per *complete* bucket wholly inside the range. `first` is
|
||||
// a ceiling and `last` a floor, so no aggregate can reach outside [b, e) -- a
|
||||
// floor on `first` or a `last` that includes the bucket holding `e-1` would emit
|
||||
// aggregates covering samples the caller did not ask for (the library's B3 fix,
|
||||
// which this mirrors). The spans left at the two edges are drawn from raw
|
||||
// samples, so the geometry still covers the range exactly and a partial bucket is
|
||||
// drawn once, not twice.
|
||||
const u64 span = u64{plot::data::kBucket} << level;
|
||||
const u64 lbase = pyramid_.level_base(level);
|
||||
const u64 lend = lbase + pyramid_.level_size(level);
|
||||
const u64 ceil_div = (origin + b) / span + ((origin + b) % span == 0 ? u64{0} : u64{1});
|
||||
const u64 first = std::max(ceil_div, lbase);
|
||||
const u64 last = std::min((origin + e) / span, lend);
|
||||
if (first >= last) {
|
||||
emit_raw(b, e);
|
||||
return;
|
||||
}
|
||||
const u64 lead = first * span - origin;
|
||||
if (b < lead) emit_raw(b, std::min(lead, e));
|
||||
|
||||
for (u64 bi = first; bi < last; ++bi) {
|
||||
const u32 count = pyramid_.bucket_count(level, bi);
|
||||
const u64 fs = bi * span - origin; // >= 0: a stored bucket starts inside the window
|
||||
switch (mode) {
|
||||
case plot::data::GatherMode::Nearest: {
|
||||
const u64 centre = bi * span + span / 2 - origin;
|
||||
f32 y = 0.0f;
|
||||
raw(centre, centre + 1, &y);
|
||||
out_xy.push_back(rel(centre));
|
||||
out_xy.push_back(y);
|
||||
break;
|
||||
}
|
||||
case plot::data::GatherMode::Mean: {
|
||||
if (count == 0) break; // no finite sample: no mean to draw
|
||||
const u64 centre = bi * span + span / 2 - origin;
|
||||
out_xy.push_back(rel(centre));
|
||||
out_xy.push_back(static_cast<f32>(pyramid_.bucket_sum(level, bi) / f64(count)));
|
||||
break;
|
||||
}
|
||||
default: { // Envelope
|
||||
if (count == 0) break;
|
||||
const plot::data::MinMax m = pyramid_.bucket(level, bi);
|
||||
const u64 ls = std::min((bi + 1) * span, origin + e) - 1 - origin;
|
||||
out_xy.push_back(rel(fs));
|
||||
out_xy.push_back(m.min);
|
||||
out_xy.push_back(rel(ls));
|
||||
out_xy.push_back(m.max);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const u64 trail = last * span - origin;
|
||||
if (trail < e) emit_raw(trail, e);
|
||||
}
|
||||
|
||||
// --- SlotSeries -------------------------------------------------------------
|
||||
|
||||
plot::Range SlotSeries::x_bounds() const {
|
||||
if (event_ != nullptr) return event_->x_bounds();
|
||||
return buffer_ != nullptr ? buffer_->x_bounds() : plot::Range{0.0, 1.0};
|
||||
}
|
||||
|
||||
plot::Range SlotSeries::y_bounds() const {
|
||||
if (event_ != nullptr) return event_->y_bounds();
|
||||
return buffer_ != nullptr ? buffer_->y_bounds() : plot::Range{0.0, 0.0};
|
||||
}
|
||||
|
||||
const plot::data::MinMaxPyramid& SlotSeries::pyramid() const {
|
||||
if (event_ != nullptr) return event_->pyramid();
|
||||
return buffer_ != nullptr ? buffer_->pyramid() : null_pyramid();
|
||||
}
|
||||
|
||||
f64 SlotSeries::x_at(u64 i) const {
|
||||
if (event_ != nullptr) return event_->x_at(i);
|
||||
return buffer_ != nullptr ? buffer_->x_at(i) : 0.0;
|
||||
}
|
||||
|
||||
bool SlotSeries::neighbours(f64 x, u64& i0, u64& i1) const {
|
||||
if (event_ != nullptr) return event_->neighbours(x, i0, i1);
|
||||
return buffer_ != nullptr && buffer_->neighbours(x, i0, i1);
|
||||
}
|
||||
|
||||
u64 SlotSeries::snap_index(f64 x) const {
|
||||
if (event_ != nullptr) return event_->snap_index(x);
|
||||
return buffer_ != nullptr ? buffer_->snap_index(x) : 0;
|
||||
}
|
||||
|
||||
void SlotSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
plot::data::GatherMode mode) const {
|
||||
if (event_ != nullptr) {
|
||||
event_->gather(level, begin, end, out_xy, mode);
|
||||
return;
|
||||
}
|
||||
if (buffer_ != nullptr) buffer_->gather(level, begin, end, out_xy, mode);
|
||||
}
|
||||
|
||||
void SlotSeries::copy_raw(u64 begin, u64 end, f32* out) const {
|
||||
if (event_ != nullptr) {
|
||||
event_->raw_values(begin, end, out);
|
||||
return;
|
||||
}
|
||||
if (buffer_ != nullptr) {
|
||||
buffer_->raw(begin, end, out);
|
||||
return;
|
||||
}
|
||||
for (u64 i = 0; i < end - begin; ++i) out[i] = 0.0f;
|
||||
}
|
||||
|
||||
} // namespace udpscope
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
// 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()` and `x_bounds()` 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. The renderer converts a visible x
|
||||
/// range into an index range with `(x - x0()) / dx()`, so this must describe the
|
||||
/// samples that are actually retained: reporting the nominal `stride * dt` made
|
||||
/// that index range exceed the series by the decimation factor, the gather
|
||||
/// clamped to nothing, and the trace drew empty -- which is exactly what a fast
|
||||
/// channel looked like next to a slow one, because only the slow one is
|
||||
/// recognisable from the few samples a wrong range still happens to cover.
|
||||
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;
|
||||
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
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include "config.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace udpscope {
|
||||
namespace {
|
||||
|
||||
[[noreturn]] void fail(const std::string& what) { throw std::runtime_error(what); }
|
||||
|
||||
/// `--name=value` / `--name value`; returns the value and advances `i` past both
|
||||
/// spellings. `argv[i]` must start with `--name`.
|
||||
std::string value_of(int argc, char** argv, int& i, const char* name) {
|
||||
const std::string arg = argv[i];
|
||||
const std::string prefix = std::string(name) + "=";
|
||||
if (arg.rfind(prefix, 0) == 0) return arg.substr(prefix.size());
|
||||
if (i + 1 >= argc) fail(std::string("missing value for ") + name);
|
||||
return argv[++i];
|
||||
}
|
||||
|
||||
double parse_double(const char* name, const std::string& v) {
|
||||
try {
|
||||
std::size_t used = 0;
|
||||
const double d = std::stod(v, &used);
|
||||
if (used != v.size()) fail(std::string("not a number: ") + name + " " + v);
|
||||
return d;
|
||||
} catch (const std::exception&) {
|
||||
fail(std::string("not a number: ") + name + " " + v);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void print_usage(std::FILE* out, const char* argv0) {
|
||||
std::fprintf(out,
|
||||
"usage: %s --server ADDR --port PORT [options]\n"
|
||||
"\n"
|
||||
" --server ADDR stream source IPv4 address (default 127.0.0.1)\n"
|
||||
" --port PORT UDP port (unicast) or TCP port (multicast) (default 8000)\n"
|
||||
" --multicast GROUP join GROUP for DATA instead of unicast\n"
|
||||
" --interface ADDR local IPv4 of the interface to join on\n"
|
||||
" --buffer-mb MB size of each of the two sample buffers (default 200)\n"
|
||||
" --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"
|
||||
" -h, --help this text\n",
|
||||
argv0);
|
||||
}
|
||||
|
||||
Config parse_args(int argc, char** argv) {
|
||||
Config c;
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
const std::string a = argv[i];
|
||||
if (a == "-h" || a == "--help") {
|
||||
print_usage(stdout, argv[0]);
|
||||
std::exit(0);
|
||||
} else if (a == "--server" || a.rfind("--server=", 0) == 0) {
|
||||
c.server = value_of(argc, argv, i, "--server");
|
||||
} else if (a == "--port" || a.rfind("--port=", 0) == 0) {
|
||||
const std::string v = value_of(argc, argv, i, "--port");
|
||||
const long p = std::strtol(v.c_str(), nullptr, 10);
|
||||
if (p <= 0 || p > 65535) fail("port out of range: " + v);
|
||||
c.port = static_cast<std::uint16_t>(p);
|
||||
} else if (a == "--multicast" || a.rfind("--multicast=", 0) == 0) {
|
||||
c.multicast = value_of(argc, argv, i, "--multicast");
|
||||
} else if (a == "--interface" || a.rfind("--interface=", 0) == 0) {
|
||||
c.interface_addr = value_of(argc, argv, i, "--interface");
|
||||
} else if (a == "--buffer-mb" || a.rfind("--buffer-mb=", 0) == 0) {
|
||||
c.buffer_mb = parse_double("--buffer-mb", value_of(argc, argv, i, "--buffer-mb"));
|
||||
if (c.buffer_mb < 1.0) fail("--buffer-mb must be at least 1");
|
||||
} else if (a == "--max-window" || a.rfind("--max-window=", 0) == 0) {
|
||||
c.max_window_s = parse_double("--max-window", value_of(argc, argv, i, "--max-window"));
|
||||
if (c.max_window_s <= 0.0) fail("--max-window must be positive");
|
||||
} else if (a == "--holdoff" || a.rfind("--holdoff=", 0) == 0) {
|
||||
c.trigger_holdoff_s = parse_double("--holdoff", value_of(argc, argv, i, "--holdoff"));
|
||||
if (c.trigger_holdoff_s < 0.0) fail("--holdoff must not be negative");
|
||||
} else if (a == "--recv-buffer" || a.rfind("--recv-buffer=", 0) == 0) {
|
||||
const std::string v = value_of(argc, argv, i, "--recv-buffer");
|
||||
const long n = std::strtol(v.c_str(), nullptr, 10);
|
||||
if (n <= 0) fail("--recv-buffer must be positive: " + v);
|
||||
c.recv_buffer_bytes = static_cast<int>(n);
|
||||
} 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);
|
||||
c.plots = static_cast<int>(n);
|
||||
} else {
|
||||
fail("unknown argument: " + a);
|
||||
}
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -0,0 +1,41 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace udpscope {
|
||||
|
||||
/// Everything the command line can say. Defaults are the ones the spec names
|
||||
/// (200 MB per buffer, 10 ms trigger hold-off) unless the user overrides them.
|
||||
struct Config {
|
||||
std::string server = "127.0.0.1"; ///< IPv4 dotted quad of the stream source.
|
||||
std::uint16_t port = 8000; ///< UDP port (unicast) or TCP port (multicast).
|
||||
std::string multicast; ///< Empty selects unicast.
|
||||
std::string interface_addr; ///< Local IPv4 to join the group on; empty = default route.
|
||||
|
||||
/// Size of *each* of the two sample buffers, in megabytes. The application
|
||||
/// always allocates two identical buffers (spec item 1).
|
||||
double buffer_mb = 200.0;
|
||||
|
||||
/// Largest time window the toolbar offers, in seconds. The buffer partition is
|
||||
/// sized to hold at least this much time (spec: decimation).
|
||||
double max_window_s = 600.0;
|
||||
|
||||
double trigger_holdoff_s = 0.010;
|
||||
int recv_buffer_bytes = 8 * 1024 * 1024;
|
||||
|
||||
/// Number of stacked plots (rows) to start with, 1..4.
|
||||
int plots = 1;
|
||||
};
|
||||
|
||||
/// Parses argv. On `--help` prints usage to stdout and exits(0); on a bad
|
||||
/// argument prints a diagnostic to stderr and exits(2).
|
||||
Config parse_args(int argc, char** argv);
|
||||
|
||||
/// Writes the usage text to `out`.
|
||||
void print_usage(std::FILE* out, const char* argv0);
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -0,0 +1,141 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
#pragma once
|
||||
|
||||
#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 {
|
||||
std::vector<f32> stamps; ///< edge-relative seconds, monotonic
|
||||
std::vector<std::vector<f32>> values; ///< one array per channel, same length
|
||||
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
|
||||
};
|
||||
|
||||
/// 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};
|
||||
}
|
||||
const plot::data::MinMaxPyramid& pyramid() const override { return empty_pyramid_; }
|
||||
bool streaming() const noexcept override { return false; }
|
||||
|
||||
void gather(u32 level, u64 begin, u64 end, 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 stride = u64{1} << level;
|
||||
if (stride == 0 || begin >= n) return;
|
||||
if (end > n) end = n;
|
||||
// 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
|
||||
// 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 = 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)]);
|
||||
}
|
||||
}
|
||||
|
||||
bool neighbours(f64 x, u64& i0, u64& i1) const override {
|
||||
const u64 n = size();
|
||||
if (n < 2) return false;
|
||||
const f64 step = dx();
|
||||
if (!(step > 0.0)) return false;
|
||||
f64 idx = (x - x0()) / step;
|
||||
if (idx < 0.0) idx = 0.0;
|
||||
if (idx > static_cast<f64>(n - 1)) idx = static_cast<f64>(n - 1);
|
||||
i0 = static_cast<u64>(idx);
|
||||
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
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include "export.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <bit>
|
||||
#include <cerrno>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
namespace udpscope {
|
||||
namespace {
|
||||
|
||||
// The writers memcpy `f64`/`f32` straight out and label them `<f8`/`<f4`, so a
|
||||
// big-endian host would produce a file whose bytes contradict its own dtype. There is no
|
||||
// such host in this app's reach and a byte-swapping path nobody runs is a path nobody
|
||||
// tests, so the assumption is stated instead of handled.
|
||||
static_assert(std::endian::native == std::endian::little,
|
||||
"the .npy writer labels its fields little-endian and does not swap");
|
||||
|
||||
/// A column label a reader can use: NumPy rejects duplicate field names outright, and a
|
||||
/// quote or a brace in one would break the header dict it is spelled into. Signal names
|
||||
/// come from the stream, so neither is this app's to promise.
|
||||
std::string sanitise(const std::string& name) {
|
||||
std::string out;
|
||||
out.reserve(name.size());
|
||||
for (const char ch : name) {
|
||||
const bool ok = (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') ||
|
||||
(ch >= '0' && ch <= '9') || ch == '_';
|
||||
out.push_back(ok ? ch : '_');
|
||||
}
|
||||
if (out.empty()) out = "col";
|
||||
return out;
|
||||
}
|
||||
|
||||
/// The final labels: sanitised, and de-duplicated against each other *and* against the
|
||||
/// two time columns, with a `_2`, `_3` suffix. Two elements of one vector signal can
|
||||
/// easily sanitise to the same string.
|
||||
std::vector<std::string> column_labels(const ExportTable& table) {
|
||||
std::vector<std::string> taken{"time", "stream_time"};
|
||||
std::vector<std::string> out;
|
||||
out.reserve(table.columns.size());
|
||||
for (const ExportTable::Column& col : table.columns) {
|
||||
const std::string base = sanitise(col.name);
|
||||
std::string label = base;
|
||||
for (int n = 2; std::find(taken.begin(), taken.end(), label) != taken.end(); ++n) {
|
||||
label = base + "_" + std::to_string(n);
|
||||
}
|
||||
taken.push_back(label);
|
||||
out.push_back(std::move(label));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
struct File {
|
||||
explicit File(const std::string& path) : f(std::fopen(path.c_str(), "wb")) {}
|
||||
~File() {
|
||||
if (f != nullptr) std::fclose(f);
|
||||
}
|
||||
File(const File&) = delete;
|
||||
File& operator=(const File&) = delete;
|
||||
std::FILE* f = nullptr;
|
||||
};
|
||||
|
||||
bool opened(const File& out, const std::string& path, std::string& error) {
|
||||
if (out.f != nullptr) return true;
|
||||
error = "cannot write '" + path + "': " + std::strerror(errno);
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
||||
ExportTable table;
|
||||
const RowSet::Row& r0 = rows.row(0);
|
||||
if (r0.plot == nullptr) return table;
|
||||
const plot::Range view = r0.plot->x_range(r0.axis_x);
|
||||
|
||||
// Which channels: every bound slot on a visible row, de-duplicated, then sorted. A
|
||||
// signal dropped on two rows is one column, and channel order is the signal list's
|
||||
// order, so the columns read in the same order as the panel.
|
||||
std::vector<int> chans;
|
||||
for (int i = 0; i < rows.row_count(); ++i) {
|
||||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||||
const int c = rows.channel_of(i, s);
|
||||
if (c >= 0 && std::find(chans.begin(), chans.end(), c) == chans.end()) {
|
||||
chans.push_back(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
std::sort(chans.begin(), chans.end());
|
||||
if (chans.empty()) return table;
|
||||
|
||||
const std::vector<ChannelInfo>& infos = model.channels();
|
||||
const auto name_of = [&](int c) {
|
||||
return static_cast<std::size_t>(c) < infos.size() ? infos[static_cast<std::size_t>(c)].name
|
||||
: std::string("ch") + std::to_string(c);
|
||||
};
|
||||
|
||||
// What the rows bind to is what gets exported, which is the same branch `RowSet::bind()`
|
||||
// takes: the frozen event in trigger mode, the live ring otherwise. Reading the ring
|
||||
// while an event is on screen would export data the user cannot see.
|
||||
std::shared_ptr<const EventSnapshot> snap = model.event();
|
||||
if (model.trigger_enabled() && snap != nullptr) {
|
||||
// The snapshot is immutable and already index-aligned, so the visible window is two
|
||||
// binary searches over its stamps and the same index range for every channel.
|
||||
const std::vector<f32>& st = snap->stamps;
|
||||
const auto lo = std::lower_bound(st.begin(), st.end(), static_cast<f32>(view.min));
|
||||
const auto hi = std::upper_bound(st.begin(), st.end(), static_cast<f32>(view.max));
|
||||
if (hi <= lo) return table;
|
||||
const std::size_t first = static_cast<std::size_t>(lo - st.begin());
|
||||
const std::size_t n = static_cast<std::size_t>(hi - lo);
|
||||
// The event's x = 0 is the edge, so the stream clock has to be added back for the
|
||||
// second column -- the one case where the visible x is not the stream's own.
|
||||
const f64 edge = model.trigger_time().load(std::memory_order_relaxed);
|
||||
|
||||
table.time.resize(n);
|
||||
table.stream_time.resize(n);
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
table.time[i] = static_cast<f64>(st[first + i]);
|
||||
table.stream_time[i] = edge + table.time[i];
|
||||
}
|
||||
for (const int c : chans) {
|
||||
const std::size_t ch = static_cast<std::size_t>(c);
|
||||
if (ch >= snap->values.size()) continue;
|
||||
const std::vector<f32>& src = snap->values[ch];
|
||||
if (src.size() < first + n) continue;
|
||||
table.columns.push_back({name_of(c), std::vector<f32>(src.begin() + static_cast<std::ptrdiff_t>(first),
|
||||
src.begin() + static_cast<std::ptrdiff_t>(first + n))});
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
// Live: one absolute index range over the shared time ring, used for every channel --
|
||||
// `live_window_locked()` is the same cut a latch makes, so an export and a capture of
|
||||
// the same window hold the same samples. The lock is against a structural change
|
||||
// dropping the rings under the copy, which is what the UI thread has to fear here.
|
||||
std::lock_guard<std::mutex> lock(model.mutex());
|
||||
// The axis reads `display_epoch + stamp`, so the window has to be put back on the
|
||||
// stream's clock before the ring is searched. It is 0 in live mode, but reading it is
|
||||
// what keeps this correct rather than merely true today.
|
||||
const f64 epoch = model.display_epoch();
|
||||
u64 first = 0, last = 0;
|
||||
model.live_window_locked(view.min - epoch, view.max - epoch, first, last);
|
||||
const u64 n = last - first;
|
||||
if (n == 0) return table;
|
||||
|
||||
BufferStore& store = model.live();
|
||||
std::vector<f32> stamps(static_cast<std::size_t>(n));
|
||||
store.time().copy_from(first, n, stamps.data());
|
||||
table.time.resize(static_cast<std::size_t>(n));
|
||||
table.stream_time.resize(static_cast<std::size_t>(n));
|
||||
for (std::size_t i = 0; i < stamps.size(); ++i) {
|
||||
table.stream_time[i] = static_cast<f64>(stamps[i]);
|
||||
table.time[i] = epoch + table.stream_time[i];
|
||||
}
|
||||
for (const int c : chans) {
|
||||
const std::size_t ch = static_cast<std::size_t>(c);
|
||||
if (ch >= model.channel_count()) continue;
|
||||
std::vector<f32> values(static_cast<std::size_t>(n));
|
||||
store.values(ch).copy_from(first, n, values.data());
|
||||
table.columns.push_back({name_of(c), std::move(values)});
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
bool write_npy(const std::string& path, const ExportTable& table, std::string& error) {
|
||||
if (table.empty()) {
|
||||
error = "nothing to export: no signal is plotted in the visible range";
|
||||
return false;
|
||||
}
|
||||
const std::vector<std::string> labels = column_labels(table);
|
||||
|
||||
// `descr` as a list of (name, format) pairs is a *packed* structured dtype -- NumPy
|
||||
// only inserts alignment padding when asked to (`align=True`) -- so the records below
|
||||
// are field widths back to back with no gaps.
|
||||
std::string descr = "[('time', '<f8'), ('stream_time', '<f8')";
|
||||
for (const std::string& label : labels) descr += ", ('" + label + "', '<f4')";
|
||||
descr += "]";
|
||||
std::string header = "{'descr': " + descr + ", 'fortran_order': False, 'shape': (" +
|
||||
std::to_string(table.rows()) + ",), }";
|
||||
// v1.0: a 6-byte magic, 2 version bytes and a 2-byte little-endian header length, and
|
||||
// the whole preamble padded so the data starts on a 64-byte boundary.
|
||||
constexpr std::size_t kPreamble = 10;
|
||||
const std::size_t unpadded = kPreamble + header.size() + 1;
|
||||
const std::size_t padded = (unpadded + 63) / 64 * 64;
|
||||
header.append(padded - unpadded, ' ');
|
||||
header.push_back('\n');
|
||||
if (header.size() > 0xFFFFu) {
|
||||
error = "too many columns for a v1.0 .npy header";
|
||||
return false;
|
||||
}
|
||||
|
||||
File out(path);
|
||||
if (!opened(out, path, error)) return false;
|
||||
const unsigned char len[2] = {static_cast<unsigned char>(header.size() & 0xFFu),
|
||||
static_cast<unsigned char>((header.size() >> 8) & 0xFFu)};
|
||||
std::fwrite("\x93NUMPY\x01\x00", 1, 8, out.f);
|
||||
std::fwrite(len, 1, 2, out.f);
|
||||
std::fwrite(header.data(), 1, header.size(), out.f);
|
||||
|
||||
// One record at a time, into a reused buffer: a column-major staging array would be a
|
||||
// second copy of the whole export, which at a full ring is tens of megabytes.
|
||||
std::vector<unsigned char> record(2 * sizeof(f64) + table.columns.size() * sizeof(f32));
|
||||
for (std::size_t i = 0; i < table.rows(); ++i) {
|
||||
std::size_t at = 0;
|
||||
std::memcpy(record.data() + at, &table.time[i], sizeof(f64));
|
||||
at += sizeof(f64);
|
||||
std::memcpy(record.data() + at, &table.stream_time[i], sizeof(f64));
|
||||
at += sizeof(f64);
|
||||
for (const ExportTable::Column& col : table.columns) {
|
||||
const f32 v = i < col.values.size() ? col.values[i] : 0.0f;
|
||||
std::memcpy(record.data() + at, &v, sizeof(f32));
|
||||
at += sizeof(f32);
|
||||
}
|
||||
if (std::fwrite(record.data(), 1, record.size(), out.f) != record.size()) {
|
||||
error = "short write to '" + path + "': " + std::strerror(errno);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (std::fflush(out.f) != 0) {
|
||||
error = "cannot flush '" + path + "': " + std::strerror(errno);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool write_csv(const std::string& path, const ExportTable& table, std::string& error) {
|
||||
if (table.empty()) {
|
||||
error = "nothing to export: no signal is plotted in the visible range";
|
||||
return false;
|
||||
}
|
||||
File out(path);
|
||||
if (!opened(out, path, error)) return false;
|
||||
|
||||
const std::vector<std::string> labels = column_labels(table);
|
||||
std::string line = "time,stream_time";
|
||||
for (const std::string& label : labels) line += "," + label;
|
||||
line += "\n";
|
||||
std::fwrite(line.data(), 1, line.size(), out.f);
|
||||
|
||||
// `%.17g` for the f64 times and `%.9g` for the f32 values: the shortest widths that
|
||||
// round-trip each type exactly. Anything less silently rewrites the samples, which is
|
||||
// the one thing an export may not do.
|
||||
char buf[64];
|
||||
for (std::size_t i = 0; i < table.rows(); ++i) {
|
||||
line.clear();
|
||||
std::snprintf(buf, sizeof(buf), "%.17g", table.time[i]);
|
||||
line += buf;
|
||||
std::snprintf(buf, sizeof(buf), "%.17g", table.stream_time[i]);
|
||||
line += ",";
|
||||
line += buf;
|
||||
for (const ExportTable::Column& col : table.columns) {
|
||||
std::snprintf(buf, sizeof(buf), "%.9g",
|
||||
double(i < col.values.size() ? col.values[i] : 0.0f));
|
||||
line += ",";
|
||||
line += buf;
|
||||
}
|
||||
line += "\n";
|
||||
if (std::fwrite(line.data(), 1, line.size(), out.f) != line.size()) {
|
||||
error = "short write to '" + path + "': " + std::strerror(errno);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (std::fflush(out.f) != 0) {
|
||||
error = "cannot flush '" + path + "': " + std::strerror(errno);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -0,0 +1,59 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "model.hpp"
|
||||
#include "plot_host.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace udpscope {
|
||||
|
||||
/// What the plots are showing, as a table: one row per sample of the visible x window,
|
||||
/// one column per plotted signal.
|
||||
///
|
||||
/// The samples are the *stored* ones, never the LOD pyramid's buckets: the pyramid is
|
||||
/// what makes a million samples drawable in a few hundred pixels, and exporting it would
|
||||
/// hand back the envelope the renderer drew rather than the acquisition. So an export is
|
||||
/// at the full rate the ring holds, however far the view is zoomed out.
|
||||
struct ExportTable {
|
||||
struct Column {
|
||||
std::string name; ///< the legend name, sanitised for a column label
|
||||
std::vector<f32> values; ///< `rows()` long, the ring's own `f32`
|
||||
};
|
||||
|
||||
/// The x the plot shows and the crosshair reads: stream seconds in live mode,
|
||||
/// edge-relative seconds in `[-pre, +post]` while an event is held.
|
||||
std::vector<f64> time;
|
||||
/// The stream's own clock, so two exports taken in different modes line up. Equal to
|
||||
/// `time` in live mode; `time + trigger_time` while an event is held, which is the one
|
||||
/// case where the visible x is not the stream's.
|
||||
std::vector<f64> stream_time;
|
||||
std::vector<Column> columns;
|
||||
|
||||
std::size_t rows() const noexcept { return time.size(); }
|
||||
bool empty() const noexcept { return columns.empty() || time.empty(); }
|
||||
};
|
||||
|
||||
/// Collects every signal bound to a visible row, over the shared x window, at the stored
|
||||
/// rate. A signal on several rows is one column; the columns come out in channel order,
|
||||
/// which is the signal list's order.
|
||||
///
|
||||
/// Takes `model.mutex()` for the live cut, so call it from the GTK thread like any other
|
||||
/// model access. In trigger mode nothing is locked: the `EventSnapshot` is immutable.
|
||||
ExportTable collect_export(ScopeModel& model, RowSet& rows);
|
||||
|
||||
/// Writes a NumPy `.npy` file holding one structured array: `time` and `stream_time` as
|
||||
/// `<f8`, every signal as `<f4` -- the width the ring actually stores, so the file is
|
||||
/// about half the size of an all-`f8` one and invents no precision.
|
||||
///
|
||||
/// `pd.DataFrame(np.load(path))` is the whole reader.
|
||||
bool write_npy(const std::string& path, const ExportTable& table, std::string& error);
|
||||
|
||||
/// Writes the same table as CSV: a header row of column names, then `%.*g` fields --
|
||||
/// enough digits to round-trip each column's own type and no more.
|
||||
bool write_csv(const std::string& path, const ExportTable& table, std::string& error);
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -0,0 +1,36 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include "acquisition.hpp"
|
||||
#include "config.hpp"
|
||||
#include "model.hpp"
|
||||
#include "plot_host.hpp"
|
||||
#include "ui.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <exception>
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
udpscope::Config cfg;
|
||||
try {
|
||||
cfg = udpscope::parse_args(argc, argv);
|
||||
} catch (const std::exception& e) {
|
||||
std::fprintf(stderr, "udpscope: %s\n", e.what());
|
||||
return 2;
|
||||
}
|
||||
|
||||
udpscope::ScopeModel model(cfg);
|
||||
udpscope::RowSet rows;
|
||||
rows.set_row_count(cfg.plots);
|
||||
udpscope::Acquisition acquisition(model, cfg);
|
||||
try {
|
||||
acquisition.start();
|
||||
} catch (const std::exception& e) {
|
||||
std::fprintf(stderr, "udpscope: %s\n", e.what());
|
||||
return 1;
|
||||
}
|
||||
|
||||
const int result = udpscope::run_ui(argc, argv, model, acquisition, rows);
|
||||
acquisition.stop();
|
||||
return result;
|
||||
}
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include "model.hpp"
|
||||
|
||||
#include <udps_client.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace udpscope {
|
||||
namespace {
|
||||
|
||||
u64 pow2_floor(u64 v) {
|
||||
if (v == 0) return 0;
|
||||
u64 p = 1;
|
||||
while (p <= v / 2) p <<= 1;
|
||||
return p;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
u64 Partition::total_samples() const noexcept {
|
||||
u64 total = time_capacity;
|
||||
for (u64 c : value_capacity) total += c;
|
||||
return total;
|
||||
}
|
||||
|
||||
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes) {
|
||||
Partition p;
|
||||
const std::size_t n = channel_dt.size();
|
||||
p.stride.assign(n, 1);
|
||||
p.value_capacity.assign(n, 0);
|
||||
if (n == 0) return p;
|
||||
|
||||
// The fastest channel drives the shared time base: every other channel is
|
||||
// decimated by the integer ratio of its own interval to that one.
|
||||
double base_dt = 0.0;
|
||||
for (double dt : channel_dt) {
|
||||
if (dt > 0.0) base_dt = base_dt > 0.0 ? std::min(base_dt, dt) : dt;
|
||||
}
|
||||
// No declared rate: assume 1 kHz rather than one sample per second, so a
|
||||
// stream that only says "here are samples" still gets a sane time scale and a
|
||||
// sane first frame in `Acquisition::build_stamps()`.
|
||||
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.
|
||||
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);
|
||||
double weight = 1.0; // the time slice itself
|
||||
for (u64 s : p.stride) weight += 1.0 / static_cast<double>(s);
|
||||
|
||||
const u64 slots = budget / static_cast<u64>(std::llround(weight));
|
||||
u64 time_capacity = pow2_floor(std::max<u64>(slots, 128));
|
||||
if (time_capacity == 0) {
|
||||
throw std::runtime_error("buffer too small for one time-base sample per channel");
|
||||
}
|
||||
|
||||
// The target span sets a floor, not a ceiling: the rest of the budget buys a
|
||||
// longer ring, which is strictly more history to scroll back through. Growth
|
||||
// stops before the total would exceed the budget. A channel's slots are rounded
|
||||
// *up* on the power of two `RingBuffer` will round them to anyway, so every
|
||||
// channel covers at least the time ring's span.
|
||||
// Floor, not ceiling: a channel's window must not be *longer* than the time ring
|
||||
// it reads its stamps from. Rounding up (with a stride that is not a power of two)
|
||||
// gave a channel more samples than the shared timeline covers, and the oldest of
|
||||
// them referenced time slots the ring had already overwritten -- they were then
|
||||
// stamped with the epoch, a plausible-looking coordinate that collapsed the trace
|
||||
// against the axis. Flooring can leave a channel holding as little as ~62% of the
|
||||
// time ring's span, which is a shorter history, not a wrong one.
|
||||
const auto capacity_for = [&](u64 ticks, u64 stride) {
|
||||
return std::max<u64>(pow2_floor(ticks / stride), 1);
|
||||
};
|
||||
const auto bytes_for = [&](u64 ticks) {
|
||||
u64 total = ticks;
|
||||
for (u64 s : p.stride) total += capacity_for(ticks, s);
|
||||
return total * sizeof(f32);
|
||||
};
|
||||
while (time_capacity * 2 <= budget && bytes_for(time_capacity * 2) <= budget_bytes) {
|
||||
time_capacity *= 2;
|
||||
}
|
||||
p.time_capacity = time_capacity;
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
p.value_capacity[i] = capacity_for(p.time_capacity, p.stride[i]);
|
||||
}
|
||||
p.span_s = static_cast<double>(p.time_capacity) * static_cast<double>(p.time_stride) * base_dt;
|
||||
return p;
|
||||
}
|
||||
|
||||
// --- BufferStore ------------------------------------------------------------
|
||||
|
||||
BufferStore::BufferStore(const Partition& part, f64 span_s)
|
||||
: part_(part),
|
||||
time_(std::make_shared<plot::data::RingBuffer<f32>>(part.time_capacity)) {
|
||||
(void)span_s;
|
||||
const std::size_t n = part.value_capacity.size();
|
||||
values_.reserve(n);
|
||||
channels_.reserve(n);
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
values_.push_back(std::make_shared<plot::data::RingBuffer<f32>>(part.value_capacity[i]));
|
||||
// `ChannelBuffer::dx()` multiplies this by the channel's stride, so the
|
||||
// value here is the *slot* interval: stride * slot_dt is the real sample
|
||||
// interval at the decimation the partition chose.
|
||||
const double dt = part.slot_dt();
|
||||
channels_.push_back(std::make_shared<ChannelBuffer>(values_[i], time_, part.stride[i], 0.0,
|
||||
dt));
|
||||
}
|
||||
}
|
||||
|
||||
void BufferStore::acquire_all() {
|
||||
for (auto& c : channels_) c->acquire();
|
||||
}
|
||||
|
||||
// --- ScopeModel -------------------------------------------------------------
|
||||
|
||||
ScopeModel::ScopeModel(const Config& cfg) : cfg_(cfg) {
|
||||
trigger_.holdoff_s = cfg.trigger_holdoff_s;
|
||||
}
|
||||
|
||||
void ScopeModel::set_signals(std::vector<SignalInfo> signals) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
// Keep the enable state across CONFIG updates for signals that kept their name.
|
||||
std::unordered_map<std::string, bool> was_enabled;
|
||||
for (std::size_t i = 0; i < signals_.size(); ++i) {
|
||||
was_enabled.emplace(signals_[i].name, i < enabled_.size() ? enabled_[i] : false);
|
||||
}
|
||||
signals_ = std::move(signals);
|
||||
enabled_.assign(signals_.size(), false);
|
||||
for (std::size_t i = 0; i < signals_.size(); ++i) {
|
||||
auto it = was_enabled.find(signals_[i].name);
|
||||
// Off until the user enables it (spec: the signal panel's switch is what
|
||||
// starts acquisition of a signal). A later CONFIG keeps whatever the user
|
||||
// chose for a signal that kept its name.
|
||||
enabled_[i] = it != was_enabled.end() ? it->second : false;
|
||||
}
|
||||
rebuild_channels();
|
||||
rebuild_locked();
|
||||
generation_.fetch_add(1, std::memory_order_release);
|
||||
}
|
||||
|
||||
void ScopeModel::rebuild_channels() {
|
||||
channels_.clear();
|
||||
for (std::size_t s = 0; s < signals_.size(); ++s) {
|
||||
const SignalInfo& sig = signals_[s];
|
||||
if (s >= enabled_.size() || !enabled_[s]) continue;
|
||||
if (is_time_signal(signals_, s)) continue;
|
||||
ChannelInfo ch;
|
||||
ch.signal = s;
|
||||
ch.enabled = true;
|
||||
// A UDPStreamer signal is one channel: its columns are consecutive samples of
|
||||
// that channel within a DATA packet (1x1000 is 1000 samples of the same
|
||||
// signal, not 1000 signals). The worker client hands a multi-element signal
|
||||
// exactly `elements` values per packet, which is the block of samples.
|
||||
ch.name = sig.name;
|
||||
ch.unit = sig.unit;
|
||||
ch.elements = sig.elements();
|
||||
ch.dt = sig.sampling_rate > 0.0 ? 1.0 / sig.sampling_rate : 0.0;
|
||||
channels_.push_back(std::move(ch));
|
||||
}
|
||||
}
|
||||
|
||||
void ScopeModel::rebuild_locked() {
|
||||
std::vector<double> dts;
|
||||
dts.reserve(channels_.size());
|
||||
for (const ChannelInfo& c : channels_) dts.push_back(c.dt);
|
||||
|
||||
if (dts.empty()) {
|
||||
part_ = Partition{};
|
||||
store_.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
part_ = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0);
|
||||
store_ = std::make_unique<BufferStore>(part_, part_.span_s);
|
||||
|
||||
if (trigger_.channel >= static_cast<int>(channels_.size())) trigger_.channel = -1;
|
||||
}
|
||||
|
||||
bool ScopeModel::is_time_signal(const std::vector<SignalInfo>& signals,
|
||||
std::size_t signal) {
|
||||
if (signal >= signals.size()) return false;
|
||||
const SignalInfo& s = signals[signal];
|
||||
// Referenced by name: another signal declares this one as its time base.
|
||||
for (const SignalInfo& other : signals) {
|
||||
if (other.time_signal_idx == signal) return true;
|
||||
}
|
||||
// A full-array signal that declares **no sample rate** is the stream's timestamp
|
||||
// signal: it has stamps but no cadence of its own, because it is not data.
|
||||
// Data channels can also be declared full-array (per-element stamps), and they
|
||||
// keep a declared rate -- treating them as time signals hid every real channel.
|
||||
if (s.time_mode == UDPS_TIME_FULL_ARRAY && s.elements() > 1 && s.sampling_rate == 0.0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ScopeModel::signal_enabled(std::size_t signal) const {
|
||||
return signal < enabled_.size() && enabled_[signal];
|
||||
}
|
||||
|
||||
void ScopeModel::set_signal_enabled(std::size_t signal, bool on) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (signal >= enabled_.size() || enabled_[signal] == on) return;
|
||||
enabled_[signal] = on;
|
||||
// The trigger names a channel by its display name, which is stable across
|
||||
// renumbering: when its own signal is disabled the channel vanishes (the trigger
|
||||
// then waits, and comes back when the signal is re-enabled).
|
||||
const int old_trigger = trigger_.channel;
|
||||
const std::string trigger_name =
|
||||
(old_trigger >= 0 && old_trigger < static_cast<int>(channels_.size()))
|
||||
? channels_[static_cast<std::size_t>(old_trigger)].name
|
||||
: std::string();
|
||||
rebuild_channels();
|
||||
trigger_.channel = -1;
|
||||
if (!trigger_name.empty()) {
|
||||
for (std::size_t i = 0; i < channels_.size(); ++i) {
|
||||
if (channels_[i].name == trigger_name) trigger_.channel = static_cast<int>(i);
|
||||
}
|
||||
}
|
||||
rebuild_locked();
|
||||
generation_.fetch_add(1, std::memory_order_release);
|
||||
}
|
||||
|
||||
void ScopeModel::set_calibration(std::size_t signal, double gain, double offset) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
bool any = false;
|
||||
for (ChannelInfo& c : channels_) {
|
||||
if (c.signal != signal) continue;
|
||||
c.gain = gain;
|
||||
c.offset = offset;
|
||||
any = true;
|
||||
}
|
||||
// Calibration is applied at ingest, so the buffers are rebuilt to avoid mixing
|
||||
// calibrated and uncalibrated samples (and stale LOD aggregates).
|
||||
if (any) {
|
||||
rebuild_locked();
|
||||
generation_.fetch_add(1, std::memory_order_release);
|
||||
}
|
||||
}
|
||||
|
||||
double ScopeModel::channel_dt(std::size_t channel) const {
|
||||
return channel < channels_.size() ? channels_[channel].dt : 0.0;
|
||||
}
|
||||
|
||||
BufferStore& ScopeModel::live() { return *store_; }
|
||||
|
||||
void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& last) {
|
||||
BufferStore& src = live();
|
||||
plot::data::RingBuffer<f32>& tsrc = src.time();
|
||||
|
||||
// The slice is one absolute index range, found in the time ring and then shared by
|
||||
// every value ring. Sharing it is what makes a cut's stamps and each of its channels
|
||||
// the same length and index-aligned *by construction*, instead of by two independent
|
||||
// truncations that could disagree.
|
||||
const u64 tbase = tsrc.produced() - tsrc.size();
|
||||
const u64 tend = tsrc.produced();
|
||||
// The bounds are rounded to the ring's own `f32` before they are compared against
|
||||
// it. The window arrives in f64 and the stamps are stored as f32, so an f64
|
||||
// comparison decides a sample that sits exactly on a bound by which way its stamp
|
||||
// happened to round -- the same nominal window then keeps or drops its first sample
|
||||
// depending on how large the stream time is (`f32(38.001) < 38.001` but
|
||||
// `f32(18.001) > 18.001`). One sample is invisible at 10 kS/s and a third of the
|
||||
// capture at 1 S/s. Comparing in the stored type makes a bound that lands on a
|
||||
// sample include it, always.
|
||||
const f32 t_lo = static_cast<f32>(lo_s);
|
||||
const f32 t_hi = static_cast<f32>(hi_s);
|
||||
|
||||
// Stream time is non-decreasing, so the bounds are a binary search. Searching the
|
||||
// stamps is the whole point: the window is in seconds and this ring is the only
|
||||
// thing that knows how many samples a second of this stream holds.
|
||||
u64 a = tbase, b = tend;
|
||||
while (a < b) {
|
||||
const u64 m = a + (b - a) / 2;
|
||||
if (tsrc[m - tbase] < t_lo) {
|
||||
a = m + 1;
|
||||
} else {
|
||||
b = m;
|
||||
}
|
||||
}
|
||||
first = a;
|
||||
b = tend;
|
||||
while (a < b) {
|
||||
const u64 m = a + (b - a) / 2;
|
||||
if (tsrc[m - tbase] <= t_hi) {
|
||||
a = m + 1;
|
||||
} else {
|
||||
b = m;
|
||||
}
|
||||
}
|
||||
last = a;
|
||||
|
||||
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
||||
plot::data::RingBuffer<f32>& from = src.values(c);
|
||||
first = std::max(first, from.produced() - from.size());
|
||||
last = std::min(last, from.produced());
|
||||
}
|
||||
if (last < first) last = first;
|
||||
}
|
||||
|
||||
void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
|
||||
auto snap = std::make_shared<EventSnapshot>();
|
||||
BufferStore& src = live();
|
||||
plot::data::RingBuffer<f32>& tsrc = src.time();
|
||||
|
||||
u64 first = 0, last = 0;
|
||||
live_window_locked(edge - std::max(0.0, pre_s), edge + std::max(0.0, post_s), first, last);
|
||||
const u64 n = last - first;
|
||||
|
||||
snap->stamps.resize(static_cast<std::size_t>(n));
|
||||
if (n > 0) {
|
||||
tsrc.copy_from(first, n, snap->stamps.data());
|
||||
// Edge-relative from here on: the frozen event reads [-pre, +post] with the
|
||||
// edge at exactly 0, with no axis-level shift any other code path could
|
||||
// disagree with.
|
||||
for (auto& t : snap->stamps) t -= static_cast<f32>(edge);
|
||||
}
|
||||
|
||||
snap->values.resize(channels_.size());
|
||||
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0});
|
||||
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;
|
||||
for (f32 v : vals) {
|
||||
lo = std::min(lo, v);
|
||||
hi = std::max(hi, v);
|
||||
}
|
||||
snap->y[c] = plot::Range{lo, hi};
|
||||
}
|
||||
|
||||
// The span is read off the stamps that were actually captured, never off the
|
||||
// request: the ring may not reach back a full `pre_s`, and the producer stops the
|
||||
// capture at the first sample past `post_s` rather than exactly on it.
|
||||
snap->pre_s = snap->stamps.empty() ? 0.0 : -static_cast<double>(snap->stamps.front());
|
||||
snap->post_s = snap->stamps.empty() ? 0.0 : static_cast<double>(snap->stamps.back());
|
||||
event_.store(std::move(snap), std::memory_order_release);
|
||||
}
|
||||
|
||||
void BufferStore::set_epoch(f64 epoch) noexcept {
|
||||
for (auto& c : channels_) c->set_epoch(epoch);
|
||||
}
|
||||
|
||||
void ScopeModel::set_display_epoch(f64 e) noexcept {
|
||||
display_epoch_.store(e, std::memory_order_relaxed);
|
||||
if (store_) store_->set_epoch(e);
|
||||
}
|
||||
|
||||
void ScopeModel::acquire_all() {
|
||||
if (store_) store_->acquire_all();
|
||||
}
|
||||
|
||||
void ScopeModel::set_trigger_enabled(bool on) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
trigger_.enabled = on;
|
||||
trigger_enabled_.store(on, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void ScopeModel::set_epoch_if_unset(f64 e) noexcept {
|
||||
if (epoch_ == 0.0) epoch_ = e;
|
||||
}
|
||||
|
||||
} // namespace udpscope
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "channel.hpp"
|
||||
#include "config.hpp"
|
||||
#include "event_series.hpp"
|
||||
#include "trigger.hpp"
|
||||
|
||||
#include <plot/data/ring_buffer.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace udpscope {
|
||||
|
||||
/// One streamed signal, as the CONFIG packet described it.
|
||||
struct SignalInfo {
|
||||
std::string name;
|
||||
std::string unit;
|
||||
std::uint8_t type_code = 0;
|
||||
std::uint8_t quant_type = 0;
|
||||
std::uint32_t rows = 1;
|
||||
std::uint32_t cols = 1;
|
||||
double range_min = 0.0;
|
||||
double range_max = 1.0;
|
||||
std::uint8_t time_mode = 0;
|
||||
double sampling_rate = 0.0;
|
||||
std::uint32_t time_signal_idx = 0xFFFFFFFFu;
|
||||
|
||||
std::uint32_t elements() const noexcept {
|
||||
const std::uint32_t n = rows * cols;
|
||||
return n == 0 ? 1u : n;
|
||||
}
|
||||
};
|
||||
|
||||
/// One plottable signal element: a signal with a single element, or one element
|
||||
/// of a vector/matrix signal. The unit of enable/disable is the *signal*; the
|
||||
/// unit of acquisition and plotting is the channel.
|
||||
struct ChannelInfo {
|
||||
std::size_t signal = 0;
|
||||
bool enabled = false; ///< whether its signal is enabled
|
||||
double gain = 1.0; ///< calibration, applied as sample * gain + offset
|
||||
double offset = 0.0;
|
||||
|
||||
std::string name; ///< the signal's name
|
||||
std::string unit;
|
||||
|
||||
/// Samples of this channel per DATA packet (`elements()` of the signal). A
|
||||
/// UDPStreamer signal is one channel: its columns are consecutive samples of the
|
||||
/// same signal, not separate channels.
|
||||
std::uint32_t elements = 1;
|
||||
|
||||
/// Sample interval of this channel's own cadence, in seconds. 0 when unknown.
|
||||
double dt = 0.0;
|
||||
};
|
||||
|
||||
/// How one buffer's storage is divided between the shared time base and the
|
||||
/// channels (spec item 2: "split in N+1", the +1 being the time base).
|
||||
///
|
||||
/// The split is by *time span*, not by bytes: every channel's ring and the time
|
||||
/// ring cover the same amount of stream time, which is what makes a shared time
|
||||
/// base meaningful. When no channel is decimated (the common case) the two rules
|
||||
/// 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)
|
||||
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
|
||||
double span_s = 0.0; ///< stream seconds one buffer covers
|
||||
|
||||
u64 total_samples() const noexcept;
|
||||
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); }
|
||||
};
|
||||
|
||||
/// 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);
|
||||
|
||||
/// One of the two identical sample buffers: a shared time-base ring plus one
|
||||
/// value ring and one `ChannelBuffer` per channel.
|
||||
class BufferStore {
|
||||
public:
|
||||
BufferStore(const Partition& part, f64 span_s);
|
||||
|
||||
/// Re-bases every channel's x (see `ChannelBuffer::set_epoch`). Used to make the
|
||||
/// axis read seconds from the trigger event.
|
||||
void set_epoch(f64 epoch) noexcept;
|
||||
|
||||
plot::data::RingBuffer<f32>& time() noexcept { return *time_; }
|
||||
const plot::data::RingBuffer<f32>& time() const noexcept { return *time_; }
|
||||
plot::data::RingBuffer<f32>& values(std::size_t channel) { return *values_[channel]; }
|
||||
ChannelBuffer& channel(std::size_t channel) { return *channels_[channel]; }
|
||||
/// Shared handle to a channel view: the caller keeps the storage alive even if
|
||||
/// the model rebuilds.
|
||||
std::shared_ptr<ChannelBuffer> shared_channel(std::size_t channel) {
|
||||
return channels_[channel];
|
||||
}
|
||||
const Partition& partition() const noexcept { return part_; }
|
||||
|
||||
/// Consumer: publishes every channel's new samples.
|
||||
void acquire_all();
|
||||
|
||||
private:
|
||||
Partition part_;
|
||||
std::shared_ptr<plot::data::RingBuffer<f32>> time_;
|
||||
std::vector<std::shared_ptr<plot::data::RingBuffer<f32>>> values_;
|
||||
std::vector<std::shared_ptr<ChannelBuffer>> channels_;
|
||||
};
|
||||
|
||||
/// Live connection and counter state, readable from the UI thread.
|
||||
struct StreamStats {
|
||||
std::atomic<std::uint64_t> packets{0};
|
||||
std::atomic<std::uint64_t> frames{0};
|
||||
std::atomic<std::uint64_t> gaps{0};
|
||||
std::atomic<std::uint64_t> reconnects{0};
|
||||
std::atomic<bool> connected{false};
|
||||
std::atomic<bool> have_config{false};
|
||||
};
|
||||
|
||||
/// The application's data model: the stream description, the two sample buffers,
|
||||
/// the trigger configuration and the counters. Structural changes (a CONFIG
|
||||
/// update, an enable/disable, a calibration) rebuild the buffers, so all mutating
|
||||
/// access is serialised on one mutex; the acquisition thread takes it once per
|
||||
/// DATA frame.
|
||||
class ScopeModel {
|
||||
public:
|
||||
explicit ScopeModel(const Config& cfg);
|
||||
|
||||
const Config& config() const noexcept { return cfg_; }
|
||||
|
||||
// --- stream description ---------------------------------------------------
|
||||
/// Takes a new CONFIG: rebuilds the signal and channel lists and the two
|
||||
/// buffers. Clears the trigger's channel selection if the stream changed.
|
||||
void set_signals(std::vector<SignalInfo> signals);
|
||||
const std::vector<SignalInfo>& signals() const noexcept { return signals_; }
|
||||
const std::vector<ChannelInfo>& channels() const noexcept { return channels_; }
|
||||
|
||||
bool signal_enabled(std::size_t signal) const;
|
||||
/// True when the signal is the stream's time base, i.e. when some other signal
|
||||
/// declares it via `time_signal_idx`. A signal used as a time base builds the x
|
||||
/// axis and is not a plottable channel.
|
||||
static bool is_time_signal(const std::vector<SignalInfo>& signals,
|
||||
std::size_t signal);
|
||||
void set_signal_enabled(std::size_t signal, bool on);
|
||||
void set_calibration(std::size_t signal, double gain, double offset);
|
||||
|
||||
std::size_t channel_count() const noexcept { return channels_.size(); }
|
||||
const Partition& partition() const noexcept { return part_; }
|
||||
|
||||
/// The channel's sample interval (0 when the stream did not say).
|
||||
double channel_dt(std::size_t channel) const;
|
||||
|
||||
// --- buffers --------------------------------------------------------------
|
||||
/// The one sample store: the producer always writes it, and the plot draws from
|
||||
/// it whenever it is not showing a frozen event. Always-filling is what
|
||||
/// guarantees a full pre-trigger history on the *first* latch after the trigger
|
||||
/// is armed.
|
||||
BufferStore& live();
|
||||
|
||||
/// Builds the captured event from the slice of the live buffer covering
|
||||
/// `[edge - pre_s, edge + post_s]` in stream seconds, and publishes it atomically.
|
||||
/// The stamps are re-based so the edge sits at x = 0. The plot binds to this
|
||||
/// immutable snapshot -- it is the "frozen buffer" -- while the live ring keeps
|
||||
/// filling behind it.
|
||||
///
|
||||
/// The cut is by time, not by a sample count: the window the trigger panel asks
|
||||
/// for is in seconds and the time ring stores seconds, so any count in between
|
||||
/// needs a cadence estimate -- which is exactly what used to size the capture
|
||||
/// wrong.
|
||||
void publish_event(double pre_s, double post_s, double edge);
|
||||
|
||||
/// The live buffer's absolute index range covering `[lo_s, hi_s]` in stream seconds,
|
||||
/// clamped to what the time ring and *every* value ring still hold. One range for the
|
||||
/// time ring and all the channels, which is what keeps a cut index-aligned by
|
||||
/// construction; the bounds are compared in the ring's own `f32`, so a bound that
|
||||
/// lands on a sample includes it whatever the magnitude of the stream time.
|
||||
///
|
||||
/// Caller holds `mutex()` (the acquisition thread already does when it latches).
|
||||
void live_window_locked(double lo_s, double hi_s, u64& first, u64& last);
|
||||
/// The event on screen, or null before the first latch.
|
||||
std::shared_ptr<const EventSnapshot> event() const noexcept {
|
||||
return event_.load(std::memory_order_acquire);
|
||||
}
|
||||
|
||||
/// Publishes the live buffer's new samples to its `ChannelBuffer`s.
|
||||
void acquire_all();
|
||||
|
||||
// --- trigger --------------------------------------------------------------
|
||||
/// The trigger configuration. Mutating it means holding `mutex()`: the producer
|
||||
/// reads it once per frame (and writes `armed` itself when a single-shot latch
|
||||
/// disarms), so an unguarded field write can be lost or read half-updated. The
|
||||
/// panel's callbacks take the lock for the fields they touch.
|
||||
TriggerConfig& trigger() noexcept { return trigger_; }
|
||||
const TriggerConfig& trigger() const noexcept { return trigger_; }
|
||||
|
||||
/// Trigger mode on/off, as both the frame tick and the producer read it: the one
|
||||
/// trigger field the tick needs and the only one written from the UI, so it has
|
||||
/// an atomic mirror and a setter that keeps the copy under the lock in step.
|
||||
bool trigger_enabled() const noexcept {
|
||||
return trigger_enabled_.load(std::memory_order_relaxed);
|
||||
}
|
||||
void set_trigger_enabled(bool on);
|
||||
|
||||
/// Whether the engine is mid-capture (an edge was seen and the post-trigger
|
||||
/// window is still filling). Purely informational: it drives the status line's
|
||||
/// wording. The plot deliberately does *not* react to it -- in trigger mode the
|
||||
/// picture changes only on a latch (SPECS: "data visualisation should not be
|
||||
/// refreshed till next event").
|
||||
bool trigger_pending() const noexcept {
|
||||
return trigger_pending_.load(std::memory_order_relaxed);
|
||||
}
|
||||
void set_trigger_pending(bool on) noexcept {
|
||||
trigger_pending_.store(on, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
/// The x origin the plot uses: 0 for "seconds since the stream started", or minus
|
||||
/// the trigger instant while in trigger mode, so the axis reads seconds from the
|
||||
/// event. Applied to the live store's channels.
|
||||
f64 display_epoch() const noexcept { return display_epoch_.load(std::memory_order_relaxed); }
|
||||
void set_display_epoch(f64 e) noexcept;
|
||||
|
||||
// --- stream state ---------------------------------------------------------
|
||||
StreamStats& stats() noexcept { return stats_; }
|
||||
const StreamStats& stats() const noexcept { return stats_; }
|
||||
/// Seconds since the epoch of the streamed time, or 0 before the first frame.
|
||||
f64 epoch() const noexcept { return epoch_; }
|
||||
void set_epoch_if_unset(f64 e) noexcept;
|
||||
|
||||
/// Bumped whenever the signal or channel lists change, so the UI knows to
|
||||
/// rebuild the panels it derives from them.
|
||||
unsigned generation() const noexcept { return generation_.load(std::memory_order_acquire); }
|
||||
|
||||
/// The length of the event the trigger latches, in seconds: the time window the
|
||||
/// toolbar selects. The pre-trigger percentage is a share of *this*, not of the
|
||||
/// whole ring, which is what makes the control mean something on a long buffer.
|
||||
void set_event_span(double seconds) noexcept {
|
||||
event_span_.store(seconds > 0.0 ? seconds : 1.0, std::memory_order_relaxed);
|
||||
}
|
||||
double event_span() const noexcept { return event_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
|
||||
/// without holding the model mutex.
|
||||
std::atomic<bool>& trigger_armed() noexcept { return trigger_armed_; }
|
||||
/// Incremented every time an event is latched, so the UI can tell "waiting for
|
||||
/// an event" from "an event is on screen".
|
||||
std::atomic<std::uint64_t>& trigger_latches() noexcept { return trigger_latches_; }
|
||||
/// The stream time of the edge behind the event on screen, and whether there is
|
||||
/// one. Written by the acquisition thread on a latch and cleared by the UI when
|
||||
/// the event stops meaning anything (arming again, or leaving trigger mode).
|
||||
std::atomic<double>& trigger_time() noexcept { return trigger_time_; }
|
||||
std::atomic<bool>& trigger_time_valid() noexcept { return trigger_time_valid_; }
|
||||
|
||||
/// Serialises a structural change against the acquisition thread.
|
||||
std::mutex& mutex() noexcept { return mutex_; }
|
||||
|
||||
/// Rebuilds buffers from the current channel list. Caller holds `mutex()`.
|
||||
void rebuild_locked();
|
||||
|
||||
private:
|
||||
void rebuild_channels();
|
||||
|
||||
Config cfg_;
|
||||
std::vector<SignalInfo> signals_;
|
||||
std::vector<ChannelInfo> channels_;
|
||||
std::vector<bool> enabled_;
|
||||
|
||||
Partition part_;
|
||||
std::unique_ptr<BufferStore> store_;
|
||||
TriggerConfig trigger_{};
|
||||
StreamStats stats_{};
|
||||
f64 epoch_ = 0.0;
|
||||
std::atomic<double> event_span_{1.0};
|
||||
std::atomic<bool> trigger_pending_{false};
|
||||
std::atomic<std::shared_ptr<const EventSnapshot>> event_; ///< the event on screen
|
||||
std::atomic<double> display_epoch_{0.0};
|
||||
std::atomic<bool> trigger_armed_{false};
|
||||
std::atomic<bool> trigger_enabled_{false};
|
||||
std::atomic<std::uint64_t> trigger_latches_{0};
|
||||
std::atomic<double> trigger_time_{0.0};
|
||||
std::atomic<bool> trigger_time_valid_{false};
|
||||
std::atomic<unsigned> generation_{0};
|
||||
std::mutex mutex_;
|
||||
};
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -0,0 +1,414 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include "plot_host.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace udpscope {
|
||||
namespace {
|
||||
|
||||
/// A small qualitative palette, one colour per *signal*. It is indexed by the signal
|
||||
/// rather than by the trace slot so that a signal plotted on several rows is the same
|
||||
/// colour in all of them: the signal list's chip is what ties a row of that list to a
|
||||
/// trace on screen, and a colour that depended on where the signal landed would name a
|
||||
/// different trace in every row.
|
||||
constexpr plot::u32 kPalette[] = {
|
||||
0x4FA3FFu, 0xFF8A4Fu, 0x5FD08Au, 0xE86A9Cu, 0xC9A227u, 0x8F7BFFu, 0x4FD0D0u, 0xFF6B6Bu,
|
||||
};
|
||||
constexpr std::size_t kPaletteSize = sizeof(kPalette) / sizeof(kPalette[0]);
|
||||
|
||||
/// The chip of a signal that is acquired but on no plot, and of one that is not even
|
||||
/// acquired. Mid grey: it has to read as "no trace" next to any palette entry.
|
||||
constexpr plot::u32 kUnplottedColor = 0x6E6E6Eu;
|
||||
|
||||
constexpr plot::u32 kCursorAColor = 0xFFD24Fu;
|
||||
constexpr plot::u32 kCursorBColor = 0x7FE0FFu;
|
||||
constexpr plot::u32 kRulerColor = 0xB0B0B0u;
|
||||
constexpr plot::u32 kTriggerColor = 0xFF5B5Bu;
|
||||
|
||||
constexpr double kParked = -1.0e12; ///< hides an instrument without removing it
|
||||
|
||||
/// The gap between two rows, in device pixels. The rows used to be separate widgets in
|
||||
/// a box, so there was a widget gap; a figure tiles its viewport, and without a gap two
|
||||
/// stacked frames read as one tall plot with a line through it.
|
||||
constexpr plot::f32 kRowGap = 6.0f;
|
||||
|
||||
} // namespace
|
||||
|
||||
plot::Color RowSet::palette_color(int index) {
|
||||
const std::size_t i = static_cast<std::size_t>(index < 0 ? 0 : index) % kPaletteSize;
|
||||
return plot::Color::hex(kPalette[i]);
|
||||
}
|
||||
|
||||
plot::Color RowSet::unplotted_color() { return plot::Color::hex(kUnplottedColor); }
|
||||
|
||||
RowSet::RowSet() { rebuild_all(); }
|
||||
|
||||
void RowSet::set_row_count(int rows) {
|
||||
const int want = std::clamp(rows, 1, kMaxRows);
|
||||
if (want == rows_) return;
|
||||
rows_ = want;
|
||||
// A `Figure`'s row count is fixed at construction, so a new count is a new figure.
|
||||
rebuild_all();
|
||||
}
|
||||
|
||||
void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
|
||||
r.plot = &p;
|
||||
r.axis_x = r.plot->add_x_axis(
|
||||
{"time", {-1.0, 1.0}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
|
||||
r.axis_y = r.plot->add_y_axis(
|
||||
{"value", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
|
||||
for (int s = 0; s < kSlotsPerRow; ++s) {
|
||||
const std::size_t i = static_cast<std::size_t>(s);
|
||||
r.plot->add_trace(
|
||||
{&r.slots[i], palette_color(r.color_index[i]), 1.2f, r.axis_x, r.axis_y, r.name[i]});
|
||||
}
|
||||
r.cursor_a = r.plot->add_cursor(0.0, plot::Color::hex(kCursorAColor));
|
||||
r.cursor_b = r.plot->add_cursor(0.0, plot::Color::hex(kCursorBColor));
|
||||
r.ruler = r.plot->add_h_ruler(0.0, plot::Color::hex(kRulerColor));
|
||||
r.trigger_marker = r.plot->add_cursor(kParked, plot::Color::hex(kTriggerColor));
|
||||
// A marker, not an instrument: it must sit exactly on the edge's time, so its
|
||||
// snap is Free and the controller never rounds it onto a sample.
|
||||
r.plot->set_cursor_snap(r.trigger_marker, plot::SnapMode::Free);
|
||||
// No `set_x_follow(Live)`: the link group owns the window *and* the follow state, and
|
||||
// `Figure::link_x()` refuses a member whose x axis still follows on its own.
|
||||
}
|
||||
|
||||
void RowSet::rebuild_all() {
|
||||
figure_ = std::make_unique<plot::Figure>(static_cast<plot::u32>(rows_));
|
||||
figure_->set_row_gap(kRowGap);
|
||||
x_group_ = figure_->add_x_link();
|
||||
for (int i = 0; i < kMaxRows; ++i) {
|
||||
Row& r = row(i);
|
||||
row_dirty_[static_cast<std::size_t>(i)] = false;
|
||||
if (i >= rows_) {
|
||||
r.plot = nullptr; // not a row of this figure: nothing to plot into
|
||||
continue;
|
||||
}
|
||||
install(r, i, figure_->plot(static_cast<plot::u32>(i)));
|
||||
place_cursors(r);
|
||||
if (trigger_marker_on_) {
|
||||
r.plot->set_cursor_x(r.trigger_marker, trigger_marker_x_, plot::CursorPlacement::Free);
|
||||
}
|
||||
figure_->link_x(static_cast<plot::u32>(i), x_group_);
|
||||
}
|
||||
if (y_gutter_px_ > 0.0) apply_y_gutter();
|
||||
// A new figure starts with no window at all, so the one the rows were showing has to
|
||||
// be restated -- as the pin it was, if an event is on screen: assigning a trace or
|
||||
// changing the row count while a capture is displayed must not throw the view back
|
||||
// onto the live edge, which in trigger mode nothing would move it off again until the
|
||||
// next latch.
|
||||
if (pinned_) {
|
||||
pin_event_window(pinned_pre_, pinned_post_);
|
||||
} else {
|
||||
set_time_window(window_s_);
|
||||
}
|
||||
}
|
||||
|
||||
bool RowSet::rebuild_if_dirty() {
|
||||
bool any = false;
|
||||
for (int i = 0; i < kMaxRows; ++i) {
|
||||
if (row_dirty_[static_cast<std::size_t>(i)]) any = true;
|
||||
}
|
||||
if (!any) return false;
|
||||
// One row's legend text changed, which `Plot` cannot express, so its traces have to
|
||||
// be created again -- and a row belongs to a figure, so the figure is rebuilt. That
|
||||
// costs the plots' CPU state and nothing else: the host keeps its GL context and is
|
||||
// handed the new figure.
|
||||
rebuild_all();
|
||||
return true;
|
||||
}
|
||||
|
||||
void RowSet::assign(int row_index, int slot, std::string signal_name,
|
||||
std::string display_name, int color_index) {
|
||||
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return;
|
||||
Row& r = row(row_index);
|
||||
const std::size_t s = static_cast<std::size_t>(slot);
|
||||
r.signal_name[s] = std::move(signal_name);
|
||||
r.channel[s] = -1; // re-resolved by the next bind()
|
||||
r.name[s] = std::move(display_name);
|
||||
r.color_index[s] = color_index;
|
||||
row_dirty_[static_cast<std::size_t>(row_index)] = true;
|
||||
}
|
||||
|
||||
bool RowSet::slot_bound(int row_index, int slot) const {
|
||||
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return false;
|
||||
return !row(row_index).signal_name[static_cast<std::size_t>(slot)].empty();
|
||||
}
|
||||
|
||||
int RowSet::channel_of(int row_index, int slot) const {
|
||||
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return -1;
|
||||
return row(row_index).channel[static_cast<std::size_t>(slot)];
|
||||
}
|
||||
|
||||
void RowSet::bind(ScopeModel& model) {
|
||||
const std::vector<SignalInfo>& signals = model.signals();
|
||||
const std::vector<ChannelInfo>& channels = model.channels();
|
||||
for (int i = 0; i < kMaxRows; ++i) {
|
||||
Row& r = row(i);
|
||||
for (int s = 0; s < kSlotsPerRow; ++s) {
|
||||
const std::size_t si = static_cast<std::size_t>(s);
|
||||
if (r.signal_name[si].empty()) {
|
||||
r.channel[si] = -1;
|
||||
r.slots[si].bind(nullptr);
|
||||
continue;
|
||||
}
|
||||
// Resolve the stable identity every frame: the channel list is rebuilt (and
|
||||
// renumbered) whenever a signal is enabled, disabled or re-described, and a
|
||||
// stored index would then point at a different signal's channel.
|
||||
int signal_index = -1;
|
||||
for (std::size_t k = 0; k < signals.size(); ++k) {
|
||||
if (signals[k].name == r.signal_name[si]) {
|
||||
signal_index = static_cast<int>(k);
|
||||
break;
|
||||
}
|
||||
}
|
||||
int channel = -1;
|
||||
if (signal_index >= 0) {
|
||||
for (std::size_t c = 0; c < channels.size(); ++c) {
|
||||
if (static_cast<int>(channels[c].signal) == signal_index) {
|
||||
channel = static_cast<int>(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
r.channel[si] = channel;
|
||||
if (channel < 0) {
|
||||
// Its signal is present but not acquired: keep the identity (so enabling it
|
||||
// brings the trace back) and plot nothing meanwhile.
|
||||
r.slots[si].bind(nullptr);
|
||||
continue;
|
||||
}
|
||||
// In trigger mode the trace shows the captured event and nothing else: the
|
||||
// last one latched, or nothing at all before the first. Never the live ring,
|
||||
// not even while the post window is filling -- a frozen picture that only
|
||||
// changes on an event is the whole point of trigger mode, and binding the
|
||||
// live ring mid-capture made the plot scroll for most of every event cycle.
|
||||
const std::size_t ci = static_cast<std::size_t>(channel);
|
||||
if (model.trigger_enabled()) {
|
||||
if (auto snap = model.event()) {
|
||||
r.slots[si].bind_event(std::move(snap), ci);
|
||||
} else {
|
||||
r.slots[si].bind(nullptr); // armed, waiting for the first event: blank
|
||||
}
|
||||
} else {
|
||||
r.slots[si].bind(model.live().shared_channel(ci));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool RowSet::live_edge(double& newest) const {
|
||||
bool any = false;
|
||||
for (int i = 0; i < rows_; ++i) {
|
||||
const Row& r = row(i);
|
||||
if (!r.plot) continue;
|
||||
double edge = 0.0;
|
||||
// Follow-independent: it reads the row's streaming traces, so a row whose own axis
|
||||
// is `Off` (which every member of a link group is) still reports its edge.
|
||||
if (!r.plot->x_live_edge(r.axis_x, edge)) continue;
|
||||
newest = any ? std::max(newest, edge) : edge;
|
||||
any = true;
|
||||
}
|
||||
return any;
|
||||
}
|
||||
|
||||
void RowSet::set_time_window(double seconds) {
|
||||
window_s_ = std::max(0.05, seconds);
|
||||
pinned_ = false;
|
||||
if (!figure_) return;
|
||||
figure_->set_x_link_follow(x_group_, plot::XFollow::Live);
|
||||
double newest = 0.0;
|
||||
if (live_edge(newest)) {
|
||||
// Under a following group the range states the span and -- through its maximum --
|
||||
// where the window sits behind the live edge, so a maximum *at* the edge is what
|
||||
// makes the offset zero and parks the window on the newest sample. Asking for
|
||||
// `{0, window}` here instead would state an offset of a whole epoch.
|
||||
figure_->set_x_link_range(x_group_, {newest - window_s_, newest});
|
||||
} else {
|
||||
figure_->set_x_link_range(x_group_, {0.0, window_s_});
|
||||
}
|
||||
}
|
||||
|
||||
void RowSet::set_y_auto(int row_index, bool on) {
|
||||
Row& r = row(row_index);
|
||||
if (!r.plot) return;
|
||||
r.plot->set_y_auto_scale(r.axis_y, on);
|
||||
if (on) r.plot->auto_fit_y(r.axis_y);
|
||||
}
|
||||
|
||||
void RowSet::set_y_range(int row_index, double lo, double hi) {
|
||||
Row& r = row(row_index);
|
||||
if (!r.plot) return;
|
||||
const double a = std::min(lo, hi);
|
||||
const double b = std::max(lo, hi);
|
||||
if (!std::isfinite(a) || !std::isfinite(b) || !(b > a)) return;
|
||||
r.plot->set_y_range(r.axis_y, {a, b});
|
||||
}
|
||||
|
||||
void RowSet::place_cursors(Row& r) {
|
||||
if (!r.plot) return;
|
||||
const bool show = r.cursors_visible;
|
||||
const double a = show ? r.cursor_a_x : kParked;
|
||||
const double b = show ? r.cursor_b_x : kParked;
|
||||
const double h = show ? r.ruler_y : kParked;
|
||||
r.plot->set_cursor_x(r.cursor_a, a);
|
||||
r.plot->set_cursor_x(r.cursor_b, b);
|
||||
r.plot->set_cursor_y(r.ruler, h);
|
||||
}
|
||||
|
||||
void RowSet::clamp_to_data() {
|
||||
if (!figure_) return;
|
||||
// The rows share one window, so they share one bound: the union of what they hold.
|
||||
// Taking each row's own would let a row whose signal was enabled a moment ago clamp
|
||||
// the window every row is looking at.
|
||||
plot::Range data{};
|
||||
bool have = false;
|
||||
for (int i = 0; i < rows_; ++i) {
|
||||
Row& r = row(i);
|
||||
if (!r.plot) continue;
|
||||
const plot::data::Series* series = nullptr;
|
||||
for (int s = 0; s < kSlotsPerRow && series == nullptr; ++s) {
|
||||
if (!r.signal_name[static_cast<std::size_t>(s)].empty()) {
|
||||
series = r.slots[static_cast<std::size_t>(s)].bound();
|
||||
}
|
||||
}
|
||||
if (series == nullptr || series->size() < 2) continue;
|
||||
const plot::Range b = series->x_bounds();
|
||||
if (!std::isfinite(b.min) || !std::isfinite(b.max) || !(b.max > b.min)) continue;
|
||||
data = have ? plot::Range{std::min(data.min, b.min), std::max(data.max, b.max)} : b;
|
||||
have = true;
|
||||
}
|
||||
if (!have) return;
|
||||
const double retained = data.max - data.min;
|
||||
const double span = figure_->x_link_range(x_group_).span();
|
||||
if (!(span > 0.0) || !std::isfinite(span)) return;
|
||||
// The *requested* window is intent, and a buffer that has not filled it yet must not
|
||||
// be allowed to redefine it. This runs every frame and cannot tell "the user zoomed
|
||||
// out past the data" from "the ring has only just started filling", and the first
|
||||
// frames after a signal is enabled hold a couple of hundred samples: clamping to
|
||||
// those shrank a requested 2 s window to ~0.02 s, and nothing here ever grows a span
|
||||
// back, so the view stayed pinned to the newest 20 ms of a 6.5 s buffer for the rest
|
||||
// of the run. That is the loudest form of the "live trace is clipped, only the most
|
||||
// recent part is visible, data is there because the crosshair reads it" defect -- the
|
||||
// data was never clipped at all, the window was. Clamping against `max(retained,
|
||||
// window_s_)` leaves a partly filled window empty on its left, which is what a scope
|
||||
// that just started should look like.
|
||||
//
|
||||
// The span only. A pan is the group's `offset` behind its live edge and the group
|
||||
// clamps it against what its members retain on every layout, so there is nothing
|
||||
// here to do about `view.min < data.min` -- and doing it anyway would state a maximum
|
||||
// away from the live edge, i.e. ask the group to scroll back.
|
||||
const double limit = std::max(retained, window_s_);
|
||||
if (span > limit) figure_->set_x_link_range(x_group_, {data.max - limit, data.max});
|
||||
}
|
||||
|
||||
void RowSet::pin_event_window(double pre_s, double post_s) {
|
||||
window_s_ = std::max(1.0e-3, pre_s + post_s);
|
||||
pinned_ = true;
|
||||
pinned_pre_ = pre_s;
|
||||
pinned_post_ = post_s;
|
||||
if (!figure_) return;
|
||||
// The captured event sits at [-pre, +post] with the edge at 0, and the bound series is
|
||||
// the immutable snapshot. The group must stop following first: Live re-derives the
|
||||
// window from a live edge, and with nothing streaming it would keep placing the window
|
||||
// from the last stale edge instead of the event.
|
||||
figure_->set_x_link_follow(x_group_, plot::XFollow::Off);
|
||||
figure_->set_x_link_range(x_group_, {-pre_s, post_s});
|
||||
}
|
||||
|
||||
void RowSet::fit_all() {
|
||||
for (int i = 0; i < rows_; ++i) {
|
||||
Row& r = row(i);
|
||||
if (r.plot) r.plot->auto_fit_y(r.axis_y);
|
||||
}
|
||||
Row& first = row(0);
|
||||
if (!first.plot || !figure_) return;
|
||||
// Row 0 fits its own x to the data it holds; that span becomes the shared window, and
|
||||
// stating it through the group is what puts every row on it.
|
||||
first.plot->auto_fit_x();
|
||||
const plot::Range fitted = first.plot->x_range(first.axis_x);
|
||||
const double span = fitted.span();
|
||||
if (!std::isfinite(span) || !(span > 0.0)) return;
|
||||
if (pinned_) {
|
||||
// Fitting a captured event: the window is the snapshot's own `[-pre, +post]` span,
|
||||
// and `set_time_window()` would re-arm the follow and re-frame on a live edge that
|
||||
// trigger mode has nothing bound to.
|
||||
window_s_ = span;
|
||||
figure_->set_x_link_range(x_group_, fitted);
|
||||
} else {
|
||||
set_time_window(span);
|
||||
}
|
||||
}
|
||||
|
||||
void RowSet::note_y_gutter(double need_px) {
|
||||
double want = std::min(need_px + 6.0, 200.0);
|
||||
// Reserve room for the widest label the formatter can produce, once, with the
|
||||
// font the labels are actually drawn in (the embedded font is monospaced, so a
|
||||
// character count is an exact width).
|
||||
if (!y_gutter_reserved_) {
|
||||
for (int i = 0; i < rows_; ++i) {
|
||||
Row& r = row(i);
|
||||
if (!r.plot) continue;
|
||||
const plot::render::TextBackend* text = r.plot->text_backend();
|
||||
if (text == nullptr) continue;
|
||||
const plot::render::Style& style = r.plot->style();
|
||||
const double advance = double(text->measure("0123456789")) / 10.0;
|
||||
const double pad = double(style.device(style.grid_label_pad));
|
||||
// "-1.23e-45" is the longest label `format_si()`'s fallback can print.
|
||||
want = std::max(want, 9.0 * advance + 2.0 * pad + 2.0);
|
||||
y_gutter_reserved_ = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (want > y_gutter_px_) y_gutter_px_ = want;
|
||||
}
|
||||
|
||||
void RowSet::apply_y_gutter() {
|
||||
if (!figure_ || !(y_gutter_px_ > 0.0)) return;
|
||||
figure_->set_min_y_gutter(static_cast<f32>(y_gutter_px_));
|
||||
}
|
||||
|
||||
void RowSet::set_trigger_marker(double x, bool visible) {
|
||||
trigger_marker_on_ = visible;
|
||||
trigger_marker_x_ = x;
|
||||
const double want = visible ? x : kParked;
|
||||
for (int i = 0; i < rows_; ++i) {
|
||||
Row& r = row(i);
|
||||
if (!r.plot) continue;
|
||||
// Re-assert against what the plot *holds*, not against a cached value: the
|
||||
// marker is a cursor, so the input layer will happily drag it, and a marker the
|
||||
// user can move off the trigger instant is not marking anything. Comparing
|
||||
// with the plot also keeps the steady state write-free (a write invalidates the
|
||||
// plot's cached measurements).
|
||||
if (r.plot->cursor(r.trigger_marker).x == want) continue;
|
||||
r.plot->set_cursor_x(r.trigger_marker, want, plot::CursorPlacement::Free);
|
||||
}
|
||||
}
|
||||
|
||||
void RowSet::set_cursors_visible(bool on) {
|
||||
for (int i = 0; i < kMaxRows; ++i) {
|
||||
Row& r = row(i);
|
||||
r.cursors_visible = on;
|
||||
place_cursors(r);
|
||||
}
|
||||
}
|
||||
|
||||
void RowSet::reset_cursors() {
|
||||
for (int i = 0; i < kMaxRows; ++i) {
|
||||
Row& r = row(i);
|
||||
if (!r.plot) continue;
|
||||
const plot::Range xr = r.plot->x_range(r.axis_x);
|
||||
const double span = xr.span() > 0.0 ? xr.span() : window_s_;
|
||||
r.cursor_a_x = xr.min + 0.25 * span;
|
||||
r.cursor_b_x = xr.min + 0.75 * span;
|
||||
r.ruler_y = r.plot->y_range(r.axis_y).min +
|
||||
0.5 * (r.plot->y_range(r.axis_y).span() > 0.0 ? r.plot->y_range(r.axis_y).span()
|
||||
: 1.0);
|
||||
place_cursors(r);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -0,0 +1,215 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "model.hpp"
|
||||
|
||||
#include <plot/plot/figure.hpp>
|
||||
#include <plot/plot/plot.hpp>
|
||||
|
||||
#include <array>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace udpscope {
|
||||
|
||||
/// The stacked plots (spec item 4).
|
||||
///
|
||||
/// The rows are the rows of **one `plot::Figure`**, rendered by one
|
||||
/// `plot::gtk4::PlotArea` in figure mode. The figure owns everything the rows have
|
||||
/// to agree on, which is why it is one figure and not N plots:
|
||||
///
|
||||
/// - the x window, through a single x link group every row joins. A gesture on any
|
||||
/// row changes that row's range, the group adopts it and pushes it to the others
|
||||
/// (`Figure::sync_x_links()`, run from `layout()`), so there is no "which row
|
||||
/// acted" to work out and nothing to propagate by hand;
|
||||
/// - the live follow, which is the *group's* (`set_x_link_follow`). A member's own
|
||||
/// axis must be `XFollow::Off` -- `Figure::link_x()` refuses a `Live` member --
|
||||
/// because a following axis would reinterpret the pushed range as its own span and
|
||||
/// offset behind its own edge, which is per-row drift by construction;
|
||||
/// - the left gutter, so a vertical feature lands in the same pixel column in every
|
||||
/// row (`Figure::layout()` lays each row out twice and imposes the maximum);
|
||||
/// - the crosshair, placed on every row at one x by `PlotArea`'s figure-mode sync.
|
||||
///
|
||||
/// A row's `Plot` cannot drop a trace, so every row carries a fixed set of trace
|
||||
/// slots created up front; assigning a signal to a slot is a rebind of that slot's
|
||||
/// `SlotSeries`, and only a *name* change -- which `Plot` cannot express -- forces a
|
||||
/// rebuild (see `row_dirty()`). A rebuild is of the whole figure: `Figure`'s row
|
||||
/// count is fixed at construction, so there is one path for "a row's traces changed"
|
||||
/// and "the row count changed", and it costs no GL state -- the area keeps its
|
||||
/// context and is simply handed the new figure.
|
||||
class RowSet {
|
||||
public:
|
||||
static constexpr int kMaxRows = 4;
|
||||
static constexpr int kSlotsPerRow = 4;
|
||||
|
||||
RowSet();
|
||||
|
||||
struct Row {
|
||||
/// The figure's row, borrowed. Null for a row beyond `row_count()`, which is how
|
||||
/// every loop here and in the UI stays inside the rows that exist. Invalidated by
|
||||
/// `rebuild_all()`, which builds a new figure.
|
||||
plot::Plot* plot = nullptr;
|
||||
std::array<SlotSeries, kSlotsPerRow> slots;
|
||||
/// Model channel per trace slot. Must start at -1: `std::array<Row, N>{}`
|
||||
/// zero-initialises a `Row`, and a zero here reads as "slot 0 already holds
|
||||
/// channel 0", which makes every drop look like the row is full.
|
||||
/// What the slot plots, as a *stable identity*: a channel index is positional,
|
||||
/// and enabling/disabling a signal or a new CONFIG renumbers the channel list,
|
||||
/// so an index would silently start plotting a different signal. Empty name =
|
||||
/// free slot.
|
||||
std::array<std::string, kSlotsPerRow> signal_name;
|
||||
/// The channel index `signal_name`/`element` resolved to, filled by `bind()`.
|
||||
std::array<int, kSlotsPerRow> channel = {-1, -1, -1, -1};
|
||||
/// The trace's legend name (the channel's display name), which is what the
|
||||
/// plot was built with.
|
||||
std::array<std::string, kSlotsPerRow> name;
|
||||
/// The palette entry the slot's trace is drawn in. A *signal's* index, not the
|
||||
/// slot's: a signal dropped on two rows has to be the same colour in both, or the
|
||||
/// signal list's colour chip cannot name a trace. Supplied by the caller, because
|
||||
/// only it knows the signal list this indexes into. A trace's colour is fixed at
|
||||
/// `add_trace()`, which is why this is stored rather than resolved per frame --
|
||||
/// changing it needs a rebuild, and `assign()` already forces one.
|
||||
std::array<int, kSlotsPerRow> color_index = {0, 1, 2, 3};
|
||||
plot::u32 axis_x = 0;
|
||||
plot::u32 axis_y = 0;
|
||||
plot::u32 cursor_a = 0;
|
||||
plot::u32 cursor_b = 0;
|
||||
plot::u32 ruler = 0;
|
||||
/// The vertical line marking the trigger instant, drawn whenever an event is
|
||||
/// on screen (and parked outside the axis otherwise). Not the user's cursor:
|
||||
/// it keeps its position whatever the cursor toggle says.
|
||||
plot::u32 trigger_marker = 0;
|
||||
/// Off by default: the cursors are switched on from the toolbar, and a rebuild
|
||||
/// (any drop, any row-count change) must not switch them back off.
|
||||
bool cursors_visible = false;
|
||||
double cursor_a_x = 0.0;
|
||||
double cursor_b_x = 0.0;
|
||||
double ruler_y = 0.0;
|
||||
};
|
||||
|
||||
int row_count() const noexcept { return rows_; }
|
||||
/// 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);
|
||||
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)]; }
|
||||
|
||||
/// The figure the rows live in. The host must be handed this again after every
|
||||
/// `rebuild_all()`.
|
||||
plot::Figure& figure() noexcept { return *figure_; }
|
||||
|
||||
/// Builds a new figure: one `Plot` per visible row with its axes, cursors and fixed
|
||||
/// trace slots, all joined by one x link group.
|
||||
void rebuild_all();
|
||||
/// Rebuilds if any row's traces changed, and reports whether it did -- in which case
|
||||
/// the caller re-points its host at `figure()`.
|
||||
bool rebuild_if_dirty();
|
||||
bool row_dirty(int i) const { return row_dirty_[static_cast<std::size_t>(i)]; }
|
||||
|
||||
/// Binds a row's trace slot to a signal element (`signal_name` empty frees it).
|
||||
/// `color_index` is the signal's palette entry (see `Row::color_index`).
|
||||
void assign(int row, int slot, std::string signal_name, std::string display_name,
|
||||
int color_index);
|
||||
/// Whether a slot plots anything.
|
||||
bool slot_bound(int row, int slot) const;
|
||||
/// The channel index a row/slot holds, or -1.
|
||||
int channel_of(int row, int slot) const;
|
||||
|
||||
/// Rebinds every slot to what it should show this frame: the model's live buffer,
|
||||
/// or -- in trigger mode -- the captured event. Called every frame, because the
|
||||
/// channel list is renumbered by any structural change.
|
||||
void bind(ScopeModel& model);
|
||||
/// Publishes the live buffer's new samples.
|
||||
void acquire(ScopeModel& model) { model.acquire_all(); }
|
||||
|
||||
/// Frames the newest `seconds` of the stream: the link group follows the live edge,
|
||||
/// with its window ending on that edge (which is what resets any pan the user had).
|
||||
void set_time_window(double seconds);
|
||||
double time_window() const noexcept { return window_s_; }
|
||||
|
||||
void set_y_auto(int row, bool on);
|
||||
void set_y_range(int row, double lo, double hi);
|
||||
|
||||
void set_cursors_visible(bool on);
|
||||
void reset_cursors();
|
||||
|
||||
/// Places the trigger marker at stream time `x` (seconds since the epoch), or
|
||||
/// parks it when there is no event on screen.
|
||||
void set_trigger_marker(double x, bool visible);
|
||||
|
||||
/// Keeps the shared x window inside the data the rings actually hold: zooming out
|
||||
/// stops at the whole retained span. A time window is a view of the acquisition, so
|
||||
/// there is nothing to see outside it -- zooming into empty time only loses the data
|
||||
/// off the edge of the plot. Only the *span*: a following group clamps its own pan
|
||||
/// against what its members retain (`Figure::place_live_window()`), while a wheel
|
||||
/// notch multiplies the span and nothing in the library bounds that.
|
||||
void clamp_to_data();
|
||||
|
||||
/// Pins the shared x window to the captured event: `[-pre, +post]` with the edge at
|
||||
/// 0, so nothing outside the pre+post window is visible.
|
||||
void pin_event_window(double pre_s, double post_s);
|
||||
|
||||
/// Fits every row's y axis to the data it holds, and re-frames the shared x window
|
||||
/// on what row 0 retains (the same thing the plot's own `f` key does). The window
|
||||
/// the toolbar reports becomes the fitted span, so the dropdown can say what is
|
||||
/// actually on screen.
|
||||
void fit_all();
|
||||
|
||||
/// Reports the left gutter the rows currently need, and reserves at least the width
|
||||
/// of the widest *plausible* y label.
|
||||
///
|
||||
/// The gutter is measured from the y tick labels, and `TickFormat::Si` makes their
|
||||
/// width change as the auto-fitted range moves (`1.5` becomes `15`, `150`, `1.5k`),
|
||||
/// so a gutter that follows them drags the whole axes -- and with it the x axis --
|
||||
/// left and right by a few pixels every frame. Since `set_min_y_gutter()` is a
|
||||
/// floor and the library still takes the larger of the two, a floor smaller than
|
||||
/// the labels cannot stop that; the reservation therefore covers the widest label
|
||||
/// `format_si()` can produce (three significant digits, a sign and a prefix, or the
|
||||
/// `%.3g` fallback) plus the label padding, measured once with the plot's own font.
|
||||
/// Both reservations only ever grow, so the gutter is constant in practice.
|
||||
void note_y_gutter(double need_px);
|
||||
|
||||
/// Applies the stable gutter as the figure's floor. Call once per frame, before the
|
||||
/// frame is drawn.
|
||||
///
|
||||
/// It has to be the *figure's* floor and not each row's: `Figure::layout()` clears
|
||||
/// every row's `set_min_y_gutter()` to measure what its own labels need, so a floor
|
||||
/// written onto the rows survives exactly until the next layout.
|
||||
void apply_y_gutter();
|
||||
|
||||
/// The palette entry `index`, wrapped. Keyed by the *signal*, so that the colour a
|
||||
/// trace is drawn in is the colour the signal list's chip shows, on every row the
|
||||
/// signal appears in.
|
||||
static plot::Color palette_color(int index);
|
||||
/// The colour of a signal that is on no plot, which the signal list shows instead of
|
||||
/// leaving its chip blank -- a blank chip is a narrower cell than a filled one, so the
|
||||
/// names beside it shifted sideways as signals were placed.
|
||||
static plot::Color unplotted_color();
|
||||
|
||||
private:
|
||||
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.
|
||||
bool live_edge(double& newest) const;
|
||||
|
||||
std::unique_ptr<plot::Figure> figure_;
|
||||
/// The one x link group every visible row belongs to. Groups are numbered from 1.
|
||||
plot::u32 x_group_ = 0;
|
||||
int rows_ = 1;
|
||||
double window_s_ = 2.0;
|
||||
double trigger_marker_x_ = 0.0;
|
||||
bool trigger_marker_on_ = false;
|
||||
/// The event window currently pinned, so a rebuild can restate it instead of
|
||||
/// re-framing on the live edge.
|
||||
double pinned_pre_ = 0.0;
|
||||
double pinned_post_ = 0.0;
|
||||
bool pinned_ = false;
|
||||
double y_gutter_px_ = 0.0;
|
||||
bool y_gutter_reserved_ = false;
|
||||
std::array<Row, static_cast<std::size_t>(kMaxRows)> storage_{};
|
||||
std::array<bool, static_cast<std::size_t>(kMaxRows)> row_dirty_{};
|
||||
};
|
||||
|
||||
} // namespace udpscope
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include "trigger.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace udpscope {
|
||||
|
||||
double TriggerConfig::post_span_s(double buffer_span_s) const {
|
||||
const double pre = std::clamp(pretrigger_pct, 0.0, 100.0) / 100.0;
|
||||
return buffer_span_s * (1.0 - pre);
|
||||
}
|
||||
|
||||
void TriggerEngine::configure(const TriggerConfig& cfg, double post_seconds,
|
||||
double pre_seconds, double oldest_s) {
|
||||
const bool channel_changed = cfg.channel != cfg_.channel;
|
||||
cfg_ = cfg;
|
||||
post_seconds_ = post_seconds > 0.0 ? post_seconds : 0.0;
|
||||
pre_seconds_ = pre_seconds > 0.0 ? pre_seconds : 0.0;
|
||||
oldest_s_ = oldest_s;
|
||||
if (channel_changed) {
|
||||
have_prev_ = false;
|
||||
pending_ = false;
|
||||
post_accum_count_ = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void TriggerEngine::arm(double now_s) noexcept {
|
||||
armed_ = true;
|
||||
pending_ = false;
|
||||
post_accum_count_ = 0.0;
|
||||
holdoff_until_s_ = now_s;
|
||||
have_prev_ = false;
|
||||
}
|
||||
|
||||
bool TriggerEngine::process(double value, double now_s) noexcept {
|
||||
if (!cfg_.enabled || !armed_ || cfg_.channel < 0) return false;
|
||||
|
||||
// Hold-off: settled, waiting for the next edge.
|
||||
if (!pending_ && now_s < holdoff_until_s_) {
|
||||
prev_ = value;
|
||||
have_prev_ = true;
|
||||
last_t_s_ = now_s;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!have_prev_) {
|
||||
prev_ = value;
|
||||
have_prev_ = true;
|
||||
last_t_s_ = now_s;
|
||||
return false;
|
||||
}
|
||||
|
||||
last_t_s_ = now_s;
|
||||
|
||||
if (pending_) {
|
||||
// Accumulate the post-trigger window; latch once the stream clock has advanced
|
||||
// past it. Time, not a sample count: `now_s` is the same clock the x axis reads,
|
||||
// so the capture covers exactly the requested span whatever the cadence is.
|
||||
++post_accum_count_;
|
||||
prev_ = value;
|
||||
if (now_s - pending_start_s_ >= post_seconds_) {
|
||||
pending_ = false;
|
||||
post_accum_count_ = 0;
|
||||
if (cfg_.mode == TriggerMode::Single) {
|
||||
armed_ = false;
|
||||
} else {
|
||||
holdoff_until_s_ = now_s + std::max(0.0, cfg_.holdoff_s);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Edge detection against the level.
|
||||
const bool crossed_up = prev_ < cfg_.level && value >= cfg_.level;
|
||||
const bool crossed_down = prev_ > cfg_.level && value <= cfg_.level;
|
||||
const bool fired = (cfg_.edge == TriggerEdge::Rising && crossed_up) ||
|
||||
(cfg_.edge == TriggerEdge::Falling && crossed_down) ||
|
||||
(cfg_.edge == TriggerEdge::Both && (crossed_up || crossed_down));
|
||||
prev_ = value;
|
||||
if (!fired) return false;
|
||||
|
||||
// The pre-trigger window has to already be behind this edge, or the capture opens
|
||||
// on a half-filled pre window and the event is short on its left side -- the plot
|
||||
// then shows a window that is correctly sized but only partly drawn. The buffer
|
||||
// fills whether or not the trigger is armed, so this normally passes on the first
|
||||
// edge; it only bites just after a rebuild (a CONFIG, or enabling the signal),
|
||||
// when the history genuinely is not there yet.
|
||||
if (now_s - oldest_s_ < pre_seconds_) return false;
|
||||
|
||||
// The edge's own time, which is what a trigger marker marks: the latch itself
|
||||
// may happen a post-window later.
|
||||
last_trigger_s_ = now_s;
|
||||
|
||||
// The edge always opens a capture; there is no "latch on the edge itself" case.
|
||||
// A zero-length post window latches on the very next sample, which is the least a
|
||||
// capture can hold, and every longer window is just more samples -- no conversion
|
||||
// from seconds to a sample count, so no conversion to get wrong.
|
||||
pending_ = true;
|
||||
pending_start_s_ = now_s;
|
||||
post_accum_count_ = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace udpscope
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace udpscope {
|
||||
|
||||
/// Which way the trigger signal crosses the level.
|
||||
enum class TriggerEdge { Rising, Falling, Both };
|
||||
|
||||
/// How an armed trigger re-arms after it fired.
|
||||
enum class TriggerMode { Normal, Single };
|
||||
|
||||
/// The trigger panel's configuration (spec item 2.2).
|
||||
///
|
||||
/// The trigger names a *signal* (an index into `ScopeModel::signals()`), not a
|
||||
/// channel: the panel lists every signal the stream describes, whether or not it
|
||||
/// is being acquired, and picking one is what arms the trigger on it. The
|
||||
/// acquisition resolves the signal to the channel carrying it.
|
||||
struct TriggerConfig {
|
||||
bool enabled = false; ///< trigger mode on/off (toolbar).
|
||||
bool armed = false; ///< arm/disarm button.
|
||||
int channel = -1; ///< index into ScopeModel::channels(), -1 = none.
|
||||
double level = 0.0;
|
||||
TriggerEdge edge = TriggerEdge::Rising;
|
||||
double pretrigger_pct = 50.0; ///< share of the buffer shown before the event.
|
||||
double holdoff_s = 0.010; ///< default from the spec.
|
||||
TriggerMode mode = TriggerMode::Normal;
|
||||
|
||||
/// Seconds of post-trigger data to collect before latching the event, given
|
||||
/// the buffer's span.
|
||||
double post_span_s(double buffer_span_s) const;
|
||||
};
|
||||
|
||||
/// The producer-side trigger state machine. Fed one sample at a time in stream
|
||||
/// order; it says when the event buffer should be latched.
|
||||
class TriggerEngine {
|
||||
public:
|
||||
/// Configures the channel and the post-trigger window. Resets the state when the
|
||||
/// channel changes.
|
||||
///
|
||||
/// `post_seconds` is the post-trigger window in **stream seconds**, the same clock
|
||||
/// `process()` is fed and the same clock the x axis reads, so the capture covers
|
||||
/// exactly that much axis by construction.
|
||||
///
|
||||
/// It used to be a count of samples, converted from seconds by a measured estimate
|
||||
/// of the channel's cadence. That estimate is what made the window wrong: when it
|
||||
/// collapsed (any stream whose time signal advances in large steps drove it to the
|
||||
/// 1 S/s floor) the count came out as 1, the engine latched on the edge sample
|
||||
/// itself, and the event had no post-trigger data at all. Seconds need no estimate.
|
||||
///
|
||||
/// `pre_seconds` is the pre-trigger window, and `oldest_s` the stream time of the
|
||||
/// oldest sample the buffer still holds. Together they gate the edge: a capture is
|
||||
/// only allowed once the history reaches back a full `pre_seconds` before it, so an
|
||||
/// event cannot latch onto a half-filled pre window. `oldest_s` has to be the real
|
||||
/// buffer's, not a timer since arming -- the ring keeps filling with the trigger
|
||||
/// off, so the history is usually already there and waiting would be wrong.
|
||||
void configure(const TriggerConfig& cfg, double post_seconds, double pre_seconds,
|
||||
double oldest_s);
|
||||
/// Disarms without forgetting the configuration.
|
||||
void disarm() noexcept { armed_ = false; pending_ = false; }
|
||||
void arm(double now_s) noexcept;
|
||||
|
||||
/// Feeds one sample of the trigger channel. `now_s` is the sample's timestamp
|
||||
/// in stream seconds. Returns true when the event has just been latched.
|
||||
bool process(double value, double now_s) noexcept;
|
||||
|
||||
bool pending() const noexcept { return pending_; }
|
||||
bool armed() const noexcept { return armed_; }
|
||||
|
||||
/// Diagnostics for the capture state machine: how many samples have landed in
|
||||
/// the post-trigger window, the window it is filling (in stream seconds), how
|
||||
/// much of that has elapsed, and the last value fed.
|
||||
std::uint64_t post_accum() const noexcept { return post_accum_count_; }
|
||||
double post_seconds() const noexcept { return post_seconds_; }
|
||||
double post_elapsed() const noexcept {
|
||||
return pending_ ? last_t_s_ - pending_start_s_ : 0.0;
|
||||
}
|
||||
/// How much history the buffer holds behind the newest sample, against the
|
||||
/// pre-trigger window an edge needs before it may open a capture.
|
||||
double history() const noexcept { return last_t_s_ - oldest_s_; }
|
||||
double pre_seconds() const noexcept { return pre_seconds_; }
|
||||
double last_value() const noexcept { return prev_; }
|
||||
bool have_last() const noexcept { return have_prev_; }
|
||||
double last_t() const noexcept { return last_t_s_; }
|
||||
double holdoff_until() const noexcept { return holdoff_until_s_; }
|
||||
|
||||
/// Stream time of the edge that armed the event now on screen. Valid once
|
||||
/// `process()` has fired; the view is centred on this instant (pre-trigger share
|
||||
/// before it, post-trigger share after), so it is the x a trigger marker belongs
|
||||
/// at. Unchanged while a later event has not fired yet.
|
||||
double last_trigger_time() const noexcept { return last_trigger_s_; }
|
||||
|
||||
private:
|
||||
TriggerConfig cfg_{};
|
||||
bool armed_ = false;
|
||||
bool have_prev_ = false;
|
||||
double prev_ = 0.0;
|
||||
bool pending_ = false; ///< an edge was seen; collecting the post window
|
||||
double pending_start_s_ = 0.0;
|
||||
double holdoff_until_s_ = 0.0;
|
||||
std::uint64_t post_accum_count_ = 0;
|
||||
double post_seconds_ = 0.0; ///< post-trigger window, in stream seconds.
|
||||
double pre_seconds_ = 0.0; ///< pre-trigger window an edge needs behind it.
|
||||
double oldest_s_ = 0.0; ///< stream time of the oldest sample still buffered.
|
||||
double last_t_s_ = 0.0;
|
||||
double last_trigger_s_ = 0.0;
|
||||
};
|
||||
|
||||
} // namespace udpscope
|
||||
+2164
File diff suppressed because it is too large.
Load diff
+17
@@ -0,0 +1,17 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace udpscope {
|
||||
|
||||
struct Config;
|
||||
class ScopeModel;
|
||||
class Acquisition;
|
||||
class RowSet;
|
||||
|
||||
/// Runs the GTK application. Never returns while the window is open; returns the
|
||||
/// `g_application_run()` result once it closes.
|
||||
int run_ui(int argc, char** argv, ScopeModel& model, Acquisition& acq, RowSet& rows);
|
||||
|
||||
} // namespace udpscope
|
||||
Reference in new issue
Block a user