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
+1772 -26

No files matched your search

+106 -1
View File
@@ -727,6 +727,99 @@ far the largest file.
pins the same way (`pin_pause_window()` sets `pinned_` from the frozen range), so the frozen
picture is equally immovable; resuming or leaving trigger mode clears it.
- **The session is the layout.** `LayoutState` persists what makes a reopened scope look like the
one that was closed: the window, the rows and their slot signals, the calibration, **the enabled
signals**, **the math channels with their graphs** and **the measurements pane**. Signal identity
is the *name* throughout (a CONFIG renumbers channels), 'enabled' is read off the channels the
model built -- an enabled signal is exactly one that produced a channel -- and `apply_layout()`
installs the graphs **before** assigning the rows, since a row may hold a math channel's name and
that name is only a signal once its graph is in. Two format rules, both learned the hard way:
write an op or a measure *type* as its index through `math_op_at()`/`measure_type_at()`, never as
the raw enum value -- those accessors' orders are the dialog's, not the declaration order, so
writing one and reading the other silently renames operations. And a top-level key written after
a `[[table]]` belongs to *that table* in TOML: `enabled = [...]` emitted after `[[calibration]]`
was parsed into the last calibration record and the enable state was lost without a word.
`mkdirs()` used to create only components with a trailing slash, so `~/.config/udpscope` was never
made and the *first* save failed silently -- worth knowing when a layout change "does not stick".
- **A math channel *is* a channel.** `ScopeModel::set_math_channels()` appends one *synthetic*
`SignalInfo` (with `math = true`) per math channel after the stream's own, so the panel's switch,
the plot binding, the measurements and the export all handle a derived channel without knowing it
is derived -- and another math channel is a valid `Source` for exactly the same reason. The
*samples* come from the producer: `Acquisition::on_data()` collects each channel's value for the
slot and calls `ScopeModel::push_math_sample()` **once per stored slot**, before the deferred
latch (`publish_event()` clamps its snapshot to every ring's `produced()`, so a derived sample
pushed later would be missing from the capture's newest slot). The math therefore runs on the
*stored* (filtered, decimated) samples, which is also why `math_prepare()` gets `slot_dt() *
time_stride`: a cutoff the user typed in Hz means the rate the filter will actually run at.
`src/math_channel.cpp` is the engine: a graph evaluated as a **list walked in order**, so a node
may only read ones *before* it and a cycle is impossible by construction rather than by a check
(`math_channel_valid()` states that as a message the dialog shows). `/tmp/check_math.cpp`-style
checks verified the biquads against known responses (LP fc=100 Hz: 50 Hz -> 0.97, 2 kHz -> 0.002;
BP fc=1 kHz q=2: 200 Hz -> 0.05, 1 kHz -> 0.95, 4 kHz -> 0.05) -- **keep test sines below
Nyquist**: at `dt = 1/10000` a "5 kHz" test tone is the Nyquist point itself and says nothing.
The editor is the *compact graph*: nodes as boxes on a `GtkDrawingArea`, click to select (its
parameters in the form beside it), click an output port then an input port to wire, drag to move;
no pan/zoom. Its widgets are created once and only shown, hidden and set by `math_dialog_sync()`,
so no rebuild can invalidate a live callback handle -- the same shape the measurements dialog
uses. `UDPSCOPE_PROBE_MATH_MS` defines two graphs and opens the editor (a construction crash test
as much as a state check), and the dump prints each derived channel's extremes beside the
source's. Four rules here were wrong first and are worth keeping:
* **A `Source` may only name an *acquired* channel** -- `ScopeModel::channels()`, which is what the
dialog's dropdown lists, **and both places that create a node must take one from there**.
`signals().front()` is the stream's *timestamp* signal: it is first in every CONFIG, it is not
acquired, and a graph sourced from it validates and then reads NaN for ever. The dialog's
default was fixed first and the pane's `+` was not, which is what "[ch] -> [gain x2] produces
only nan" was. The dropdown also *reconciles*: a node whose source is not in the list is moved
to what the form is showing, rather than the form showing one channel while the node holds
another, and `Apply` refuses a source that is not acquired (the engine's validator is about the
graph -- an output, every input wired above it -- and cannot see the acquired list).
* **The ingest must skip a derived channel.** `Acquisition::on_data()` iterates every channel and
reads each one from the packet, so a math channel with no signal read *zero* and pushed it --
while `push_math_sample()` pushed the real value on the same slot. Two pushes per slot into a
ring whose time ring advances once: the values alternated between the real one and 0, and once
both rings wrapped the value ring had drifted a whole window off the time ring, so the derived
trace disappeared. `ChannelInfo::math` is the flag (`channel.math`) and the loop `continue`s on
it. **A derived channel's `values(c).produced()` must equal `live().time().produced()`** -- one
push per stored slot -- and the dump prints both as `N samples vs M time slots` for exactly
this: the arithmetic on the plot can look plausible long after the pairing is wrong.
* **`gtk_window_destroy()` does not emit `close-request`.** The math dialog's pointer is cleared
by a `close-request` handler, so a Cancel that *destroyed* the window left `app.math_dialog`
pointing at a dead widget: every later `open_math_dialog()` took the "already open" branch and
presented it -- a GTK critical, and nothing on screen, which is "I cannot edit a math channel
again". Both paths close (`gtk_window_close()`), which emits the signal, lets the handler clear
the pointer, and then destroys. The same lesson as the other GTK traps here: a programmatic
destroy is not a user close, and only one of them runs the handlers.
* **Boxes must not overlap on the canvas.** The first auto-place staggered nodes by 24 px for a
132 px box, so every box sat on the one before it, `math_node_at()`'s topmost-first search always
returned the last, and a drag or a click landed on the wrong node -- which reads as the dialog
ignoring the pointer. Two columns spaced 160 px apart, rows 58. The probe prints the box the
drag *began over* for exactly this reason. Offering the whole signal list let a graph name something nobody is
acquiring (no data for ever) and put the stream's timestamp signal, which has no samples, in it.
* **`prepare_math_locked()` runs from `rebuild_locked()`, so *every* plan change re-prepares the
filters**: a CONFIG, an enable/disable, and a window change that moves the decimation (that path
is `set_target_span()` -> `rebuild_locked()`). Coefficients are a function of the *stored*
interval and the filter's state is the old rate's history, so both are rebuilt together.
Preparing only on an edit left the cutoff wrong and the state ringing after any window change --
that is what "I tested a low pass and it was a mess" was.
* **The graph's output defaults to the *last* node, and a node added to a linear graph becomes
it.** The output began at index 0 -- the *source* -- so every operation a user built was
computed and then thrown away: a gain of 2 and a low pass both produced a channel equal to its
input. The output is drawn as the orange dot and settable explicitly, so an earlier node chosen
deliberately is kept; this is the default for a graph that has never been given a choice.
* **`gtk_spin_button_new_with_range()` can leave the value at the range's *minimum*.** The offset
field began at -1e6, and `on_math_param_changed()` reads the widgets, so `x*2` saved as
`x*2 - 2000000`. Every numeric field is seeded with its own identity value; the range-minimum
default is never a useful starting point for a gain or a corner frequency.
* **The form's rows are grouped per *op*, not per field**: gain and offset are one group because
`gain + offset` is one op. The offset was a row of its own that nothing ever hid, which read as
a property of every node.
* **A `GtkSpinButton` holds zero decimals by default**, so every numeric field sets its digits
(4 for gain/offset/clamp, 3 for `fc`/`Q`); without that a 12.5 Hz cutoff was stored as 12 and a
gain of 0.25 as 0. And the canvas drag reports the *total* offset from where the gesture began,
so adding each update's delta to a value the handler then writes back is a runaway box -- it
must record the position at `drag-begin` and ignore a drag that did not begin on a box.
- **Every instrument this app *computed* is out of the pointer's reach.** The trigger marker and
the measurement marks are cursors created with `Plot::add_cursor()`/`add_h_ruler()`, and a cursor
is grabbable by default -- so each takes `set_cursor_selectable(id, false)`. **Parking it outside
@@ -874,5 +967,17 @@ far the largest file.
would clamp a legitimately short capture up to the requested window.
- **The header bar's buttons are icon-only**, so headless probes must reach them
through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via
`find_labelled_button()`. The Arm/Disarm button in the trigger panel still has text.
`find_labelled_button()`. The **pane switches are toolbar buttons** too (`cursors_toggle`,
`measures_toggle`, `math_toggle` -- packed with `header_bar_pack_end()`, where the **first** packed
is the rightmost, and the bar is an *ancestor* rather than the parent because packing parents into
an internal box), and the cursors pane owns its own
**Reset** button rather than the overflow menu owning one for it. The dump prints
`panel controls: … ; toolbar, left to right: …` to say where they are and in what order. The widget
tree's child order *is* the visual order left to right, so that line reads as the *reverse* of the
pack order -- it is how an arrangement gets checked with no screenshot, and it is what finally
settled the pack-order rule above (two comments in `build_toolbar()` disagreed with each other and
one of them with reality): it is computed on the first *tick*, because the dump
runs after the window is destroyed and asking GTK about a widget then is four criticals, which is
how the first version of that line earned them. What stays in the menu is what acts on the *data*
(Fit, Export) and the two editors that are not panes (Layout, Signal list). The Arm/Disarm button in the trigger panel still has text.
- Every source file carries an SPDX MIT header; match it in new files.
+1
View File
@@ -42,6 +42,7 @@ add_executable(udpscope
src/export.cpp
src/layout_store.cpp
src/measure.cpp
src/math_channel.cpp
src/ui.cpp
)
+71
View File
@@ -347,6 +347,77 @@ it holds more than the window the bound became the whole ring, and a zoom-out wa
view past the window into data the window was never set to show. A partly filled window
simply has an empty left-hand side.
**The panel switches are on the toolbar, and the panes own their own controls.** Cursors,
Measurements and Math channels are icon toggles in the header bar rather than rows in the overflow
menu, and **Reset cursors** sits in the cursors pane beside the mode it resets (it acts on that
pane's instruments and is only visible while they exist). The rows read, left to right: **window control, trigger, pause** — the trigger and the pause are
read against the span on screen, so they sit with the control that sets it — then, on the right,
**cursors, measurements, math, sidebar** and **the menu last**. With `HeaderBar::pack_end()` the
**first** packed is the rightmost, so the list is written right to left; that took a run of the dump
to settle (two comments in the builder disagreed), and the line it prints
(`panel controls: … ; toolbar, left to right: …`) is how a layout claim gets checked without a
screenshot. The menu keeps what acts on the *data* (Fit, Export) and the two
editors that are not panes (Layout, Signal list). The dump reports the
arrangement as `panel controls: cursors=1 measurements=1 math=1 in the toolbar, reset=1 in the
cursors pane`, computed on the first frame — a widget's parent is the only thing that says it, and
the dump runs after the window is gone.
**The session is saved with the layout**, per data source, in
`~/.config/udpscope/<host>_<port>.toml`: the display window, the rows and which signal each slot
holds, the per-signal calibration, **which signals were enabled**, **the math channels with their
graphs** and **the measurements pane**. Identity is the signal *name* everywhere, because a CONFIG
renumbers the channels; the graphs are installed before the rows are assigned, since a row can hold
a math channel's name and that name only exists as a signal once its graph is in. So a scope
reopened on the same source comes back as the one that was closed — including a signal that was
enabled only to feed a math channel.
**Math channels.** A math channel is derived from the others by a graph of operations and is then
an *ordinary channel*: the model synthesises a signal entry for it, so an enabled one is acquired,
plotted, measured, exported and listed in the signal panel exactly like a streamed signal — and
another math channel can be its source. The operations are `channel` (a source), `gain + offset`,
`abs`, `sqrt`, `clamp`, `low pass`, `high pass`, `band pass` (biquads, cutoffs in Hz against the
stored rate), `sum`, `difference`, `product` and `ratio`; the arithmetic ops take two inputs, which
is what makes branching — the sum of a raw and a filtered channel — expressible at all.
The editor is a compact node graph: each node is a box, its inputs are the squares on its left edge
and its output the square on its right. Click a box to select it and its parameters appear in the
form beside the canvas; click an output square and then an input square to wire them; drag a box to
move it. The graph is a *list* walked in order, so a node can only read the ones above it — a cycle
is not detected, it is unrepresentable, and an input square is only offered the nodes that qualify.
The node carrying the channel's value is the **last one** of a graph built top to bottom (as the
orange dot on the canvas says, and settable with *set as output*): it defaulted to the first node —
the source — so every operation was computed and discarded, and a gain or a filter produced a
channel identical to its input. Numeric fields are seeded with their own identity values too, since
`gtk_spin_button_new_with_range()` leaves a spin button at its range's minimum: the offset started
at -1e6, which turned `x*2` into `x*2 - 2000000`.
Wiring is checked before the channel is created, and the reason is shown rather than the dialog
closing on the work.
A `Source` node lists only the channels the scope is actually **acquiring**: naming a disabled
signal would read as no data for ever, and the stream's timestamp signal has no samples at all. The
filters are re-prepared from the *stored* sample interval on **every** plan change — a CONFIG, an
enable/disable, and a window change that moves the decimation — because a biquad's coefficients are
a function of that interval and its state belongs to the rate it was running at; preparing them
only when the graph is edited left a wrong cutoff and a ringing transient after any window change.
The samples are produced at **ingest**, once per stored slot, from the values the other channels
just pushed — and a derived channel is *skipped* by the loop that reads the stream, since it has no
packet signal to read. That mattered more than it looks: reading it gave zero, and pushing that
alongside the real value advanced its sample ring twice per time slot, so the trace alternated
between its value and 0 while the buffer filled and then slid a whole window off the time ring once
both wrapped and vanished. One push per slot is the invariant, and the probe's dump prints it as `N
samples vs M time slots` — a ratio the plot cannot show you. That is what makes the filters real: an IIR has memory, so a low pass computed from a
window would be a different (and wrong) answer every time the window moved, while one fed the
stream sample by sample is the filter the user asked for. It also means the pane's numbers and the
plot agree by construction — both come from the same stored samples.
**Closing the editor clears it.** The dialog's pointer is cleared by a `close-request` handler, and
`gtk_window_destroy()` — which is what Cancel and Save used to call — does **not** emit that signal.
The pointer therefore stayed set on a destroyed window, and every later Edit took the "already open"
branch and presented the corpse: a GTK critical and nothing on screen. Both buttons call
`gtk_window_close()`, which emits the signal, clears the pointer, and destroys.
**The trigger marker is a readout, not a handle.** It is a cursor, and a cursor is grabbable by
default — so parking it outside the axis, which is how it is hidden when no event is on screen, did
not stop a *drag*: the controller holds a marker for the gesture's life and follows the pointer
+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;
+153 -6
View File
@@ -37,14 +37,16 @@ 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;
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);
}
}
}
std::string toml_escape(const std::string& s) {
@@ -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