Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
189 lines
14 KiB
Markdown
189 lines
14 KiB
Markdown
# Architecture: threads, the data path and the layers
|
|
|
|
The two threads and what they share, the four display modes, the display clock, and the layers to keep straight.
|
|
|
|
Part of [CLAUDE.md](../../CLAUDE.md).
|
|
|
|
`SPECS.md` is the requirement document. `README.md` documents what was actually built,
|
|
including deliberate deviations from the spec and known limitations — read it before
|
|
concluding something is a bug.
|
|
|
|
### Threads
|
|
|
|
Exactly two: a producer thread in `Acquisition` owns a `DataSource` and calls
|
|
`source->poll(50)`; the GTK main thread owns every widget and GL context. They share
|
|
`ScopeModel` under `ScopeModel::mutex()`, taken once per DATA frame by the producer and
|
|
once per frame by the UI (`tick()` in `src/ui.cpp`) around `RowSet::bind()`/`acquire()`.
|
|
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_`). 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.
|
|
|
|
### Sources
|
|
|
|
What feeds `Acquisition` is a `DataSource`, one of the registered *kinds*.
|
|
|
|
- The kinds live in `src/source/`. `register_builtin_sources()` fills the registry explicitly --
|
|
there is no static-initialiser registration, which a linker may drop -- and a future `dlopen`
|
|
loader would only call `register_kind()`.
|
|
- A `DataSource` delivers into `Acquisition` (a `SourceSink`) from inside `poll()`. `Frame` is
|
|
source-neutral, and its pointers are valid **only during `on_data`**: copy what is kept.
|
|
- The GTK thread reaches a source **only** through `DataSource::post(Command)`; the queue is drained
|
|
at the top of `poll()`. A panel never calls a source directly.
|
|
- `UdpsSource` wraps `deps/udpsclient` unchanged; `udps_*` names appear only in
|
|
`src/source/udps_*.cpp` (`grep -rn "udps_" src` outside those files is a leak of the old coupling).
|
|
- The frame is **single-timebase**. A multi-timebase source (ABScope) will change the interface and
|
|
the data path together; do not bend one frame to carry two clocks.
|
|
- A kind's capability panels are pages of `side_stack`, named `source-<id>`. A disconnected source
|
|
leaves them **insensitive, not hidden** (`apply_source_panels` in `src/ui_source.cpp`).
|
|
- Session file naming for a non-UDPS kind: the pseudo-host is `<kind>-<identity>` (characters outside
|
|
`[A-Za-z0-9._-]` become `_`) on port **1** (0 is rejected by the session-file parser), the kind is
|
|
stored as `source_kind` and its settings as `source_opt_<key>`. A UDPS file keeps `<host>_<port>` and
|
|
has neither key, so a legacy session is read as UDPS and nothing existing moves.
|
|
- **The E-1608 kind has three layers, and only the top one knows the framework.** `e1608_link`
|
|
(discovery, handshake, framed TCP commands, the scan socket) and `e1608_device` (typed commands,
|
|
factory calibration, `decode_scans`) are plain POSIX C++ with no GTK or `DataSource` types; discovery is
|
|
`Scan` (one socket per interface, polled by the caller: the form polls it from a GLib timer on the main
|
|
thread, the source's lookup goes through `discover_broadcast`);
|
|
`e1608_source` is the `DataSource`; `e1608_kind` and `e1608_panels` are the UI. A change to the wire
|
|
never reaches the UI files.
|
|
- The E-1608 source runs **everything inside `poll()`** on the producer thread: posted commands, the
|
|
connect state machine (Down, Scanning, Halted), the scan read, the 500 ms status check (its not-running restart is skipped when a hardware trigger is configured), the digital
|
|
input/counter poll and the 20 ms waveform tick. There is no second thread. A connect attempt (discovery,
|
|
handshake, calibration read, scan start) can block `poll()` for as long as the command timeout allows each
|
|
step; that delays only the command queue. Every exception is caught inside `poll()`: an escape would end
|
|
the producer thread, and `drain_commands()` runs inside the same `try` for that reason.
|
|
- Its frames are **fixed-size blocks** of about 10 ms (`round(scan_rate / 100)` scans, 1 to 4096), not
|
|
whatever one `recv` returned: `build_stamps` warns once when a frame's sample count differs from the
|
|
first one's. Digital lines and the counter are held across a block (a step trace).
|
|
- The E-1608 panels read the board's state from `E1608Readouts`, a set of atomics the producer writes
|
|
and a tick callback throttled to 100 ms reads; a panel never touches the source's internals, and writes
|
|
only through `post()`. `Acquisition::on_event` ignores the detail string and does nothing for `Error`,
|
|
so the source also writes each new error to stderr as `[e1608] ...` (once per distinct message, not per
|
|
retry); that line is where a user sees why a connection failed.
|
|
- **The flat UDP kind is three files and no thread.** `flatudp_signals` is the `signals` string codec,
|
|
the validity rules and the decoders, with no GTK and no sockets (the probe round-trips it);
|
|
`flatudp_source` is `FlatUdpSettings` plus the `DataSource`; `flatudp_kind` is the form, a signal-row
|
|
editor (a name, a type, a count and, while the count is above 1, a "Vector as" drop-down per row). The
|
|
datagram has no description, so a list that is wrong is invisible except as dropped datagrams: the size
|
|
check is the only defence, and it is why a wrong-size datagram is counted (`stale`) and reported once per
|
|
size.
|
|
- A vector row is shown one of two ways, chosen per row (`Row::wave`, the `w` suffix in the text). As
|
|
**channels** it is `count` scalar channels of one sample per cycle, so the list batches like an
|
|
all-scalar one. As a **waveform** it is one channel with `rows = count` and
|
|
`sampling_rate = rate * count`, one datagram per `Frame` (`num_samples = 1`). The two cannot share a
|
|
list because the model spans a frame by its largest element block and `Frame::value` is 0 past a scalar's
|
|
one value: a scalar beside a vector block would be zero-padded, so `problem()` demands that a list with a
|
|
waveform row be waveform rows of one count, and the form shows that message in the subtitle. The channel
|
|
total is capped at 256.
|
|
- Its frames are **fixed-size blocks** like the E-1608's for the same reason (`build_stamps` warns when the
|
|
sample count varies): `round(rate / 100)` datagrams for a list with no waveform row, a partial block
|
|
flushed after 100 ms. `rate` is required because `sampling_rate = 0` makes the model size the ring and
|
|
the ingest decimation for 1 kHz; there is no time-base switch, since `acquisition.cpp` has no stamping
|
|
path that ignores the declared cadence.
|
|
- `Disconnected` after 2 s without a *valid* datagram (wrong-size ones do not refresh the clock); it resets
|
|
`Acquisition`'s per-session state, so the next datagram re-anchors rather than reading as a gap, and the
|
|
history survives (`CONFIG` is sent once, from the first `poll()`).
|
|
- The form's row editor removes a row from inside its own button's `clicked` emission (GTK keeps the emitter
|
|
alive to the end of the emission, and the handler copies the form and the row out of its context first);
|
|
every handler tests `Form::alive`, which the groups' `destroy` clears, because the chooser may have
|
|
destroyed the sibling widgets the handlers reach by raw pointer.
|
|
|
|
### Data path
|
|
|
|
`ScopeModel` holds **one `BufferStore`** (`live()`): one shared time-base ring plus one
|
|
value ring per channel, sized by `plan_partition()` to cover `--max-window` seconds
|
|
within the byte budget. The producer always writes it, whatever the trigger is doing —
|
|
that is what guarantees a full pre-trigger history on the *first* latch after arming.
|
|
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.
|
|
|
|
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 clock of the **newest sample**
|
|
(`display_epoch() == -newest`), so the axis is a fixed `[-window, 0]` and x reads as an age.
|
|
- `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, whose stamps are already edge-relative,
|
|
so the display epoch is 0.
|
|
- `Waiting` — armed, nothing captured yet: blank, with the live epoch (there is nothing bound,
|
|
but the axis is still a live one).
|
|
|
|
**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.
|
|
|
|
**The display clock is not the stream clock, and `ScopeModel::stream_at_x0()` is the only
|
|
conversion.** `display_x = stream_t - stream_at_x0()` in **every** mode, and the offset lives in
|
|
one of two places: a snapshot carries its own (`EventSnapshot::x_offset`, frozen with the
|
|
picture) and a live view carries it as minus `display_epoch()`. Anything crossing between the
|
|
two clocks goes through that one accessor rather than picking the branch itself — the export's
|
|
`abs_seconds` column, the pause handler putting a display window back on the ring to cut it, the
|
|
measurement marks — because there are four modes and picking the branch by hand gets one of them
|
|
wrong. It reads atomics only, so it is safe to call inside an existing `mutex()` guard.
|
|
Three things about the live epoch specifically:
|
|
- **It is written after `bind()` and `acquire_all()`, not before.** It must be minus the newest
|
|
sample the *traces* can report, and asking before the acquire is one frame of production
|
|
stale — at 1 MS/s over a 50 ms window that is a third of the screen to the right of the axis.
|
|
- **There is no feedback loop**, even though the newest stamp is read through the epoch that is
|
|
about to change: `RowSet::live_edge()` reports in the epoch currently in force, so
|
|
`display_epoch() - live_edge()` is minus the *raw* stamp and the fixed point is reached in one
|
|
frame rather than converged on.
|
|
- **`Waiting` takes the live epoch too.** Nothing is bound, but the axis is still a live one, and
|
|
an epoch of 0 there would make the axis jump on the frame a capture closes.
|
|
|
|
**A measurement mark is glued to its sample; a cursor stays on screen.** The two instruments move
|
|
in opposite directions under a rolling axis and that is deliberate — a bench scope's cursors sit
|
|
where the user put them while the waveform rolls under, and a measurement's mark names the sample
|
|
it measured. The cursors need no code: their x *is* a display coordinate. The marks are stored in
|
|
**stream** seconds and reconverted every frame by `place_measure_marks()`, since a mark left in
|
|
display coordinates would crawl forward at one window per window. `App::measure_window` is
|
|
stream-clocked for the same reason, and that one is load-bearing: the recompute trigger is "the
|
|
window moved by 2 % of its span", and in display coordinates a live window is a fixed
|
|
`[-window, 0]` — it never moves, so the measurements would never be recomputed again.
|
|
|
|
Layers to keep straight:
|
|
|
|
- `SignalInfo` — one signal as the CONFIG described it. The unit of enable/disable.
|
|
- `ChannelInfo` / `ChannelBuffer` — the unit of acquisition and plotting. A
|
|
`ChannelBuffer` is a `plot::data::Series` over a (value, time) ring pair.
|
|
- `SlotSeries` (`src/channel.hpp`) — a trace's stable series object, rebound each frame
|
|
to either a `ChannelBuffer` or an `EventSeries`.
|
|
- `RowSet` (`src/plot_host.hpp`) — **one `plot::Figure`** of up to `kMaxRows` rows (added
|
|
and removed one at a time by the layout editor), each a
|
|
borrowed `plot::Plot*` with `kSlotsPerRow` fixed trace slots. The figure owns
|
|
everything the rows have to agree on: the x window (one x link group every row joins),
|
|
the live follow (the *group's* — a member's own axis must be `XFollow::Off`, since
|
|
`Figure::link_x()` refuses a `Live` member), the left gutter and the crosshair.
|
|
|
|
- `ExportTable` (`src/export.hpp`) — the visible window as columns, collected from the
|
|
snapshot or the live rings and handed to the `.npy`/`.csv` writers. Always the stored
|
|
samples, never the LOD pyramid's buckets.
|
|
|
|
`src/ui.cpp` is the GTK4 window (toolbar, trigger panel, signal panel, plot box) plus
|
|
the `tick()` frame function that binds, updates the trigger state and rewrites the
|
|
status line. One `plot::gtk4::PlotArea` in figure mode draws the whole figure. It is by
|
|
far the largest file.
|