Initial commit

This commit is contained in:
Martino Ferrari committed 2026-10-01 15:42:48 +02:00
commit 8059b90de7
29 files changed
+7893

No files matched your search

+476
View File
@@ -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
+83
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+41
View File
@@ -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
+141
View File
@@ -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
View File
@@ -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
+59
View File
@@ -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
+36
View File
@@ -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
View File
@@ -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
View File
@@ -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
+414
View File
@@ -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
+215
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+17
View File
@@ -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