Fixed triggering mode change when acquiring

This commit is contained in:
Martino Ferrari committed 2026-10-01 17:54:17 +02:00
1 parent 3d4085898e
commit 0d741c401b
10 files changed
+504 -106

No files matched your search

+89 -12
View File
@@ -42,7 +42,13 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
then dump the whole pipeline state to stdout (frame/sample counts, per-plot painted
frames, trace bindings, panel sizes, status line in both states).
- `UDPSCOPE_ENABLE_ALL_MS`, `UDPSCOPE_ARM_TRIGGER_MS`, `UDPSCOPE_PROBE_DISARM_MS`,
`UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes.
`UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes. `UDPSCOPE_PROBE_DISARM_MS` only
*starts* its sequence: it switches the trigger on and then polls for a capture to open,
so the Disarm lands ~300 ms into one rather than wherever a fixed delay happened to fall.
- `UDPSCOPE_PROBE_FIELDS_MS` — change the trigger panel's edge and Normal/Single
dropdowns. As much a crash test as a state check (see the GTK signal-signature invariant
below), so time it inside a capture: `[fields-probe] ... capturing=1` followed by no
SIGSEGV *is* the assertion.
- `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are
measured on a still picture.
- `UDPSCOPE_EXPORT_MS` (+ optional `UDPSCOPE_EXPORT_PATH`, default
@@ -75,11 +81,20 @@ Two lines of the self-check output are assertions in disguise:
the documented probes have shown one ~320 px step — the side panel's width — that
reverts; it is intermittent and unexplained, so read `changes` before calling a range
the twitch bug.
- `[trig-probe] ... trigger=on ... display=...` (stderr, `UDPSCOPE_TRIG_TRACE_MS`) —
with `trigger=on`, `display` must read `event(frozen)` or `nothing`, **never**
`live`, except for the single interval that contains the toggle itself — recognise
it by `latches=0` *and* the next probe reading `nothing` with the same frozen
`axis=`. And `snap=[a..b]` must equal `axis=[a..b]`, with `b` positive by roughly
- `[trig-probe] ... trigger=on ... display=... epoch=...` (stderr,
`UDPSCOPE_TRIG_TRACE_MS`) — with `trigger=on`, `display` must read `event(frozen)`,
`live(capturing)` or `nothing`, **never plain `live`**, except for the single interval
that contains the toggle itself — recognise it by `latches=0` *and* the next probe
reading `nothing` or `live(capturing)` with a frozen `axis=`. Plain `live` with the
trigger on is the old "the plot keeps scrolling in trigger mode" defect; the named
`live(capturing)` is the feature. In that state `epoch=` must equal minus the capture's
edge and `axis=` must stay at `[-pre..+post]` across consecutive probes while
`data=`'s upper bound climbs towards `+post` — an axis that moves instead means the
capture is being treated as a live view (`clamp_to_data()` reaching a pinned window).
`epoch=` must be back to `0.0000` in every other state. Note the probe reads
`pending=` fresher than `display=` (which is last tick's decision), so a single
`pending=0 display=live(capturing)` line is one frame of sampling skew, not a defect.
And `snap=[a..b]` must equal `axis=[a..b]`, with `b` positive by roughly
the post-trigger share: that is the "post window is empty" defect's observable.
**`drawn=[a..b]` must equal `data=[a..b]`** in both modes: `data` is `x_at()` (what
the crosshair reads) and `drawn` is `x0() + gather()` (what the renderer maps), so a
@@ -109,6 +124,15 @@ Two lines of the self-check output are assertions in disguise:
is the same class of bug, not rounding: `publish_event()` clamps the slice to every
value ring's `produced()`, so publishing before each channel of the latching sample
has been pushed silently drops it.
- `[disarm-probe] was_capturing=1 display=.. seq=A->B events=C->D snap=[a..b] n=N
axis=[..]` (stderr, `UDPSCOPE_PROBE_DISARM_MS`) — with `was_capturing=1`, **`display`
must read `event(frozen)`** and `snap`/`axis` must be `[-pre..e]` where `e` is how far
the post window had filled, with `n` that span times the stream's real rate plus one.
`display=nothing` (or a `snap` from the *previous* event) is the "disarming mid-capture
makes the event completely disappear" defect. **`seq` must advance by exactly one while
`events` does not move**: no event fired, so the partial publish deliberately leaves
`trigger_latches()` alone — which is also what keeps the `trigger events: N total, M at
the disarm` line in the dump meaning what it says.
- `[export-probe] mode=.. axis=[a..b] rows=N cols=M time=[c..d] stream=[..]` (stderr,
`UDPSCOPE_EXPORT_MS`) — **`time=` must equal `axis=`** and **`rows=` must be the
span times the stream's real rate, plus one** (2 s at 10 kS/s is 20001). The export
@@ -116,7 +140,13 @@ Two lines of the self-check output are assertions in disguise:
cut from the LOD pyramid, and a `time=` narrower than `axis=` is a window searched on
the wrong clock (the live branch subtracts `display_epoch()` before searching the
ring). In `mode=event`, `stream=` must be the edge ± the window; in `mode=live` it
equals `time=`. The two `bytes=` lines are the writers: the `.npy` is
equals `time=`.
The one exception to both: an export taken **mid-capture** reports `mode=live` with
`time=[-pre..e]` where `e` is how far the post window has filled, and `stream=` offset
from it by the edge (so `stream - time` reads the edge exactly) — the export is what is
on screen, and during a capture that is a partly filled window. Pin the probe outside a
capture when checking the `rows=` count.
The two `bytes=` lines are the writers: the `.npy` is
`64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)`, so a size that is not that is a
mispacked record. Read it back with `pd.DataFrame(np.load(path))` after changing
either writer.
@@ -146,7 +176,9 @@ once per frame by the UI (`tick()` in `src/ui.cpp`) around `RowSet::bind()`/`acq
Samples themselves cross via `plot::data::RingBuffer` (single producer, single
consumer) and never through the lock. Cross-thread scalars are atomics on `ScopeModel`
(`trigger_enabled_`, `trigger_armed_`, `trigger_latches_`, `trigger_time_`,
`event_`, `generation_`).
`event_`, `generation_`). Two of them are a pair: the producer writes `capture_edge_`
before `trigger_pending_` and those two carry release/acquire, so a consumer that sees a
capture open sees which edge it belongs to.
### Data path
@@ -157,10 +189,37 @@ that is what guarantees a full pre-trigger history on the *first* latch after ar
The spec's second buffer (item 1) is the latched `EventSnapshot`, which is already an
immutable copy of the captured window; see README for why two live rings were dropped.
In trigger mode `RowSet::bind()` binds each trace to the snapshot and to nothing else
(the last event, or nothing before the first). **Never the live ring**, not even while
the post window is filling — SPECS:37 says the view must not refresh until the next
event, and `trigger_pending()` is informational only, for the status line's wording.
What a trace binds to is `ScopeModel::display_mode()`, one of four states, and
`RowSet::bind()` takes it as an argument rather than recomputing it:
- `Live` — no trigger: the live ring on the stream clock.
- `Capturing` — an edge has been accepted (so the pre window was full) and the post
window is still filling: the live ring, on the coming event's own clock
(`display_epoch() == -edge`), inside an axis already pinned to `[-pre, +post]`. The
post side fills in view and the marker sits at x = 0 throughout.
- `Held` — the frozen `EventSnapshot` of the last event.
- `Waiting` — armed, nothing captured yet: blank.
**Disarm during a capture publishes what the capture got to**, then disarms: the producer
cuts the window at the newest sample and that partial snapshot becomes the held event, so
the picture on screen at the moment of the Disarm stays on screen. `engine.disarm()` clears
`pending_`, which drops the display out of `Capturing`, and with nothing published the view
fell back to the *previous* event — or to blank on a first capture. No event fired, so
`trigger_latches()` is deliberately not bumped; `ScopeModel::event_seq()` counts published
snapshots instead, and the frame tick keys its event framing on *that*. Keep the two
distinct: the latch count is what the probes assert "how many events fired" with.
`Capturing` is a **deliberate departure from SPECS:37** ("data visualisation should not
be refreshed till next event"), asked for explicitly; that reading is what the old "never
the live ring mid-capture" rule enforced. What survives of the rule is that `Held` binds
the snapshot *only* — a held event is never quietly replaced by live data — and that the
*axis* does not move during a capture: it is the event's window from the edge onwards,
never a sliding live view.
`display_mode()` must be read **once per frame** and passed around (the tick does this,
and hands it to `bind()`). The producer can open or close a capture between any two
reads, and two reads in one frame put the bind, the epoch, the pin and the status line on
different clocks.
Layers to keep straight:
@@ -265,6 +324,15 @@ far the largest file.
capture may legitimately be shorter than asked for — the ring need not reach back a
full pre-trigger share on the first latch after arming, so `snap=[-0.339..1.000]`
for a ±1 s request is correct there rather than a defect.
- **A capture in progress is the one place the axis is pinned from the *request*.** There
is no snapshot to measure yet, so `DisplayMode::Capturing` frames
`[-(span - post_span_s(span)), +post_span_s(span)]` — computed with the producer's own
`TriggerConfig::post_span_s()`, not a second expression that rounds differently, because
the marker at x = 0 has to land where the coming cut will put it. The latch then re-pins
from the snapshot, and the two agreeing is why the picture does not jump at the latch.
- **`clamp_to_data()` must be skipped while `capturing` as well as while `event_held`.**
A capture is live data on a pinned axis — the exact combination the clamp was written
for and against: it would follow the live edge and drag the frozen window off the event.
- **`publish_event()` cuts one absolute index range and uses it for the time ring and
every value ring.** That is what makes `stamps` and each `values[c]` equal-length
and index-aligned by construction instead of by two truncations that can disagree —
@@ -288,6 +356,15 @@ far the largest file.
tick as the channel dump; the status line only formats what is already there. The
badge's markup is likewise rewritten on a change of phase (twice a second), never on
all sixty frames.
- **A `notify::<prop>` handler takes three parameters, a `value-changed` handler two.**
`GObject::notify` passes `(GObject*, GParamSpec*, gpointer)` while
`GtkSpinButton::value-changed` passes `(GtkSpinButton*, gpointer)`, so one callback cannot
serve both: on the `notify::` connection the `GParamSpec*` lands in the `user_data` slot
and the handler dereferences it as its `App*`. `G_CALLBACK` is a cast, so nothing warns.
That is why the trigger panel's spin buttons and its dropdowns have separate thin entry
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.
- **The header bar's buttons are icon-only**, so headless probes must reach them
through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via
`find_labelled_button()`. The Arm/Disarm button in the trigger panel still has text.