added math signals
This commit is contained in:
1 parent
02620ca946
commit
aa2508e431
10 files changed
+1772
-26
No files matched your search
@@ -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.
|
||||
@@ -42,6 +42,7 @@ add_executable(udpscope
|
||||
src/export.cpp
|
||||
src/layout_store.cpp
|
||||
src/measure.cpp
|
||||
src/math_channel.cpp
|
||||
src/ui.cpp
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -37,15 +37,17 @@ 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) {
|
||||
std::string out = "\"";
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user