Initial commit
This commit is contained in:
commit
8059b90de7
29 files changed
+7893
No files matched your search
+297
@@ -0,0 +1,297 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "channel.hpp"
|
||||
#include "config.hpp"
|
||||
#include "event_series.hpp"
|
||||
#include "trigger.hpp"
|
||||
|
||||
#include <plot/data/ring_buffer.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace udpscope {
|
||||
|
||||
/// One streamed signal, as the CONFIG packet described it.
|
||||
struct SignalInfo {
|
||||
std::string name;
|
||||
std::string unit;
|
||||
std::uint8_t type_code = 0;
|
||||
std::uint8_t quant_type = 0;
|
||||
std::uint32_t rows = 1;
|
||||
std::uint32_t cols = 1;
|
||||
double range_min = 0.0;
|
||||
double range_max = 1.0;
|
||||
std::uint8_t time_mode = 0;
|
||||
double sampling_rate = 0.0;
|
||||
std::uint32_t time_signal_idx = 0xFFFFFFFFu;
|
||||
|
||||
std::uint32_t elements() const noexcept {
|
||||
const std::uint32_t n = rows * cols;
|
||||
return n == 0 ? 1u : n;
|
||||
}
|
||||
};
|
||||
|
||||
/// One plottable signal element: a signal with a single element, or one element
|
||||
/// of a vector/matrix signal. The unit of enable/disable is the *signal*; the
|
||||
/// unit of acquisition and plotting is the channel.
|
||||
struct ChannelInfo {
|
||||
std::size_t signal = 0;
|
||||
bool enabled = false; ///< whether its signal is enabled
|
||||
double gain = 1.0; ///< calibration, applied as sample * gain + offset
|
||||
double offset = 0.0;
|
||||
|
||||
std::string name; ///< the signal's name
|
||||
std::string unit;
|
||||
|
||||
/// Samples of this channel per DATA packet (`elements()` of the signal). A
|
||||
/// UDPStreamer signal is one channel: its columns are consecutive samples of the
|
||||
/// same signal, not separate channels.
|
||||
std::uint32_t elements = 1;
|
||||
|
||||
/// Sample interval of this channel's own cadence, in seconds. 0 when unknown.
|
||||
double dt = 0.0;
|
||||
};
|
||||
|
||||
/// How one buffer's storage is divided between the shared time base and the
|
||||
/// channels (spec item 2: "split in N+1", the +1 being the time base).
|
||||
///
|
||||
/// The split is by *time span*, not by bytes: every channel's ring and the time
|
||||
/// ring cover the same amount of stream time, which is what makes a shared time
|
||||
/// base meaningful. When no channel is decimated (the common case) the two rules
|
||||
/// coincide and every slice is the same size.
|
||||
struct Partition {
|
||||
u64 time_capacity = 0; ///< time-base slots retained
|
||||
u64 time_stride = 1; ///< base ticks per time-base slot (reserved; 1)
|
||||
std::vector<u64> stride; ///< time slots per channel sample (>= 1)
|
||||
std::vector<u64> value_capacity; ///< samples retained per channel
|
||||
double base_dt = 1.0; ///< seconds per base tick
|
||||
double span_s = 0.0; ///< stream seconds one buffer covers
|
||||
|
||||
u64 total_samples() const noexcept;
|
||||
u64 total_bytes() const noexcept { return total_samples() * sizeof(f32); }
|
||||
/// Seconds between two time-base slots (the sample interval of every channel).
|
||||
double slot_dt() const noexcept { return base_dt * static_cast<double>(time_stride); }
|
||||
};
|
||||
|
||||
/// Plans a partition for `channel_dt` cadences within `buffer_bytes`, targeting a
|
||||
/// span of `target_span_s` seconds. Throws std::runtime_error when the budget
|
||||
/// cannot hold even one sample per channel.
|
||||
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes);
|
||||
|
||||
/// One of the two identical sample buffers: a shared time-base ring plus one
|
||||
/// value ring and one `ChannelBuffer` per channel.
|
||||
class BufferStore {
|
||||
public:
|
||||
BufferStore(const Partition& part, f64 span_s);
|
||||
|
||||
/// Re-bases every channel's x (see `ChannelBuffer::set_epoch`). Used to make the
|
||||
/// axis read seconds from the trigger event.
|
||||
void set_epoch(f64 epoch) noexcept;
|
||||
|
||||
plot::data::RingBuffer<f32>& time() noexcept { return *time_; }
|
||||
const plot::data::RingBuffer<f32>& time() const noexcept { return *time_; }
|
||||
plot::data::RingBuffer<f32>& values(std::size_t channel) { return *values_[channel]; }
|
||||
ChannelBuffer& channel(std::size_t channel) { return *channels_[channel]; }
|
||||
/// Shared handle to a channel view: the caller keeps the storage alive even if
|
||||
/// the model rebuilds.
|
||||
std::shared_ptr<ChannelBuffer> shared_channel(std::size_t channel) {
|
||||
return channels_[channel];
|
||||
}
|
||||
const Partition& partition() const noexcept { return part_; }
|
||||
|
||||
/// Consumer: publishes every channel's new samples.
|
||||
void acquire_all();
|
||||
|
||||
private:
|
||||
Partition part_;
|
||||
std::shared_ptr<plot::data::RingBuffer<f32>> time_;
|
||||
std::vector<std::shared_ptr<plot::data::RingBuffer<f32>>> values_;
|
||||
std::vector<std::shared_ptr<ChannelBuffer>> channels_;
|
||||
};
|
||||
|
||||
/// Live connection and counter state, readable from the UI thread.
|
||||
struct StreamStats {
|
||||
std::atomic<std::uint64_t> packets{0};
|
||||
std::atomic<std::uint64_t> frames{0};
|
||||
std::atomic<std::uint64_t> gaps{0};
|
||||
std::atomic<std::uint64_t> reconnects{0};
|
||||
std::atomic<bool> connected{false};
|
||||
std::atomic<bool> have_config{false};
|
||||
};
|
||||
|
||||
/// The application's data model: the stream description, the two sample buffers,
|
||||
/// the trigger configuration and the counters. Structural changes (a CONFIG
|
||||
/// update, an enable/disable, a calibration) rebuild the buffers, so all mutating
|
||||
/// access is serialised on one mutex; the acquisition thread takes it once per
|
||||
/// DATA frame.
|
||||
class ScopeModel {
|
||||
public:
|
||||
explicit ScopeModel(const Config& cfg);
|
||||
|
||||
const Config& config() const noexcept { return cfg_; }
|
||||
|
||||
// --- stream description ---------------------------------------------------
|
||||
/// Takes a new CONFIG: rebuilds the signal and channel lists and the two
|
||||
/// buffers. Clears the trigger's channel selection if the stream changed.
|
||||
void set_signals(std::vector<SignalInfo> signals);
|
||||
const std::vector<SignalInfo>& signals() const noexcept { return signals_; }
|
||||
const std::vector<ChannelInfo>& channels() const noexcept { return channels_; }
|
||||
|
||||
bool signal_enabled(std::size_t signal) const;
|
||||
/// True when the signal is the stream's time base, i.e. when some other signal
|
||||
/// declares it via `time_signal_idx`. A signal used as a time base builds the x
|
||||
/// axis and is not a plottable channel.
|
||||
static bool is_time_signal(const std::vector<SignalInfo>& signals,
|
||||
std::size_t signal);
|
||||
void set_signal_enabled(std::size_t signal, bool on);
|
||||
void set_calibration(std::size_t signal, double gain, double offset);
|
||||
|
||||
std::size_t channel_count() const noexcept { return channels_.size(); }
|
||||
const Partition& partition() const noexcept { return part_; }
|
||||
|
||||
/// The channel's sample interval (0 when the stream did not say).
|
||||
double channel_dt(std::size_t channel) const;
|
||||
|
||||
// --- buffers --------------------------------------------------------------
|
||||
/// The one sample store: the producer always writes it, and the plot draws from
|
||||
/// it whenever it is not showing a frozen event. Always-filling is what
|
||||
/// guarantees a full pre-trigger history on the *first* latch after the trigger
|
||||
/// is armed.
|
||||
BufferStore& live();
|
||||
|
||||
/// Builds the captured event from the slice of the live buffer covering
|
||||
/// `[edge - pre_s, edge + post_s]` in stream seconds, and publishes it atomically.
|
||||
/// The stamps are re-based so the edge sits at x = 0. The plot binds to this
|
||||
/// immutable snapshot -- it is the "frozen buffer" -- while the live ring keeps
|
||||
/// filling behind it.
|
||||
///
|
||||
/// The cut is by time, not by a sample count: the window the trigger panel asks
|
||||
/// for is in seconds and the time ring stores seconds, so any count in between
|
||||
/// needs a cadence estimate -- which is exactly what used to size the capture
|
||||
/// wrong.
|
||||
void publish_event(double pre_s, double post_s, double edge);
|
||||
|
||||
/// The live buffer's absolute index range covering `[lo_s, hi_s]` in stream seconds,
|
||||
/// clamped to what the time ring and *every* value ring still hold. One range for the
|
||||
/// time ring and all the channels, which is what keeps a cut index-aligned by
|
||||
/// construction; the bounds are compared in the ring's own `f32`, so a bound that
|
||||
/// lands on a sample includes it whatever the magnitude of the stream time.
|
||||
///
|
||||
/// Caller holds `mutex()` (the acquisition thread already does when it latches).
|
||||
void live_window_locked(double lo_s, double hi_s, u64& first, u64& last);
|
||||
/// The event on screen, or null before the first latch.
|
||||
std::shared_ptr<const EventSnapshot> event() const noexcept {
|
||||
return event_.load(std::memory_order_acquire);
|
||||
}
|
||||
|
||||
/// Publishes the live buffer's new samples to its `ChannelBuffer`s.
|
||||
void acquire_all();
|
||||
|
||||
// --- trigger --------------------------------------------------------------
|
||||
/// The trigger configuration. Mutating it means holding `mutex()`: the producer
|
||||
/// reads it once per frame (and writes `armed` itself when a single-shot latch
|
||||
/// disarms), so an unguarded field write can be lost or read half-updated. The
|
||||
/// panel's callbacks take the lock for the fields they touch.
|
||||
TriggerConfig& trigger() noexcept { return trigger_; }
|
||||
const TriggerConfig& trigger() const noexcept { return trigger_; }
|
||||
|
||||
/// Trigger mode on/off, as both the frame tick and the producer read it: the one
|
||||
/// trigger field the tick needs and the only one written from the UI, so it has
|
||||
/// an atomic mirror and a setter that keeps the copy under the lock in step.
|
||||
bool trigger_enabled() const noexcept {
|
||||
return trigger_enabled_.load(std::memory_order_relaxed);
|
||||
}
|
||||
void set_trigger_enabled(bool on);
|
||||
|
||||
/// Whether the engine is mid-capture (an edge was seen and the post-trigger
|
||||
/// window is still filling). Purely informational: it drives the status line's
|
||||
/// wording. The plot deliberately does *not* react to it -- in trigger mode the
|
||||
/// picture changes only on a latch (SPECS: "data visualisation should not be
|
||||
/// refreshed till next event").
|
||||
bool trigger_pending() const noexcept {
|
||||
return trigger_pending_.load(std::memory_order_relaxed);
|
||||
}
|
||||
void set_trigger_pending(bool on) noexcept {
|
||||
trigger_pending_.store(on, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
/// The x origin the plot uses: 0 for "seconds since the stream started", or minus
|
||||
/// the trigger instant while in trigger mode, so the axis reads seconds from the
|
||||
/// event. Applied to the live store's channels.
|
||||
f64 display_epoch() const noexcept { return display_epoch_.load(std::memory_order_relaxed); }
|
||||
void set_display_epoch(f64 e) noexcept;
|
||||
|
||||
// --- stream state ---------------------------------------------------------
|
||||
StreamStats& stats() noexcept { return stats_; }
|
||||
const StreamStats& stats() const noexcept { return stats_; }
|
||||
/// Seconds since the epoch of the streamed time, or 0 before the first frame.
|
||||
f64 epoch() const noexcept { return epoch_; }
|
||||
void set_epoch_if_unset(f64 e) noexcept;
|
||||
|
||||
/// Bumped whenever the signal or channel lists change, so the UI knows to
|
||||
/// rebuild the panels it derives from them.
|
||||
unsigned generation() const noexcept { return generation_.load(std::memory_order_acquire); }
|
||||
|
||||
/// The length of the event the trigger latches, in seconds: the time window the
|
||||
/// toolbar selects. The pre-trigger percentage is a share of *this*, not of the
|
||||
/// whole ring, which is what makes the control mean something on a long buffer.
|
||||
void set_event_span(double seconds) noexcept {
|
||||
event_span_.store(seconds > 0.0 ? seconds : 1.0, std::memory_order_relaxed);
|
||||
}
|
||||
double event_span() const noexcept { return event_span_.load(std::memory_order_relaxed); }
|
||||
|
||||
/// Trigger state for the UI, published by the acquisition thread. Atomics
|
||||
/// rather than fields of `TriggerConfig` because the producer writes the arm
|
||||
/// state when a single-shot trigger disarms itself, and the frame tick reads it
|
||||
/// without holding the model mutex.
|
||||
std::atomic<bool>& trigger_armed() noexcept { return trigger_armed_; }
|
||||
/// Incremented every time an event is latched, so the UI can tell "waiting for
|
||||
/// an event" from "an event is on screen".
|
||||
std::atomic<std::uint64_t>& trigger_latches() noexcept { return trigger_latches_; }
|
||||
/// The stream time of the edge behind the event on screen, and whether there is
|
||||
/// one. Written by the acquisition thread on a latch and cleared by the UI when
|
||||
/// the event stops meaning anything (arming again, or leaving trigger mode).
|
||||
std::atomic<double>& trigger_time() noexcept { return trigger_time_; }
|
||||
std::atomic<bool>& trigger_time_valid() noexcept { return trigger_time_valid_; }
|
||||
|
||||
/// Serialises a structural change against the acquisition thread.
|
||||
std::mutex& mutex() noexcept { return mutex_; }
|
||||
|
||||
/// Rebuilds buffers from the current channel list. Caller holds `mutex()`.
|
||||
void rebuild_locked();
|
||||
|
||||
private:
|
||||
void rebuild_channels();
|
||||
|
||||
Config cfg_;
|
||||
std::vector<SignalInfo> signals_;
|
||||
std::vector<ChannelInfo> channels_;
|
||||
std::vector<bool> enabled_;
|
||||
|
||||
Partition part_;
|
||||
std::unique_ptr<BufferStore> store_;
|
||||
TriggerConfig trigger_{};
|
||||
StreamStats stats_{};
|
||||
f64 epoch_ = 0.0;
|
||||
std::atomic<double> event_span_{1.0};
|
||||
std::atomic<bool> trigger_pending_{false};
|
||||
std::atomic<std::shared_ptr<const EventSnapshot>> event_; ///< the event on screen
|
||||
std::atomic<double> display_epoch_{0.0};
|
||||
std::atomic<bool> trigger_armed_{false};
|
||||
std::atomic<bool> trigger_enabled_{false};
|
||||
std::atomic<std::uint64_t> trigger_latches_{0};
|
||||
std::atomic<double> trigger_time_{0.0};
|
||||
std::atomic<bool> trigger_time_valid_{false};
|
||||
std::atomic<unsigned> generation_{0};
|
||||
std::mutex mutex_;
|
||||
};
|
||||
|
||||
} // namespace udpscope
|
||||
Reference in new issue
Block a user