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

+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