Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
147 lines
12 KiB
Markdown
147 lines
12 KiB
Markdown
# Invariants: the trigger, the capture and a pinned view
|
|
|
|
The four display modes' framing rules: what the event window is counted in, what pins an axis, and what each pin is measured from.
|
|
|
|
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
|
|
|
- **`clamp_to_data()` caps the span at the *requested window*, never at the retained
|
|
span.** It runs every frame and cannot distinguish "the user zoomed out past the data"
|
|
from "the ring has only just started filling", so `window_s_` — what the window control
|
|
states, capped by `--max-window` — is the bound. Clamping to `retained` alone turned a
|
|
requested 2 s window into the ~0.02 s the first frames held, permanently: nothing there
|
|
grows a span back. Loosening it to `max(retained, window_s_)` fixed that and broke the
|
|
other state instead — while the ring is filling the two are equal, but once it holds
|
|
more than the window the bound becomes the whole ring, and a wheel zoom-out walked the
|
|
view into data the window was never set to show. `window_s_` is right in both: it never
|
|
shrinks a window a caller asked for, and a partly filled buffer is left empty on its
|
|
left, which is what a scope that just started should look like. `UDPSCOPE_PROBE_PAN_MS`
|
|
reports it as `zoom ... span=.. window=.. limited=1`.
|
|
Its pan clamping is likewise gated on the data being wider than the view, or the empty
|
|
left-hand side drags the live edge off the right-hand edge.
|
|
- **Signals start disabled** — enabling is what starts acquisition. A trigger on a
|
|
signal nobody acquires can never fire, so selecting/arming enables it.
|
|
- **The trigger's event window is separate from the display window**, and
|
|
`pretrigger_pct` is a share of the *event* span, not of the ring.
|
|
- **The event window is counted in stream seconds end to end; nothing converts it to
|
|
a sample count.** `TriggerEngine` latches once the stamps it is fed have advanced
|
|
past the post window, and `ScopeModel::publish_event()` cuts the snapshot by binary
|
|
search over the time ring. There used to be a measured samples-per-second estimate
|
|
in `Acquisition` doing that conversion, and it is precisely why the post window came
|
|
out empty: any stream whose time signal advances in large steps drove it to its
|
|
1 S/s floor, the count came out as 1, and the engine latched on the edge sample
|
|
itself. **Do not reintroduce a rate estimate on this path.**
|
|
- **The engine identifies its channel by *name*, never by index.** `TriggerEngine::configure()`
|
|
drops an open capture when the trigger moves to a different channel, and it is re-called from
|
|
`Acquisition::on_data()` on **every packet** out of `model_.trigger()`. `TriggerConfig::channel`
|
|
is an index into `channels()`, and that index is not stable: every rebuild of the channel list
|
|
renumbers it — enabling a signal that sorts before the trigger's own is enough — and
|
|
`ScopeModel::set_signal_enabled()` re-resolves the trigger by name and writes the *new* index
|
|
back. Keying the reset on the index therefore made any such rebuild abandon the capture **and
|
|
set no hold-off**, so the next edge inside the post window became t0: that is the "the trigger
|
|
re-triggers during a capture and the hold-off does not apply" defect, and it needed no glitch
|
|
and no user action beyond enabling a channel. The name changes when the source genuinely
|
|
changes, so a deliberate re-point still resets. The rebuild also replaces the `BufferStore`, so
|
|
the surviving capture's pre side is short and the snapshot reports its own `pre_s` — which may
|
|
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.
|
|
- **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
|
|
clamps the position into it (and states `Off`, since a following group would re-derive from a
|
|
live edge). **A zoom in must keep working** -- "not outside the event" is not "not readable",
|
|
and this function's first version *re-stated* the pin every frame, which made the wheel a no-op
|
|
in single trigger mode: an acquired event could not be examined at all.
|
|
**The request is read from the row's own axis, not from the group.** A member's `set_x_range`
|
|
-- what the controller writes for a gesture -- is *not* adopted into the group while the follow
|
|
is off, so a clamp that read the group only ever saw its own previous answer (the probe showed
|
|
`group` and `member` both equal to the pin while the ask was gone). `pinned_stated_` remembers
|
|
what the clamp last wrote: a member that matches it is our own answer coming back, one that
|
|
differs is a gesture to honour -- which is also what keeps a *fresh* pin
|
|
(`pin_event_window()`, `pin_pause_window()`, both of which write the group in the same tick)
|
|
from being reverted to the member's stale window. Without it nothing bounds a drag or
|
|
a zoom in those states: the library clamps a *following* group's offset only, and the live
|
|
clamp is deliberately skipped here — so a gesture walked the frozen view off its axis, over
|
|
data the scope never had. The two are one branch in `tick()` for that reason. A paused view
|
|
pins the same way (`pin_pause_window()` sets `pinned_` from the frozen range), so the frozen
|
|
picture is equally immovable; resuming or leaving trigger mode clears it.
|
|
- **A pinned view is navigable *within its data*, and that is the bound -- not the request.**
|
|
Two things had to agree for this, and both were wrong in the same direction:
|
|
the **pause** pinned on the window it asked for rather than on the snapshot it got (a cut
|
|
clamped to the ring is shorter), and the **held event** pinned on the snapshot's recorded
|
|
`pre_s`/`post_s` -- the *request* again -- which a capture shorter than the request exceeds.
|
|
Either way the shortfall was drawn as an empty margin, and a zoom-in moved the view *into* it,
|
|
which is what made it look as if zooming caused it. `tick()` now re-states the pin from
|
|
`RowSet::event_data_window()` (the bound **event series'** own stamps) while an event is held,
|
|
from the snapshot's stamps on a pause, and `pin_pause_window()`/the pause handler set the axis
|
|
to it as well; `[pause-probe]` prints `axis=` beside `frozen_x=` and the axis must lie inside.
|
|
A `Capturing` view is the exception and stays on the *requested* `[-pre, +post]`: its post side
|
|
is empty on purpose, and that is the capture filling in view, not a margin.
|
|
- **A pause is pinned from its snapshot's own stamps too, not from the window it requested.** The
|
|
pause freezes the *visible window*, but `publish_pause_view()` cuts that window out of the ring
|
|
and the cut is clamped to what the ring holds -- so a window reaching back past the oldest
|
|
retained sample produces a *shorter* snapshot. Pinning the axis to the request left the shortfall
|
|
as an **empty margin on the left**, visible before any zoom and made worse by one: zooming in
|
|
moved the view *into* the empty part, which is what made it look as if zooming caused it. The pin
|
|
is the snapshot's own stamps now. `[pause-probe]` prints `axis=` beside `frozen_x=`, and while
|
|
paused **the axis must lie inside `frozen_x`** -- equal at the moment of the pause, a subset
|
|
afterwards if the probe zoomed in. Before the fix the two disagreed outright:
|
|
`axis=[-1.44..0.56]` over data that started at `0.0`. (The event pin already worked this way; the
|
|
pause now agrees.)
|
|
- **The event axis is pinned from the snapshot's own stamps, not from the request.** A
|
|
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.
|
|
- **The frame that *frames* a snapshot is the one that consumes its `event_seq()`.** The
|
|
tick reads `display_mode()` *before* it takes `mutex()`, while the producer publishes
|
|
inside that mutex and lowers `trigger_pending` only at the end of its packet — so a tick
|
|
can read `Capturing`, block on the lock through `publish_event()`, and reach the framing
|
|
step with a new sequence number it is not bound to. (The other case is benign and was
|
|
always handled: a publication and the *next* edge in one frame, where the capture has
|
|
framed its own window.) Advancing `seen_event_seq` on such a frame dropped the pin
|
|
permanently — the axis kept the capture's requested `[-pre, +post]` over a shorter
|
|
snapshot and `event_held` stayed false, letting `clamp_to_data()` drag the frozen view —
|
|
so the test is `seq != seen_event_seq && display == Held` and the pin stays owed until a
|
|
frame is bound to the snapshot. Visible only at high rates, where the publish is long
|
|
enough to lose the race often; `[disarm-probe]`'s `axis=` against `snap=` is the guard,
|
|
which means **running the disarm probe at 10 kS/s alone does not check this.**
|
|
- **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 —
|
|
`EventSeries::gather()` indexes both at the same `i`.
|
|
- **`live_window_locked()` is the one live cut, shared by `publish_event()` and the
|
|
export.** Both need the same absolute index range over the time ring and every value
|
|
ring, or an export and a capture of the same window would hold different samples. Call
|
|
it under `mutex()`.
|
|
- **The trigger engine is not fed a reserved hole's samples.** A NaN comparison is false anyway,
|
|
so the engine would not latch; but the real reason the samples are withheld is the other one:
|
|
feeding them would advance the hold-off clock across time nothing was sampled in. The probe
|
|
asserts `trigger_in_hole=0` rather than relying on NaN's arithmetic properties.
|
|
- **`EventDetector` owns every condition shape and holds no arming state; `TriggerEngine` owns
|
|
the arming, the hold-off and the post window and holds no condition.** The split is what lets
|
|
the buffer search ask the trigger's exact question without owning a trigger, and it is only
|
|
true as long as neither half leaks into the other: a detector that knew about the hold-off
|
|
would answer a search differently from the live trigger, which is the one thing the
|
|
`agree=1` cross-check in `[search-probe]` exists to catch.
|
|
- **The detector is fed on *every* sample, including during the hold-off and during a capture.**
|
|
A pulse width, a window excursion and a dropout timeout are all *stateful over several
|
|
samples*: skipping the feed while the engine is busy loses the opening edge, and the condition
|
|
then measures from whatever sample the engine happened to resume on. The hold-off is applied
|
|
to the detector's *answer*, not to its input — `process()` feeds first and tests
|
|
`now_s < holdoff_until_s_` afterwards.
|
|
- **An event's time is the detector's, not the sample's.** A pulse-width event is detected at
|
|
the closing edge but could in principle be timed at the opening one, and a dropout is detected
|
|
one sample after the timeout expires. `feed()` therefore returns the event's *own* time and
|
|
`process()` uses that for `last_trigger_s_`, `pending_start_s_` **and** the pre-window gate —
|
|
all three, or the marker, the cut and the gate would disagree with each other by the width of
|
|
a pulse.
|