Three defects, all of them a window describing data that is not there. A math edit refilled the derived ring with `clear()`, which renumbers it from 0, leaving `produced()` short by the inputs' retained base. Since `live_window_locked()` clamps against every channel's `produced()`, that one lagging channel answered `n = 0` for the live view, every pause view and every trigger capture until a whole ring of fresh data had gone by -- the reported freeze, invisible until the ring first wraps, hence its intermittence. It now restarts with `clear_to(base)`, and `ChannelBuffer::republish_from(base)` puts the published window back: `acquire()` is incremental, so a rewrite in place published nothing and the pyramid went on describing the samples the edit replaced. The trigger's channel is an index, re-resolved by name after a renumbering -- but only in `rebuild_enabled_locked()`, so a math add or delete went through `set_math_channels()` with no restore, the index was clamped to -1, and `process()` returned at once while the panel still named the channel. The restore moves into `rebuild_channels()`, which *is* the renumbering, so no caller can forget it. And `update_measures()` suppressed its "the window moved" test while an event was held -- the one mode where a gesture is the only thing that can move it. Zooming a latched event left the pane describing the whole captured event. A pinned window needs no exemption: it lands on the range it already measured and fails the 2 % test by itself. The checks that missed all three: `UDPSCOPE_PROBE_MATHWRAP_MS` edits a graph whose ring has already wrapped, which `math_probe` structurally cannot reach; `[measure-trace]` now prints the mode, the window on screen and the window the numbers describe; and the dump's `marks_in_window` counts against the window on screen instead of against the one the marks were computed over, where it was a tautology. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
356 lines
31 KiB
Markdown
356 lines
31 KiB
Markdown
# Invariants: the math channels and the node editor
|
|
|
|
A derived channel is an ordinary channel; the engine runs on the producer thread, so its cost is measured in packets.
|
|
|
|
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
|
|
|
- **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), **drag from an output port to an input port to wire** (the old
|
|
two-click protocol is gone), drag a box to move it; **wheel zooms about the pointer, background
|
|
drag pans, `f` fits, `Ctrl+0` resets**.
|
|
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 three graphs -- the third a `Source` of the first channel into a
|
|
`DeNoise` node, so the editor's median row is built and synced on a baseline run -- 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. Read it on the fast mock, where the two assertions that matter are **`gaps=0`** (the
|
|
math is on the producer thread, so what it can break is packet capture, never the frame rate)
|
|
and each `math '<name>': ... N samples vs M time slots` being **equal**, with `math x2` at
|
|
`y=[-2, 2]`, `math lp` at `y=[-0.998, 0.998]` and `math3` at `y=[-2, 2]`. The `0 visible
|
|
(order breaks 0, first at -1), y=[0, 0]` tail of that line is **not** a failure: it is
|
|
`visible_samples()` reading row 0's axis after the window was destroyed, and
|
|
`UDPSCOPE_EXIT_AFTER_MS` arms the trigger first, so the dump can land in `Waiting`. The live
|
|
`ch N '<name>' ... y=[..]` lines are the race-free reading of the same thing.
|
|
- **A file view has no producer, so a structural change would leave it empty.** `set_math_channels()`
|
|
rebuilds the channels *and* the buffer, and a rebuild drops the history: right for a stream, whose producer
|
|
refills it from the next packet, and wrong for a file, which has nothing to refill it with. Adding a math
|
|
channel to an open waveform therefore emptied *every* channel -- `samples=0` on the sources too, which a user
|
|
reads as "the plot is broken" -- and the same emptiness follows any structural change in a file view.
|
|
`reload_file_view()` (in `ui.cpp`) re-reads the file after such a change, which is also what computes the
|
|
derived channels over it: the load's own pushes run the math once the set is in place. It fires only when the
|
|
buffer was *really* dropped -- `stored_slots() > 0` means the change found nothing to do -- and it says so
|
|
on stderr (`reloaded <path> after a structural change`), because a second read of a file is worth
|
|
accounting for. `set_math_channels()` in turn treats an identical list as no change at all, so a no-op
|
|
application (the session apply, a re-open) neither rebuilds nor drops nor re-reads; the comparison leaves the
|
|
nodes' `x`/`y` out on purpose, since those are the canvas's own and no op reads them -- a graph that only
|
|
*moved* is not a change, and rebuilding for it would drop the buffer to store two numbers. It is why `--open`
|
|
works at all with a session that defines math channels, since `main()` loads the file *before* the UI (and
|
|
so before the session's graphs) exists.
|
|
- **Every path that fills a store must skip a derived channel, not just the packet path.**
|
|
`ChannelInfo::math` states the rule for the ingest; `push_loaded_slot()` broke it, so loading a file pushed
|
|
its own value into each derived ring and `push_math_sample()` then pushed the computed one: two samples per
|
|
slot, i.e. a trace at double rate. The reading that catches it is the one already in this file --
|
|
`math '<name>': N samples vs M time slots` must be **equal** -- and a file view now has to be read as well
|
|
as a live one (`--open <the file>` with `UDPSCOPE_PROBE_MATH_MS`). An export writes every *plotted* channel,
|
|
so a file carries the derived ones as ordinary columns: those are real channels and are filled normally,
|
|
which is why the fix is in the push and not in the loader.
|
|
`math cost: S slots, N ns/slot (T timed), X ms total est` is the per-slot cost; see the
|
|
math-cost invariant below for why it is sampled, why it is an upper bound, and why a figure
|
|
anywhere in 78--101 ns/slot is one reading of the same thing; the three-graph run reads **103**, the
|
|
de-noise channel's per-sample median being the difference.
|
|
The probe also asserts the new editor mechanics. `dialog build:` reads the identity row back as well --
|
|
`name='math3' unit=''` -- which is the pair the `.ui` holds the widgets for and the code fills from the
|
|
channel being edited, so a row that stopped being the one the code writes shows there.
|
|
`editor frame:` is the same idea one level down: it reads the canvas's allocation, whether that canvas is
|
|
*mapped*, and whether the canvas and the name entry are inside the dialog at all -- delayed by 500 ms, because
|
|
an allocation only exists after a layout. A canvas that is visible but parentless never maps and stays `0x0`,
|
|
and reading values off the widgets *inside* the editor cannot see that. It is the reading that catches
|
|
`math_body` written as a child of the window instead of the box above it. Each line carries
|
|
`(want ...)` and the reading
|
|
taken from a real run is the reference:
|
|
* `[math-probe] hit: inside node=0 which=-1, past-edge node=0 which=-1 (want 0 -1 0 -1)` — the
|
|
output square straddles the box edge, so a hit test that recovers the node by a box test misses
|
|
every pixel past it. `past-edge node=-1` is the silent disarm that was "edges cannot be added".
|
|
* `[math-probe] cancel: dialog=cleared (want cleared)` — Cancel must `gtk_window_close()`, not
|
|
`gtk_window_destroy()`. The probe clicks the **button**, because the defect was in the button's
|
|
connection and a direct handler call cannot see it.
|
|
* `[math-probe] view: zoom scale=1.210 anchor kept dx=0.0000 dy=0.0000 (want 0.0000 0.0000)` —
|
|
a wheel zoom that does not keep the graph point under the pointer reads as the canvas sliding
|
|
away from the hand. Both deltas must be `0.0000`.
|
|
* `[math-probe] view: pan+zoom hit node=0 which=-1 (want 0 -1)` — hit tests go through
|
|
`math_to_graph()`. At the identity view a test that forgot the inverse passes anyway, so this
|
|
one is taken after a pan *and* a zoom.
|
|
* `[math-probe] view: fit scale=1.591 all_inside=1 (want 1)` and
|
|
`[math-probe] view: reset scale=1.000 offset=(0.0,0.0) (want 1.000 (0.0,0.0))`.
|
|
* `[math-probe] view: pan mode=3 dview=30.0 dnode=0.0 (want 3 30.0 0.0)` — a background drag
|
|
pans the view and moves no node.
|
|
* `[math-probe] wire: connect mode=2 in_a=0 (want 2 0)`,
|
|
`[math-probe] detach: emptied_at_begin=-1 in_a=-1 (want -1 -1)`,
|
|
`[math-probe] detach: escape restores in_a=0 (want 0)`.
|
|
* `[math-probe] wire: pick node=1 which=0 -> in_a=-1 off_sample=8.4 tol=3.0 (want 1 0 -1,
|
|
off_sample > tol)` — the pick is taken at `t = 0.52`, **between** samples 12 and 13 of the 24 the
|
|
curve is walked at, on a wire whose two boxes are several rows apart. `off_sample` is the measured
|
|
distance from the pick to the nearest sample point and `tol` the pick radius in the same units:
|
|
with `off_sample` the larger, a hit test that measured to the sample *points* rather than to the
|
|
*segments* between them would miss. Taken at `t = 0.5` — exactly sample 12 — the line passed for
|
|
both implementations and asserted nothing, the same mistake as a hit test taken at the identity
|
|
view.
|
|
* `[math-probe] wire: backwards ok=1 order=[0:-1,1:0,2:1] output=2 valid=1 why=[] (want 1 ... 1 [])` —
|
|
the re-sort. `valid=1` is the assertion; the `order=` string is the reading that explains it.
|
|
* `[math-probe] wire: self refused=1 why=[a node cannot read itself]` and
|
|
`[math-probe] wire: cycle refused=1 why=[that wire would make a loop]` — and **nothing is
|
|
written** on either. Both call `math_connect()` directly, so both assert its out-parameter and
|
|
neither says anything about the UI.
|
|
* `[math-probe] wire: self through drag-end node=1 problem=[a node cannot read itself] (want the
|
|
refusal text, not empty)` — the other half, and the one that matters: the refusal has to reach
|
|
the **validation line**. The UI path used to screen the self case out before `math_connect()` and
|
|
then drop its `why` on the floor, so the user drew a wire, nothing appeared and nothing was said
|
|
while the two lines above passed. No synthetic GTK gesture is reachable headlessly, so the probe
|
|
sets `drag_mode`/`wire_from`/`press_cx`/`press_cy` and calls `math_canvas_drag_end()` — which is
|
|
still the handler under test. An empty `problem=[]` here is also how the sync **re-entrancy** was
|
|
found: setting the op dropdown emits `notify::selected`, whose handler syncs, so the form's own
|
|
write re-entered `math_dialog_sync()` and the inner call rewrote the label the outer had just
|
|
written. `MathDialog::syncing` guards it.
|
|
* `[math-probe] validate: broken problem=[..] label=[..] apply=0; fixed problem=[] apply=1 (want non-empty 0; empty 1)`,
|
|
`[math-probe] validate: no-selection label=[..] (want non-empty)` — the label is written
|
|
**above** `math_dialog_sync()`'s early return; a stale message when there is no selection to
|
|
show is the failure mode.
|
|
* `[math-probe] validate: source list=6 acquired=6 signals=7 (want list==acquired)` — the
|
|
dropdown lists exactly the acquired channels, not all signals.
|
|
* `[math-probe] op pick: nodes 3->4 op_index=7 q_row=1 (want 4 7 1)` and
|
|
`[math-probe] op pick: index round trip=1 (want 1)`.
|
|
* `[math-probe] add node: after a delete cells=[(24,20) (24,78) (184,78) (184,20)] overlaps=0
|
|
(want 0)` — the boxes are put back on the grid, the middle one deleted and one added. The new box
|
|
takes cell 1, `(184,20)`, which the deletion freed; placing by `nodes.size()` would have put it on
|
|
`(184,78)`, which is occupied, and an overlapped box is an unclickable one. `overlaps` is the
|
|
assertion, `cells` the reading that explains it.
|
|
* `[math-probe] tidy: xs=[24,24,204,384] overlaps=0 (want 0)` — four nodes in three depth columns
|
|
(the delete above left two roots), so two share the first column and the x values repeat. Only
|
|
`overlaps=0` is the assertion.
|
|
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 while a source that is not acquired is in the graph the **live validation line is
|
|
non-empty and Apply is insensitive** (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. The **add-node grid** places in two columns spaced 160 px apart, rows 58,
|
|
and `math_add_node()` takes the **first free cell** rather than cell `nodes.size()`: placing by
|
|
the count reuses an occupied cell as soon as anything has been deleted — delete the middle of
|
|
three and add one and the new box lands exactly on an existing one. The free-cell search uses the
|
|
same overlap test the tidy probe asserts with, and falls through to cell `nodes.size()` after 64
|
|
cells rather than searching for ever. The **tidy layout** uses a column pitch of `kNodeW + 48` =
|
|
180, which is *wider* than that 160 and is what gives a wire's 40-unit horizontal tread its
|
|
clearance — at 160 the bezier's control points cross and the wire doubles back on itself. 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.
|
|
* **A port hit must name its own node.** The output square is drawn centred on `n.x + kNodeW`,
|
|
straddling the box's right edge, with a `±kPort` hit radius against a `±kPort*0.5` drawn
|
|
half-width — so recovering the node with `math_node_at()`, which matches only inside the box,
|
|
returns `-1` for every pixel past the edge. `math_port_at()` returns the node directly for
|
|
that reason.
|
|
* **Every hit test goes through `math_to_graph()`, and every tolerance is a screen distance
|
|
divided by `view_scale`.** A tolerance left in graph units makes a port unhittable zoomed out
|
|
and greedy zoomed in. **A probe that tests a hit at the identity view asserts nothing** — the
|
|
check has to be taken after a pan and a zoom.
|
|
* **A drag's mode is decided once, at `drag-begin`, and the precedence is port, box, background.**
|
|
Asking the box first makes the output square unreachable, which is the same geometry as the
|
|
bullet above.
|
|
* **A detach empties the input at `drag-begin`.** That is what makes a release over nothing the
|
|
detach gesture rather than a second verb, and it is why `Escape` has something to restore.
|
|
* **A backwards wire re-sorts the list; it is not refused.** The evaluator walks the nodes in
|
|
order, and until now that made the order the *user's* problem — an earlier node's input was not
|
|
even drawn as connectable. `math_connect()` runs Kahn's algorithm over the **trial** edge set,
|
|
so a cycle is found before anything is written, breaks ties by the current list index so a graph
|
|
already in order is not permuted, and remaps **`output`, `selected`, every `in_a`/`in_b`,
|
|
`drag_node`, `wire_from`, `wire_detach` and `selected_wire.node`** in one block. A missed index
|
|
silently renames an operation or re-points a wire. Its `edges_of` masks each input by the op's
|
|
**arity**: a node changed from binary to unary keeps a stale `in_b`, and counting it makes an
|
|
indegree that never reaches zero and so reports a loop that is not there.
|
|
* **The validation line is written above `math_dialog_sync()`'s early return.** An out-of-range
|
|
selection is exactly the state in which a stale message misleads most: the graph can still be
|
|
broken and there is no form to read instead.
|
|
* **The line is written from `math_dialog_sync()` and from nowhere else, it ellipsizes, and its
|
|
tooltip carries the full text.** Three consequences. (1) A *refusal* — "a node cannot read
|
|
itself", a cycle — is not a state `math_dialog_problem()` can describe, since it is a thing that
|
|
did not happen, so `drag-end` leaves it on `MathDialog::refusal`, which the sync shows in
|
|
preference to the graph's own problem and **consumes as it writes**: the message lasts until the
|
|
next sync, and every mutation syncs. `Apply`'s sensitivity still follows the graph alone, because
|
|
a refused wire wrote nothing. (2) The label **ellipsizes rather than wraps**, and the tooltip is
|
|
set beside the text so the two cannot disagree — layout must not depend on content, and this
|
|
label sits in a dialog whose canvas is `hexpand`. (3) The sync is **not re-entrant**
|
|
(`MathDialog::syncing`): writing the op dropdown emits `notify::selected`, whose handler syncs,
|
|
and the inner call rewrote the label the outer one had just written.
|
|
* **`math_op_index()`, never a cast.** `math_op_at()`'s order is the dialog's — what a dropdown
|
|
lists and what a session file stores — and `static_cast<guint>(op)` worked only because the
|
|
two happen to agree today. The session-file rule in this file already says an op is written as
|
|
its index through `math_op_at()`; this is the other direction of the same rule.
|
|
* **A `Delete` key removes the selected *wire* and never falls through to the selected node.**
|
|
A key whose target depends on an invisible selection state is how a graph gets destroyed by
|
|
accident; the `delete node` button stays the only way to remove a node.
|
|
- **`MathOp` is append-only, because `math_op_at()` *is* the serialisation.** A session stores a node's op
|
|
as its index through that accessor, so an op inserted in the middle reinterprets every node in every file
|
|
written before it; `DeNoise` is therefore index 12, after `Div`, and sits last in the dropdown.
|
|
`math_op_uses_filter()` keeps its meaning of *a biquad* -- `math_op_uses_fc()` is the wider question
|
|
("does this op read `fc` at all") and is what the dialog's corner row asks.
|
|
- **The de-noise node's median window is *pooled* in `MathRuntime::median`, not held in `MathNodeRt`**, so a
|
|
node's state stays on one cache line; nine doubles per node would push every node in the graph, filter or
|
|
not, across two. `MathNodeRt::med_at` / `::med_n` / `::med_pos` / `::med_have` are that pool's slice,
|
|
length, write position and fill, and the pool is sized in `math_prepare()`.
|
|
- **Median *then* pole, and the order is the design.** The median kills an impulse outright -- one shorter
|
|
than half its window cannot move it at all -- and the pole takes the broadband hash it leaves behind. The
|
|
other order feeds the pole the impulse, which smears it over the pole's whole settling time and leaves the
|
|
median a wide bump it cannot reject.
|
|
- **The de-noise pole reuses `b0` and `z1`, deliberately.** `math_prepare()` carries `z1` across a
|
|
re-prepare, so the pole keeps its history through a decimation change exactly as the biquads do -- while the
|
|
median window starts from rest, the same choice the biquads' `z2` makes. `median_n()` rounds *up* to odd and
|
|
caps at 9, and it is what enforces the window rather than the dialog: a session file holding 4 or 40 still
|
|
loads. The cap is what `double sorted[9]` in the step is sized for.
|
|
- **The math channels' cost is the *producer's*, and the instrument must not be part of it.**
|
|
`push_math_sample()` runs once per stored slot -- up to the wire rate -- so what a regression
|
|
here looks like is `gaps`, never a frame time. Three things were taken off that path and must
|
|
not come back: a `std::vector` *local* (a heap allocation and free per slot; it is the member
|
|
`math_values_`, `assign()`d), a `Source` node's channel resolved **by name** per sample (a
|
|
linear scan with a `std::string` compare per candidate, per node, per slot, behind a
|
|
type-erased call -- `math_prepare()` resolves it once into `MathNodeRt::src`, and every rebuild
|
|
re-prepares), and a **`fetch_add` on the cost counters**. That last one was 20-25 % of the
|
|
measured figure: a `lock xadd` costs about what a two-node graph's whole arithmetic does, so
|
|
counting the slots cost more than the work being counted. The counters are producer-written
|
|
only, so each is a relaxed `store()` of a value already in hand (`math_slot_count_` and
|
|
friends). Two things about the figure itself:
|
|
* **It is sampled, one slot in `kMathCostSample`, and it is an upper bound.** A slot's work is
|
|
the same order as one `clock_gettime`, so the measured slot carries a clock read. Timing a
|
|
*run* of consecutive slots to amortise that away **is wrong and was tried**: the producer gets
|
|
its slots a packet at a time, so a run long enough to be worth timing straddles a packet
|
|
boundary now and then and charges the measured slots the **wait for the next packet**. At 2000
|
|
samples a packet a 64-slot run crosses one ~3 % of the time, and a ~1.5 ms gap spread over 64
|
|
slots reported **799 ns/slot against the true ~80**. One slot is always inside one packet.
|
|
* **78--101 ns/slot is one reading.** Four identical-config runs spread that far, so the per-slot
|
|
path is at the noise floor of this harness and a micro-optimisation cannot be shown to work
|
|
here. Converting `MathRuntime` from eight parallel `std::vector<double>` to one array of
|
|
`MathNodeRt` was measured and bought **nothing** -- at six nodes the whole runtime is
|
|
L1-resident either way. It was kept for the shape, not for a number; do not expect a cache
|
|
argument to pay at this size.
|
|
|
|
## A gap is a gap in a filter's output, never the end of its state
|
|
|
|
A reserved hole is NaN in the source channel, and a biquad that integrates it poisons `z1`/`z2`
|
|
for ever: every later sample comes out NaN, so a filtered trace died at the first drop and never
|
|
came back. Relative time made it systematic — a counter gap reserves NaN slots by construction,
|
|
where a stream-time gap is often absorbed by the stamps — but EMI caused it in both modes, and
|
|
the reading was unambiguous: the memoryless channels held 95% finite samples while every
|
|
`LowPass`/`HighPass`/`BandPass` channel held `finite=0`.
|
|
|
|
`biquad_step()` therefore returns NaN **without touching the state words** when its input is not
|
|
finite. The hole stays visible in the filtered trace (the gap is the truth about the data), the
|
|
filter's history remains the last *real* samples it saw, and the trace resumes after the hole
|
|
with at most the transient a re-start costs. The exit dump's `math '<name>': finite=N/M` line is
|
|
the assertion: `finite` must be about `M` minus the hole share, and `finite=0` beside non-zero
|
|
`hole_slots` is this defect returned.
|
|
|
|
- **A math channel is a row of the *signal* list, under a `MATH` divider, and there is no math pane.**
|
|
`append_math_signals_locked()` already puts every channel in `signals()`, so the pane was the same
|
|
list drawn twice; the divider is what the pane uniquely contributed. It is non-selectable and
|
|
non-activatable -- a heading that highlighted under the pointer would read as a row you can plot --
|
|
and it carries the `+` that creates a channel, appended even when there are none, because a `+` that
|
|
waited for a channel to exist could never create the first one. A math row's menu is *Edit graph…*,
|
|
*Default colour*, *Delete*: no Information and no Calibrate, because calibration is applied at
|
|
ingest and a derived channel has no stream to describe, and no rename, because the node editor owns
|
|
a channel's name and unit and a second place to change them would be a second answer to the same
|
|
question. A row's tick is the channel's own switch and must reach `App::math` -- the copy the
|
|
session file is written from -- or the list and the file disagree about what "enabled" was.
|
|
- **A graph *edit* does not move the plan; adding or removing a channel does.** The plan is a partition over
|
|
the channels, so `set_math_channels()` takes a surgical path whenever the *set* is unchanged -- same channels,
|
|
names and enables: the store is never touched, every other channel keeps its samples **and** its runtimes, and
|
|
only the edited graphs are prepared again. `math_apply_locked()` computes the whole list on a recompute
|
|
(the graphs are walked in order, so a channel's inputs must be there) but writes only the channels in the
|
|
affected mask; the affected set is the edited channels plus whatever *names* them, since one channel's output
|
|
is another's input. Before this, every edit rebuilt the store and every channel in the window started again,
|
|
which is the "modifying one expression invalidates all the others" report.
|
|
`[math-probe] graph edit: stored_slots N -> M` reads it: M must be at least N -- the store keeps filling
|
|
while the edit runs -- and never 0.
|
|
|
|
- **A recompute rewrites a ring *in place*, and both of the ring's counters have to survive it.**
|
|
`recompute_math_locked()` replays the inputs' **retained** window, which begins at the time ring's
|
|
`base()` and not at zero, so an emptied derived ring is restarted with `clear_to(base)` and never
|
|
`clear()`. With `clear()` the refill renumbered the channel from 0 and left its `produced()` short by
|
|
exactly `base` -- and `live_window_locked()` clamps its window against *every* channel's `produced()`,
|
|
so that one lagging channel answered `n = 0` for the live view, every pause view **and** every trigger
|
|
capture, until a whole ring of fresh data had gone by. That is the "a math edit freezes acquisition,
|
|
and the trigger stops firing on a channel that is not even derived" report, and it is **invisible
|
|
before the ring first wraps** (`base == 0`, where the two numberings coincide), which is why it reads
|
|
as intermittent and uptime-dependent. The dump's `math '<name>': N samples vs M time slots` is the
|
|
reading -- both numbers are `produced()`, so a mismatch *is* this defect -- but only on a run whose
|
|
ring has wrapped: give it a small `--buffer-mb`. `UDPSCOPE_PROBE_MATHWRAP_MS` is the probe that
|
|
arranges exactly that, and it exists because **`math_probe` structurally cannot** reach this state:
|
|
every edit that probe makes follows a structural change, which replans the buffer and restarts every
|
|
ring at absolute 0, where both halves of this are identities.
|
|
The published window is the second counter. `acquire()` is incremental, so a rewrite that leaves
|
|
`produced()` where it was publishes **nothing** and the pyramid -- what the renderer and every
|
|
measurement read -- goes on describing the samples the edit replaced. `ChannelBuffer::republish_from(base)`
|
|
is what puts it back, and it takes the ring's base rather than 0 because `invalidate()` would make the
|
|
next `acquire()` walk every absolute index ever produced (an hour at 1 MS/s is 3.6 G appends) to publish
|
|
one window.
|
|
|
|
- **A math channel whose source is switched off is switched off with it.** `Source` resolves to a *channel*, so
|
|
a derived channel cannot be computed from a signal nobody acquires: left enabled it would silently compute
|
|
nothing, which is what "all math signals depending on it do not compute anymore" looked like from outside.
|
|
`on_signal_switch()` disables the channels that read that signal -- **transitively**, since one math channel
|
|
may name another -- through the same path the pane's own switch uses (`MathChannel::enabled` and
|
|
`apply_math_channels()`), and takes their traces off the plots along with the signal's. The channels that do
|
|
not read it are untouched: `[enable-all-probe]`'s `math_on=[…]` is the half that reads this. Re-enabling the
|
|
signal does **not** switch them back on, for the same reason re-enabling does not restore the traces.
|