11 Commits
Author SHA1 Message Date
Martino Ferrari 667ed0c100 docs: the noise probe arms at 4 s, not 3, and why
`UDPSCOPE_PROBE_NOISE_MS=3000` collides with `enable_all_probe`'s own last act, which fires at
`ENABLE_ALL_MS + 2000` = 3 s: that lambda walks `signal_list` for the front channel's acquire
tick and clicks it, and a probe that installs a math graph at the same instant rebuilds the
signal panel under it. The off-step then emits `active` on a freed `GtkCheckButton` --
`GLib-GObject-CRITICAL: instance with invalid (NULL) class pointer`, stack in
`enable_all_probe()::{lambda}` -- which the baseline ("no Gtk-CRITICAL at all") counts as a
finding. Confirmed by bisection: armed at 3 s it is reproducible, at 4 s it is gone, and a plain
exit-dump run shows none.

That fragility is the probe's, not the feature's, and it is now documented beside the recipe: every
documented probe time is offset from `ENABLE_ALL_MS + 2000`, not merely from the other probes.
The reading times move with it (legs ~11.5 s, de-noise ~16.5 s, both inside the same 18 s exit),
and the corrected run reproduces every documented figure bit for bit.

Readings at 4 s: `setup channels=1`, `trigger band: bare=49/2 band=7/2 ratio=7.00`,
`trigger hf: hf=7/2 band+hf=7/2 band=7`, `pw`/`period band`/`period hf` exactly 0.200000,
`measure windows=2`, `denoise raw=2.987696/1 denoised=2.000000/1 ratio=0.669 channels=1`, no
`Gtk-CRITICAL`.
2026-10-13 16:07:50 +02:00
Martino Ferrari 749b34a2cb docs: record the three noise-rejection controls and their invariants
README gains the three controls where each lives -- the trigger's `band`/`reject`, the
measurement's `noise`/`hf`, and the math channel's `de-noise` node -- each with its unit, what
"off" is, and what it costs, plus the two deliberate deviations from the design spec next to the
features they belong to: the band applies to `edge` and `window` only (a pulse's width is measured
from a crossing to its return, so a band there changes the measured width rather than rejecting
noise; a dropout is an absence with no level to compare), and the band is verified through
`BufferSearch` hit counts rather than `[condition-probe]` latch times, because probe-readings.md
states that a `t0` is not an assertion.

The invariant docs gain the rules a later change must not break: `EventCondition`'s two new fields
and the once-per-packet `configure(dt)` that must not reset; the crossing rule that a change is
between two *decided* sides; the measurement path's exception to "a readout quotes a stored
sample", why the band moves the edge decision only, why `measure_accumulate` loops samples outer,
and why a filtered window disables both bucket fast paths; that `MathOp` is append-only (the index
*is* the serialisation), that the median window is pooled in the runtime rather than held per node,
why the pole reuses `b0`/`z1`, and that median-then-pole is the design; the session's named
`noise`/`hf` keys and its thirteen-field node string that still reads twelve; and the two GTK
traps -- positionally brace-initialised dialogs, and a control that cannot apply being hidden
rather than disabled.
2026-10-13 16:05:28 +02:00
Martino Ferrari c38630643d test: the noise probe reads the de-noise node, and the math probe builds one
The probe gains a staged chain rather than one phase, and the schedule moves with it:
`UDPSCOPE_PROBE_NOISE_MS` now times the *setup* stage, which installs the de-noise graph through
`apply_math_channels()` -- a structural rebuild that replaces the buffer and drops its history --
five seconds before anything scans it. The legs and the five measurements then run at about
10.5 s, and the reading that says the median rejected every impulse at 15.5 s, so the documented
run needs a 20 s mock and an 18 s exit.

The de-noise channel is installed **disabled** and enabled only at 13 s, for its own reading. An
acquired channel is a share of the ring, so a fifth one shortens every channel's retained span --
and the six `BufferSearch` leg counts and `pkpk bare` are all peaks and counts *of that window*.
Installing it enabled (as the plan had it) moved `bare` from 49 to 21, `ratio` from 7.00 to 3.00
and `pkpk bare` from 2.988 to 2.488, i.e. it would have rewritten the older feature's documented
proof to record a regression that is not one. With it enabled only at the end, every reading from
Tasks 4 and 8 is bit-for-bit what the doc records, and the new line reads
`denoise raw=2.987696/1 denoised=2.000000/1 ratio=0.669 channels=1` -- `denoised` exactly the
clean sine's 2.0, which is the claim the node makes.

Two other findings, both folded in: a derived channel whose source is switched off through the
panel's checkbox follows it off and does *not* come back when the source returns (the enable-all
probe's last act switches `sine` off at ~3 s), and the probes reload leftover math channels from
`$XDG_CONFIG_HOME/udpscope`, which is why a `setup channels=` count above 1 means an earlier run's
session, not a defect.

`math_probe` gains a third channel with a `DeNoise` node so the editor's median row is built and
synced on a baseline run: `3 math channels on 'sine' and 'noise'`, every other line as before,
`math cost: 103.3 ns/slot` (one reading of the same path).

verification.md: the recipe, and why the two stages may not be collapsed -- a probe that rebuilt
the buffer under its own legs reads `bare=0` and looks like a broken comparator. Also that the
noise and math probes may not share a run: both write `app.math`.
2026-10-13 16:01:44 +02:00
Martino Ferrari 5c2c221777 ui: the node editor offers the de-noise node and persists its window
The form gains a `median samples` row, shown only for `DeNoise`; the corner row widens to
anything that reads `fc` (`math_op_uses_fc()`) while `q` stays band-pass-only, and the wiring
row keeps its place at the end so the form's order is unchanged for every existing op.

The session format gains a thirteenth field, `med_n`, after `y`. A twelve-field node -- a file
written before this node existed -- still loads, with `med_n` left at the struct's default,
which is what such a file meant; the reader's `return number(...) && ...` chain had to become a
guarded sequence so the thirteenth field could be read conditionally.

The math probe enables the channel it builds its graphs from, the same self-sufficiency rule
the noise and measurement probes already follow: `enable_all_probe` ends by switching one
signal off, and with `sine` gone the graphs landed on `square`, changing a documented line for
reasons unrelated to the math.

Readings: `[math-probe] 2 math channels on 'sine' and 'noise'`, `pane add: 'math3' sources
'sine'`, `graph edit: stored_slots 31000 -> 32000`, `dialog build: nodes=2, output after add=1`
and the rest of the suite unchanged; no `Gtk-CRITICAL`.
2026-10-13 15:50:58 +02:00
Martino Ferrari 1bde8809dd feat: a de-noise math node rejects impulses with a median and a one-pole
One op, not two: a running median followed by a one-pole at `fc`. The median kills an impulse
outright -- a peak shorter than half its window cannot move it at all -- and the pole then
takes the broadband hash the median leaves behind. Chaining the existing `LowPass` after a
median would let the user wire them the wrong way round, and a pole *before* a median feeds
the impulse into the filter's state for its whole settling time, which is the thing the node
exists to avoid.

`DeNoise` is **appended** at `math_op_at(12)` (with `Div` given its explicit `case 11`, so the
`default` no longer swallows it): a session stores an op as its index through that accessor,
so inserting it beside the filters in the dropdown would reinterpret every node in every
existing session file. It sits last in the dropdown instead, which is the right side of that
trade.

The median window is `MathNode::med_n`, odd and 3..9, enforced by `median_n()` rather than by
the dialog -- a file holding anything else still loads -- and the ring is *pooled* in
`MathRuntime::median`, sized in `math_prepare()`, because nine doubles as a member would push
`MathNodeRt` across two cache lines for every node, filter or not. The pole reuses `b0`/`z1`,
which `math_prepare()` already carries across a re-prepare, so a decimation change does not
make its trace jump.

Warm-up takes the median of what the window holds rather than NaN: in this application NaN
means *a hole in the data*, and spending that meaning on a start-up transient would make a gap
ambiguous. A non-finite input stays a hole and leaves both the window and the pole alone.
2026-10-13 15:48:27 +02:00
Martino Ferrari 2c0e21fd20 test: the noise probe reads a measurement's band and HF reject
Five measurements on the same spiked `sine`, asking for corners 0, 50, 0, 0, 50 on one
channel: two windows under the (channel, fc) key, which is what exercises it.

    measure pkpk bare      noise=10.0 hf=0.0  value=2.987692   the stimulus, impulses included
    measure pkpk hf        noise=10.0 hf=50.0 value=1.990075   the waveform: pass one is filtered
    measure period noband  noise=0.0  hf=0.0  value=0.021290   supposed to be WRONG -- the control
    measure period band    noise=25.0 hf=0.0  value=0.200000   the band alone
    measure period hf      noise=0.0  hf=50.0 value=0.200000   the reject alone
    measure windows=2                                          the corner is part of the key

`pkpk hf` is the reading that says the filter reached *pass one* and not only pass two; a
value near 3 means it did not. `period noband` is deliberately the defect the band exists to
fix, and a future reader who "fixes" it has removed the check.

The band leg asks for **25 %** rather than the 10 % default, and that is arithmetic rather
than a workaround: the band is a share of the *window's* peak-to-peak, which with the
impulses in it is about 3, so 10 % is a half-band of 0.3 -- less than the 0.5 an impulse
moves the signal by. `MeasureOptions::noise_pct`'s own rationale says 10 % holds to a 0.3
interferer, and this mock's is 0.5, so the default could not clear it. The default band is
what the `period noband` and `period hf` legs are about.

verification.md now says the noise and measurement probes must not share a run: both push
onto `app.measures` and the later one would read the earlier one's rows.
2026-10-13 15:46:24 +02:00
Martino Ferrari 84aebf4116 ui: a measurement's noise band and HF reject are settable and persisted
Two rows in the measure dialog, built the way the threshold and side rows are and appended
beside them: the band as a percentage of the window's own peak-to-peak (the unit the engine
takes), the corner in Hz with 0 off. They are seeded when editing, shown only where they do
something -- the band for the crossing types, since it moves the edge decision and nothing
else, the reject for all of them, since it sits before both walks -- and read into
`MeasureOptions` on apply.

They persist as two *named* keys, `noise` and `hf`, on the same `o <<` chain as the keys
beside them and read back through the same keyed reader, so a session written before this
change loads with 10.0 and 0.0: today's behaviour exactly, with no version check.

[configs] now proves the round trip rather than assuming it. The probe sets the first
measurement's band and corner to 7.5 and 250 before saving, so a key that never reached the
file reads back as the defaults:

    [configs] round trip read=1 comment='written by the configs probe' ... measures=4 math=0 noise=7.5 hf=250

The rest of that line is unchanged, and the run is free of Gtk-CRITICAL.
2026-10-13 15:42:26 +02:00
Martino Ferrari 380ddb9a77 perf: the measurements pane keys its shared walks by channel and HF corner
The pane shared its two walks per *channel*, but the HF corner is a per-measurement
setting: two measurements on one signal at different corners read two different signals
and cannot share a walk. `App::MeasureWindow` therefore carries `channel` and `fc`, the
container is a vector, and the grouping loop matches on both -- keeping its dedupe of the
level accumulators by `(pct, noise)`, since a type that does not read the band must not
split an accumulator over it.

Each window carries two filters, one per walk: they are separate walks, so separate state.
A filtered window also stops using the LOD pyramid, which one `if` does -- leaving
`buckets` empty makes `bulk_from == bulk_to == n`, so the raw walk covers the window and
`lo_search`/`hi_search` stay 0, which makes the pinning pass a no-op. Nothing downstream
needs to know.

The readout loop looks its window up by index rather than by channel, which is what the
`(channel, fc)` key needs.

The documented measurement baseline is unchanged, and it is the assertion:

    [measure-trace] average=0.000000 top=0.636620 peak to peak=2.000000 amplitude=1.000000
    [udpscope] measure cost: ... covered=75378013 buckets=1177164 raw=159674 (0.2%)

`top` is 2/pi to the digit -- the one reading a band leaking into the sums would move --
and `raw` at 0.2 % of `covered` says the fast path is still being used on an unfiltered
window.

The measure probe now enables `sine` itself, as the noise probe does: `enable_all_probe`
ends by switching a signal off, and with `sine` gone the probe measured `square`, where
`top` is 1.0 and the baseline above is unreachable.
2026-10-13 15:38:53 +02:00
Martino Ferrari 2730707372 feat: a measurement's band is a setting and its walks can reject HF
`MeasureOptions` gains `noise_pct` (the hysteresis band, % of the window's peak-to-peak,
defaulting to the 10 % the file-scope constant held) and `hf_fc` (a one-pole HF reject over
the measured window). The band's measured rationale moves with the number rather than
becoming an orphan comment, and `measure_set_level()` takes the fraction as an argument
because the band is a per-measurement setting while the call is once per level.

