added math signals

This commit is contained in:
Martino Ferrari committed 2026-10-03 17:28:17 +02:00
1 parent 02620ca946
commit aa2508e431
10 files changed
+1773 -27

No files matched your search

+15
View File
@@ -395,6 +395,7 @@ void Acquisition::on_data(const udps_frame_t* frame) {
acc_channels_ = channels.size();
value_acc_.assign(channels.size(), 0.0);
value_acc_n_.assign(channels.size(), 0);
math_sample_.assign(channels.size(), std::nanf(""));
time_acc_ = 0.0;
time_acc_n_ = 0;
}
@@ -528,6 +529,12 @@ void Acquisition::on_data(const udps_frame_t* frame) {
for (std::size_t i = 0; i < channels.size(); ++i) {
const ChannelInfo& ch = channels[i];
// A derived channel is filled by `push_math_sample()` below, once per stored slot, from the
// values the streamed ones just pushed. Reading it from the packet would give zero (it has no
// signal) and pushing that would advance its value ring twice per time slot -- which is an
// alternation between the value and 0 while the buffer is filling, and a complete loss of the
// pairing once both rings wrap.
if (ch.math) continue;
// 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.
@@ -549,6 +556,9 @@ void Acquisition::on_data(const udps_frame_t* frame) {
value_acc_[i] = 0.0;
value_acc_n_[i] = 0;
store.values(i).push(value);
// This slot's value, for the math channels below. Every channel is pushed on the same slot
// (per-channel strides are 1 by design), so the vector is complete by the time the loop ends.
if (i < math_sample_.size()) math_sample_[i] = value;
// The trigger sees the *stored* signal, not the wire signal. An edge found in a
// sample that is not in the buffer would be cut from data that does not contain it,
// and with the filter in front of it a spike above the stored Nyquist is exactly
@@ -560,6 +570,11 @@ void Acquisition::on_data(const udps_frame_t* frame) {
}
}
}
// The derived channels: once per *stored* slot, and **before** the latch publishes.
// `publish_event()` clamps its snapshot to every value ring's `produced()`, so a derived
// channel pushed after it would be missing its newest sample from the capture -- the same
// one-sample clamp the deferred latch above exists for.
if (tick % g == g - 1) model_.push_math_sample(math_sample_);
if (latch_here) latch(latch_edge);
}
last_stamp_ = last_stamp_used;
+3
View File
@@ -96,6 +96,9 @@ class Acquisition {
/// instead of reappearing as a false low frequency. Cleared whenever the partition
/// changes, since the group length is `time_stride`.
std::vector<double> value_acc_;
/// The channels' values for the current *stored* slot, which the math channels are evaluated from
/// once the slot's samples have all been pushed. One entry per channel, refilled every slot.
std::vector<f32> math_sample_;
std::vector<std::uint64_t> value_acc_n_;
double time_acc_ = 0.0;
std::uint64_t time_acc_n_ = 0;
+154 -7
View File
@@ -37,13 +37,15 @@ std::string config_dir() {
}
void mkdirs(const std::string& path) {
// **Every** component, the last one included. The condition used to be "the path has a trailing
// slash", so the final directory was never created: `~/.config/udpscope` did not exist, the
// first `save_layout()` failed silently, and the session appeared to persist only on a machine
// where something else had already made the directory.
std::string cur;
for (const char c : path) {
cur.push_back(c);
if (c == '/' || cur.size() == path.size()) {
if (cur == "/" || cur.back() != '/') continue;
::mkdir(cur.c_str(), 0755);
}
for (std::size_t i = 0; i < path.size(); ++i) {
cur.push_back(path[i]);
if (path[i] != '/' && i + 1 != path.size()) continue;
::mkdir(cur.c_str(), 0755);
}
}
@@ -115,6 +117,57 @@ std::string layout_path(const std::string& host, std::uint16_t port) {
return dir + "/" + sanitize(host) + "_" + std::to_string(port) + ".toml";
}
std::string math_node_text(const MathNode& node) {
// The op is written as its index through `math_op_at()`, not as the raw enum value: that accessor
// is the contract the dialog, the engine and this file share, so the format is tied to it rather
// than to the declaration order of an enum nobody else has to know about.
int kind = 0;
for (int i = 0; i < math_op_count(); ++i) {
if (math_op_at(i) == node.op) kind = i;
}
std::ostringstream o;
o << kind << '|' << node.source << '|' << node.in_a << '|' << node.in_b << '|' << node.gain << '|'
<< node.offset << '|' << node.fc << '|' << node.q << '|' << node.lo << '|' << node.hi << '|'
<< node.x << '|' << node.y;
return o.str();
}
bool math_node_from_text(const std::string& text, MathNode& out) {
std::vector<std::string> f;
std::string cur;
for (const char c : text) {
if (c == '|') {
f.push_back(cur);
cur.clear();
} else {
cur.push_back(c);
}
}
f.push_back(cur);
if (f.size() != 12u) return false;
const auto number = [&](std::size_t i, double& v) {
try {
v = std::stod(f[i]);
return true;
} catch (...) {
return false;
}
};
double kind = 0.0;
double in_a = -1.0;
double in_b = -1.0;
if (!number(0, kind) || !number(2, in_a) || !number(3, in_b)) return false;
const int k = static_cast<int>(kind);
if (k < 0 || k >= math_op_count()) return false;
out = MathNode{};
out.op = math_op_at(k);
out.source = f[1];
out.in_a = static_cast<int>(in_a);
out.in_b = static_cast<int>(in_b);
return number(4, out.gain) && number(5, out.offset) && number(6, out.fc) && number(7, out.q) &&
number(8, out.lo) && number(9, out.hi) && number(10, out.x) && number(11, out.y);
}
bool load_layout(const std::string& path, LayoutState& out) {
std::ifstream in(path);
if (!in) return false;
@@ -123,10 +176,13 @@ bool load_layout(const std::string& path, LayoutState& out) {
st.window_s = 0.0;
st.rows.clear();
st.calibration.clear();
st.enabled.clear();
st.math.clear();
st.measures.clear();
std::string line;
// What the last `[[...]]` header opened, so keys land in the right record.
enum class Section { Top, Plot, Calibration } section = Section::Top;
enum class Section { Top, Plot, Calibration, Math, Measure } section = Section::Top;
int plot_count = 0;
bool plot_section_seen = false;
while (std::getline(in, line)) {
@@ -140,6 +196,12 @@ bool load_layout(const std::string& path, LayoutState& out) {
} else if (t == "[[calibration]]") {
section = Section::Calibration;
st.calibration.emplace_back();
} else if (t == "[[math]]") {
section = Section::Math;
st.math.emplace_back();
} else if (t == "[[measure]]") {
section = Section::Measure;
st.measures.emplace_back();
} else {
section = Section::Top; // an unknown table: keep its keys out of ours
}
@@ -168,6 +230,10 @@ bool load_layout(const std::string& path, LayoutState& out) {
st.window_s = std::stod(value);
} catch (...) {
}
} else if (key == "enabled") {
if (value.size() >= 2 && value.front() == '[' && value.back() == ']') {
parse_string_array(value.substr(1, value.size() - 2), st.enabled);
}
} else if (key == "plots") {
// Only a fallback for a file with no [[plot]] sections: the writer emits
// both, and honouring both appended the sections onto the pre-created
@@ -185,6 +251,52 @@ bool load_layout(const std::string& path, LayoutState& out) {
}
st.rows.back().signals = names;
}
} else if (section == Section::Math && !st.math.empty()) {
MathChannel& m = st.math.back();
if (key == "name") {
m.name = strip_quote(value);
} else if (key == "unit") {
m.unit = strip_quote(value);
} else if (key == "enabled") {
m.enabled = value == "true";
} else if (key == "output") {
try {
m.output = std::stoi(value);
} catch (...) {
}
} else if (key == "nodes") {
std::vector<std::string> texts;
if (value.size() >= 2 && value.front() == '[' && value.back() == ']') {
parse_string_array(value.substr(1, value.size() - 2), texts);
}
m.nodes.clear();
for (const std::string& text : texts) {
MathNode node;
// A node this version cannot read is dropped rather than failing the whole file: a graph
// with a hole in it is invalid and the editor says so, which beats losing the session.
if (math_node_from_text(text, node)) m.nodes.push_back(node);
}
}
} else if (section == Section::Measure && !st.measures.empty()) {
Measure& m = st.measures.back();
if (key == "signal") {
m.signal = strip_quote(value);
} else if (key == "type") {
try {
const int index = std::stoi(value);
if (index >= 0 && index < measure_type_count()) m.type = measure_type_at(index);
} catch (...) {
}
} else if (key == "threshold") {
try {
m.options.threshold_pct = std::stod(value);
} catch (...) {
}
} else if (key == "above") {
m.options.above = value == "true";
} else if (key == "show") {
m.options.show_on_plot = value == "true";
}
} else if (section == Section::Calibration && !st.calibration.empty()) {
if (key == "name") {
st.calibration.back().name = strip_quote(value);
@@ -213,6 +325,17 @@ bool save_layout(const std::string& path, const LayoutState& state) {
o << "# udpscope session layout (written by the app; safe to edit)\n";
if (state.window_s > 0.0) o << "window_s = " << state.window_s << "\n";
o << "plots = " << state.rows.size() << "\n";
// Top level, before any table. Emitted after the `[[calibration]]` tables it belonged to the last
// of them -- a bare key after a header is that table's key in TOML -- so the parser, correctly,
// never saw it and a session's enable state was silently lost.
if (!state.enabled.empty()) {
o << "enabled = [";
for (std::size_t i = 0; i < state.enabled.size(); ++i) {
if (i != 0) o << ", ";
o << toml_escape(state.enabled[i]);
}
o << "]\n";
}
for (const auto& row : state.rows) {
o << "\n[[plot]]\nsignals = [";
for (std::size_t i = 0; i < row.signals.size(); ++i) {
@@ -225,6 +348,30 @@ bool save_layout(const std::string& path, const LayoutState& state) {
o << "\n[[calibration]]\nname = " << toml_escape(cal.name) << "\ngain = " << cal.gain
<< "\noffset = " << cal.offset << "\n";
}
for (const MathChannel& m : state.math) {
o << "\n[[math]]\nname = " << toml_escape(m.name) << "\nunit = " << toml_escape(m.unit)
<< "\nenabled = " << (m.enabled ? "true" : "false") << "\noutput = " << m.output
<< "\nnodes = [";
for (std::size_t i = 0; i < m.nodes.size(); ++i) {
if (i != 0) o << ", ";
o << toml_escape(math_node_text(m.nodes[i]));
}
o << "]\n";
}
for (const Measure& m : state.measures) {
// The *accessor's* index, not the enum's value: `measure_type_at()` is the mapping the dialog
// and this file share, and its order is the dropdown's -- which is not the declaration order of
// the enum. Writing the raw value and reading it back through the accessor turned `top` into
// whatever sat at that position (the same mistake the math nodes' op index was written to avoid).
int kind = 0;
for (int i = 0; i < measure_type_count(); ++i) {
if (measure_type_at(i) == m.type) kind = i;
}
o << "\n[[measure]]\nsignal = " << toml_escape(m.signal) << "\ntype = " << kind
<< "\nthreshold = " << m.options.threshold_pct
<< "\nabove = " << (m.options.above ? "true" : "false") << "\nshow = "
<< (m.options.show_on_plot ? "true" : "false") << "\n";
}
// A sibling temp file + rename: a crash mid-write leaves the old layout, not a
// truncated one that would fail to parse next start.
+23
View File
@@ -6,6 +6,9 @@
#include <string>
#include <vector>
#include "math_channel.hpp"
#include "measure.hpp"
namespace udpscope {
/// The user-arranged part of a session, persisted per data source in
@@ -28,8 +31,28 @@ struct LayoutState {
double offset = 0.0;
};
std::vector<Calibration> calibration;
/// Signals the session had enabled. Enabling is what starts acquisition, so this is what makes a
/// reopened scope look like the one that was closed -- and it is saved by *name*, like every other
/// identity here, because a CONFIG renumbers the channels.
std::vector<std::string> enabled;
/// The derived channels, graphs and all: the editor's work is part of the session, not of the
/// process. A graph is stored as one string per node so the file stays in this store's TOML
/// subset (tables, string arrays, scalars) rather than growing a nested-table parser for it; see
/// `math_node_text()` for the field order.
std::vector<MathChannel> math;
/// The measurements pane, by signal *name* and with the type and options a row was built with.
std::vector<Measure> measures;
};
/// One `MathNode` as `kind|source|in_a|in_b|gain|offset|fc|q|lo|hi|x|y`, and back. The kind is the
/// op's *index* into `math_op_at()`, which is why the enum's order is a stored format and not just
/// an implementation detail: reordering it renames every saved graph's operations.
std::string math_node_text(const MathNode& node);
bool math_node_from_text(const std::string& text, MathNode& out);
/// `~/.config/udpscope/<host>_<port>.toml`, with the directory created.
/// Characters unsafe in a filename are replaced in the host part.
std::string layout_path(const std::string& host, std::uint16_t port);
+105 -1
View File
@@ -148,11 +148,18 @@ ScopeModel::ScopeModel(const Config& cfg) : cfg_(cfg) {
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.
// Keep the enable state across CONFIG updates for signals that kept their name. The math
// channels' synthetic entries carry theirs in `math_`, so they are skipped here -- a CONFIG must
// not be able to turn a derived channel off, and must not lose it either (it is re-appended
// below).
std::unordered_map<std::string, bool> was_enabled;
for (std::size_t i = 0; i < signals_.size(); ++i) {
if (signals_[i].math) continue;
was_enabled.emplace(signals_[i].name, i < enabled_.size() ? enabled_[i] : false);
}
signals.erase(std::remove_if(signals.begin(), signals.end(),
[](const SignalInfo& s) { return s.math; }),
signals.end());
signals_ = std::move(signals);
enabled_.assign(signals_.size(), false);
for (std::size_t i = 0; i < signals_.size(); ++i) {
@@ -162,17 +169,103 @@ void ScopeModel::set_signals(std::vector<SignalInfo> signals) {
// chose for a signal that kept its name.
enabled_[i] = it != was_enabled.end() ? it->second : false;
}
append_math_signals_locked();
rebuild_channels();
rebuild_locked();
generation_.fetch_add(1, std::memory_order_release);
}
void ScopeModel::append_math_signals_locked() {
// One synthetic signal per math channel, appended after the stream's own. They go through the
// ordinary signal path on purpose: a derived channel is a channel, so the panel's switch, the
// plot binding, the measurements and the export all work on it without knowing it is derived.
// A `Source` in a graph names a *signal*, so another math channel is a valid source too.
math_signal_names_.clear();
for (const MathChannel& m : math_) {
if (m.name.empty()) continue;
SignalInfo sig;
sig.name = m.name;
sig.unit = m.unit;
sig.math = true;
sig.sampling_rate = 0.0; // not a streamed signal: the model's own dt applies
math_signal_names_.push_back(m.name);
signals_.push_back(std::move(sig));
enabled_.push_back(m.enabled);
}
}
void ScopeModel::set_math_channels(std::vector<MathChannel> channels) {
std::lock_guard<std::mutex> lock(mutex_);
math_ = std::move(channels);
// Lift the previous synthetic entries out of the signal list; `append_math_signals_locked()`
// puts the current ones back, so an edit is the same kind of structural rebuild as a CONFIG.
signals_.erase(std::remove_if(signals_.begin(), signals_.end(),
[](const SignalInfo& s) { return s.math; }),
signals_.end());
enabled_.resize(signals_.size());
append_math_signals_locked();
rebuild_channels();
rebuild_locked();
// Prepare the runtimes for the rate this plan chose: a cutoff the user typed in Hz is only
// meaningful against the rate the filter will actually run at, and decimation can have changed
// it under them.
prepare_math_locked();
generation_.fetch_add(1, std::memory_order_release);
}
void ScopeModel::prepare_math_locked() {
// Called from `rebuild_locked()`, so it runs on **every** plan change -- a CONFIG, an
// enable/disable, and a window change that moves the decimation. A biquad's coefficients are a
// function of the sample interval, so one prepared for the old rate is a filter with the wrong
// cutoff the moment the plan moves; and its state is the old rate's history, which is not a
// starting point for the new one. Both are reset here, together, so the two cannot drift apart.
math_rt_.assign(math_.size(), MathRuntime{});
const double dt = part_.slot_dt();
for (std::size_t i = 0; i < math_.size(); ++i) math_prepare(math_[i], math_rt_[i], dt);
}
void ScopeModel::push_math_sample(const std::vector<f32>& sample) {
// Values for this slot, extended as the derived ones are computed: a graph may name another math
// channel, and nodes are evaluated in list order, so by the time one is asked for it is there.
std::vector<f32> values = sample;
const auto value_of = [&](const std::string& name) -> double {
for (std::size_t c = 0; c < channels_.size(); ++c) {
if (channels_[c].name != name) continue;
return c < values.size() ? static_cast<double>(values[c]) : std::nan("");
}
return std::nan("");
};
for (std::size_t m = 0; m < math_.size(); ++m) {
const int channel = m < math_channel_.size() ? math_channel_[m] : -1;
if (channel < 0 || m >= math_rt_.size()) continue;
const double out = math_step(math_[m], math_rt_[m], value_of);
live().values(static_cast<std::size_t>(channel)).push(static_cast<f32>(out));
if (values.size() <= static_cast<std::size_t>(channel)) {
values.resize(static_cast<std::size_t>(channel) + 1, 0.0f);
}
values[static_cast<std::size_t>(channel)] = static_cast<f32>(out);
}
}
void ScopeModel::rebuild_channels() {
channels_.clear();
// Which channel each math channel ended up with, by name: a disabled one has none, and the
// producer must not push a derived sample into another channel's ring.
math_channel_.assign(math_.size(), -1);
const auto math_index_of = [&](const std::string& name) -> int {
for (std::size_t k = 0; k < math_.size(); ++k) {
if (math_[k].name == name) return static_cast<int>(k);
}
return -1;
};
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;
if (sig.math) {
const int k = math_index_of(sig.name);
if (k >= 0) math_channel_[static_cast<std::size_t>(k)] = static_cast<int>(channels_.size());
}
ChannelInfo ch;
ch.signal = s;
ch.enabled = true;
@@ -182,6 +275,7 @@ void ScopeModel::rebuild_channels() {
// exactly `elements` values per packet, which is the block of samples.
ch.name = sig.name;
ch.unit = sig.unit;
ch.math = sig.math;
ch.elements = sig.elements();
ch.dt = sig.sampling_rate > 0.0 ? 1.0 / sig.sampling_rate : 0.0;
// A calibration the user already set for this name (before it was enabled, or
@@ -210,6 +304,7 @@ void ScopeModel::rebuild_locked() {
store_ = std::make_unique<BufferStore>(part_, part_.span_s);
if (trigger_.channel >= static_cast<int>(channels_.size())) trigger_.channel = -1;
prepare_math_locked();
}
bool ScopeModel::set_target_span(double seconds) {
@@ -262,6 +357,15 @@ 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;
// A math channel's synthetic signal is enabled *here* -- it is the panel's switch -- while
// `append_math_signals_locked()` re-states `enabled_` from `math_` on the next edit. The two have
// to agree, or an edit would quietly turn a derived channel off (and the rebuild below reads
// `enabled_`, so the channel itself would go with it).
if (signal < signals_.size() && signals_[signal].math) {
for (MathChannel& m : math_) {
if (m.name == signals_[signal].name) m.enabled = 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).
+44 -1
View File
@@ -16,6 +16,8 @@
#include <memory>
#include <mutex>
#include <string>
#include "math_channel.hpp"
#include <vector>
namespace udpscope {
@@ -23,6 +25,10 @@ namespace udpscope {
/// One streamed signal, as the CONFIG packet described it.
struct SignalInfo {
std::string name;
/// True for the entries the model *synthesised* for a math channel: the stream never sends them,
/// the producer fills their samples by evaluating the graph. The flag is what lets a CONFIG
/// update tell them from the stream's own signals and put them back afterwards.
bool math = false;
std::string unit;
std::uint8_t type_code = 0;
std::uint8_t quant_type = 0;
@@ -50,7 +56,13 @@ struct ChannelInfo {
double offset = 0.0;
std::string name; ///< the signal's name
std::string unit;
/// True for a channel the producer *derives* rather than reads. The ingest must skip it: it has
/// no packet signal, so reading one gives zero -- and pushing that zero into a ring whose time
/// ring advances once per slot is what made a math channel alternate between its value and 0, and
/// then drift off the time ring entirely once the buffer was full.
bool math = false;
std::string unit; ///< the signal's unit, or a math channel's own
/// Samples of this channel per DATA packet (`elements()` of the signal). A
/// UDPStreamer signal is one channel: its columns are consecutive samples of the
@@ -178,6 +190,20 @@ class ScopeModel {
/// 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);
/// The user's math channels, as configured. Read it under the lock, like the other accessors
/// here -- the UI copies what it needs while it holds it.
std::vector<MathChannel> math_channels() const { return math_; }
/// Replaces the math channels. A *structural* change, exactly like a CONFIG or an
/// enable/disable: the enabled ones are re-appended to the signal list, their channels are
/// rebuilt and their runtimes re-prepared for the new stored rate. **Takes the lock itself** --
/// do not call it holding `mutex()`, which is not recursive.
void set_math_channels(std::vector<MathChannel> channels);
/// One stored sample's worth of derived values, called by the producer right after it has pushed
/// the streamed channels. `sample[c]` is channel `c`'s value for this slot; the math channels are
/// evaluated from those, in order, so a math channel may use another one.
void push_math_sample(const std::vector<f32>& sample);
const std::vector<SignalInfo>& signals() const noexcept { return signals_; }
const std::vector<ChannelInfo>& channels() const noexcept { return channels_; }
@@ -387,10 +413,27 @@ class ScopeModel {
private:
void rebuild_channels();
/// Re-appends one synthetic signal per math channel, after the stream's own. Called under the
/// lock by both `set_signals()` (a CONFIG) and `set_math_channels()` (an edit).
void append_math_signals_locked();
/// Rebuilds the math runtimes for the *current* plan: the biquad coefficients follow the stored
/// sample interval, and their state is reset with them. Called at the end of `rebuild_locked()`,
/// so a window change that moves the decimation re-prepares the filters exactly as an edit does.
void prepare_math_locked();
Config cfg_;
std::vector<SignalInfo> signals_;
std::vector<ChannelInfo> channels_;
// --- math channels --------------------------------------------------------
std::vector<MathChannel> math_;
std::vector<MathRuntime> math_rt_;
/// The channel index each math channel was given by `rebuild_channels()`, -1 while it has none
/// (disabled, or before the first rebuild).
std::vector<int> math_channel_;
/// The names of the synthetic signal entries, so they can be lifted out of `signals_`/`enabled_`
/// before the stream's own are re-appended -- a CONFIG must not leave a stale derived channel
/// behind, and must not drop a live one either.
std::vector<std::string> math_signal_names_;
std::vector<bool> enabled_;
Partition part_;
+1251 -17
View File
File diff suppressed because it is too large. Load diff