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

+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