`MeasureFilter` is that reject as a walk carries it: one pole, one state word, `a == 1` a
pass-through so an unfiltered walk pays a multiply and no branch, and a non-finite sample
passing through without touching the state. Both raw walks step it -- and stepping it is why
`measure_accumulate()` now loops samples-outer/levels-inner: a levels-outer loop would step
one filter state once per level per sample, and every level after the first would read a
signal filtered k times over. The inner `continue` is a `continue` of the *level* loop, which
is what it has to be: a sample inside one level's band may be outside another's.

`measure_uses_noise()` names the three types the band reaches (the crossing types -- a band
moves the edge decision and nothing else), and both bucket functions say in their comments
that a bucket aggregates unfiltered samples, so a walk with a reject must not use them.

This leaves `ui_measure.cpp`'s five call sites failing to compile, deliberately: they are the
next task, and pretending otherwise would mean a shim that the task then deletes.
2026-10-13 15:36:16 +02:00
Martino Ferrari fec0a1032e test: the noise probe reads the trigger's band and HF reject
Six legs over one retained buffer, each a full rising-edge search through `BufferSearch` at
level 0, run back to back without yielding so every leg sees the same samples: the bare
comparator on a spiked sine, a 0.8 band, a 50 Hz reject, both, and the reject's group delay
on `square` -- which carries no impulses, so a one-pole's crossing of zero after a step is
exactly `tau*ln2` and the delay is an exact prediction rather than a coincidence.

    trigger band:  bare=49/2 band=7/2 ratio=7.00
    trigger hf:    hf=7/2 band+hf=7/2 band=7
    trigger delay: clean=4 filtered=4 delay=0.002206 want=0.002206 ratio=1.000

`ratio >= 5`, both HF legs within 2 of the band, `ratio` between 0.7 and 1.4, `state=2` on
every leg, no Gtk-CRITICAL. `bare` and `band` are proportional to the retained buffer, so
neither is a fixed number; the ratios and the agreement between legs are the assertions.

The probe enables the signals it measures itself rather than relying on another probe's side
effects -- `enable_all_probe` ends by switching one off again, which is how `sine` was missing
from the first run of this one -- and arms in two phases because that enable is a structural
change that replaces the store, so the ring has to fill before there is anything to search.
Its "no stream" line now names the channels that *are* there: an index alone says a probe
found nothing, not what it saw.
2026-10-13 15:35:03 +02:00
Martino Ferrari 1583741e03 ui: the trigger panel offers a noise band and an HF reject
Two label-only rows in trigger-panel.ui (band_row, hf_row, after holdoff_row and before
mode_row) and the two spins the code appends into them: the band on the level spins' scale
in signal units, the HF reject at 0-1e7 Hz with 0 off. Both are published with the other
fields -- read before the model lock is taken, assigned inside it -- and both join the
value-changed loop, so editing either republishes the condition.

The rows are shown per type: the band for every type but Dropout, which is an absence a
band cannot describe, and the HF reject for all of them, since it sits before every
comparison. `sync_trigger_rows()` resolves a row as its spin's parent rather than keeping a
second handle to it: the row comes from the file and holds one child.

The exit dump counts the panel's controls, so it reads controls_inside=15/15 now -- and the
probe confirms it, along with both search readings and no Gtk-CRITICAL:

    [udpscope] trigger panel: boxes=15 controls_inside=15/15 search_widgets=4/4 search_inside=4/4
2026-10-13 15:31:44 +02:00
21 changed files with 1002 additions and 64 deletions

No files matched your search

+50 -1
View File
@@ -208,6 +208,23 @@ to reject noise with, and counting both edges halves the period.
The split is a share of the **window's own** min..max, not volts: a measurement is about this
signal in this window, and a level in volts would have to be typed for every signal.
Two more options ride on it, both at their old behaviour by default:
| option | what it is | unit | off |
|---|---|---|---|
| `noise` | the hysteresis band's half-width, as the next section describes | % of the window's peak-to-peak | `0` disables the band; the default `10` is the band the app has always counted with |
| `hf` | a one-pole low-pass over the window, before *any* statistic | Hz corner | `0` is off |
The band is clamped to half the room between the split and the nearer extreme, so a 50 % band cannot invert
the thresholds. `hf` is the **one** place where a readout does not quote a stored sample: with `hf > 0` the
extremes, the mean and the levels come from a filtered copy of the window, so a ruler can sit at a level the
drawn trace -- which is raw -- never reaches. That is what filtering *is*; it must not be "fixed" by snapping
the value back to the nearest sample, and with `hf = 0` the old rule holds exactly.
Both cost: a filtered window disables the two bucket fast paths, because a bucket is an aggregate of *raw*
samples and cannot be mixed into a filtered walk. A filtered window is still walked once and shared by every
measurement that asks for it, so a pane of five measurements at two corners costs two walks, not five.
### Why a crossing is counted with hysteresis
A crossing is not read off a single level. It is read off a **band**: two thresholds a tenth of
@@ -993,6 +1010,24 @@ modes are unchanged, and only the question "did an event happen at this sample"
- **dropout** — the *absence* of a crossing for longer than a timeout. One event per outage:
the condition re-arms only when a crossing returns.
**Noise rejection on the trigger's own input.** A *band* and a *reject* are offered beside the level.
`band` is a share of the channel's own span either side of the level -- a second, undecided threshold, so a
sample inside it decides nothing -- and `reject` is a one-pole low-pass corner in Hz, one state per
configured condition, recomputed when the corner or the sample interval changes. **Off is `band = 0` and
`reject = 0`**, which is bit for bit the trigger that was there before them.
They apply to **edge** and **window** and *not* to **pulse width** or **dropout**. That is a deliberate
deviation from the design spec, which asked for every condition type: a pulse's width is measured from a
crossing to its return, so a band there would change the *measured width* rather than reject noise (the
documented pulse-width readings move with it), and a dropout is a measured *absence*, which a band cannot
describe -- there is no level to compare.
The band and the reject are verified through `BufferSearch` hit counts rather than through
`[condition-probe]` latch times, also deliberately: `probe-readings.md` states that a `t0` is **not** an
assertion, being a stream stamp rebased by whichever rebuild last dropped history -- it read `1.7103`,
`1.6975` and `1.8719` on three consecutive runs of the same leg. The counts, and what each must read, are in
`probe-readings.md`.
A shape's own rows are the only ones shown; the rest are hidden, so the panel never offers a
width to a window trigger. The condition is **not** persisted in the session file — the session
has never persisted the trigger — so a restart comes up on an edge trigger.
@@ -1112,9 +1147,23 @@ an *ordinary channel*: the model synthesises a signal entry for it, so an enable
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
stored rate), `de-noise` (a running median followed by a one-pole), `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.
`de-noise` is **last in the dropdown on purpose**: a node's op is stored in the session as its index
through `math_op_at()`, so an op inserted among the others would reinterpret every node in every file written
before it.
**`de-noise` is one node, not a median the user chains into a low pass.** Its window is `med_n` samples, odd
and 3..9 (the dialog offers only those, and the engine rounds to it anyway, so a hand-edited file loads), and
an impulse shorter than half the window cannot move the median at all; the one-pole at `fc` Hz then takes the
hash the median leaves behind, and `fc = 0` is the median alone. The order is the design: a pole *before* a
median feeds the impulse into the filter's state for its whole settling time, which is the thing the node
exists to avoid. It costs a per-sample insertion sort of at most nine doubles into a local, on the producer
thread where every other op runs -- `math cost:` reads **103 ns/slot** with a de-noise channel in the probe's
three graphs.
**A gap is a gap in a filtered trace, never the end of it.** A hole the resequencer reserved is
NaN in the source, and the filters deliberately do not integrate it: the hole shows as a hole in
the filtered trace, the filter's history stays the last real samples it saw, and the trace picks
+8
View File
@@ -18,6 +18,14 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
points (`on_trigger_field_changed`, `on_trigger_drop_changed`) onto one
`publish_trigger_fields()`: changing the edge or Normal/Single dropdown used to segfault
outright. `UDPSCOPE_PROBE_FIELDS_MS` is the guard.
- **`MeasureDialog` and `MathDialog` are brace-initialised *positionally* in `src/ui.cpp`**, so a field
added in the middle of either silently shifts every argument after it: add at the end of the widget group
and read the initialiser in the same commit. A row array widened by a new control is the same trap from
the other side -- a `rows[N]` that was not grown shows a control the sync never touches, and the compiler
says so as an out-of-bounds warning on `rows`.
- **A control that cannot affect the current type is *hidden*, not disabled**: the trigger's band row is
hidden for `dropout`, the measurement's for every non-crossing type, and the node editor's median row for
every op but `de-noise`. A greyed row is a question the user can see and cannot answer.
- **`GtkDropDown`'s `notify::selected` arrives after the `set_selected()` that caused it
returns**, so the `updating_panels` flag — set around a programmatic write and cleared when
it returns — does *not* suppress it. Where the write is part of a readout the handler reads
+24 -4
View File
@@ -26,9 +26,10 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
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 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. Read it on the fast mock, where the two assertions that matter are **`gaps=0`** (the
`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
@@ -61,7 +62,8 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
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.
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.
@@ -239,6 +241,24 @@ and reading values off the widgets *inside* the editor cannot see that. It is th
* **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
+10
View File
@@ -76,6 +76,16 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
the pyramid for nothing.
`measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say
anything.
- **`MeasureOptions::noise_pct` is a percentage, clamped in `measure_set_level()` to half the room to the
nearer extreme**, so a band can never invert its own thresholds. It moves the **edge decision only**: the
sums still split at the bare level, because `Top` on a unit sine must read 2/pi (0.636620).
- **`measure_accumulate` loops samples outer, levels inner, and that is not backwards.** The filter holds one
state for the whole walk, so levels-outer would step it once per level *per sample* and hand every level but
the last a different filter state.
- **A non-zero `hf_fc` disables *both* bucket fast paths**, with the single
`if (!filtered && reader.bucket_range(...))`. An empty `buckets` makes `bulk_from == bulk_to == n` *and*
leaves `lo_search` / `hi_search` at 0, so `pin()` becomes a no-op -- and that is the right answer, because a
bucket is an aggregate of unfiltered samples and cannot be mixed into a filtered walk in either direction.
- **A crossing is counted with hysteresis, and the band moves the *edge decision* only.**
`MeasureLevel::hysteresis` is a Schmitt trigger about `level`: an edge is counted once the signal
has passed `level + h` going up or `level - h` going down, and a sample inside the band decides
@@ -32,6 +32,15 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
`crosshair_interpolated_mode_blends_the_two_samples` and
`the_crosshair_mark_is_bigger_than_a_sample_dot`, plus `[cursors-probe]`'s `first mark at x=..
y=..` agreeing with the pane's first value.
- **A measurement with a non-zero `hf_fc` is the one exception**, and it is inherent rather than a rounding
error. Its `min`, `max`, `peak to peak`, `average`, `top` and `bottom` are computed from a one-pole-filtered
copy of the window, so the quoted value need not equal any stored sample -- and with `show_on_plot` its
ruler sits at a level the drawn trace, which is raw, never reaches. This is what filtering *is*; it must
not be "fixed" by snapping the readout to the nearest sample. Every measurement with `hf_fc == 0`, which is
the default, still quotes a stored sample exactly as the rule above says. The filter is seeded with the
window's **first sample** and never from history before the window, so a measurement never depends on data
outside the window it reports on: the left-edge step transient is removed by the seeding, not by priming
from earlier samples.
- **`format_measure()` is the one formatter for both panes, and its SI prefix does not depend on
there being a unit.** The cursors pane's values and the measurements pane's `text=` go through
it, so a change reaches both. The prefix ladder used to be inside `if (!unit.empty())`, which
@@ -23,6 +23,13 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
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".
- **Measurement options persist under *named* keys -- `noise` and `hf` -- so a file written before them
loads at the defaults**, which are exactly the behaviour it was written against: `noise = 10` is the
hysteresis band the app has always counted with, and `hf = 0` is no filter.
- **The math node string is thirteen pipe fields, and the reader accepts twelve *or* thirteen.** `med_n` is
the thirteenth, after `y`; a twelve-field node is a file written before the de-noise node existed, and its
`med_n` stays at the struct's default -- which is what such a file meant. Field 0 is the op as a
`math_op_at()` index, which is why `MathOp` may only be *appended* to.
- **The export is collected at click time, not in the file-dialog callback.** The live
view keeps scrolling while the chooser is up, so a late collection would write a
window the user never saw. The writer is picked by the chosen path's extension.
@@ -45,6 +45,18 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
be **negative** when the new ring starts after the edge. That is a faithful reading, not a
defect: `[-pre_s, post_s]` still describes the samples the snapshot holds. The guard is
`UDPSCOPE_PROBE_TRIGRENUM_MS`, whose `t0_held=1` is the assertion.
- **`EventCondition` carries the trigger's own rejection: `noise_band` and `hf_fc`.** Both are in the
struct's `operator==`, so a change to either *is* a change to the condition. `EventDetector::configure()`
takes `dt` and early-returns only when the condition **and** the interval are unchanged -- it runs on
*every packet*, so a configure that reset unconditionally would restart the machine before any pulse could
close. `dt` is `Partition::slot_dt()`, the *stored* interval and not the CONFIG's nominal rate, and comes
from exactly three call sites (`acquisition.cpp`, `ui_trigger.cpp`, `ui_probe.cpp`); a zero there leaves
the reject's coefficient at 1, which is a pass-through.
- **A crossing is a change between two *decided* sides, never a change from the undecided one.** The band
makes each sample decide above / below / undecided, and an edge is counted only when the signal has reached
the far threshold of the side it is leaving. Seeding the previous side from `0` -- the undecided code --
fires on a signal that starts *inside* the band and leaves it without ever reaching the level, an event the
trigger is not supposed to see.
- **A pinned window is *clamped* into the event every frame, and a zoom in is allowed.**
While an event is held, a capture is filling, or the view is paused, `tick()` calls
`RowSet::hold_pinned_window()`, which caps the span at `[-pinned_pre_, +pinned_post_]`'s and
+110 -4
View File
@@ -851,9 +851,13 @@ popover row reads `failed: <strerror>` — one stderr line, no retry storm again
[defaults] calibrated: 2.5 -1.25 model=2.5 -1.25
[configs] before=default active=default
[configs] save-as=1 names=probe,default active=probe
[configs] round trip read=1 comment='written by the configs probe' buffer_mb=8 relative=0 capture=0 plots=1 signals=4 measures=0 math=0
[configs] round trip read=1 comment='written by the configs probe' buffer_mb=8 relative=0 capture=0 plots=1 signals=4 measures=0 math=0 noise=-1 hf=-1
[configs] after forget names=default
```
`noise=` and `hf=` are the first measurement's band and corner as they came back from the file. The probe
sets them to **7.5 and 250** before saving, so a key that never reached the file reads back as the defaults
(10 and 0) instead -- which is the only way a round trip can tell a written key from an absent one. They read
`-1` when the saved configuration holds no measurement, which is what the run above does.
| field | value it must read | the defect a wrong reading names |
| --- | --- | --- |
@@ -1165,14 +1169,15 @@ through one row type, for the reason in `invariants-gtk-and-chrome.md`. The form
## `trigger panel:`
```
[udpscope] trigger panel: boxes=13 controls_inside=13/13 search_widgets=4/4 search_inside=4/4
[udpscope] trigger panel: boxes=15 controls_inside=15/15 search_widgets=4/4 search_inside=4/4
```
The trigger panel's frame, read from the window: `boxes` is the row boxes the `.ui` holds, `controls_inside`
counts the thirteen controls the code built and then appended into those rows, `search_widgets` is the four
counts the fifteen controls the code built and then appended into those rows -- the last two being the
noise band and the HF reject, whose rows arrived with the noise-rejection work, `search_widgets` is the four
search buttons having resolved by id, and `search_inside` is those four being inside the block the expander
opens onto. A control built and never placed -- an id that drifted between the file and the code -- reads as
`controls_inside` below 13 while every other line still looks right, which is the panel that has a field
`controls_inside` below 15 while every other line still looks right, which is the panel that has a field
missing.
The search walk starts at `gtk_expander_get_child()`, not at the panel: an expander's child is not in the
@@ -1275,3 +1280,104 @@ which must be untouched:
channels and an edit does not move it, so the store survives: the count *grows* (the stream keeps filling
while the edit runs) and never falls to 0, which is what a rebuild would do. Every channel then still reads
`N samples vs M time slots` with `N == M`, so no channel was left behind either.
## `[noise-probe]`
```
[noise-probe] setup channels=1
[noise-probe] trigger band: bare=49/2 band=7/2 ratio=7.00
[noise-probe] trigger hf: hf=7/2 band+hf=6/2 band=7
[noise-probe] trigger delay: clean=3 filtered=4 delay=0.002206 want=0.002206 ratio=1.000
```
`UDPSCOPE_PROBE_NOISE_MS`, and it needs the mock's **spiked** sine -- `--spike-every 0.005 --spike-amp 0.5
--spike-width 2` -- where every other probe uses the plain mock. It runs as **four stages**, and the chain is the point:
1. At `UDPSCOPE_PROBE_NOISE_MS` the setup installs the de-noise graph through `apply_math_channels()`.
That is a structural rebuild that *drops the buffer's history*, so the five seconds to the next stage are
what let the ring refill before anything scans it.
2. Five seconds later the legs run. They enable the signals they measure *themselves*, because a probe must
not depend on another probe's side effects (`enable_all_probe` ends by switching one off again, which is
how `sine` went missing from the first run of this one), and wait 2.5 s for the ring to refill after that
structural change. The legs then each search the whole retained buffer through `BufferSearch`, one after
another **without yielding**, so all six see the same buffer -- and the five measurements come next, over
the same retained history.
3. Two and a half seconds later the de-noise channel is **enabled**, by name, through the same switch the
panel drives. The setup installs it *disabled* and this stage turns it on, because an acquired channel is
a share of the ring: a fifth one shortens *every* channel's retained span, and every figure above scales
with that span -- the six leg counts, and `pkpk bare`, which is a peak-to-peak *of the retained window*.
The readings above are the older feature's proof, so the new channel stays out of the acquisition plan
until they have all run.
4. Two and a half seconds after that, the de-noise reading. A math channel starts producing when it is
applied *and* acquired, so the channel's first sample is the first one after stage 3, and a peak-to-peak
needs a window holding a trough *and* a crest: twelve cycles of the 5 Hz source.
With `UDPSCOPE_PROBE_NOISE_MS=4000` the legs therefore land at about 11.5 s and the last line at 16.5 s,
which is why the documented run needs `UDPSCOPE_EXIT_AFTER_MS=18000`. Four seconds and not three: the
enable-all probe's own last act, a click on the front channel's tick at `ENABLE_ALL_MS + 2000`, rebuilds
nothing itself but *is* rebuilt under when a graph is installed at its instant -- see `verification.md`.
- **`setup channels=1`** -- one math channel was installed, and it is printed by the *first* stage, before
any leg: it is the line that says the graph exists at all. A count above 1 means a session file from an
earlier run was loaded on top of it (`$XDG_CONFIG_HOME/udpscope`); those channels load disabled, so they
do not change the partition, and a clean run of the documented command prints `1`.
- **`trigger band:`** -- `bare` is the bare comparator on the spiked sine (every impulse that lands near zero
carries it across, so the count is dominated by spikes), `band` the same with a 0.8 band. **`ratio` must be
at or above 5**, and `band` about the number of sine cycles retained. Neither count is a *fixed* number:
both scale with the retained buffer's length (7 at a 2.5 s buffer, ~30 at 6 s), so the ratio and the
agreement between the legs are the assertions. A `ratio` near 1 means the band is not being applied --
`decide()` is not reached from `feed_edge`.
- **`trigger hf:`** -- `hf` (no band, 50 Hz reject) and `band+hf` (both) must be **within 2 of `band`**: the
reject removes the impulses on its own. `hf` near `bare` means the pole never left `a == 1` (check
`configure()` recomputes it, and that `hf_fc` reached the condition); `band+hf` near 0 means the reject is
attenuating the sine itself, which would be the Nyquist clamp clamping 50 Hz.
- **`trigger delay:`** -- the reject's group delay, measured on **`square`**, which carries no impulses: a
one-pole turns a -1 -> +1 step into a zero crossing at exactly `tau*ln2`, so `delay` must read
`want = 0.002206 s` and **`ratio` between 0.7 and 1.4**. A `ratio` near 0 is a pass-through; a large one
means `a` is far too small, i.e. `dt` never arrived at the detector.
- `state` is `2` (Done) or `4` (Capped) on every leg. `3` (Truncated) on more than one leg means the pump loop
is being overtaken -- nothing to fix in the feature, but say so.
The same probe then puts five measurements on the same spiked `sine` as **two windows** -- three legs at
`hf=0` and two at `hf=50` -- and reads them back:
```
[noise-probe] measure pkpk bare noise=10.0 hf=0.0 valid=1 value=2.987692
[noise-probe] measure pkpk hf noise=10.0 hf=50.0 valid=1 value=1.990075
[noise-probe] measure period noband noise=0.0 hf=0.0 valid=1 value=0.021290
[noise-probe] measure period band noise=25.0 hf=0.0 valid=1 value=0.200000
[noise-probe] measure period hf noise=0.0 hf=50.0 valid=1 value=0.200000
[noise-probe] measure windows=2
[noise-probe] denoise raw=2.987696/1 denoised=2.000000/1 ratio=0.669 channels=1
```
- **`pkpk bare`** -- about **3.0**: the sine's 2 plus an impulse either way. The impulses dominate the
window's extremes, so this is the *stimulus*.
- **`pkpk hf`** -- about **1.99** (`2/sqrt(1 + (5/50)^2)`): the *waveform*. This is the reading that says the
filter reached **pass one** and not only pass two; a value near 3 means it did not -- check `measure_scan`
is handed `w.scan_filter`, and that `filtered` disabled the buckets, since a bucket's aggregate is raw.
- **`period noband`** -- about **0.021**, far below the true 0.2. **This one is supposed to read wrong**: it is
the control the other two are compared against, the defect the band exists to fix. "Fixing" it removes the
check.
- **`period band`** -- exactly **0.200000**, the true period, from the band alone. Near `period noband` means
`noise_pct` is not reaching `measure_set_level`. The leg asks for **25 %** and not the 10 % default: the band
is a share of the *window's* peak-to-peak, which with the impulses in it is about 3, so 10 % is a half-band
of 0.3 -- less than the 0.5 an impulse moves the signal by, which is exactly what
`MeasureOptions::noise_pct`'s own rationale says (10 % holds to a 0.3 interferer).
- **`period hf`** -- exactly **0.200000** with the band off: the reject alone. Near `period noband` means the
pole coefficient stayed at 1 -- check `measure_filter_set` was given a non-zero `dt`.
- **`denoise`** -- the feature under test. `raw` is the spiked sine's peak-to-peak over the visible window and
`denoised` the same reading from the **`math denoise`** channel -- a `Source` of `sine` into `DeNoise` with
`med_n=5` and `fc=0`, i.e. the median alone. **`denoised` must read exactly `2.000000`**: that is the clean
sine's own peak-to-peak, so *not one impulse survived*, which is the whole claim the node makes (the
impulses are 2 samples wide and the median is 5, shorter than half a window). `denoised` near `raw` means the
median is passing them: check `med_n` arrived as 5 and not 0, since a `med_n <= 0` node returns NaN and a
peak-to-peak over holes would read `--` instead. **`denoised=0.000000` with `valid=0` means the channel never
produced** -- it was never enabled, its source `sine` was never re-enabled, or `apply_math_channels()` was
never called. `ratio` is `denoised / raw` and follows `raw`, which is whatever the visible window's extremes
happen to hold: 0.669 here, and up to about 0.8 in a run whose window misses the spike at one extreme. The
assertion is on `denoised` and on `raw` being above it; the ratio is the reading, not the test.
`channels=` is the math channel *count* (`a->math.size()`), not whether the channel is acquired.
- **`measure windows=2`** -- the five legs ask for corners `0, 50, 0, 0, 50` on one channel, which is two
distinct windows. **`windows=1` means the corner is not part of the key**: the five collapsed onto one walk
and two of them read a signal they did not ask for.
+51
View File
@@ -522,3 +522,54 @@ hold ports.
The probes are the regression net — when changing UI or plot-host behaviour, extend
the probe/dump in `src/ui.cpp` rather than adding a separate harness.
## The noise probe needs a spiked mock
Every other probe runs against the plain mock. The noise probe reads what a *band* and an *HF reject* do to
impulsive interference, so its mock must have some: an alternating-sign impulse train of 2 samples at
amplitude 0.5 every 5 ms, added to `sine` alone (so a spiked channel can be read against a clean one in the
same run).
```bash
cd /home/martino/Projects/tools/udpscope
python3 tools/mock_udpstreamer.py --port 8108 --rate 1300000 --batch 1000 --duration 20 \
--spike-every 0.005 --spike-amp 0.5 --spike-width 2 > /tmp/noise-mock.log 2>&1 &
sleep 1.5
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=18000 UDPSCOPE_ENABLE_ALL_MS=1000 \
UDPSCOPE_PROBE_NOISE_MS=4000 \
./build/udpscope --server 127.0.0.1 --port 8108 2>&1 | grep -E 'noise-probe|CRITICAL'
```
`UDPSCOPE_PROBE_NOISE_MS` is **not the time its readings are taken**. It times the probe's first stage, which
installs a math graph through `apply_math_channels()` -- and that is a *structural rebuild* that replaces the
buffer and drops its history. Every reading the probe takes is over retained history, so the rebuild has to
happen seconds *before* them, and the chain is what the number is really choosing: `4000` puts the graph in at
4 s, `noise_probe` at 9 s (it enables the signals it measures itself and waits 2.5 s for the ring to refill
after *that* change), the legs and the five measurements at about 11.5 s, the de-noise channel enabled at
14 s, and the de-noise reading at 16.5 s -- hence the 18 s exit and the 20 s mock. **Collapsing the two
stages into one is the failure mode**: a probe that rebuilt the buffer at 11.5 s and then scanned it
immediately would read `bare=0` on every search leg, which looks exactly like a broken comparator.
**Why 4 s and not 3.** `enable_all_probe`'s last act is a timeout of its own, `ENABLE_ALL_MS + 2000` -- 3 s
under this recipe -- which walks `signal_list` for the front channel's acquire tick and clicks it. A probe
that installs a math graph at that same instant rebuilds the signal panel under it, and the off-step then
emits `active` on a freed `GtkCheckButton`: `GLib-GObject-CRITICAL: instance with invalid (NULL) class
pointer`, with the backtrace in `enable_all_probe()::{lambda}`. It is a real fragility of that probe's lookup
and not of the feature, and it is why every documented probe time is offset from `ENABLE_ALL_MS + 2000`, not
merely from each other.
The de-noise channel is installed *disabled* and enabled only for its own reading (stage 3). An acquired
channel is a share of the ring, so a fifth one shortens every channel's retained span -- and the six leg
counts and `pkpk bare` all scale with that span. The readings above the de-noise line belong to the older
feature and are the proof it still works, so the new channel stays out of the acquisition plan until they
have run. `--spike-every`/`--spike-amp`/
`--spike-width` are the mock's own; `--batch` must stay at or below 1000 (2000 samples x 5 signals x f64 is
80 kB, past UDP's ~65507-byte payload, and the mock dies on its first `sendto` while the run still exits 0).
The three lines and what each must read are in `probe-readings.md`.
`UDPSCOPE_PROBE_NOISE_MS`, `UDPSCOPE_PROBE_MEASURE_MS` and `UDPSCOPE_PROBE_MATH_MS` must **not** be scheduled
in the same run. The noise and measurement probes both push onto `app.measures`, and the later one would read
the earlier one's rows as its own; the noise and math probes both write `app.math`, so the later one's graph
lands on top of the earlier one's and both sets of readings describe a state neither probe set up. The noise probe carries
its own measurement legs (see `probe-readings.md`), so nothing is lost by keeping them apart.
+26 -5
View File
@@ -366,7 +366,7 @@ std::string math_node_text(const MathNode& node) {
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;
<< node.x << '|' << node.y << '|' << node.med_n;
return o.str();
}
@@ -382,7 +382,9 @@ bool math_node_from_text(const std::string& text, MathNode& out) {
}
}
f.push_back(cur);
if (f.size() != 12u) return false;
// Twelve fields is a session file written before the de-noise node existed; its `med_n` stays at the
// struct's default, which is what that file meant.
if (f.size() != 12u && f.size() != 13u) return false;
const auto number = [&](std::size_t i, double& v) {
try {
v = std::stod(f[i]);
@@ -402,8 +404,16 @@ bool math_node_from_text(const std::string& text, MathNode& out) {
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);
if (!(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))) {
return false;
}
if (f.size() == 13u) {
double med = 0.0;
if (!number(12, med)) return false;
out.med_n = static_cast<int>(med);
}
return true;
}
bool load_layout(const std::string& path, LayoutState& out) {
@@ -556,6 +566,16 @@ bool load_layout(const std::string& path, LayoutState& out) {
m.options.threshold_pct = std::stod(value);
} catch (...) {
}
} else if (key == "noise") {
try {
m.options.noise_pct = std::stod(value);
} catch (...) {
}
} else if (key == "hf") {
try {
m.options.hf_fc = std::stod(value);
} catch (...) {
}
} else if (key == "above") {
m.options.above = value == "true";
} else if (key == "show") {
@@ -668,7 +688,8 @@ bool save_layout(const std::string& path, const LayoutState& state) {
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";
<< (m.options.show_on_plot ? "true" : "false") << "\nnoise = " << m.options.noise_pct
<< "\nhf = " << m.options.hf_fc << "\n";
}
// A sibling temp file + rename: a crash mid-write leaves the old layout, not a
+88 -2
View File
@@ -59,6 +59,28 @@ void biquad(MathOp op, double fc, double q, double dt, double& b0, double& b1, d
/// One biquad step, transposed direct form II -- the form whose state words are the two things
/// that actually need carrying between samples.
/// One pole's coefficient for a corner of `fc` Hz at `dt` seconds a sample: `a = 1 - exp(-2*pi*fc*dt)`,
/// clamped just under Nyquist so `a` cannot exceed 1. `fc == 0` returns 1, which is a pass-through -- so
/// "no pole" costs the step no branch.
double pole_a(double fc, double dt) {
if (!(fc > 0.0) || !(dt > 0.0)) return 1.0;
const double nyquist = 0.5 / dt;
const double use = fc < nyquist * 0.99 ? fc : nyquist * 0.99;
return 1.0 - std::exp(-2.0 * kPi * use * dt);
}
/// The median window's length: odd, and between 3 and 9.
///
/// Odd because an even window's median is the average of two samples, which is a value the signal never
/// took -- and the whole point of a median here is that its output is a sample that was really there.
/// Rounded *up*, so a request for 4 gets 5 rather than 3. The ceiling of 9 is what `math_step`'s fixed
/// `double sorted[9]` is sized for.
int median_n(int want) {
int n = want < 3 ? 3 : (want > 9 ? 9 : want);
if ((n & 1) == 0) ++n;
return n;
}
double biquad_step(double x, double b0, double b1, double b2, double a1, double a2, double& z1,
double& z2) {
// A reserved hole is NaN in the source, and a NaN integrated into the state words is
@@ -90,11 +112,12 @@ const char* math_op_name(MathOp op) {
case MathOp::Sub: return "difference";
case MathOp::Mul: return "product";
case MathOp::Div: return "ratio";
case MathOp::DeNoise: return "de-noise";
}
return "?";
}
int math_op_count() { return 12; }
int math_op_count() { return 13; }
MathOp math_op_at(int index) {
switch (index) {
@@ -109,7 +132,8 @@ MathOp math_op_at(int index) {
case 8: return MathOp::Sum;
case 9: return MathOp::Sub;
case 10: return MathOp::Mul;
default: return MathOp::Div;
case 11: return MathOp::Div;
default: return MathOp::DeNoise;
}
}
@@ -136,6 +160,8 @@ bool math_op_uses_filter(MathOp op) {
return op == MathOp::LowPass || op == MathOp::HighPass || op == MathOp::BandPass;
}
bool math_op_uses_clamp(MathOp op) { return op == MathOp::Clamp; }
bool math_op_uses_fc(MathOp op) { return math_op_uses_filter(op) || op == MathOp::DeNoise; }
bool math_op_uses_median(MathOp op) { return op == MathOp::DeNoise; }
void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt,
const std::function<int(const std::string&)>& index_of) {
@@ -152,6 +178,17 @@ void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt,
}
rt.dt = dt;
rt.ready = true;
// The median windows, pooled: one pass to size the pool, then the node loop hands out the slices. A
// window starts from rest after a re-prepare -- the same choice the biquads' `z2` makes -- while the
// pole's `z1` is carried, so a decimation change does not make the trace jump.
std::size_t med_total = 0;
for (std::size_t i = 0; i < n; ++i) {
if (channel.nodes[i].op == MathOp::DeNoise) {
med_total += static_cast<std::size_t>(median_n(channel.nodes[i].med_n));
}
}
rt.median.assign(med_total, 0.0);
std::size_t med_next = 0;
for (std::size_t i = 0; i < n; ++i) {
const MathNode& node = channel.nodes[i];
MathNodeRt& out = rt.node[i];
@@ -159,6 +196,15 @@ void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt,
out.src = index_of ? index_of(node.source) : -1;
continue;
}
if (node.op == MathOp::DeNoise) {
out.med_n = median_n(node.med_n);
out.med_at = med_next;
out.med_pos = 0;
out.med_have = 0;
med_next += static_cast<std::size_t>(out.med_n);
out.b0 = pole_a(node.fc, dt);
continue;
}
if (!math_op_uses_filter(node.op)) continue;
biquad(node.op, node.fc, node.q, dt, out.b0, out.b1, out.b2, out.a1, out.a2);
}
@@ -221,6 +267,46 @@ double math_step(const MathChannel& channel, MathRuntime& rt, const float* value
x = std::fabs(d) > 0.0 ? at(node.in_a) / d : 0.0;
break;
}
case MathOp::DeNoise: {
const double in = at(node.in_a);
// A hole stays a hole, and the window is left alone: folding a NaN into the median would smear one
// gap across `med_n` samples, and the whole trace after it in the pole. `med_n <= 0` is a node the
// runtime has not prepared -- the window pointer below would be a read past the pool, so it is
// checked rather than assumed.
if (!std::isfinite(in) || self.med_n <= 0) {
x = kNaN;
break;
}
const std::size_t len = static_cast<std::size_t>(self.med_n);
double* win = rt.median.data() + self.med_at;
win[self.med_pos] = in;
self.med_pos = self.med_pos + 1 == len ? 0 : self.med_pos + 1;
if (self.med_have < len) ++self.med_have;
// An insertion sort of at most nine doubles into a local. The window is tiny and fixed, so this
// beats anything that allocates, and it leaves the ring itself in arrival order -- which is what
// lets the oldest sample be overwritten in place. Before the window has filled, the median is taken
// over what there is: a shorter window, deliberately, rather than NaN. A derived trace that reads
// "no data" for its first few samples after every rebuild looks like a broken graph.
double sorted[9];
const std::size_t m = self.med_have;
for (std::size_t j = 0; j < m; ++j) {
const double v = win[j];
std::size_t p = j;
while (p > 0 && sorted[p - 1] > v) {
sorted[p] = sorted[p - 1];
--p;
}
sorted[p] = v;
}
const double med = sorted[m / 2];
// Median *then* pole, and the order is the design. The median kills an impulse outright; the pole
// then takes the broadband hash it leaves behind. The other way round 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.
self.z1 += self.b0 * (med - self.z1);
x = self.z1;
break;
}
}
self.value = x;
}
+29 -1
View File
@@ -25,6 +25,10 @@ enum class MathOp {
Sub, ///< a - b
Mul, ///< a * b
Div, ///< a / b, zero when b is 0 (a scope trace must not go to inf/NAN)
/// A running median of `med_n` samples, then a one-pole at `fc` Hz. For impulsive interference -- an EM
/// peak a few samples wide -- which a linear filter can only smear over its own settling time. `fc = 0`
/// is the median alone.
DeNoise,
};
const char* math_op_name(MathOp op);
@@ -41,6 +45,11 @@ int math_op_arity(MathOp op);
bool math_op_uses_gain(MathOp op);
bool math_op_uses_filter(MathOp op);
bool math_op_uses_clamp(MathOp op);
/// Whether an op reads `fc`. Wider than `math_op_uses_filter()`, which keeps its present meaning of *a
/// biquad*: `DeNoise` reads `fc` for a one-pole and has no `q`.
bool math_op_uses_fc(MathOp op);
/// Whether an op reads `med_n`.
bool math_op_uses_median(MathOp op);
/// One node of a math channel's graph. Node indices are positions in `MathChannel::nodes`, and a
/// node may only read ones before it: the evaluator walks the list in order, which is what makes a
@@ -56,6 +65,10 @@ struct MathNode {
double q = 0.707; ///< BP quality factor.
double lo = -1.0; ///< `Clamp` bounds.
double hi = 1.0;
/// `DeNoise`: the median window, in samples. Odd and between 3 and 9 -- `median_n()` in the
/// implementation is what enforces that, so a session file holding anything else still loads. An impulse
/// up to `med_n / 2` samples wide is rejected outright.
int med_n = 5;
double x = 0.0; ///< the editor's box position, logical pixels.
double y = 0.0;
@@ -67,7 +80,7 @@ struct MathNode {
bool operator==(const MathNode& other) const {
return op == other.op && source == other.source && in_a == other.in_a && in_b == other.in_b &&
gain == other.gain && offset == other.offset && fc == other.fc && q == other.q &&
lo == other.lo && hi == other.hi;
lo == other.lo && hi == other.hi && med_n == other.med_n;
}
};
@@ -110,10 +123,25 @@ struct MathNodeRt {
/// per source node, per sample, behind a type-erased call. Names cannot change without a
/// rebuild, and every rebuild re-prepares.
int src = -1;
/// `DeNoise`: where this node's median window starts in `MathRuntime::median`, how long it is, the ring's
/// write position, and how many samples it holds. The window is *pooled* rather than held here because a
/// node's state belongs on one cache line, and nine doubles would push this struct across two for every
/// node in the graph, filter or not.
///
/// The one-pole reuses `b0` for its coefficient and `z1` for its value, which is not a shortcut:
/// `math_prepare()` deliberately carries `z1` across a re-prepare, so the pole keeps its history through
/// a decimation change exactly as the biquads keep theirs.
std::size_t med_at = 0;
int med_n = 0;
std::size_t med_pos = 0;
std::size_t med_have = 0;
};
struct MathRuntime {
std::vector<MathNodeRt> node;
/// Every `DeNoise` node's median window, end to end: one allocation for the channel, sized by
/// `math_prepare()`, a node's slice starting at its own `med_at`.
std::vector<double> median;
/// The rate the coefficients were built for; a change rebuilds them.
double dt = 0.0;
bool ready = false;
+33 -16
View File
@@ -28,16 +28,13 @@ void enter_side(MeasureLevel& acc, std::size_t e, double t) {
acc.open[e] = true;
}
/// The hysteresis band's half-width, as a share of the window's peak-to-peak. Measured rather than
/// chosen: 5 % reads the mock's 0.1 interferer correctly and fails at 0.2 (`period=0.075` for a
/// true 0.2), while 10 % holds to an interferer amplitude of 0.3 -- a third of the waveform -- and
/// to white noise at sigma=0.05. Above that the band starts eating a real waveform's own extremes
/// on anything less square than a sine.
constexpr double kHysteresisFrac = 0.10;
// The hysteresis band and its rationale moved to `MeasureOptions::noise_pct`: it is a per-measurement
// setting now, and a second copy of the number here would be a second thing to keep in step.
constexpr double kPi = 3.14159265358979323846;
} // namespace
void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct) {
void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct, double noise_frac) {
const double span = s.hi - s.lo;
acc.level = s.lo + pct * span;
// Half the room to the nearer extreme is the clamp. A threshold at 90 % has a tenth of the span
@@ -46,10 +43,21 @@ void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct) {
// a noisy reading. Measured on a 3.3 V logic signal with ringing at thr=90 %: `enters=0` with a
// fixed band, `enters=4 period=0.200000` with the clamp.
const double room = std::min(s.hi - acc.level, acc.level - s.lo);
acc.hysteresis = std::max(0.0, std::min(kHysteresisFrac * span, 0.5 * room));
const double want = noise_frac > 0.0 ? noise_frac * span : 0.0;
acc.hysteresis = std::max(0.0, std::min(want, 0.5 * room));
}
void measure_scan(const MeasureChunk& chunk, MeasureStats& s) {
void measure_filter_set(MeasureFilter& f, double fc, double dt) {
f.reset();
f.a = 1.0;
if (fc > 0.0 && dt > 0.0) {
const double nyquist = 0.5 / dt;
const double use = fc < nyquist * 0.99 ? fc : nyquist * 0.99;
f.a = 1.0 - std::exp(-2.0 * kPi * use * dt);
}
}
void measure_scan(const MeasureChunk& chunk, MeasureStats& s, MeasureFilter& filter) {
// The chunk's own extremes first, in a loop that touches nothing but the values: *where* an
// extreme is costs an index, and the caller turns the two that survive into stamps once at the
// end. The sum is accumulated per chunk and folded in once, which is also kinder numerically
@@ -62,7 +70,7 @@ void measure_scan(const MeasureChunk& chunk, MeasureStats& s) {
std::size_t at_hi = 0;
bool any = false;
for (std::size_t i = 0; i < chunk.count; ++i) {
const double v = static_cast<double>(chunk.value[i]);
const double v = filter.step(static_cast<double>(chunk.value[i]));
if (!std::isfinite(v)) continue;
if (!any) {
lo = hi = v;
@@ -143,12 +151,16 @@ void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, Measu
void measure_accumulate(const MeasureChunk& chunk,
const std::function<double(std::size_t)>& time_of, MeasureLevel* levels,
std::size_t level_count) {
for (std::size_t k = 0; k < level_count; ++k) {
MeasureLevel& acc = levels[k];
for (std::size_t i = 0; i < chunk.count; ++i) {
const double v = static_cast<double>(chunk.value[i]);
if (!std::isfinite(v)) continue;
std::size_t level_count, MeasureFilter& filter) {
// Samples outer, levels inner -- the reverse of what reads more naturally, and deliberately. The filter
// holds one state for the whole walk, so a levels-outer loop would step it once per level per sample and
// every level after the first would read a signal filtered `k` times over. Each level's own state is
// independent of the others, so the swap costs nothing else.
for (std::size_t i = 0; i < chunk.count; ++i) {
const double v = filter.step(static_cast<double>(chunk.value[i]));
if (!std::isfinite(v)) continue;
for (std::size_t k = 0; k < level_count; ++k) {
MeasureLevel& acc = levels[k];
// The *sums* split at the bare level: `Top` and `Bottom` are defined as the mean above and
// below the threshold, and a band here would move a documented exact reading.
const int s = v > acc.level ? 1 : 0;
@@ -278,6 +290,11 @@ bool measure_uses_threshold(MeasureType type) {
bool measure_uses_side(MeasureType type) { return type == MeasureType::PulseDuration; }
bool measure_uses_noise(MeasureType type) {
return type == MeasureType::PulseDuration || type == MeasureType::Period ||
type == MeasureType::Frequency;
}
bool measure_can_show_on_plot(MeasureType) { return true; }
MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level, MeasureType type,
+61 -3
View File
@@ -46,6 +46,23 @@ struct MeasureOptions {
/// extremes (min/max), the pulse's two edges as vertical markers (pulse duration). The
/// markers are readouts -- they follow the window, and the user cannot drag them.
bool show_on_plot = false;
/// `PulseDuration`/`Period`/`Frequency`: the half-width of the hysteresis band about the threshold, as
/// a percentage of the window's own peak-to-peak. A crossing counts only once the signal has passed the
/// band, so an edge count is a count of the *waveform's* cycles rather than of its noise.
///
/// The default is measured rather than chosen: 5 % reads the mock's 0.1 interferer correctly and fails
/// at 0.2 (`period=0.075` for a true 0.2), while 10 % holds to an interferer amplitude of 0.3 -- a third
/// of the waveform -- and to white noise at sigma=0.05. Above that the band starts eating a real
/// waveform's own extremes on anything less square than a sine. 0 reduces the walk exactly to a single
/// level.
double noise_pct = 10.0;
/// Every type: the corner of a one-pole low pass applied to each sample before *either* walk reads it,
/// so the extremes, the mean and the crossings all describe the filtered signal. 0 is off. In Hz.
///
/// It rejects what a band cannot: an impulse large enough to cross the band on its own. It is not free
/// -- a filtered window cannot use the LOD pyramid, because a bucket aggregates unfiltered samples --
/// so a non-zero corner costs a full raw walk.
double hf_fc = 0.0;
};
/// One entry in the measurements pane, and everything needed to recompute it: the signal by
@@ -105,6 +122,37 @@ struct MeasureStats {
bool valid = false;
};
/// The HF reject, as a walk carries it: one pole, one state word.
///
/// `a == 1` is a pass-through and is the default, so an unfiltered walk pays one multiply and no branch,
/// and every caller can step it unconditionally. A non-finite sample passes through unchanged and leaves
/// the state alone -- a dropout is a real reading, and letting it poison `y` would make every sample
/// after it non-finite too.
struct MeasureFilter {
double a = 1.0;
double y = 0.0;
bool have = false;
double step(double v) noexcept {
if (!std::isfinite(v)) return v;
if (!have) {
y = v;
have = true;
return y;
}
y += a * (v - y);
return y;
}
void reset() noexcept {
y = 0.0;
have = false;
}
};
/// `a = 1 - exp(-2*pi*fc*dt)`, clamped just under Nyquist so `a` cannot exceed 1. `fc == 0` or `dt == 0`
/// leaves it at 1, which is the pass-through. Resets the state.
void measure_filter_set(MeasureFilter& f, double fc, double dt);
/// One chunk of a window's values, in time order, exactly as the rings hold them.
///
/// The engine is *fed* the window this way rather than handed a materialized copy. A visible
@@ -126,6 +174,9 @@ struct MeasureChunk {
/// as it arrives, at amortised O(1), so a bucket is 64 samples the measurement walks never have
/// to read. A 1.7 M-sample window is 26 k buckets, and only the handful that *straddle* a level
/// are opened.
///
/// A bucket aggregates **unfiltered** samples, so a walk with an HF reject must not use these at all --
/// that is what makes a non-zero `hf_fc` cost a full raw walk.
struct MeasureBucket {
double lo = 0.0;
double hi = 0.0;
@@ -139,7 +190,7 @@ struct MeasureBucket {
/// and the sum and count of the finite samples. Feed the chunks in window order into a zeroed
/// `stats` -- the first sample to reach an extreme is the one the mark names, so a later chunk
/// that merely ties it must not move it.
void measure_scan(const MeasureChunk& chunk, MeasureStats& stats);
void measure_scan(const MeasureChunk& chunk, MeasureStats& stats, MeasureFilter& filter);
/// Pass one from whole buckets: the same figures, exactly, for 1/64 of the reads. The extreme's
/// position comes out as a *bucket* (`lo_search`/`hi_search` non-zero), which one raw
@@ -192,14 +243,17 @@ struct MeasureLevel {
/// nearer extreme. The clamp is what keeps a threshold near an extreme usable -- `PulseDuration`
/// lets the user put one at 90 %, and a fixed band there puts the upper threshold above every
/// sample in the window, which reads as nothing measured at all rather than as a noisy reading.
void measure_set_level(MeasureLevel& level, const MeasureStats& stats, double pct);
/// `noise_frac` is `MeasureOptions::noise_pct / 100`. It is the caller's because the band is a
/// per-measurement setting and this is called once per *level*, which several measurements may share; the
/// clamp to half the room to the nearer extreme stays here, so every caller gets it.
void measure_set_level(MeasureLevel& level, const MeasureStats& stats, double pct, double noise_frac);
/// Pass two: one chunk into every level at once. The usual case is a single level -- every
/// crossing type reads the mid level and `Top`/`Bottom` default to it too -- so a pane full of
/// measurements on one signal costs two walks, not two per measurement.
void measure_accumulate(const MeasureChunk& chunk,
const std::function<double(std::size_t)>& time_of, MeasureLevel* levels,
std::size_t level_count);
std::size_t level_count, MeasureFilter& filter);
/// Pass two from one whole bucket, into one level. True when the bucket answered it: a bucket
/// every one of whose samples is on the same side of the level holds no crossing, so its sums
@@ -243,6 +297,10 @@ bool measure_uses_threshold(MeasureType type);
/// Whether a type uses the `above` side (only `PulseDuration`, where it says which half of
/// the waveform is the pulse).
bool measure_uses_side(MeasureType type);
/// Whether a type reads the hysteresis band, so the dialog can show the field only then. The band moves
/// the *edge* decision and nothing else, so it is exactly the crossing types: `Top`/`Bottom` split their
/// sums at the bare level and read the same answer at any band.
bool measure_uses_noise(MeasureType type);
/// Whether a type draws its result on the plot when asked.
bool measure_can_show_on_plot(MeasureType type);
+40 -5
View File
@@ -1678,15 +1678,32 @@ void open_measure_dialog(App& app, int edit_index) {
GtkWidget* side_row = labelled_row("pulse above level", above, &side_label);
gtk_box_append(GTK_BOX(box), side_row);
// A percentage of the window's own peak-to-peak, which is the unit the engine takes.
GtkWidget* noise = spin(0.0, 50.0, 1.0, 1, 10.0);
// `labelled_row()` writes the label out unconditionally, and these two never retitle, so the
// out-parameters are named and then explicitly unused rather than passed as nullptr.
GtkWidget* noise_label = nullptr;
GtkWidget* noise_row = labelled_row("noise band %", noise, &noise_label);
gtk_box_append(GTK_BOX(box), noise_row);
(void)noise_label;
// 0 is off. The engine clamps to just under the stream's Nyquist, which the dialog cannot know.
GtkWidget* hf = spin(0.0, 1.0e7, 1.0, 3, 0.0);
GtkWidget* hf_label = nullptr;
GtkWidget* hf_row = labelled_row("HF reject Hz", hf, &hf_label);
gtk_box_append(GTK_BOX(box), hf_row);
(void)hf_label;
GtkWidget* show = gtk_check_button_new_with_label("show on plot");
gtk_widget_set_tooltip_text(show,
"draw the result on the plot: a ruler at the level, a marker "
"at the time it happened, the pulse's edges as markers");
gtk_box_append(GTK_BOX(box), show);
auto* d = new MeasureDialog{&app, edit_index, signal_drop, type_drop,
threshold, above, show, window,
threshold_row, threshold_label, side_row, side_label,
auto* d = new MeasureDialog{&app, edit_index, signal_drop, type_drop,
threshold, above, show, window,
threshold_row, threshold_label, side_row, side_label,
noise, noise_row, hf, hf_row,
sig_names};
app.measure_dialog = window;
g_signal_connect(window, "destroy", G_CALLBACK(+[](GtkWidget*, gpointer user) {
@@ -1709,6 +1726,8 @@ void open_measure_dialog(App& app, int edit_index) {
}
}
gtk_spin_button_set_value(GTK_SPIN_BUTTON(threshold), editing->options.threshold_pct);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(noise), editing->options.noise_pct);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(hf), editing->options.hf_fc);
gtk_switch_set_active(GTK_SWITCH(above), editing->options.above);
gtk_check_button_set_active(GTK_CHECK_BUTTON(show), editing->options.show_on_plot);
} else {
@@ -2121,6 +2140,14 @@ void open_math_dialog(App& app, int edit) {
gtk_box_append(GTK_BOX(form), clamp_rows);
}
// Odd, 3..9. The engine rounds up to odd anyway, but a step of 2 means the widget never offers an even
// one.
d->med = gtk_spin_button_new_with_range(3.0, 9.0, 2.0);
gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->med), 0);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->med), 5.0);
g_signal_connect(d->med, "value-changed", G_CALLBACK(on_math_param_changed), d);
gtk_box_append(GTK_BOX(form), math_row("median samples", d->med, &d->rows[6]));
d->inputs = gtk_label_new("");
gtk_label_set_wrap(GTK_LABEL(d->inputs), true);
gtk_widget_add_css_class(d->inputs, "dim-label");
@@ -2140,7 +2167,7 @@ void open_math_dialog(App& app, int edit) {
gtk_widget_set_visible(d->problem, FALSE);
gtk_box_append(GTK_BOX(form), d->problem);
// A menu of the twelve ops rather than one button that always makes a gain: building anything
// A menu of the thirteen ops rather than one button that always makes a gain: building anything
// else was add-then-change-the-dropdown, which is two steps for the common case.
GtkWidget* add = gtk_menu_button_new();
gtk_menu_button_set_label(GTK_MENU_BUTTON(add), "add node");
@@ -5395,6 +5422,12 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_DROP_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), drop_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_NOISE_MS")) {
const int ms = std::atoi(env);
// Times the *setup* stage; `noise_probe` runs 5000 ms after it, so 3000 puts the legs at the same
// 8000 ms they were measured at.
if (ms > 0) g_timeout_add(static_cast<guint>(ms), noise_probe_setup, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_ROWMENU_MS")) {
rowmenu_probe_next(app, 0, static_cast<guint>(std::atoi(env)));
}
@@ -5812,7 +5845,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
(app.search_run_button != nullptr) + (app.search_cancel_button != nullptr) +
(app.search_prev_button != nullptr) + (app.search_next_button != nullptr);
std::printf(
"[udpscope] trigger panel: boxes=%d controls_inside=%d/13 search_widgets=%d/4 "
"[udpscope] trigger panel: boxes=%d controls_inside=%d/15 search_widgets=%d/4 "
"search_inside=%d/4\n",
boxes,
inside(app.trigger_signal_drop, app.trigger_panel) +
@@ -5827,6 +5860,8 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
inside(app.trigger_level_spin, app.trigger_panel) +
inside(app.trigger_pre_spin, app.trigger_panel) +
inside(app.trigger_holdoff_spin, app.trigger_panel) +
inside(app.trigger_band_spin, app.trigger_panel) +
inside(app.trigger_hf_spin, app.trigger_panel) +
inside(app.events_record_switch, app.trigger_panel),
search_present,
inside(app.search_run_button, search_root) + inside(app.search_cancel_button, search_root) +
+24
View File
@@ -162,6 +162,30 @@
</child>
</object>
</child>
<child>
<object class="GtkBox" id="band_row">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<child>
<object class="GtkLabel">
<property name="label" translatable="yes">noise band</property>
<property name="halign">start</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkBox" id="hf_row">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<child>
<object class="GtkLabel">
<property name="label" translatable="yes">HF reject Hz</property>
<property name="halign">start</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkBox" id="mode_row">
<property name="orientation">horizontal</property>
+26 -3
View File
@@ -170,6 +170,8 @@ struct App {
GtkWidget* trigger_level_spin = nullptr;
GtkWidget* trigger_pre_spin = nullptr;
GtkWidget* trigger_holdoff_spin = nullptr;
GtkWidget* trigger_band_spin = nullptr; ///< EventCondition::noise_band
GtkWidget* trigger_hf_spin = nullptr; ///< EventCondition::hf_fc, 0 is off
GtkWidget* signal_list = nullptr;
GtkWidget* pause_button = nullptr;
/// When the pause began, on the **monotonic wall clock**, or 0 while running. The elapsed time
@@ -272,13 +274,23 @@ struct App {
/// them asked for. Several measurements on one signal must cost two walks, not two each -- and
/// the window itself is never stored, which is what the chunked walks bought (see
/// `MeasureChunk`).
/// One channel's window, at one HF corner, with every level accumulator the pane asked for.
///
/// Keyed by `(channel, fc)` and not by channel alone: the corner is a per-measurement setting, so two
/// measurements on one signal at different corners are reading two different signals and cannot share a
/// walk.
struct MeasureWindow {
int channel = -1;
double fc = 0.0; ///< the HF corner this window was walked at; 0 is unfiltered
MeasureStats stats;
std::vector<double> pct; ///< the level shares asked for, deduplicated
std::vector<double> pct; ///< the level shares asked for, deduplicated
std::vector<double> noise; ///< each entry's band, as a percentage; parallel to `pct`
std::vector<MeasureLevel> level; ///< one accumulator per entry of `pct`
MeasureFilter scan_filter; ///< pass one's state
MeasureFilter acc_filter; ///< pass two's; a separate walk, so a separate state
bool ok = false;
};
std::unordered_map<int, MeasureWindow> measure_samples;
std::vector<MeasureWindow> measure_samples;
/// The window's LOD buckets, reused across recomputes: a 1.7 M-sample window is 26 k of them,
/// and both passes walk the same list.
std::vector<MeasureBucket> measure_bucket_scratch;
@@ -898,6 +910,12 @@ struct MeasureDialog {
GtkWidget* threshold_label = nullptr;
GtkWidget* side_row = nullptr;
GtkWidget* side_label = nullptr;
/// The two noise-rejection rows. `noise` is a percentage of the window's peak-to-peak, `hf` a corner in
/// Hz with 0 off; both are shown only where they do something (see `measure_dialog_sync`).
GtkWidget* noise = nullptr;
GtkWidget* noise_row = nullptr;
GtkWidget* hf = nullptr;
GtkWidget* hf_row = nullptr;
/// The names the signal dropdown was built from, in order. `apply` resolves an index
/// through *this*, not through the model's signal list: the dropdown lists the plotted
/// traces, so index `i` here is the i-th trace -- indexing the CONFIG with it selected
@@ -960,9 +978,10 @@ struct MathDialog {
GtkWidget* offset = nullptr;
GtkWidget* fc = nullptr;
GtkWidget* q = nullptr;
GtkWidget* med = nullptr; ///< MathNode::med_n
GtkWidget* lo = nullptr;
GtkWidget* hi = nullptr;
GtkWidget* rows[6] = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr};
GtkWidget* rows[7] = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr};
GtkWidget* inputs = nullptr;
/// The validation line and the button it governs. Both are set from `math_dialog_sync()` and from
/// nowhere else, so what the line says and what the button allows cannot disagree.
@@ -1402,6 +1421,10 @@ gboolean events_probe_step(gpointer user);
gboolean events_group_probe_step(gpointer user);
void events_group_probe_next(App& a, int step, guint delay_ms);
gboolean drop_probe(gpointer user);
gboolean noise_probe(gpointer user);
gboolean noise_probe_setup(gpointer user);
gboolean noise_probe_math(gpointer user);
gboolean noise_probe_denoise(gpointer user);
void collect_spins(GtkWidget* w, std::vector<double>* out);
gboolean defaults_probe(gpointer user);
gboolean keys_probe(gpointer user);
+6 -1
View File
@@ -835,6 +835,7 @@ void on_math_param_changed(GtkWidget*, gpointer user) {
n.offset = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->offset));
n.fc = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->fc));
n.q = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->q));
n.med_n = static_cast<int>(gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->med)));
n.lo = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->lo));
n.hi = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->hi));
if (d->source_drop != nullptr) {
@@ -930,14 +931,18 @@ void math_dialog_sync_form(MathDialog* d) {
const bool source = op == MathOp::Source;
gtk_widget_set_visible(d->rows[0], source); // the source channel
gtk_widget_set_visible(d->rows[1], gain);
gtk_widget_set_visible(d->rows[2], filter);
// The corner row is shown for anything that reads `fc`, which is now the three biquads *and* the
// de-noise node; `q` stays a band-pass-only row.
gtk_widget_set_visible(d->rows[2], math_op_uses_fc(op));
gtk_widget_set_visible(d->rows[3], filter && op == MathOp::BandPass);
gtk_widget_set_visible(d->rows[4], clamp);
gtk_widget_set_visible(d->rows[5], true); // the inputs, always: they say what is wired
gtk_widget_set_visible(d->rows[6], math_op_uses_median(op));
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->gain), n.gain);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->offset), n.offset);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->fc), n.fc);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->q), n.q);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->med), static_cast<double>(n.med_n));
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->lo), n.lo);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->hi), n.hi);
if (d->source_drop != nullptr) {
+63 -15
View File
@@ -94,6 +94,10 @@ void measure_dialog_sync(MeasureDialog* d) {
const bool side = measure_uses_side(type);
gtk_widget_set_visible(d->threshold_row, threshold);
gtk_widget_set_visible(d->side_row, side);
// The band moves the edge decision, so it is shown for exactly the crossing types. The HF reject sits
// before both walks, so it is shown for all of them.
gtk_widget_set_visible(d->noise_row, measure_uses_noise(type));
gtk_widget_set_visible(d->hf_row, TRUE);
gtk_widget_set_visible(d->show, measure_can_show_on_plot(type));
// The words follow the type: one row, several meanings.
@@ -166,6 +170,8 @@ void on_measure_dialog_apply(GtkButton*, gpointer user) {
m.options.threshold_pct = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->threshold));
m.options.above = gtk_switch_get_active(GTK_SWITCH(d->above));
m.options.show_on_plot = gtk_check_button_get_active(GTK_CHECK_BUTTON(d->show));
m.options.noise_pct = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->noise));
m.options.hf_fc = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->hf));
if (d->edit >= 0 && static_cast<std::size_t>(d->edit) < app.measures.size()) {
app.measures[static_cast<std::size_t>(d->edit)] = std::move(m);
} else {
@@ -261,6 +267,13 @@ void update_measures(App& app, bool force, bool accumulate) {
app.measure_stamp_valid = true;
const gint64 pass_start_us = g_get_monotonic_time();
app.measure_samples.clear();
// Before any `MeasureWindowReader` exists: the reader holds `model->mutex()` for a live window, and asking
// the model for anything while one is alive would deadlock.
double slot_dt = 0.0;
{
std::lock_guard<std::mutex> lock(app.model->mutex());
slot_dt = app.model->partition().slot_dt();
}
app.measure_results.assign(app.measures.size(), MeasureResult{});
app.measure_last_valid.resize(app.measures.size());
@@ -284,18 +297,42 @@ void update_measures(App& app, bool force, bool accumulate) {
// trace costs the same two walks as a single one. The common case is exactly one level: every
// crossing type reads the mid level and `Top`/`Bottom` default to it.
std::vector<int> chan(app.measures.size(), -1);
std::vector<int> want_window(app.measures.size(), -1);
std::vector<int> want_level(app.measures.size(), -1);
for (std::size_t i = 0; i < app.measures.size(); ++i) {
const Measure& m = app.measures[i];
const int channel = measure_channel(app, m.signal);
chan[i] = channel;
if (channel < 0) continue; // unresolvable this pass: a rebuild has emptied the channel list
App::MeasureWindow& w = app.measure_samples[channel];
const double fc = m.options.hf_fc > 0.0 ? m.options.hf_fc : 0.0;
std::size_t wi = 0;
while (wi < app.measure_samples.size() &&
(app.measure_samples[wi].channel != channel || app.measure_samples[wi].fc != fc)) {
++wi;
}
if (wi == app.measure_samples.size()) {
App::MeasureWindow fresh;
fresh.channel = channel;
fresh.fc = fc;
measure_filter_set(fresh.scan_filter, fc, slot_dt);
measure_filter_set(fresh.acc_filter, fc, slot_dt);
app.measure_samples.push_back(fresh);
}
want_window[i] = static_cast<int>(wi);
App::MeasureWindow& w = app.measure_samples[wi];
double pct = 0.0;
if (!measure_level_pct(m.type, m.options, pct)) continue;
// A type that does not read the band must not split the accumulator over it: its sums split at the bare
// level, so a `Top` is the same walk whatever the band is. Grouping those at the default is what keeps
// the common pane at exactly one level.
const double noise =
measure_uses_noise(m.type) ? m.options.noise_pct : MeasureOptions{}.noise_pct;
std::size_t k = 0;
while (k < w.pct.size() && w.pct[k] != pct) ++k;
if (k == w.pct.size()) w.pct.push_back(pct);
while (k < w.pct.size() && (w.pct[k] != pct || w.noise[k] != noise)) ++k;
if (k == w.pct.size()) {
w.pct.push_back(pct);
w.noise.push_back(noise);
}
want_level[i] = static_cast<int>(k);
}
@@ -313,10 +350,9 @@ void update_measures(App& app, bool force, bool accumulate) {
// degrades back to the O(window) cost and nothing else changes.
constexpr std::size_t kChunk = 8192;
std::vector<MeasureBucket>& buckets = app.measure_bucket_scratch;
for (auto& entry : app.measure_samples) {
App::MeasureWindow& w = entry.second;
for (App::MeasureWindow& w : app.measure_samples) {
const gint64 fetch_start_us = g_get_monotonic_time();
MeasureWindowReader reader(app, static_cast<std::size_t>(entry.first));
MeasureWindowReader reader(app, static_cast<std::size_t>(w.channel));
const std::size_t n = reader.size();
const std::function<double(std::size_t)> time_of = [&reader](std::size_t i) {
return reader.time_of(i);
@@ -325,7 +361,12 @@ void update_measures(App& app, bool force, bool accumulate) {
u64 kb = 0;
u64 ke = 0;
buckets.clear();
if (reader.bucket_range(kb, ke)) {
// A filtered window cannot use the pyramid: a bucket aggregates *unfiltered* samples, so its extremes
// and its sum describe a different signal. Leaving `buckets` empty is the whole disable -- `bulk_from ==
// bulk_to == n` makes the raw walk cover the window, and `lo_search`/`hi_search` stay 0, which makes
// `pin()` below a no-op. One `if` turns off both fast paths, and nothing downstream needs to know.
const bool filtered = w.fc > 0.0;
if (!filtered && reader.bucket_range(kb, ke)) {
buckets.reserve(static_cast<std::size_t>(ke - kb));
for (u64 k = kb; k < ke; ++k) buckets.push_back(reader.bucket(k));
}
@@ -342,7 +383,7 @@ void update_measures(App& app, bool force, bool accumulate) {
}
};
const auto scan = [&](const MeasureChunk& c) { measure_scan(c, w.stats); };
const auto scan = [&](const MeasureChunk& c) { measure_scan(c, w.stats, w.scan_filter); };
raw(0, bulk_from, scan);
if (!buckets.empty()) measure_scan_buckets(buckets.data(), buckets.size(), w.stats);
raw(bulk_to, n, scan);
@@ -351,8 +392,11 @@ void update_measures(App& app, bool force, bool accumulate) {
if (w.stats.valid) {
const auto pin = [&](std::size_t& index, std::size_t& search, bool want_hi) {
if (search == 0) return;
// Only the bucket path leaves `search` non-zero, and a filtered window has no buckets, so this is
// reached pass-through only: a fresh filter here is the *same* filter.
MeasureFilter unfiltered;
MeasureStats one;
measure_scan(MeasureChunk{reader.values(index, search), search, index}, one);
measure_scan(MeasureChunk{reader.values(index, search), search, index}, one, unfiltered);
if (one.valid) index = want_hi ? one.hi_i : one.lo_i;
raw_read += search;
search = 0;
@@ -372,10 +416,10 @@ void update_measures(App& app, bool force, bool accumulate) {
// The level *and* its hysteresis band, in the engine: how wide the band is is a property of
// how an edge is counted, not of the pane, and two expressions for it is how the walk and
// the bucket fast path end up disagreeing about where a crossing was.
measure_set_level(w.level[k], w.stats, w.pct[k]);
measure_set_level(w.level[k], w.stats, w.pct[k], 0.01 * w.noise[k]);
}
const auto accumulate = [&](const MeasureChunk& c) {
measure_accumulate(c, time_of, w.level.data(), w.level.size());
measure_accumulate(c, time_of, w.level.data(), w.level.size(), w.acc_filter);
};
raw(0, bulk_from, accumulate);
for (const MeasureBucket& b : buckets) {
@@ -388,7 +432,8 @@ void update_measures(App& app, bool force, bool accumulate) {
values = reader.values(b.offset, b.span);
raw_read += b.span;
}
measure_accumulate(MeasureChunk{values, b.span, b.offset}, time_of, &level, 1);
measure_accumulate(MeasureChunk{values, b.span, b.offset}, time_of, &level, 1,
w.acc_filter);
}
}
raw(bulk_to, n, accumulate);
@@ -402,8 +447,11 @@ void update_measures(App& app, bool force, bool accumulate) {
for (std::size_t i = 0; i < app.measures.size(); ++i) {
const Measure& m = app.measures[i];
GtkWidget* label = app.measure_labels[i];
const auto it = chan[i] < 0 ? app.measure_samples.end() : app.measure_samples.find(chan[i]);
if (it == app.measure_samples.end() || !it->second.ok) {
const App::MeasureWindow* wp =
want_window[i] >= 0 && static_cast<std::size_t>(want_window[i]) < app.measure_samples.size()
? &app.measure_samples[static_cast<std::size_t>(want_window[i])]
: nullptr;
if (wp == nullptr || !wp->ok) {
// Its signal is momentarily unresolvable, or the window holds nothing -- so there is nothing
// to measure *this* pass. The marks still fall back (below); `continue`ing out of the marks
// was the other half of the rulers blinking off.
@@ -411,7 +459,7 @@ void update_measures(App& app, bool force, bool accumulate) {
if (label != nullptr) gtk_label_set_text(GTK_LABEL(label), "--");
continue;
}
const App::MeasureWindow& w = it->second;
const App::MeasureWindow& w = *wp;
const MeasureLevel* level =
want_level[i] >= 0 && static_cast<std::size_t>(want_level[i]) < w.level.size()
? &w.level[static_cast<std::size_t>(want_level[i])]
+307 -3
View File
@@ -281,6 +281,16 @@ gboolean eventzoom_probe(gpointer user) {
gboolean math_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
// The channel the graphs are built from is enabled *here*, for the reason the noise and measurement
// probes give: `enable_all_probe` finishes by switching one signal off, and with `sine` gone the graphs
// land on `square`, which turns the documented "2 math channels on 'sine' and 'noise'" into a different
// line for reasons that have nothing to do with the math.
{
const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t i = 0; i < signals.size(); ++i) {
if (signals[i].name == "sine") a->model->set_signal_enabled(i, true);
}
}
std::string name;
std::string late_name;
{
@@ -316,11 +326,28 @@ gboolean math_probe(gpointer user) {
lp.output = 1;
channels.push_back(lp);
// A third channel so the editor's new form row is built and synced on a baseline run. Assigned field
// by field: a partial brace-initialiser would add a fifth build warning.
MathChannel dn;
dn.name = "math denoise";
dn.enabled = true;
MathNode dn_src;
dn_src.op = MathOp::Source;
dn_src.source = name;
MathNode dn_op;
dn_op.op = MathOp::DeNoise;
dn_op.in_a = 0;
dn_op.med_n = 5;
dn_op.fc = 500.0;
dn.nodes = {dn_src, dn_op};
dn.output = 1;
channels.push_back(dn);
// Through the app's list, which is the pane's source of truth: a probe that wrote the model
// directly left the two disagreeing, and the next pane edit then silently dropped its channels.
a->math = std::move(channels);
apply_math_channels(*a);
std::fprintf(stderr, "[math-probe] 2 math channels on '%s' and '%s'\n", name.c_str(),
std::fprintf(stderr, "[math-probe] 3 math channels on '%s' and '%s'\n", name.c_str(),
late_name.c_str());
// And open the editor on the first one: as much a construction crash test as a state check,
// since a modal window is presented and its canvas, form and gestures are all built here.
@@ -1000,6 +1027,16 @@ gboolean math_probe(gpointer user) {
gboolean measure_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
// The signal this probe measures is enabled *here*, for the same reason the noise probe enables its own:
// a probe must not depend on another probe's side effects. `enable_all_probe` ends by switching a signal
// off (it reads what a disabled signal does to the plots), and with `sine` gone the readings below were
// taken on `square` -- where `top` is 1.0, not the 2/pi the documented baseline is.
{
const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t i = 0; i < signals.size(); ++i) {
if (signals[i].name == "sine") a->model->set_signal_enabled(i, true);
}
}
std::string name;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
@@ -2943,6 +2980,13 @@ gboolean configs_probe(gpointer user) {
std::fprintf(stderr, "[configs] before=%s active=%s\n", joined().c_str(),
active_configuration(a->source_host, a->source_port).c_str());
// A non-default band and corner before the save, so the round trip below proves the two keys are
// *written*: left at their defaults, a key that never made it to the file would read back the same.
const bool has_measure = !a->measures.empty();
if (has_measure) {
a->measures.front().options.noise_pct = 7.5;
a->measures.front().options.hf_fc = 250.0;
}
const bool saved = save_configuration_as(*a, "probe", "written by the configs probe");
std::fprintf(stderr, "[configs] save-as=%d names=%s active=%s\n", saved ? 1 : 0,
joined().c_str(), active_configuration(a->source_host, a->source_port).c_str());
@@ -2957,11 +3001,14 @@ gboolean configs_probe(gpointer user) {
if (!sig.empty()) ++signals;
}
}
const double noise_back = st.measures.empty() ? -1.0 : st.measures.front().options.noise_pct;
const double hf_back = st.measures.empty() ? -1.0 : st.measures.front().options.hf_fc;
std::fprintf(stderr,
"[configs] round trip read=%d comment='%s' buffer_mb=%.6g relative=%d "
"capture=%u plots=%zu signals=%d measures=%zu math=%zu\n",
"capture=%u plots=%zu signals=%d measures=%zu math=%zu noise=%.6g hf=%.6g\n",
read ? 1 : 0, st.comment.c_str(), st.buffer_mb, st.relative_time ? 1 : 0,
st.capture_packets, st.rows.size(), signals, st.measures.size(), st.math.size());
st.capture_packets, st.rows.size(), signals, st.measures.size(), st.math.size(),
noise_back, hf_back);
forget_configuration(a->source_host, a->source_port, "probe");
std::fprintf(stderr, "[configs] after forget names=%s\n", joined().c_str());
@@ -3422,6 +3469,263 @@ void events_group_probe_next(App& a, int step, guint delay_ms) {
g_timeout_add(delay_ms, events_group_probe_step, &a);
}
namespace {
struct NoiseLeg {
std::size_t hits = 0;
double first = std::numeric_limits<double>::quiet_NaN();
int state = 0;
};
/// One rising-edge search of the whole retained buffer at level 0.
///
/// Synchronous on purpose. Every leg must see the *same* buffer, and yielding to the main loop between legs
/// would let the producer append between them, so the counts would no longer be comparable. The bound is a
/// safety net: a chunk is `BufferSearch::kChunk` samples, so 20000 pumps cover 160 M of them, far more than
/// the ring holds.
NoiseLeg noise_leg(App* a, int channel, double dt, double band, double hf) {
EventCondition c;
c.type = TriggerType::Edge;
c.edge = TriggerEdge::Rising;
c.level = 0.0;
c.noise_band = band;
c.hf_fc = hf;
a->search.start(c, channel, dt);
for (int i = 0; i < 20000 && a->search.pump(*a->model); ++i) {
}
NoiseLeg r;
r.hits = a->search.hits().size();
if (r.hits > 0) r.first = a->search.hits().front();
r.state = static_cast<int>(a->search.state());
return r;
}
/// The difference of two first-hit stamps, folded into +/- half a period, so a pair whose first hits landed
/// on different cycles still reads as a delay.
double noise_fold(double d, double period) {
const double half = 0.5 * period;
d = std::fmod(d, period);
if (d > half) d -= period;
if (d < -half) d += period;
return d;
}
int noise_channel(App* a, const char* name) {
const std::vector<ChannelInfo>& channels = a->model->channels();
for (std::size_t i = 0; i < channels.size(); ++i) {
if (channels[i].name == name) return static_cast<int>(i);
}
return -1;
}
} // namespace
// Stage one of three. `apply_math_channels()` is a structural rebuild that drops the buffer's
// history, and every reading in `noise_probe` is taken over retained history -- so the rebuild
// happens here, five seconds before the first leg runs, and the buffer refills in between. The
// de-noise channel also needs that time: a math channel starts producing when it is applied.
//
// The channel is installed **disabled**, and `noise_probe_math` enables it two and a half seconds
// before its own reading. An acquired channel is a share of the ring, so a fifth one shortens every
// channel's retained span -- and that moves every figure above it: the six search legs' counts scale
// with the retained buffer, and `pkpk bare` is a peak-to-peak *of that window*. Those readings are
// the older feature's proof, so the new channel stays out of the acquisition plan until they have
// all run, and only the de-noise comparison is taken with it in.
//
// Fields assigned rather than brace-initialised: a partial brace-initialiser on `MathNode` is
// what the two `-Wmissing-field-initializers` in this file already are, and the build baseline
// is exactly four warnings.
gboolean noise_probe_setup(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
MathChannel dn;
dn.name = "math denoise";
dn.enabled = false; // enabled by `noise_probe_math`, so it costs no ring while the legs run
MathNode src_node;
src_node.op = MathOp::Source;
src_node.source = "sine";
MathNode dn_node;
dn_node.op = MathOp::DeNoise;
dn_node.in_a = 0;
dn_node.med_n = 5;
// The median alone: a 2-sample impulse is shorter than half a 5-sample window, so it is rejected
// outright and the pole has nothing left to do. A corner here would attenuate the 5 Hz sine as well
// and make the peak-to-peak reading a function of two things.
dn_node.fc = 0.0;
dn.nodes = {src_node, dn_node};
dn.output = 1;
a->math.push_back(dn);
apply_math_channels(*a);
std::fprintf(stderr, "[noise-probe] setup channels=%zu\n", a->math.size());
std::fflush(stderr);
// 3300 + 4700 = 8000 ms, where Tasks 4 and 8 measured the legs.
g_timeout_add(5000, noise_probe, a);
return G_SOURCE_REMOVE;
}
gboolean noise_probe(gpointer user) {
static int phase = 0;
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
if (phase == 0) {
// The signals this probe measures are enabled *here*. Not because it is tidier, but because it must not
// depend on another probe's side effects: `enable_all_probe` ends by switching one signal off again (it
// reads what a disabled signal does to the plots), and a run that arms both would take `sine` away.
const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t i = 0; i < signals.size(); ++i) {
if (signals[i].name == "sine" || signals[i].name == "square") {
a->model->set_signal_enabled(i, true);
}
}
phase = 1;
// The enable is a structural change: it replaces the store, so the ring has to fill again before there
// is anything to search.
g_timeout_add(2500, noise_probe, a);
return G_SOURCE_REMOVE;
}
const int sine = noise_channel(a, "sine");
const int square = noise_channel(a, "square");
double dt = 0.0;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
dt = a->model->partition().slot_dt();
}
if (sine < 0 || square < 0 || !(dt > 0.0)) {
// Name the channels that *are* there: an index alone says a probe found nothing, not what it saw.
std::string have;
for (const ChannelInfo& c : a->model->channels()) have += c.name + " ";
std::fprintf(stderr, "[noise-probe] no stream: sine=%d square=%d dt=%g channels=[%s]\n", sine, square,
dt, have.c_str());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
// A: the bare comparator on a spiked sine. Every impulse landing where the sine is within the impulse's own
// amplitude of zero fires a crossing.
const NoiseLeg a_leg = noise_leg(a, sine, dt, 0.0, 0.0);
// B: a 0.8 band. A +/-0.5 impulse cannot carry the signal across a 1.6-wide band, so only the sine's own
// cycles are left.
const NoiseLeg b_leg = noise_leg(a, sine, dt, 0.8, 0.0);
// C: no band, a 50 Hz reject. A 2-sample 0.5 impulse moves a pole with a = 1 - exp(-2*pi*50*dt) by about
// 5e-4, which cannot reach the level.
const NoiseLeg c_leg = noise_leg(a, sine, dt, 0.0, 50.0);
// D: both. The reject attenuates a 5 Hz sine by only 1/sqrt(1 + (5/50)^2), so its peaks still clear the band
// and the count is unchanged.
const NoiseLeg d_leg = noise_leg(a, sine, dt, 0.8, 50.0);
// E/F: the reject's group delay, on `square`, which carries no impulses. A one-pole turns a -1 -> +1 step
// into -1 + 2*(1 - exp(-t/tau)), which crosses zero at tau*ln2 -- exact, and the same on every edge.
const NoiseLeg e_leg = noise_leg(a, square, dt, 0.0, 0.0);
const NoiseLeg f_leg = noise_leg(a, square, dt, 0.0, 50.0);
const double period = 0.2; // the mock's default --freq is 5 Hz.
const double delay = noise_fold(f_leg.first - e_leg.first, period);
const double want = std::log(2.0) / (2.0 * 3.14159265358979323846 * 50.0);
std::fprintf(stderr, "[noise-probe] trigger band: bare=%zu/%d band=%zu/%d ratio=%.2f\n", a_leg.hits,
a_leg.state, b_leg.hits, b_leg.state,
b_leg.hits > 0 ? static_cast<double>(a_leg.hits) / static_cast<double>(b_leg.hits) : 0.0);
std::fprintf(stderr, "[noise-probe] trigger hf: hf=%zu/%d band+hf=%zu/%d band=%zu\n", c_leg.hits,
c_leg.state, d_leg.hits, d_leg.state, b_leg.hits);
std::fprintf(stderr,
"[noise-probe] trigger delay: clean=%zu filtered=%zu delay=%.6f want=%.6f ratio=%.3f\n",
e_leg.hits, f_leg.hits, delay, want, want > 0.0 ? delay / want : 0.0);
std::fflush(stderr);
// Five measurements on the spiked sine, as *two* windows: three legs at `hf=0` and two at `hf=50`. The
// pane keys its walks by (channel, corner), so this is also what checks that a second corner does not
// reuse the first one's walk.
std::string name;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
if (a->model->channels().empty()) return G_SOURCE_REMOVE;
name = a->model->channels().front().name; // `sine`; signal 0 is the timestamp
}
struct Leg {
const char* label;
MeasureType type;
double noise;
double hf;
};
// The impulses dominate the window's extremes, so `peak to peak` reads the *stimulus* (about 3)
// unfiltered and the *waveform* (about 2) filtered -- the one reading that says the filter reached pass
// one and not only pass two.
const Leg legs[] = {
{"pkpk bare", MeasureType::PeakToPeak, 10.0, 0.0},
{"pkpk hf", MeasureType::PeakToPeak, 10.0, 50.0},
{"period noband", MeasureType::Period, 0.0, 0.0},
// 25 % and not the 10 % default: the band is a share of the *window's* peak-to-peak, which with the
// impulses in it is about 3, so 10 % is a half-band of 0.3 -- less than the 0.5 an impulse moves the
// signal by, and the `MeasureOptions::noise_pct` comment says as much ("10 % holds to an interferer
// amplitude of 0.3"). The default is what `period noband` and `period hf` are about; this leg asks for
// a band that clears the stimulus, which is what makes the reading legible.
{"period band", MeasureType::Period, 25.0, 0.0},
{"period hf", MeasureType::Period, 0.0, 50.0},
};
const std::size_t base = a->measures.size();
for (const Leg& leg : legs) {
MeasureOptions options;
options.noise_pct = leg.noise;
options.hf_fc = leg.hf;
a->measures.push_back({name, leg.type, options});
}
rebuild_measures_pane(*a);
update_measures(*a, /*force=*/true, /*accumulate=*/true);
for (std::size_t i = 0; i < std::size(legs); ++i) {
const std::size_t at = base + i;
// A *value*, not a reference: the conditional's other arm is a temporary.
const MeasureResult r = at < a->measure_results.size() ? a->measure_results[at] : MeasureResult{};
std::fprintf(stderr, "[noise-probe] measure %-14s noise=%.1f hf=%.1f valid=%d value=%.6f\n",
legs[i].label, legs[i].noise, legs[i].hf, int(r.valid), r.primary);
}
std::fprintf(stderr, "[noise-probe] measure windows=%zu\n", a->measure_samples.size());
std::fflush(stderr);
a->search.clear();
update_search_panel(*a);
g_timeout_add(2500, noise_probe_math, a);
return G_SOURCE_REMOVE;
}
gboolean noise_probe_math(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
{
// Enabled here for the reason `noise_probe_setup` gives: until this moment the channel must not
// be a share of the ring. Enabled *by name*, through the same switch the panel drives, so the
// model and the panel agree.
const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t i = 0; i < signals.size(); ++i) {
if (signals[i].name == "math denoise") a->model->set_signal_enabled(i, true);
}
}
// A fill before the reading: the channel's first sample is the first one after this enable, and a
// peak-to-peak needs a window with a sine trough *and* a crest in it. Two and a half seconds is
// twelve cycles of the 5 Hz source.
g_timeout_add(2500, noise_probe_denoise, a);
return G_SOURCE_REMOVE;
}
gboolean noise_probe_denoise(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const std::size_t base = a->measures.size();
// Both unfiltered and at no band: the only difference between the two readings is the node.
MeasureOptions options;
a->measures.push_back({std::string("sine"), MeasureType::PeakToPeak, options});
a->measures.push_back({std::string("math denoise"), MeasureType::PeakToPeak, options});
rebuild_measures_pane(*a);
update_measures(*a, /*force=*/true, /*accumulate=*/true);
const MeasureResult raw =
base < a->measure_results.size() ? a->measure_results[base] : MeasureResult{};
const MeasureResult den =
base + 1 < a->measure_results.size() ? a->measure_results[base + 1] : MeasureResult{};
std::fprintf(stderr,
"[noise-probe] denoise raw=%.6f/%d denoised=%.6f/%d ratio=%.3f channels=%zu\n",
raw.primary, int(raw.valid), den.primary, int(den.valid),
raw.primary > 0.0 ? den.primary / raw.primary : 0.0, a->math.size());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean drop_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || a->model->signals().empty()) return G_SOURCE_REMOVE;
+18 -1
View File
@@ -69,6 +69,8 @@ void publish_trigger_fields(App* app) {
const double width2 = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_width2_spin));
const double level2 = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_level2_spin));
const double timeout = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_timeout_spin));
const double band = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_band_spin));
const double hf = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_hf_spin));
{
std::lock_guard<std::mutex> lock(app->model->mutex());
TriggerConfig& tc = app->model->trigger();
@@ -85,6 +87,8 @@ void publish_trigger_fields(App* app) {
tc.condition.width2_s = width2;
tc.condition.level2 = level2;
tc.condition.timeout_s = timeout;
tc.condition.noise_band = band;
tc.condition.hf_fc = hf;
}
}
@@ -110,6 +114,11 @@ void sync_trigger_rows(App& app) {
show(app.trigger_width2_row, pulse && (cmp == WidthCmp::Within || cmp == WidthCmp::Outside));
show(app.trigger_level2_row, window);
show(app.trigger_timeout_row, type == TriggerType::Dropout);
// A band widens the comparator every type but Dropout compares with; a dropout is an absence and a
// band cannot describe one. The HF reject sits before every comparison, so it applies to all types.
// The rows come from the file and hold their spin, so the spin's parent *is* the row.
show(gtk_widget_get_parent(app.trigger_band_spin), type != TriggerType::Dropout);
show(gtk_widget_get_parent(app.trigger_hf_spin), true);
// A band of zero width reduces the condition to a float-equality test on a real-valued
// signal, which is unusable: it fires on the exact value and nothing else, which in practice
// is never. The panel is where that is refused: the second level is pushed at least one
@@ -282,6 +291,13 @@ GtkWidget* build_trigger_panel(App& app) {
app.trigger_holdoff_spin = spin(0.0, 3600.0, 0.001, 4,
app.model->config().trigger_holdoff_s);
row_for(ui, "holdoff_row", app.trigger_holdoff_spin);
// A band in signal units, on the same scale as the level spins.
app.trigger_band_spin = spin(0.0, 1.0e12, 0.01, 4, 0.0);
row_for(ui, "band_row", app.trigger_band_spin);
// 0 is off. The upper bound is generous; `configure()` clamps to just under the stream's Nyquist,
// which the panel cannot know.
app.trigger_hf_spin = spin(0.0, 1.0e7, 1.0, 3, 0.0);
row_for(ui, "hf_row", app.trigger_hf_spin);
const char* modes[] = {"normal", "single", nullptr};
app.trigger_mode_drop = gtk_drop_down_new_from_strings(modes);
@@ -314,7 +330,8 @@ GtkWidget* build_trigger_panel(App& app) {
for (GtkWidget* w : {app.trigger_level_spin, app.trigger_pre_spin, app.trigger_holdoff_spin,
app.trigger_width_spin, app.trigger_width2_spin,
app.trigger_level2_spin, app.trigger_timeout_spin}) {
app.trigger_level2_spin, app.trigger_timeout_spin, app.trigger_band_spin,
app.trigger_hf_spin}) {
g_signal_connect(w, "value-changed", G_CALLBACK(on_trigger_field_changed), &app);
}
g_signal_connect(app.trigger_edge_drop, "notify::selected",