Compare commits
6
Commits
b505706d92
..
0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62df049f53 | ||
|
|
811c6d80f0 | ||
|
|
b313cac46e | ||
|
|
2f4530c128 | ||
|
|
39f30d913f | ||
|
|
e2985aa159 |
No files matched your search
@@ -62,7 +62,11 @@ app's plain `GtkApplicationWindow` is supported and warning-free; it was checked
|
||||
|
||||
`deps/plotter` is upstream's own repository and this app's figure support was added
|
||||
there rather than worked around here: `Figure::set_min_y_gutter()` (a floor under the
|
||||
common left gutter), and in the GTK4 adapter `PlotArea`'s **figure mode** —
|
||||
common left gutter), `Figure::set_right_gutter()` / `Plot::set_right_gutter()` (an empty
|
||||
band on the *other* side, which the layout had no concept of — `axes` reached the
|
||||
viewport's right edge, so the rightmost x tick label was centred on a line it had half of
|
||||
itself outside of; the guard is `figure_rows_share_one_right_gutter`), and in the GTK4
|
||||
adapter `PlotArea`'s **figure mode** —
|
||||
`set_figure()`, one `input::Controller` per row, nearest-row event routing, a
|
||||
figure-wide crosshair — plus `PlotArea::row_at()` for naming the row a pointer is over.
|
||||
|
||||
@@ -72,9 +76,14 @@ it, and `LineProgram::set_snap()` / `u_snap` rounds an axis-aligned segment's in
|
||||
whole pixels for the chrome that frames a plot (grid and axis lines), never for anything
|
||||
drawn at a data position. `generate_ticks()` also stores the tick at the origin as
|
||||
exactly `0.0` — the walk starts at `ceil(min/stride)*stride` and so never lands on zero
|
||||
when the stride is not a power of two, which labelled it `-139a`. All three carry
|
||||
when the stride is not a power of two, which labelled it `-139a`. `format_time()` picks its
|
||||
unit rung by the **rounded** mantissa (`t < u.scale * 0.9995`, and the same slack on the
|
||||
whole-second branch) — the `>= 999.5` spill guard `format_si()` in the same file always had —
|
||||
because a tick a hair under a rung's top rounds to 1000 at three significant figures and
|
||||
`%.3g` writes that `1e+03`, which is how a one-second tick read `1e+3ms`. All four carry
|
||||
upstream tests — `the_atlas_is_filtered_not_point_sampled`,
|
||||
`grid_hairlines_land_on_whole_pixels` and `the_tick_at_the_origin_reads_zero`. The minor
|
||||
`grid_hairlines_land_on_whole_pixels`, `the_tick_at_the_origin_reads_zero` and
|
||||
`format_time_never_lets_a_field_reach_its_radix`. The minor
|
||||
grid's look is set there too, not here: `grid_minor` carries 50% alpha, `grid_minor_width` is
|
||||
0.75 (sub-pixel, expressed as coverage at 1x) and `grid_minor_dash_on`/`_off` are 3/3 — a
|
||||
fragment discard, no extra geometry — so the subdivisions read as texture under the labelled
|
||||
@@ -105,9 +114,18 @@ Two baselines every run is read against. A clean build emits **five** warnings a
|
||||
`-Woverlength-strings` on **both** generated GResource `.c` files — the help pages' and the
|
||||
icons', whose blob crossed C99's 4095-byte string-literal limit when the two app icons joined the
|
||||
four action ones (a note about a generated file, which GCC itself supports). And an exit-dump run
|
||||
emits exactly **two** `Gtk-CRITICAL`s, `gtk_widget_get_height` and `gtk_widget_get_width`, from
|
||||
the dump asking a destroyed widget for its size. A sixth warning, or a third critical, is a
|
||||
finding.
|
||||
emits **no** `Gtk-CRITICAL` at all. A sixth warning, or any critical, is a finding.
|
||||
|
||||
That second baseline used to be "exactly two, `gtk_widget_get_height` and `gtk_widget_get_width`,
|
||||
from the dump asking a destroyed widget for its size", and tolerating them was a mistake: the dump
|
||||
runs from the destroy handler, so the widget pointer is **freed memory**, and a critical is only
|
||||
the lucky reading of it. A run that had built and destroyed other windows first —
|
||||
`UDPSCOPE_PROBE_HELP_MS`, which opens three — has had that memory reused, and the same two calls
|
||||
then **SIGSEGV in the middle of the dump**, which reads as a truncated dump rather than as a
|
||||
crash in a debug line. Every size the dump prints is therefore captured on the *tick*, while the
|
||||
window is alive (`App::dbg_*`), and **no `gtk_widget_*` call may appear in the dump** — the
|
||||
`panel controls:` line was already written that way for the same reason, and that comment is the
|
||||
precedent, not an exception.
|
||||
|
||||
- `UDPSCOPE_EXIT_AFTER_MS` — run the trigger-on → go-live probe, close the window,
|
||||
then dump the whole pipeline state to stdout (frame/sample counts, per-plot painted
|
||||
@@ -208,8 +226,8 @@ finding.
|
||||
since `set_time_window()` frames the newest span before the pin clamps it — both are expected.
|
||||
- `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are
|
||||
measured on a still picture. It now also asserts what a pause *is*
|
||||
(`[pause-probe] <action> paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..]` on
|
||||
stderr):
|
||||
(`[pause-probe] <action> paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..]
|
||||
glance=[..]` on stderr):
|
||||
it presses the **button**, not `Acquisition::set_paused()`, because pausing is a display
|
||||
state as well as an acquisition one and the view snapshot and axis pin live in the
|
||||
handler. Across the three lines **`ring_produced` must grow** (the producer never
|
||||
@@ -218,6 +236,17 @@ finding.
|
||||
the ring) and **`frozen_x` must not move between the pause and the resume**; after the
|
||||
resume `event_size` is 0 and the window is back on the newest data. Pausing used to stop
|
||||
the producer, which is why the counts grew by nothing and the resume left a hole.
|
||||
`frozen_x` is the *rebased* snapshot — the pause view's own stamps are relative to the freeze
|
||||
instant now, so the range reads `[-window..0]`-ish rather than stream seconds, and a
|
||||
`frozen_x` in the thousands is the rebase gone.
|
||||
**`glance=` is the assertion for the paused status field**, and this is the only line that can
|
||||
make it: the exit dump's `status bar:` is read live and its two `mode …` lines are the live and
|
||||
triggered states. While paused it must read `paused · <rate> · <window> · for <elapsed>`, with
|
||||
the elapsed figure **growing between the two paused lines** and **absent on the resume line**.
|
||||
It is **wall** time (`g_get_monotonic_time()`), not stream time, because the producer never
|
||||
stops while paused and "how long have I been looking at this" is a question about the user, not
|
||||
about the stream; it is written through `format_elapsed()` so the field steps to a coarser unit
|
||||
instead of growing digits (see the layout rule).
|
||||
- `UDPSCOPE_EXPORT_MS` (+ optional `UDPSCOPE_EXPORT_PATH`, default
|
||||
`/tmp/udpscope-export`) — collect the export table and write it both ways, skipping
|
||||
only the file chooser.
|
||||
@@ -235,10 +264,18 @@ Two lines of the self-check output are assertions in disguise:
|
||||
monotone (`plot_host.cpp`), its one-shot widest-label reservation can land a frame or
|
||||
two after the first measured frame, and the figure serves its floor out of the label
|
||||
strip (`y_width = max(measured, floor - y_title_w)`, `plot.cpp`) so the reservation
|
||||
converges over two layouts rather than one. A `y-gutter 44..74` dragging
|
||||
`axes.x 67..80`/`axes.w 546..559` with it is that settle, not a twitch — confirm it
|
||||
converges over two layouts rather than one. A `y-gutter 88..104` dragging
|
||||
`axes.x 88..104`/`axes.w 707..782` with it is that settle, not a twitch — confirm it
|
||||
with the `changes` line below. A range that keeps moving is the "twitching axis" bug:
|
||||
the plot being re-measured every frame. **`x-desync` must read 0**: it counts frames
|
||||
the plot being re-measured every frame. **A range that keeps *climbing* to 200 is the
|
||||
other one**: `note_y_gutter()` is handed the gutter the row was laid out with, which
|
||||
already includes the floor imposed last frame, so slack added to it is added again every
|
||||
frame and the gutter ratchets to its own 200 px cap — `axes.x 88..200 / y-gutter 88..200
|
||||
/ axes.w changes: 17` on an 870 px viewport is what that reads as, and 17 changes looks
|
||||
like a settle. The reservation's own padding is the only slack there may be.
|
||||
`axes.w` also no longer reaches the viewport's right edge: `apply_gutters()` reserves an
|
||||
empty band there (~59 px of 870), so `viewport.w - axes.x - axes.w` is a *constant*, not
|
||||
a zero. **`x-desync` must read 0**: it counts frames
|
||||
in which any row's x range differed from row 0's by more than a pixel of data, i.e.
|
||||
frames the x link group did not reach. `x-ticks` is content, not layout, and
|
||||
legitimately varies by one as a live
|
||||
@@ -258,7 +295,11 @@ Two lines of the self-check output are assertions in disguise:
|
||||
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
|
||||
`epoch=` is **minus the newest acquired stamp** in `live` and `nothing` — it is what makes the
|
||||
live axis `[-window, 0]`, so there it grows (in magnitude) with the stream and a `0.0000` is
|
||||
the defect, not the healthy reading. It is back to `0.0000` only in `event(frozen)`, where the
|
||||
snapshot carries its own origin (`EventSnapshot::x_offset`) and the display epoch has nothing
|
||||
left to do. 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
|
||||
@@ -305,19 +346,24 @@ Two lines of the self-check output are assertions in disguise:
|
||||
`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,
|
||||
- `[export-probe] mode=.. axis=[a..b] rows=N cols=M time=[c..d] abs=[..]` (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
|
||||
is the *visible* range at the *stored* rate, so a count far short of that is a table
|
||||
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 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 wrong clock (the live branch puts the window back on the stream's clock with
|
||||
`stream_at_x0()` before searching the ring, and that offset is a whole window while
|
||||
streaming — not the 0 it once was).
|
||||
**`abs - time` must be a single constant across both ends of a line**, and that constant is
|
||||
where the display's origin sits in the stream's own clock: `epoch()` plus the newest stamp in
|
||||
`mode=live`, `epoch()` plus the accepted edge in `mode=event`. That is the one assertion on
|
||||
the second column, and it replaces the old "`stream=` must be the edge ± the window" reading,
|
||||
which only ever held because the display clock *was* the stream clock. A `mode=live` line
|
||||
whose `abs - time` differs between the two ends is the two columns computed from different
|
||||
epochs — the defect `abs_base` exists as a single `const` to prevent.
|
||||
An export taken **mid-capture** reports `mode=live` with `time=[-pre..e]` where `e` is how far
|
||||
the post window has filled — 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
|
||||
@@ -401,11 +447,17 @@ Two lines of the self-check output are assertions in disguise:
|
||||
(a min/max showing both its marks counts two of its own). `measures window:` is the
|
||||
timeliness assertion -- the window the numbers were computed over against the one on screen.
|
||||
**The reading that must hold is `marks_in_window=K/K`**: every *time* mark inside the window
|
||||
on screen. `drift` itself is a **single sample of a sawtooth** and is not an assertion on its
|
||||
on screen. Both ranges are on the **stream's** clock, which is the space `measure_window` and
|
||||
the time marks are stored in -- a live axis is a fixed `[-window, 0]`, so comparing display x
|
||||
would read no drift whatever the data did.
|
||||
`drift` itself is a **single sample of a sawtooth** and is not an assertion on its
|
||||
own: a live view scrolls between recomputes, so it rises to one `kMeasureRefreshUs` of scroll
|
||||
and drops to zero at each pass. On a 2 s window at the 100 ms cadence the top of that sawtooth
|
||||
is `drift=5%`, which is what a dump landing just before a scheduled pass reads -- a `0.0000%`
|
||||
is a dump that landed just after one, not a better state. Read it against the recompute count:
|
||||
and drops to zero at each pass. The figure is `(|Δmin| + |Δmax|) / span`, so a pure scroll
|
||||
counts **twice** and the top of the sawtooth is `2 × refresh / window`: ~10 % on a 2 s window
|
||||
at the 100 ms cadence, and a few *hundred* percent on a window shorter than the interval (a
|
||||
50 ms window reads 254 % for 63.6 ms of scroll, and its ceiling is 400 %). Compute the ceiling
|
||||
for the window the run actually used before calling a figure large. A `0.0000%`
|
||||
is a dump that landed just after a pass, not a better state. Read it against the recompute count:
|
||||
*more* recomputes with a *higher* drift sample is the sawtooth, while a drift that exceeds one
|
||||
refresh interval of scroll, or marks outside the window, is the defect. A measurement is
|
||||
recomputed when the window moves (2 % of its span) or the held/live state changes, never on a
|
||||
@@ -435,20 +487,36 @@ Two lines of the self-check output are assertions in disguise:
|
||||
*not* every channel, and never a stream's timestamp signal, which is what an acquired-but-
|
||||
unplotted list put first and auto-selected), and `drop_h` must be a combo box's height
|
||||
(tens of pixels — a scrolled-window wrapper asked it to grow to 160).
|
||||
- `[timelabels-probe] <action> relative=D window=.. origin=.. note=[..] ticks=[..]
|
||||
first_value=.. first_rel=.. range=[..]` + the dump's `time labels: relative=D origin=..
|
||||
note=[..] x_origin_set=.. ticks=[..] first_rel=..` (stderr,
|
||||
- `[timelabels-probe] <action> relative=D distinct=D window=.. origin=.. note=[..] ticks=[..]
|
||||
first_value=.. first_rel=.. range=[..]` + the dump's `time labels: relative=D distinct=D
|
||||
origin=.. note=[..] x_origin_set=.. ticks=[..] first_rel=..` (stderr,
|
||||
`UDPSCOPE_PROBE_TIMELABELS_MS`) — the x label form across a zoom, driven through
|
||||
`apply_time_window()`: **under the figure's x link group the group owns the window, so a
|
||||
row-level `set_time_window()` does nothing** (a probe that changes the window must use the
|
||||
app's path). Absolute at 2 s and at 500 ms, `relative=1` below a second, and the caption
|
||||
must be non-empty exactly then. The probe *races the paint* — it reads the axis on a timer,
|
||||
app's path). **`distinct` must be 1 on every line, and that is the assertion** — it says no two
|
||||
major labels on the axis are the same string, which is the one thing the relative form exists to
|
||||
buy and the only reading that holds whatever the stream's uptime is. `relative=D` is **expected
|
||||
to read 0 on every line now**, and that is a consequence of the newest-relative axis rather than
|
||||
a change to the rule: the form is decided by the tick step against the label resolution at the
|
||||
axis' **magnitude** (see the invariant below), and no display mode carries a large magnitude any
|
||||
more — live and waiting are `[-window, 0]`, a capture and a held event are edge-relative, a pause
|
||||
is freeze-relative. The 50 ms window that used to read absolute at 2 s of uptime (`2.13s, 2.14s,
|
||||
2.15s`) and relative at 17 s (`100µs, 10.1ms, 20.1ms`, with the caption) now reads
|
||||
`-40ms, -30ms, -20ms, -10ms` at any uptime. The rule and the caption are kept because they are
|
||||
the answer for *any* axis magnitude and nothing in them is mode-specific — do not delete them on
|
||||
the strength of this reading — but what the probe asserts is `distinct`, not the form. The
|
||||
**caption must be non-empty exactly when `relative=1`**, which here means empty throughout. Run it after
|
||||
`UDPSCOPE_ARM_TRIGGER_MS` as well, which is the case the span rule got wrong: a triggered 50 ms
|
||||
window must read `relative=0 note=[] ticks=[-20ms,-10ms,0,10ms]` — a `t0` caption on an axis
|
||||
that is already edge-relative re-anchors the ticks away from the trigger, which is the one
|
||||
position the axis exists to show. The probe *races the paint* — it reads the axis on a timer,
|
||||
and the ticks it sees are the previous layout's — so its `first_rel` (the first major minus
|
||||
the axis' origin) is a fraction of a step; the dump runs after the last paint and is the
|
||||
race-free reading. Either way the assertion that matters is the *sum*: `first_value` must
|
||||
equal `origin + first_rel` as a *tick label* plus its caption, i.e. the labels and the
|
||||
caption must share one anchor. `x_origin_set` is read back out of the plot; it must be 0 in
|
||||
the absolute form.
|
||||
the absolute form. Note the last two steps both report `window=0.05`: `RowSet::set_time_window()`
|
||||
floors the window there, so the 20 ms step is clamped.
|
||||
- `[fit-probe] <action> pinned=D window=.. event_span=.. x=[..] span=.. y=[..]` (stderr,
|
||||
`UDPSCOPE_PROBE_FIT_MS`) — the two fits, through the **menu handlers** rather than the bare
|
||||
`RowSet` calls, because the notice and the control are the handler's half. A fit is the **undo
|
||||
@@ -552,15 +620,25 @@ Two lines of the self-check output are assertions in disguise:
|
||||
first run's values, and the signals it names must come back *acquired* (a `row 0 slot 0
|
||||
-> channel ... samples=N` line). Delete the file before a run whose layout you did not
|
||||
mean to inherit (`127.0.0.1_9540.toml` and friends accumulate otherwise).
|
||||
- `[udpscope] starved: connected (config=D), reconnects=N, no new frames in the last
|
||||
- `[udpscope] starved: connected (config=1), reconnects=N, no new frames in the last
|
||||
second ...` (stderr, once per second while the state holds, plus a status-line notice) —
|
||||
"connected but nothing is arriving after a reconnect", which over unicast is the second
|
||||
instance taking the source's single session. Two ways to see it without a second client:
|
||||
point the app at a UDP port where nothing answers (the client CONNECTs, times out,
|
||||
reconnects; `config=0`), and check it does **not** fire against a live source. The status
|
||||
line carries ` | N reconnects` only when N > 0, which is the same counter without the
|
||||
prose.
|
||||
- **The export's `time`/`stream_time` columns must be strictly increasing and evenly
|
||||
instance taking the source's single session. Check it does **not** fire against a live
|
||||
source. The status line carries ` | N reconnects` only when N > 0, which is the same
|
||||
counter without the prose.
|
||||
**The same stall test has two outcomes, split on `have_config`, and they are different
|
||||
diagnoses.** With a signal list in hand, the above. With **no CONFIG at all** it prints
|
||||
`[udpscope] connecting: connected (config=0), reconnects=N, no configuration from the
|
||||
stream ...` and the notice reads *trying to connect* — nothing was taken away, because
|
||||
with no signal list nothing can be enabled and no DATA frame is owed, and the old wording
|
||||
blamed a second reader that does not exist. That state is also named *without* waiting for
|
||||
the three-sample stall: the glance word is `connecting` and the badge is **yellow** from the
|
||||
first frame (see `badge frames:`). The way to see it with no second client is to point the
|
||||
app at a UDP port where nothing answers (the client CONNECTs, times out, reconnects), which
|
||||
is now the `config=0` branch and no longer exercises the `starved:` one at all — a check of
|
||||
the unicast-contention wording needs a real second reader, or a source that configures and
|
||||
then goes quiet.
|
||||
- **The export's `time`/`abs_seconds` columns must be strictly increasing and evenly
|
||||
spaced.** Check the written file, not just the probe: `np.diff(t)` with no `<= 0` and
|
||||
`rows / (t[-1] - t[0])` equal to the stored rate (the `ch` line's figure). Duplicate or
|
||||
backwards stamps mean the recorded timeline holds two clocks or a collapsed cadence —
|
||||
@@ -568,18 +646,30 @@ Two lines of the self-check output are assertions in disguise:
|
||||
README) and a per-sample step derived from the client's `lost` counter (now the
|
||||
stream's measured arrival cadence). A `rows=` count far below the span times the rate
|
||||
is the other signature, that of a table cut from the LOD pyramid.
|
||||
**`set(np.diff(t))` must hold exactly one value**, and this is the check that needs a long
|
||||
run at a high rate: a *plateau* of equal stamps, or a diff taking two or three values a few
|
||||
hundred nanoseconds apart, is the time ring losing precision with uptime (see the `f64`
|
||||
time-ring invariant below — the figure to compute is the ULP of `t[0]`, since the defect
|
||||
grows with how long the app has run, not with the export's own length).
|
||||
- `badge frames: lit=L dim=D idle=I` — the activity badge's phase per frame. Over a run
|
||||
that acquired anything **both `lit` and `dim` must be non-zero**: that is what "it
|
||||
**The step must hold to a few ULP of the column's own magnitude**, and this is the check that
|
||||
needs a long run at a high rate: the old form of this assertion was "`set(np.diff(t))` holds
|
||||
exactly one value", and that was only true while `time` *was* the stored stamp. Both columns
|
||||
are derived now — `time` is `stamp - stream_at_x0()` and `abs_seconds` is that plus a base of a
|
||||
wholly different magnitude — so each subtraction costs a rounding and `set()` legitimately
|
||||
holds a handful of neighbouring doubles. Measured: four values spanning ~1.5 ULP, 6.8e-16 s.
|
||||
The thing that must not come back is the **plateau**: equal stamps, or a diff taking two or
|
||||
three values a few *hundred nanoseconds* apart, is the time ring losing precision with uptime
|
||||
(see the `f64` time-ring invariant below). The figure to compute is the ULP of `t[0]` and of
|
||||
`abs_seconds[0]` — the defect that invariant guards is microseconds, nine orders of magnitude
|
||||
above the arithmetic noise, so the two readings cannot be confused.
|
||||
- `badge frames: lit=L dim=D idle=I connecting=C` — the activity badge's phase per frame. Over
|
||||
a run that acquired anything **both `lit` and `dim` must be non-zero**: that is what "it
|
||||
blinks" means, and a badge stuck lit is indistinguishable from a working one in a
|
||||
screenshot. One or two `idle` frames at the start are the frames before the socket
|
||||
connected; a large `idle` is correct only when the run paused or disarmed (a
|
||||
`UDPSCOPE_PROBE_DISARM_MS` run should show hundreds). The `mode ...` lines carry the
|
||||
rest of the status line, so `rate` and `last event ... ago` are checked there.
|
||||
`UDPSCOPE_PROBE_DISARM_MS` run should show hundreds). **`connecting` must be 0 on a run
|
||||
against a live source** — it is the steady *yellow* phase, "the socket answered but the
|
||||
stream has sent no CONFIG", and a non-zero count against a working mock means a signal list
|
||||
that arrived late or not at all. It is the *only* non-zero phase on a dead-port run
|
||||
(measured: `lit=0 dim=0 idle=90 connecting=446`, the `idle` being the frames before the
|
||||
first CONNECT), which is also the check that a source that has sent nothing never gets the
|
||||
blinking green. The `mode ...` lines carry the rest of the status line, so `rate` and
|
||||
`last event ... ago` are checked there.
|
||||
- `frame cost: tick A/B ms over N, draw C/D ms (max at frame F), paint E/G ms over M` and
|
||||
`frame path: raw=R lod=L frames, segments S/T (mean/max), visible max V samples` — the
|
||||
drawing cost, split into the frame tick, `PlotArea`'s own draw (timed from the frame
|
||||
@@ -597,10 +687,12 @@ Two lines of the self-check output are assertions in disguise:
|
||||
lines and the disarm probe's `axis=`/`snap=` need a fast source to say anything —
|
||||
`tools/mock_udpstreamer.py --rate 1300000 --batch 20000` — since at the default 10 kS/s a
|
||||
whole window fits in the raw path and the publish is too short to lose a race.
|
||||
- `trace ink: N paints checked, C collapsed, S staircase, H short, worst cover P%` plus a
|
||||
- `trace ink: N paints checked, C collapsed, S staircase, H short, worst cover P%, worst
|
||||
drawn-vs-quoted D px over R raw paints` plus a
|
||||
`[ink] paint N <flags> mode=.. axis=[..] n=.. idx=[..) spp=.. level=.. pts=.. kept=..
|
||||
ink=[..] cover=..% x0=.. x_at=[..] bounds=[..] rel=[..] dx=.. kept_fixed_x0=.. drops=[..]
|
||||
back=..` line per anomalous frame (stderr) — **`collapsed` and `staircase` must both be 0**.
|
||||
ink=[..] cover=..% x0=.. x_at=[..] bounds=[..] rel=[..] dx=.. drops=[..]
|
||||
back=..` line per anomalous frame (stderr) — **`collapsed` and `staircase` must both be 0**,
|
||||
and **`drawn-vs-quoted` must stay far under a pixel** (0.0000 is what a clean run reads).
|
||||
This is the only check that can see a frame whose *waveform* vanished: `staged_segments`
|
||||
counts geometry including the grid and the instruments, and `lod_traces` counts a trace whose
|
||||
points are then collapsed, so both read healthy through the flicker. The probe runs on every
|
||||
@@ -610,11 +702,22 @@ Two lines of the self-check output are assertions in disguise:
|
||||
`collapsed` is fewer than two distinct positions left (nothing to draw at all), `staircase`
|
||||
is the filter dropping over three quarters of the points, `short` is ink reaching under a
|
||||
quarter of the axis (reported only once the data spans the window, since a filling ring is
|
||||
*meant* to leave its left side empty). `kept_fixed_x0` is the same filter with `x0()` hoisted
|
||||
out of the loop: it exceeding `kept` means `x0()` moved *during* the walk. **`back` ≈ one ring
|
||||
span is the signature** of the torn `x0()` read (see the cached-first-stamp invariant); so is
|
||||
an `ink=` range disjoint from `axis=`, or an `idx=` covering the whole ring with `cover` far
|
||||
over 100%. It is a race, so it needs load and a reason for long frames —
|
||||
*meant* to leave its left side empty). `drawn-vs-quoted` is the other reading, and it is the
|
||||
one that names a *defect of position* rather than of presence: at a raw level a gathered pair
|
||||
**is** sample `first + k`, so the line differences where a sample is drawn
|
||||
(`axis.min + gather[2k]`, the renderer's own reconstruction) against the x a readout quotes
|
||||
for it (`x_at()`, exact f64) and reports the worst of them in pixels. That is the
|
||||
gather-origin invariant below read from the screen's side; it was over a hundred pixels at a
|
||||
deep zoom before the origin became the caller's. It is measured on the raw levels only — above
|
||||
them a gathered point is a bucket's envelope and has no single sample to be off from — so `R`
|
||||
is legitimately below `N`. **`back` ≈ one ring span is the signature** of the torn `x0()` read
|
||||
(see the cached-first-stamp invariant); so is an `ink=` range disjoint from `axis=`, or an
|
||||
`idx=` covering the whole ring with `cover` far over 100%. There was a `kept_fixed_x0` field
|
||||
beside `kept` — the same filter with `x0()` hoisted out of the loop, since the renderer used to
|
||||
re-read `x0()` per point and a ring-backed one advances with the producer. The origin is a
|
||||
local of the renderer's now, so that failure mode is structurally impossible and the field is
|
||||
gone; the `[ink]` walk and the renderer still agree because both add `axis.min`. It is a race,
|
||||
so it needs load and a reason for long frames —
|
||||
`tools/mock_udpstreamer.py --rate 1300000 --batch 2000 --clock-jitter 0.4` with the
|
||||
measurements *and* cursors panes up:
|
||||
`UDPSCOPE_ENABLE_ALL_MS=1000 UDPSCOPE_PROBE_MEASURES_MS=2000 UDPSCOPE_PROBE_CURSORS_MS=3500
|
||||
@@ -661,7 +764,7 @@ Two lines of the self-check output are assertions in disguise:
|
||||
it afterwards.
|
||||
- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `<key>: <widget-description>: <before>-><after>` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values).
|
||||
- `[zoomkeys] ctrl+plus x=[..]->[..] y_held=1 | alt+plus y=[..]->[..] x_held=1` (stderr, `UDPSCOPE_PROBE_ZOOMKEYS_MS`) — **both `_held` must be 1 and both zooms must still happen.** Run it inside a pinned state, where a frame cannot re-frame the other axis for its own reasons: `UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200`. A `_held=0` is the modifier not reaching the plotter's controller; an unchanged range on the *zoomed* axis is the opposite failure, the key doing nothing. Ctrl restricts to x and Alt to y; Ctrl+Alt zooms neither (no special case needed — neither flag alone fires).
|
||||
- `status bar: glance=[● live · 1.09 MS/s · 2 s window] max_glance_chars=53 detail_fields=11 popover_rebuilds=0` (exit dump) — **`max_glance_chars` must stay small** (under about 60) and **`popover_rebuilds` must be 0**. The status row is directly above the plot, so this is the "layout must not depend on content" rule read from the bar's own side: the state field is one word from a fixed set, the rate field is `%.3g` (so `10 kS/s`, never `10.0 kS/s`), and the only field trigger mode adds is the elapsed time, which steps units rather than growing digits. `detail_fields` is the popover's label count, a literal, so adding a label without thinking about layout shows up here. The glance rate format matters: `%.3g` drops the trailing zero that a `%.4g` or a fixed-precision format would keep — a rate that reads differently in the dump and in the help page is a format disagreement, and `14-status.txt`'s example must match it.
|
||||
- `status bar: glance=[● live · 1.09 MS/s · 2 s window] max_glance_chars=53 detail_fields=11 popover_rebuilds=0` (exit dump) — **`max_glance_chars` must stay small** (under about 60) and **`popover_rebuilds` must be 0**. The status row is directly above the plot, so this is the "layout must not depend on content" rule read from the bar's own side: the state field is one word from a fixed set (`connecting`, `live`, `paused`, `armed`, `capturing`, `event held`, `disarmed` — a word added to it must be **no wider than the widest already there**, which is what let `connecting` join at ten characters beside `event held`; the dead-port glance reads `max_glance_chars=51` against a live run's 54), the rate field is `%.3g` (so `10 kS/s`, never `10.0 kS/s`), and the two fields a *state* adds are both elapsed times — trigger mode's `last event … ago` and a pause's `for <elapsed>` — which step units rather than growing digits (`format_elapsed()`). A **paused** glance is not in the dump at all: it is read by `[pause-probe]`'s `glance=` field, since the dump's own line is taken live. `detail_fields` is the popover's label count, a literal, so adding a label without thinking about layout shows up here. The glance rate format matters: `%.3g` drops the trailing zero that a `%.4g` or a fixed-precision format would keep — a rate that reads differently in the dump and in the help page is a format disagreement, and `14-status.txt`'s example must match it.
|
||||
|
||||
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.
|
||||
@@ -697,13 +800,16 @@ immutable copy of the captured window; see README for why two live rings were dr
|
||||
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.
|
||||
- `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.
|
||||
- `Waiting` — armed, nothing captured yet: blank.
|
||||
- `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
|
||||
@@ -726,6 +832,35 @@ and hands it to `bind()`). The producer can open or close a capture between any
|
||||
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.
|
||||
@@ -935,15 +1070,32 @@ far the largest file.
|
||||
`gather()`, `x_at()`, `neighbours()` and `x_bounds()` use the real time ring.
|
||||
Nothing may walk that grid to find *which* samples a range covers — the renderer did,
|
||||
and it lost the two end segments (see the margin invariant above). `gather()` emits x
|
||||
relative to `x0()` because absolute epoch seconds do not survive `f32`.
|
||||
- **`gather()` emits x relative to `x0()` in *every* series here**, `EventSeries`
|
||||
included, and the renderer adds `x0()` back. An event's stamps are small (edge
|
||||
relative, `[-pre, +post]`), so emitting them absolute does not overflow anything and
|
||||
does not look like a bug — it just draws the whole capture `x0()` = one pre-trigger
|
||||
window to the left of the axis. That was the "half the plot is empty / the marker sits
|
||||
at the end of the data / nothing is drawn but the crosshair reads values" defect: the
|
||||
crosshair reads `x_at()`, which was right all along. The probe's `data=` vs `drawn=`
|
||||
is the guard.
|
||||
relative to an origin because absolute epoch seconds do not survive `f32`.
|
||||
- **`gather()` emits x relative to an origin *the caller names*, in every series here**,
|
||||
`EventSeries` included, and the consumer adds that same origin back in `f64`. Two separate
|
||||
rules live here and both were learned from a defect.
|
||||
(1) **It must be relative at all.** An event's stamps are small (edge relative,
|
||||
`[-pre, +post]`), so emitting them absolute does not overflow anything and does not look
|
||||
like a bug — it just draws the whole capture `x0()` = one pre-trigger window to the left of
|
||||
the axis. That was the "half the plot is empty / the marker sits at the end of the data /
|
||||
nothing is drawn but the crosshair reads values" defect: the crosshair reads `x_at()`, which
|
||||
was right all along. `[trig-probe]`'s `data=` vs `drawn=` is the guard.
|
||||
(2) **The origin is the *view's* minimum, not `x0()`.** `x0()` is the start of the
|
||||
**retained window**, so the f32 had to resolve 2^-24 of the whole ring however little of it
|
||||
was on screen: 1.9 µs over 20 s of retained data, which at a view 10 µs wide across a 716 px
|
||||
axis is over a hundred pixels between where a sample is drawn and the x `x_at()` quotes for
|
||||
it — and it halved on every zoom out, so it read as "zooming in makes the crosshair select a
|
||||
point beside the data". `Renderer::draw_traces` passes `x_range.min`, making the error a fixed
|
||||
4e-5 of a pixel at any zoom. The parameter sits **before** `out_xy` deliberately, so a call
|
||||
site that was not thought about is a compile error rather than silent drift. A caller that has
|
||||
no view in mind may still pass `x0()`, and `[trig-probe]`'s `drawn=`/`data=` does exactly that
|
||||
on purpose: it reads the series' *whole* span, which is the widest relative value a gather can
|
||||
be asked to stage. The two guards are upstream's
|
||||
`a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes` and the
|
||||
`trace ink:` line's `drawn-vs-quoted` figure.
|
||||
**A probe that recomputes what the renderer gathers must pass the same origin the renderer
|
||||
does** (`[ink]` and `[zoom-probe]` both pass the axis minimum), or its reconstruction stops
|
||||
being the renderer's and its pixel readings mean nothing.
|
||||
- **Every series bound to a trace must carry a real `MinMaxPyramid`**, including an
|
||||
immutable one. `MinMaxPyramid::level_for()` descends to level 0 — raw samples — on a
|
||||
pyramid with no levels, whatever `n` is, so a series that answers `pyramid()` with an
|
||||
@@ -976,12 +1128,15 @@ far the largest file.
|
||||
the doubling against overflow); `live_window_locked()` compares its bounds in the ring's own
|
||||
type with **no rounding step**, the old `f32` round-trip having existed only so that a
|
||||
sample sitting exactly on a bound was kept whatever the stream's absolute time was; and
|
||||
`EventSnapshot::stamps` is `f64` because a **pause** view is deliberately *not* rebased and
|
||||
therefore carries full stream time (an event's own stamps are edge-relative and small — the
|
||||
symmetry is not the reason). `RingBuffer<T>` stores slots as `std::atomic<T>` behind a
|
||||
`static_assert(is_always_lock_free)`, which `double` satisfies on every supported target.
|
||||
The guard is the **export**: `np.diff(t)` over a multi-million-row `.npy` must hold exactly
|
||||
one distinct step. Three steps at 2, 2.5 and 3 ULP of the stamps' magnitude is this defect.
|
||||
`EventSnapshot::stamps` is `f64` because it is cut from this ring and is therefore in the same
|
||||
units, whatever a given snapshot's own origin is (both kinds are rebased now — an event's
|
||||
stamps are edge-relative, a pause's freeze-relative — so neither is magnitude-bound the way the
|
||||
ring is, and the width is for the cut rather than for the coordinate). `RingBuffer<T>` stores
|
||||
slots as `std::atomic<T>` behind a `static_assert(is_always_lock_free)`, which `double`
|
||||
satisfies on every supported target. The guard is the **export**: over a multi-million-row
|
||||
`.npy`, `np.diff(t)` must hold one step to within a few ULP of the column's magnitude (see
|
||||
the export guard above for why it is no longer exactly one value). Three steps at 2, 2.5 and
|
||||
3 ULP of *the stream's uptime* — microseconds, not femtoseconds — is this defect.
|
||||
- **A dropped time slot is clamped, never answered with a sentinel.** `acquire()` pins
|
||||
the published window to the value ring's base and both rings keep sliding afterwards —
|
||||
their capacities are equal, so there is no slack — which means the oldest few samples
|
||||
@@ -999,14 +1154,19 @@ far the largest file.
|
||||
sample 0 is the ring's oldest, the very slot the producer is about to overwrite. `time_of()`
|
||||
checks the bounds and then copies, and when the producer laps the slot in between the stamp it
|
||||
returns is **one whole ring-span too new**. Everything else on a `ChannelBuffer` is read at
|
||||
paint time with no lock, and `x0()` is the origin the renderer reconstructs every trace point
|
||||
from (`gather[i] + series.x0()`), read both inside its increasing-suffix filter and again at
|
||||
`stage_xy()` — so one torn read of that one slot produced three pictures, all of them "the
|
||||
paint time with no lock, and `x0()` *was* the origin the renderer reconstructed every trace
|
||||
point from (`gather[i] + series.x0()`), read both inside its increasing-suffix filter and again
|
||||
at `stage_xy()` — so one torn read of that one slot produced three pictures, all of them "the
|
||||
waveform is gone while the grid and the axes stay": `neighbours()` tests against `x_at(0)` and
|
||||
refused the whole visible range (the renderer then gathered the *entire* ring at a coarse level
|
||||
and drew it off the axis — `idx=[0..8388608) level=7 cover=320%`); `x0()` changing *during* the
|
||||
filter loop dropped three quarters of the points; and `stage_xy()` shifted the finished polyline
|
||||
a ring-span off its axis. That is the "with measurements and cursors active the plot flickers as
|
||||
a ring-span off its axis. The gather origin is the *view's* minimum now (see the `gather()`
|
||||
invariant), a local of the renderer's, so the second and third of those are structurally
|
||||
impossible — but **the first is not**, `x_at(0)` is still what `neighbours()` tests the visible
|
||||
range against, and that is the one that gathered the whole ring. The cache is load-bearing for
|
||||
that reason; do not read the slot at paint time because two of its three symptoms are gone.
|
||||
That is the "with measurements and cursors active the plot flickers as
|
||||
if there were no data, at no constant frequency" defect. It is **live only** (a snapshot is
|
||||
immutable, so `x0()` is constant for `Held`/`Paused`), it is a race against packet arrival so it
|
||||
is irregular, and the measurements pane makes it both likelier and larger: the pane holds
|
||||
@@ -1036,8 +1196,18 @@ far the largest file.
|
||||
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.
|
||||
- **Layout must not depend on content.** The y gutter is reserved at the widest label
|
||||
`TickFormat::Si` can produce and only ever grows; the status label is ellipsized so
|
||||
`TickFormat::Si` can produce *for an axis with no unit* — a sign, three significant
|
||||
figures and a one-glyph prefix, measured and not counted in characters — and only ever
|
||||
grows; the status label is ellipsized so
|
||||
its text cannot drive the paned divider. Both exist to stop per-frame relayout.
|
||||
The reservation deliberately does **not** cover `format_si()`'s `%.3g` fallback
|
||||
(`-1.23e-45`), which it used to: that form needs a value past 1e24, so it bought a third
|
||||
of the gutter for a label no stream produces, and the floor being a monotone maximum over
|
||||
the *measured* need means one that did appear widens the gutter for good anyway. Erring
|
||||
wide is not free here — it is a third of the plot's width.
|
||||
And **nothing may add slack to the gutter it is told**: `note_y_gutter()`'s argument is
|
||||
`layout().y_axis_width`, which is `max(measured, floor)` — partly our own answer coming
|
||||
back — so a constant added to it compounds. See the ratchet reading under Verification.
|
||||
The same rule is why the activity badge is one glyph in three colours rather than
|
||||
three glyphs, why the `rate` field collapses to one figure or a range instead of a
|
||||
per-channel list, and why `format_elapsed()` steps to a coarser unit instead of
|
||||
@@ -1047,6 +1217,16 @@ far the largest file.
|
||||
plotter for this), not on the rows: `Figure::layout()` clears every row's
|
||||
`set_min_y_gutter()` to measure what its own labels need, so a floor written onto the
|
||||
rows survives exactly until the next layout.
|
||||
- **The right-hand band is a want, and the left gutter is a measurement.** The two sides of
|
||||
the plot are not symmetric in kind: the left holds the y labels, so it is measured and
|
||||
floored; the right holds nothing, so there is nothing to measure and `RowSet` simply asks
|
||||
for half the widest time label plus the label padding (`right_gutter_px_`, reserved in the
|
||||
same one-shot block). Half a label is the figure that matters: an x tick label is
|
||||
*centred* on its tick and a live axis is `[-window, 0]`, so there is always a tick exactly
|
||||
on the axes' right edge, and with `axes` flush to the viewport half of that label was
|
||||
drawn outside the plot. Both go on the **figure** and for the same reason — the rows have
|
||||
to end on one column as well as start on one — which is why `apply_gutters()` applies the
|
||||
pair and `rebuild_all()` calls it: a new `Figure` carries neither.
|
||||
- **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
|
||||
@@ -1406,6 +1586,41 @@ far the largest file.
|
||||
reasons (an event pin) call `sync_window_display()` alone, which only restates the control and
|
||||
never moves the plot — calling `apply_time_window()` there would clamp a legitimately short
|
||||
capture up to the requested window.
|
||||
- **The x labels go relative on the tick *step against the label resolution at the axis'
|
||||
magnitude*, never on the span.** `RowSet::update_time_labels()`'s question is only ever "can
|
||||
this step be written at this magnitude": `TickFormat::Time` carries three significant figures
|
||||
below a minute, **whole seconds** through `m:ss`/`h:mm:ss` up to a day, and three figures of
|
||||
days above — so the resolution is a step function of the magnitude, which `time_label_resolution()`
|
||||
states and nothing else may re-derive. The old test was `span < 1.0` and it was wrong in both
|
||||
directions: a 2 s window at twenty minutes of uptime reads `20:00` on every tick and stayed
|
||||
absolute, and a **triggered** sub-second window — whose axis is already `[-25ms, +25ms]` about
|
||||
the edge — went relative and so re-anchored its ticks *away from the trigger*, with a `t0`
|
||||
caption that had nothing to explain. Three details hold it up. The step is read back from the
|
||||
majors the **last layout** produced, which is not a feedback loop (a tick's value depends on the
|
||||
range and the axis' pixel width, never on the label form) but *is* absent before the first
|
||||
layout — and with no ticks the form must be **left alone**, because a step guessed from the span
|
||||
would be latched by the hysteresis below. The hysteresis is a 1.05 band on *leaving* the relative
|
||||
form. And *entering* it needs a ULP of slack (`1 - 1e-9`): a step differenced from two tick
|
||||
values and a resolution from `pow(10, n)` differ in their last bits when they are the same
|
||||
number, and `step == res` is a readable axis (`1.73s`, `1.74s`, `1.75s` — one in the last digit
|
||||
is exactly enough), so the bare comparison picked a different form from one frame to the next.
|
||||
The guard is `[timelabels-probe]`'s `distinct=1`, which asserts what the form is *for* rather
|
||||
than which form a given window gets.
|
||||
- **The `t0 = …` caption is chrome, never an annotation.** It is `Plot::set_x_note()` (added to
|
||||
`deps/plotter` for this), drawn by `Renderer::draw_x_note()` on the legend's own
|
||||
`draw_readout_panel()` backplate in the bottom-left corner of the axes, in **pixels**. It was an
|
||||
`Annotation` first, and an annotation's anchor is a *data* coordinate: the caption therefore
|
||||
crawled across the plot under a pan and drifted under a zoom, which is what "[t0=...s] move
|
||||
slightly around when user zoom/pan" was. Three things follow. The **text is the host's**, because
|
||||
the caption has to name the anchor precisely enough that label + caption is the exact absolute
|
||||
time and `format_time()` would round it to three figures (`"t0 = %.6g s"`); only the **bottom
|
||||
row** of the figure carries one, every other row being set to `""`; and the renderer measures it
|
||||
rather than `Plot::layout()` — it reserves no space and nothing can be picked on it, so there is
|
||||
no rect to read back, and a caption explaining the x labels must not be able to move the layout
|
||||
those labels were measured into. The guards are the library's
|
||||
`the_x_note_is_chrome_that_a_pan_or_a_zoom_does_not_move` (the footprint is the pixels a frame
|
||||
with the caption differs from one without, and a pan plus a 64× zoom must give the same box to
|
||||
the pixel) and `[timelabels-probe]`'s `note=`, which reads `Plot::x_note()` back.
|
||||
- **`fit_x()` restores the *whole of what the view may show*, which is not always `window_s_`.**
|
||||
Live, that is the newest `window_s_` at the live edge (`set_time_window(window_s_)`, the same
|
||||
path a resume takes). Pinned — a held event, a filling capture or a paused view — it is the
|
||||
@@ -1523,9 +1738,14 @@ far the largest file.
|
||||
Those two used to read the *label*, and the label no longer carries `packets`, `gaps`,
|
||||
`plot 1:` or the buffer figures — so a `gtk_label_get_text()` there would silently reduce
|
||||
every documented `mode …:` assertion to four fields. The glance line's state field is **one
|
||||
word from a fixed set** (`live`, `paused`, `armed`, `capturing`, `event held`, `disarmed`) for
|
||||
the layout reason above; a seventh word, or a field whose width tracks the stream, breaks the
|
||||
row the plot is laid out under.
|
||||
word from a fixed set** (`connecting`, `live`, `paused`, `armed`, `capturing`, `event held`,
|
||||
`disarmed`) for the layout reason above; a word **wider than the widest already there**, or a
|
||||
field whose width tracks the stream, breaks the row the plot is laid out under. The set may
|
||||
grow — `connecting` was added for the no-CONFIG state — but only on that condition, and the
|
||||
check is the dump's `max_glance_chars`. `connecting` is derived from `have_config` alone and
|
||||
sits **ahead of every other branch**, including the display mode's: with no signal list, every
|
||||
other word claims something that does not exist yet. The `mode` prose branches the same way for
|
||||
the same reason, which is what keeps the two tiers from disagreeing.
|
||||
- **The ⓘ popover's widgets are built once and only their text is set.** It is visible when the
|
||||
tick updates it, so a rebuild is a per-frame relayout of a widget on screen.
|
||||
`update_status_popover()` only ever calls `gtk_label_set_text()`, and `popover_rebuilds` in
|
||||
|
||||
@@ -41,6 +41,15 @@ both states, the frame and per-channel sample counts, each plot's painted-frame
|
||||
count, which channel each trace slot holds, and the allocated size and reveal
|
||||
state of both side panels.
|
||||
|
||||
Every size it prints is read from a value the frame tick captured while the window was alive, not
|
||||
from the widget: the dump runs from the destroy handler, where the widget pointer is already freed
|
||||
memory. It used to ask two of them directly, which is where the long-standing "an exit-dump run
|
||||
emits exactly two `Gtk-CRITICAL`s" baseline came from — and a critical was only the lucky reading.
|
||||
`UDPSCOPE_PROBE_HELP_MS` builds and destroys three windows of its own before the dump, so by then
|
||||
the memory had been reused and the same two calls **crashed in the middle of the dump**, which
|
||||
looks like a truncated log rather than like a segfault in a debug line. The dump now makes no
|
||||
`gtk_widget_*` call at all and a clean run carries no critical.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
@@ -63,27 +72,101 @@ frame and the GUI thread takes once per frame around binding the traces.
|
||||
Samples cross the boundary through `plot::data::RingBuffer` (single producer,
|
||||
single consumer), never through the lock.
|
||||
|
||||
## Measurements
|
||||
## The x axis is relative to now
|
||||
|
||||
**A live axis is fixed at `[-window, 0]`, with the newest sample at 0.** That is what a bench
|
||||
scope in roll mode shows and what the window control's figure means: a 2 s window reads `-2s` to
|
||||
`0` and keeps reading that for as long as the stream runs, with the trace flowing right to left
|
||||
under it. The axis used to carry the stream's own clock, so the labels counted the app's uptime
|
||||
upwards (`20:00`, `20:01`, …) and the one number a user actually wants — how long ago a feature
|
||||
happened — had to be worked out by subtraction.
|
||||
|
||||
Every mode now expresses x as *seconds from the thing the view is about*, and the rule is one
|
||||
line: `display_x = stream_t - ScopeModel::stream_at_x0()`, where `stream_at_x0()` is the stream
|
||||
time of display x = 0.
|
||||
|
||||
| display mode | x = 0 is | `stream_at_x0()` |
|
||||
| --- | --- | --- |
|
||||
| `Live`, `Waiting` | the newest acquired sample | the newest stamp |
|
||||
| `Capturing` | the accepted edge | the edge |
|
||||
| `Held` | the event's edge | the snapshot's `x_offset` |
|
||||
| `Paused` | the instant of the freeze | the snapshot's `x_offset` |
|
||||
|
||||
The live answer is written by the frame tick **after** `bind()` and `acquire()`, because it has to
|
||||
be minus the newest sample the *traces* can report: asking the model before they are bound puts
|
||||
the newest data a frame's production to the right of the axis, which at 1 MS/s over a 50 ms window
|
||||
is a third of the screen. It converges in exactly one frame and cannot oscillate — `live_edge()`
|
||||
reports in the epoch currently in force, so `new = old - live_edge()` is `-raw_newest` whatever the
|
||||
old value was.
|
||||
|
||||
A snapshot carries its own offset (`EventSnapshot::x_offset`) rather than leaning on the model's
|
||||
epoch, because its coordinates outlive the epoch that produced them. One consequence is a reversal
|
||||
of an earlier deliberate choice: **a pause snapshot is now rebased** like an event's. Leaving its
|
||||
stamps on the stream's clock would have thrown the frozen picture a whole uptime to the right of an
|
||||
axis that is now newest-relative. `x_offset` keeps the stream clock recoverable, which is what the
|
||||
export's second column reads — and it fixed a latent bug on the way, the export's snapshot branch
|
||||
having added `trigger_time()` to a *paused* view's stamps: an unrelated edge, or none at all before
|
||||
the first latch.
|
||||
|
||||
Two things move in **opposite** directions under a rolling axis, and the asymmetry is deliberate.
|
||||
A **cursor** stays on screen: placed at -1.5 s it keeps meaning "1.5 s ago" and the data flows
|
||||
under it, which is a bench scope's roll behaviour and is free here, the cursors already being
|
||||
stored in display coordinates. A **measurement mark** is glued to the sample it measured, because
|
||||
a mark is a *result* rather than an instrument — so the marks are stored in stream seconds and
|
||||
reconverted every frame (`place_measure_marks()`), which is what makes them drift left with their
|
||||
own data between recomputes. `App::measure_window` is on the stream's clock for a related reason:
|
||||
"has the window moved" is the question that triggers a recompute, and a fixed `[-window, 0]` makes
|
||||
it invisible in display coordinates, so the pane would have computed once and never again.
|
||||
|
||||
**Pause freezes the picture, not the acquisition.** The producer keeps writing the live ring
|
||||
while paused — the same thing it does when the trigger is off — and the plot binds a *snapshot*
|
||||
of the window that was on screen at the pause, with the axes pinned there. That is the same
|
||||
immutable-series path a held trigger event uses (`DisplayMode::Paused` sits beside `Held`); the
|
||||
difference is the anchoring: an event's stamps are edge-relative, a pause's are the stream's own,
|
||||
so the times on the axis do not change when the pause begins. Resuming drops the snapshot and
|
||||
immutable-series path a held trigger event uses (`DisplayMode::Paused` sits beside `Held`).
|
||||
Resuming drops the snapshot and
|
||||
re-frames on the newest data, so the seconds that passed while paused are *there*, not a hole —
|
||||
which is what pausing used to leave, because it stopped the producer outright.
|
||||
which is what pausing used to leave, because it stopped the producer outright. The status bar
|
||||
says how long it has been frozen (`paused · 10 kS/s · 2 s window · for 12 s`), in **wall**
|
||||
seconds: the producer never stopped, so the stream's own clock says nothing about how long the
|
||||
user has been looking at a still picture. It is written through `format_elapsed()`, which steps to
|
||||
a coarser unit instead of growing digits, because the status row sits directly above the plot and
|
||||
a field whose width tracks the wait would relayout it.
|
||||
|
||||
**Sub-second windows read relative.** The x ticks are absolute until the window's span goes
|
||||
under a second (with a band to 1.05 s, so the form cannot flicker while a zoom is nudged), and
|
||||
relative below it: the ticks are then generated on a grid anchored at the window's left edge —
|
||||
so they read `0`, `5ms`, `10ms` instead of `12.345671s`, `12.345672s`, which three significant
|
||||
digits cannot even tell apart — and a caption names that anchor in the axes' top-left corner:
|
||||
`t0 = 12.3456 s`. The labels scale through the prefixed units the SI formatter uses — `500ms`,
|
||||
`500us`, `5us`, `5ns` — so a microsecond window reads in microseconds instead of `0.005ms`. Adding the two is the tick's absolute time. The caption is an annotation
|
||||
inside the axes rather than the axis title on purpose: its text changes with the window, and a
|
||||
title of a changing width would relayout the plot every frame. Everything a user copies out —
|
||||
the cursors' `T0`/`T1`/`dT`, a measurement, an export — stays in absolute stream seconds.
|
||||
## Measurements
|
||||
|
||||
**The x ticks read relative when, and only when, they could not be written absolutely.** A
|
||||
`TickFormat::Time` label carries three significant digits, so the test is the tick **step**
|
||||
against the label's own **resolution at the axis' magnitude**: `12.345671s` and `12.345672s` are
|
||||
the same string, and there the ticks are re-anchored on a grid at the window's left edge — so
|
||||
they read `0`, `5ms`, `10ms` — with a caption naming that anchor, `t0 = 12.3456 s`. Adding the
|
||||
two is the tick's absolute time. The labels scale through the prefixed units the SI formatter
|
||||
uses — `500ms`, `500us`, `5us`, `5ns` — so a microsecond window reads in microseconds instead of
|
||||
`0.005ms`.
|
||||
|
||||
The rule used to be the *span* alone (relative under a second, with a band to 1.05 s), and a
|
||||
span says nothing about whether a label can be written: it was wrong in **both** directions. A
|
||||
2 s window at twenty minutes of uptime is labelled `20:00` on every tick — `m:ss` carries whole
|
||||
seconds — and was left absolute and unreadable. And a **triggered** sub-second window, whose
|
||||
axis is already `[-25ms, +25ms]` about the edge, reads `-20ms`, `-10ms`, `0`, `10ms` perfectly
|
||||
well, yet was re-anchored anyway: the one position a triggered axis exists to show was displaced
|
||||
by a `t0` caption that had nothing to explain. The step is read back from the majors the last
|
||||
layout produced (a function of the range and the axis width, not of the label form, so no
|
||||
feedback loop), the magnitude is the larger of the two endpoints, and the comparison carries a
|
||||
1.05 band leaving the relative form plus a ULP of slack entering it — a step *equal* to the
|
||||
resolution is readable (`1.73s`, `1.74s`, `1.75s`: one in the last digit is exactly enough), so a
|
||||
decision taken on the last bits of two differently computed doubles picked a different form from
|
||||
one frame to the next. The caption is **chrome** — `Plot::set_x_note()`, added to the plotter for
|
||||
it, drawn on the legend's own backplate in the bottom-left corner of the axes — rather than the
|
||||
axis title or an annotation. Not the title, because its text changes with the window and a title
|
||||
of a changing width would relayout the plot every frame; not an annotation, because an
|
||||
annotation's anchor is a *data* coordinate, so the caption crawled across the plot under a pan and
|
||||
drifted under a zoom, which is what it was reported as. The library test is
|
||||
`the_x_note_is_chrome_that_a_pan_or_a_zoom_does_not_move`: the footprint is measured as the pixels
|
||||
a frame with the caption differs from the same frame without one, and a pan to another decade plus
|
||||
a 64× zoom must produce the same box to the pixel. Everything a user reads off the screen is in
|
||||
the *display* coordinate the labels name — the cursors' `T0`/`T1`/`dT`, a measurement's time
|
||||
marks, the crosshair — so live they are negative ages and in trigger mode they are offsets from
|
||||
the edge. Only the **export** carries the stream's own clock as well, in a second column
|
||||
(`abs_seconds`); see *Export*.
|
||||
|
||||
The anchor is taken by the *axis* on every layout (`Axis::origin_follows_range`), not written
|
||||
by the app: the window's edge is derived from the newest sample during the layout, so a value
|
||||
@@ -921,13 +1004,21 @@ connection, the measured rate, the packet/frame/gap counters, what plot 1 holds
|
||||
the buffer's size and span. Three of those are worth calling out:
|
||||
|
||||
- **The badge blinks green at 1 Hz while the pipeline is actually running** — the
|
||||
stream is connected, acquisition is not paused, and in trigger mode the trigger is
|
||||
armed. Anything else leaves it a steady grey, with the reason on its tooltip
|
||||
(`paused`, `trigger disarmed`, `waiting for the stream`). *Blinking* is the signal,
|
||||
not the colour: a steady badge is a stopped pipeline, and that distinction survives
|
||||
a screenshot. It is one glyph whose colour is all that changes, so blinking it
|
||||
stream is connected, a CONFIG has arrived, acquisition is not paused, and in trigger
|
||||
mode the trigger is armed. Anything else leaves it steady, with the reason on its
|
||||
tooltip (`paused`, `trigger disarmed`, `waiting for the stream`). *Blinking* is the
|
||||
signal, not the colour: a steady badge is a stopped pipeline, and that distinction
|
||||
survives a screenshot. It is one glyph whose colour is all that changes, so blinking it
|
||||
cannot change its width — a badge that grew and shrank twice a second would move the
|
||||
label beside it and re-lay out the plot with it.
|
||||
There are two steady colours, and the difference is how far the handshake got.
|
||||
**Grey** is a socket with nothing behind it, or a pipeline the user stopped.
|
||||
**Yellow** is a source that answered CONNECT but has sent **no CONFIG**: there is no
|
||||
signal list, so nothing can be enabled and no DATA frame is owed. The glance line reads
|
||||
`connecting` in both, because neither has the signal list every other state word claims;
|
||||
the colour is what separates "no socket yet" from "a socket and no stream behind it". A
|
||||
blinking green over a source that has sent nothing would claim a working pipeline, which
|
||||
is the one reading the badge exists to prevent.
|
||||
- **`rate 9.99 kS/s` is the rate the buffer actually stored over the last second**, not
|
||||
the rate the CONFIG declared. The two agree unless the requested window is too long
|
||||
to hold at the wire rate, in which case the ingest resamples (see "A window longer
|
||||
@@ -1001,13 +1092,40 @@ which is how the second one was found:
|
||||
what its labels need, so a floor written onto the rows would survive exactly
|
||||
until the next layout. The figure seeds its common gutter — the maximum over
|
||||
the rows, which is also what aligns their axes — with that reservation.
|
||||
The reservation is the widest label `format_si()` writes for an axis with no
|
||||
unit (a sign, three significant figures and a one-glyph prefix), not the
|
||||
wider `%.3g` fallback it used to cover: that form needs a value past 1e24, so
|
||||
it cost a third of the gutter for a label no real signal produces — and since
|
||||
the floor is a monotone maximum over the *measured* need, one that did appear
|
||||
would widen the gutter for good anyway.
|
||||
2. *The status line drove the pane width.* A `GtkLabel`'s minimum width is its
|
||||
text, and the status line is rewritten every frame; as its counters and
|
||||
messages changed width they moved the paned divider, which resized the plot,
|
||||
which re-laid out the axes and their ticks. The label is now ellipsized, so its
|
||||
minimum is “…”; the plot keeps the width it was given.
|
||||
3. *The gutter was a ratchet.* `note_y_gutter()` is handed the gutter the row was
|
||||
**laid out with** — already the maximum of its labels and the floor imposed on
|
||||
the frame before — and it added six pixels of slack to it. Those six pixels came
|
||||
back next frame and were added again, so the left margin climbed a decade of
|
||||
pixels a second until it hit the 200 px cap and stopped: 200 px of an 870 px
|
||||
viewport, with `axes.w changes: 17` beside it, which reads as a settle and is a
|
||||
runaway that ran out of room. The reservation's own padding is the slack, once;
|
||||
nothing is added to a measurement that is partly our own answer.
|
||||
|
||||
The self-check reports both. `viewport.w` must collapse to a single value, and
|
||||
**The two sides are balanced.** The axes rect used to reach the viewport's right
|
||||
edge, because that is all the library's layout had ever reserved — so the
|
||||
rightmost x tick label, *centred* on its tick, had half of itself outside the plot
|
||||
(a live axis is `[-window, 0]`, so there is always a tick exactly on that edge) and
|
||||
the newest sample's ink landed on the frame. `Plot::set_right_gutter()` and
|
||||
`Figure::set_right_gutter()` were added upstream for this: an empty band, served
|
||||
from the same half-viewport allowance as the label and title strips, set once on
|
||||
every row because the rows' axes have to end on a common column as well as start on
|
||||
one. `RowSet` asks for half the widest time label plus the label padding, which is
|
||||
exactly what the last label needs to be drawn whole. With the two fixes above the
|
||||
figure now sits in a 104 px left gutter holding the y labels and a 59 px empty band
|
||||
on the right, where it was 200 px and nothing.
|
||||
|
||||
The self-check reports all of it. `viewport.w` must collapse to a single value, and
|
||||
`axes.x`, `axes.w` and `y-gutter` must settle: the one-shot label reservation
|
||||
lands a few frames after the first measured frame, and because the figure serves
|
||||
its floor out of the label strip the reservation converges over two layouts, so
|
||||
@@ -1018,9 +1136,9 @@ bug. `x-ticks` is content, and varies by one as a live window slides over a roun
|
||||
number:
|
||||
|
||||
```
|
||||
layout over 952 frames: axes.x 67..80, axes.w 546..559, viewport.w 627..627,
|
||||
y-gutter 44..74, x-ticks 20..21, x-desync 0
|
||||
axes.w changes: 2 (last at frame 35, side panel up=1)
|
||||
layout over 583 frames: axes.x 88..104, axes.w 707..782, viewport.w 870..870,
|
||||
y-gutter 88..104, x-ticks 20..41, x-desync 0
|
||||
axes.w changes: 1 (last at frame 1, side panel up=1)
|
||||
```
|
||||
|
||||
Set `UDPSCOPE_PROBE_PAUSE_MS=<ms>` to pause the acquisition part-way through a
|
||||
@@ -1086,6 +1204,16 @@ A third, found the same way, is in the tick generator rather than the shaders:
|
||||
— where the same residue read `3.19e-13ms`. A range whose ticks are *legitimately*
|
||||
that small keeps them.
|
||||
|
||||
4. *One second was labelled `1e+3ms`.* `format_time()` picked its rung by comparing the raw
|
||||
value against each unit's scale and then wrote the mantissa with `%.3g` — so a tick a hair
|
||||
under a rung's top (a 1 s tick computed as 0.9999999 s, which is what differencing a live
|
||||
axis gives) stayed on the rung below and its mantissa *rounded up to 1000*, which `%.3g`
|
||||
writes in exponential form. The fix is to pick the rung by the **rounded** mantissa
|
||||
(`t < u.scale * 0.9995`), which is exactly the `>= 999.5` spill guard `format_si()` in the
|
||||
same file already had; the whole-second branch takes the same slack. The test is
|
||||
`format_time_never_lets_a_field_reach_its_radix`, extended with the boundary of every rung
|
||||
from seconds down to nanoseconds and its negative.
|
||||
|
||||
**The minor grid is texture, the labelled grid is structure.** The subdivisions used to be
|
||||
drawn exactly like the labelled lines — same width, same opacity — so a plot dense enough to
|
||||
need them read as two competing sets of ticks. They are now three things the style states
|
||||
@@ -1112,6 +1240,7 @@ so it is one path, and it costs what the crosshair's own lookup costs.
|
||||
|
||||
Each carries a test upstream (`the_atlas_is_filtered_not_point_sampled`,
|
||||
`grid_hairlines_land_on_whole_pixels`, `the_tick_at_the_origin_reads_zero`,
|
||||
`format_time_never_lets_a_field_reach_its_radix`,
|
||||
`a_sparse_window_between_samples_still_inks_both_margins`), and each
|
||||
was verified to fail with its own fix reverted.
|
||||
|
||||
@@ -1173,6 +1302,31 @@ those are two places. The library's hover crosshair snaps the same way and draws
|
||||
since a snapped mark lands among those dots and at their size would read as one more sample
|
||||
instead of as the answer to the question the hover asks.
|
||||
|
||||
**A zoomed-in trace is drawn on the x its readout quotes, which needed the gather's origin to
|
||||
become the caller's.** Separately from the half-sample offset above — which is a property of
|
||||
*which datum* is quoted — there was an offset in *where the polyline was drawn*, and it grew
|
||||
with the zoom. `plot::data::Series::gather()` stages x as `f32`, which cannot hold an absolute
|
||||
epoch time, so it emits a value relative to an origin the consumer adds back in `f64`. That
|
||||
origin was fixed at `x0()`, the start of the **retained window**: the f32 then had to resolve
|
||||
2^-24 of the whole ring however little of it was on screen — 1.9 µs over 20 s of retained data
|
||||
— while a readout reads `x_at()`, exact `f64`. At a view 10 µs wide across a 716 px axis
|
||||
(14 ns a pixel) that is over a hundred pixels between the drawn sample and the x reported for
|
||||
it, and it halves with every zoom *out*, which is why it read as "zooming in makes the
|
||||
crosshair pick the wrong point".
|
||||
|
||||
The origin is now an argument, and `Renderer::draw_traces` passes the **visible range's
|
||||
minimum** — so the quantisation is 2^-24 of the span on screen, a fixed 4e-5 of a pixel, at
|
||||
every zoom. The fix is upstream (`deps/plotter`), where the parameter was deliberately placed
|
||||
*before* the output buffer so that every one of the library's and this app's call sites was a
|
||||
compile error rather than a silent change of behaviour. One simplification fell out of it:
|
||||
`stage_xy()` took a gather origin and a draw origin, those are now always the same value, and
|
||||
the linear case hands the gathered buffer straight to the line batch with no per-point copy.
|
||||
Two guards: the library's
|
||||
`a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes`, which measures the
|
||||
error in pixels both ways (under 0.05 px with the view's minimum, over 10 px with `x0()`), and
|
||||
this app's `trace ink:` line, which now reports `worst drawn-vs-quoted P px` over the raw-level
|
||||
paints of a whole run.
|
||||
|
||||
The readouts are a **table**: a header naming the columns, then one row per instrument —
|
||||
`| who | A | B | delta |`. The first row is the two times and their difference, and the
|
||||
rows below it are the rulers (one per row, in `horizontal`/`both`) or the traces (in
|
||||
@@ -1312,7 +1466,7 @@ them all with nothing to propagate by hand. The live follow is the *group's*
|
||||
(`set_x_link_follow`), not each row's — `Figure::link_x()` refuses a member whose
|
||||
own axis still follows, because a following axis would reinterpret the pushed
|
||||
range as its own span and offset behind its own edge, which is per-row drift by
|
||||
construction. The figure also owns the left gutter and the crosshair, so a
|
||||
construction. The figure also owns both gutters and the crosshair, so a
|
||||
vertical feature lands in the same pixel column in every row.
|
||||
|
||||
The self-check's layout line carries `x-desync N`, the number of frames in which
|
||||
@@ -1363,16 +1517,30 @@ division.
|
||||
over the shared x window, at the **stored** sample rate — never the LOD pyramid's
|
||||
buckets, which are the envelope the renderer drew rather than the acquisition. A
|
||||
signal on several rows is one column; the columns come out in channel order, so the
|
||||
file reads in the same order as the signal panel. The table has two time columns:
|
||||
`time` is the axis as displayed (stream seconds live, `[-pre, +post]` while an event
|
||||
is held) and `stream_time` is always the stream's own clock, so two exports taken in
|
||||
different modes line up.
|
||||
file reads in the same order as the signal panel.
|
||||
|
||||
The table has **two time columns**, which is the whole answer to "relative or absolute":
|
||||
|
||||
- **`time`** is the axis exactly as it was displayed — negative ages ending at 0 live, offsets
|
||||
from the edge in trigger mode — so a row's `time` is the x the user read a value at.
|
||||
- **`abs_seconds`** is the stream's own clock for the same sample: `epoch() + stream_at_x0() +
|
||||
time[i]`, one expression correct in all four display modes (see *The x axis is relative to
|
||||
now*). Two exports taken in different modes therefore line up on this column, and
|
||||
`abs - time` is a constant per file naming where the display's origin sat in the stream.
|
||||
|
||||
Both are `f64` seconds and not the wire's integer, because the wire's integer never exists in
|
||||
this process: `udps_frame_t::values` is a `const double*`, so the time signal has already been
|
||||
widened by the client before udpscope sees it. One caveat follows from that and from `time`
|
||||
being a *derived* coordinate: `abs_seconds` is a sum of two numbers of very different
|
||||
magnitudes, so consecutive differences wobble by a few ULP of the stream's uptime
|
||||
(femtoseconds at any plausible uptime — note that the `f32` defect this used to guard against
|
||||
was *microseconds*, four orders of magnitude up).
|
||||
|
||||
Two formats, picked by the extension the save dialog ends up with:
|
||||
|
||||
| file | what it is |
|
||||
|---|---|
|
||||
| `.npy` | one NumPy structured array, `time`/`stream_time` as `<f8` and every signal as `<f4` — the width the ring stores, so the file invents no precision and is about half an all-`f8` one. `pd.DataFrame(np.load(path))` is the whole reader. |
|
||||
| `.npy` | one NumPy structured array, `time`/`abs_seconds` as `<f8` and every signal as `<f4` — the width the ring stores, so the file invents no precision and is about half an all-`f8` one. `pd.DataFrame(np.load(path))` is the whole reader. |
|
||||
| `.csv` | the same table as text, `%.17g` for the times and `%.9g` for the values: the shortest widths that round-trip each type exactly. |
|
||||
|
||||
Parquet (the original ask) would have meant linking Arrow — tens of megabytes of
|
||||
@@ -1441,7 +1609,8 @@ fails validation renders a one-line stand-in so the window always opens rather t
|
||||
no `Gtk-CRITICAL`.
|
||||
|
||||
**The status bar is two tiers.** The bar itself shows one glance line: the activity badge, the
|
||||
display state (one word from a fixed set), the stored rate and the window. Everything else —
|
||||
display state (one word from a fixed set — `connecting`, `live`, `paused`, `armed`, `capturing`,
|
||||
`event held`, `disarmed`, none wider than ten characters), the stored rate and the window. Everything else —
|
||||
packet and frame counts, gaps, buffer size and span, per-plot trace counts — is behind the ⓘ
|
||||
button at the bar's right-hand end, opening a popover. The two moved there because a bar that
|
||||
carried all of them was too wide to read while watching a signal, and because the bar sits
|
||||
@@ -1526,6 +1695,17 @@ status line as `N reconnects` once it is nonzero) it says so in the status line
|
||||
leaving an empty plot to be puzzled over, and prints a `starved:` line to stderr while the
|
||||
state holds.
|
||||
|
||||
That notice is **split on whether a CONFIG ever arrived**, because the two states are
|
||||
different diagnoses and only one of them is about another reader. With a signal list in
|
||||
hand, a stream that stops is the fight above: something took the session away, and the
|
||||
notice says so (`starved:` on stderr). With **no CONFIG at all** nothing was ever taken —
|
||||
there is no signal list, so nothing can be enabled and no DATA frame is owed — and the app
|
||||
says `trying to connect` instead (`connecting:` on stderr), with the glance line reading
|
||||
`connecting` and the badge yellow from the first frame rather than after the three-second
|
||||
stall test. Calling that one "connected, but no data is arriving" blamed a second reader
|
||||
that does not exist; the usual cause is a streamer that is not running, or one serving a
|
||||
different port.
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Multi-element (vector/matrix) signals are acquired with a zero-order hold.**
|
||||
|
||||
Vendored
+1
-1
Submodule deps/plotter updated: 03c41adbec...bee318870f.
+5
-5
@@ -189,7 +189,7 @@ void ChannelBuffer::raw(u64 begin, u64 end, f32* out) const {
|
||||
for (u64 i = count; i < end - begin; ++i) out[i] = 0.0f;
|
||||
}
|
||||
|
||||
void ChannelBuffer::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
void ChannelBuffer::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||
plot::data::GatherMode mode) const {
|
||||
const u64 n = pyramid_.size();
|
||||
const u64 b = std::min(begin, n);
|
||||
@@ -197,7 +197,7 @@ void ChannelBuffer::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_
|
||||
if (b >= e) return;
|
||||
|
||||
const u64 origin = pyramid_.window_base();
|
||||
const f64 base = x0(); // x is emitted relative to this (see the header)
|
||||
const f64 base = x_origin; // x is emitted relative to this (see the header)
|
||||
|
||||
const auto emit_raw = [&](u64 from, u64 to) {
|
||||
for (u64 i = from; i < to; ++i) {
|
||||
@@ -302,13 +302,13 @@ u64 SlotSeries::snap_index(f64 x) const {
|
||||
return buffer_ != nullptr ? buffer_->snap_index(x) : 0;
|
||||
}
|
||||
|
||||
void SlotSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
void SlotSeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||
plot::data::GatherMode mode) const {
|
||||
if (event_ != nullptr) {
|
||||
event_->gather(level, begin, end, out_xy, mode);
|
||||
event_->gather(level, begin, end, x_origin, out_xy, mode);
|
||||
return;
|
||||
}
|
||||
if (buffer_ != nullptr) buffer_->gather(level, begin, end, out_xy, mode);
|
||||
if (buffer_ != nullptr) buffer_->gather(level, begin, end, x_origin, out_xy, mode);
|
||||
}
|
||||
|
||||
void SlotSeries::copy_raw(u64 begin, u64 end, f32* out) const {
|
||||
|
||||
+9
-6
@@ -80,9 +80,11 @@ class ChannelBuffer final : public plot::data::Series {
|
||||
plot::Range x_bounds() const override;
|
||||
plot::Range y_bounds() const override;
|
||||
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
|
||||
/// x is emitted **relative to `x0()`** (the library's contract): staging an
|
||||
/// absolute epoch time as f32 would quantise it far coarser than a pixel.
|
||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
/// x is emitted **relative to `x_origin`** (the library's contract): staging an
|
||||
/// absolute epoch time as f32 would quantise it far coarser than a pixel, and
|
||||
/// staging it relative to the whole *retained window* quantises it coarser than a
|
||||
/// pixel of a zoomed view -- see `plot::data::Series::gather()`.
|
||||
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override;
|
||||
|
||||
/// `copy_raw`, published so the retargetable trace series can forward the
|
||||
@@ -109,8 +111,9 @@ class ChannelBuffer final : public plot::data::Series {
|
||||
/// slot cannot be overwritten under the read. Everything else on this object is read at
|
||||
/// *paint* time with no lock, and this one slot is the ring's oldest: the one the producer is
|
||||
/// about to overwrite. Reading it there returned a stamp a whole ring-span too new whenever
|
||||
/// the producer lapped it between the bounds check and the copy -- and `x0()` is the origin
|
||||
/// the renderer reconstructs every trace point from (`gather[i] + series.x0()`), so one such
|
||||
/// the producer lapped it between the bounds check and the copy -- and `x0()` is what
|
||||
/// `neighbours()` tests the visible range against, as well as the origin the renderer
|
||||
/// reconstructed every trace point from before the caller named one, so one such
|
||||
/// read moved the entire polyline a ring-span off its axis (an invisible trace) or, caught
|
||||
/// inside the renderer's increasing-suffix filter, made it drop most of the points. That is
|
||||
/// the "live plot flickers as if there were no data" defect. The cached value is also the
|
||||
@@ -163,7 +166,7 @@ class SlotSeries final : public plot::data::Series {
|
||||
plot::Range x_bounds() const override;
|
||||
plot::Range y_bounds() const override;
|
||||
const plot::data::MinMaxPyramid& pyramid() const override;
|
||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override;
|
||||
bool streaming() const noexcept override { return event_ == nullptr && buffer_ != nullptr; }
|
||||
|
||||
|
||||
+28
-18
@@ -26,14 +26,24 @@ using plot::u64;
|
||||
/// live ring buffers switched by a pending flag, plus a display-epoch shift)
|
||||
/// could not stop.
|
||||
struct EventSnapshot {
|
||||
/// Seconds, monotonic -- edge-relative for a trigger event, the stream's own for a
|
||||
/// pause view. **f64, like the live ring it is cut from** (see `BufferStore::time()`):
|
||||
/// an event's stamps are small enough that f32 would nearly do, but a *pause* is
|
||||
/// deliberately not rebased, so its stamps carry the full stream time and quantise at
|
||||
/// the same 3.8 us per 32 s of uptime the live ring did. Keeping the two the same type
|
||||
/// also makes the cut in `publish_event()`/`publish_pause_view()` a straight copy.
|
||||
/// Seconds, monotonic, and **in the coordinates the axis reads**: a trigger event's
|
||||
/// stamps are edge-relative and a pause view's are relative to the instant it froze,
|
||||
/// which is what `x_offset` below records. **f64, like the live ring it is cut from**
|
||||
/// (see `BufferStore::time()`), which also makes the cut in `publish_event()`/
|
||||
/// `publish_pause_view()` a straight copy.
|
||||
std::vector<f64> stamps;
|
||||
std::vector<std::vector<f32>> values; ///< one array per channel, same length
|
||||
/// The stream time of this snapshot's x = 0, so `x_offset + stamps[i]` is the clock the
|
||||
/// data arrived on: the edge for a trigger event, the newest sample at the moment of the
|
||||
/// freeze for a pause view.
|
||||
///
|
||||
/// It exists because a snapshot is the one thing on screen whose coordinates outlive the
|
||||
/// `display_epoch()` that produced them, and `ScopeModel::stream_at_x0()` is what reads
|
||||
/// it: an export has to name the stream's own clock whatever is on screen, and the pause
|
||||
/// handler has to put a *display* window back on the ring to cut it. Before it, the
|
||||
/// export added `trigger_time()` to a pause view's stamps -- an unrelated edge, or none
|
||||
/// at all -- for want of anywhere to record which clock a snapshot was on.
|
||||
double x_offset = 0.0;
|
||||
double pre_s = 0.0; ///< seconds of pre-trigger data kept
|
||||
double post_s = 0.0; ///< seconds of post-trigger data kept
|
||||
std::vector<plot::Range> y; ///< per-channel value bounds, precomputed
|
||||
@@ -108,7 +118,7 @@ class EventSeries final : public plot::data::Series {
|
||||
/// Indices need no rebasing: the pyramid is built from sample 0 and nothing is ever
|
||||
/// dropped from it, so `window_base()` is 0 and a window index *is* the absolute one
|
||||
/// (unlike the ring, whose window slides).
|
||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||
plot::data::GatherMode mode =
|
||||
plot::data::GatherMode::Envelope) const override {
|
||||
if (channel_ >= snap_->values.size()) return;
|
||||
@@ -118,17 +128,17 @@ class EventSeries final : public plot::data::Series {
|
||||
const u64 b = std::min(begin, n);
|
||||
const u64 e = std::min(std::max(end, b), n);
|
||||
if (b >= e) return;
|
||||
// x **relative to x0()**, which is the library's gather contract: the renderer
|
||||
// adds `x0()` back before mapping to the screen. Emitting the absolute stamp made
|
||||
// it add x0 twice, so the whole capture was drawn one `x0()` -- a full
|
||||
// pre-trigger window -- to the left of the axis it had just been measured
|
||||
// against. The event's own stamps are small (edge-relative, so [-pre, +post]),
|
||||
// which is why that shift looked like a plausible picture: with a 50/50 split the
|
||||
// right half of the plot was simply empty, zooming out showed the trace ending at
|
||||
// the x = 0 marker, and zooming in past the shift showed nothing at all while the
|
||||
// crosshair -- which reads `x_at()`, never `gather()` -- went on reporting the
|
||||
// right values.
|
||||
const f64 base = x0();
|
||||
// x **relative to `x_origin`**, which is the library's gather contract: the
|
||||
// renderer adds that same origin back before mapping to the screen. Emitting the
|
||||
// absolute stamp made it add an origin twice, so the whole capture was drawn one
|
||||
// `x0()` -- a full pre-trigger window -- to the left of the axis it had just been
|
||||
// measured against. The event's own stamps are small (edge-relative, so
|
||||
// [-pre, +post]), which is why that shift looked like a plausible picture: with a
|
||||
// 50/50 split the right half of the plot was simply empty, zooming out showed the
|
||||
// trace ending at the x = 0 marker, and zooming in past the shift showed nothing
|
||||
// at all while the crosshair -- which reads `x_at()`, never `gather()` -- went on
|
||||
// reporting the right values.
|
||||
const f64 base = x_origin;
|
||||
const auto rel = [&](u64 i) {
|
||||
return static_cast<f32>(stamps[static_cast<std::size_t>(i)] - base);
|
||||
};
|
||||
|
||||
+23
-19
@@ -38,7 +38,7 @@ std::string sanitise(const std::string& name) {
|
||||
/// two time columns, with a `_2`, `_3` suffix. Two elements of one vector signal can
|
||||
/// easily sanitise to the same string.
|
||||
std::vector<std::string> column_labels(const ExportTable& table) {
|
||||
std::vector<std::string> taken{"time", "stream_time"};
|
||||
std::vector<std::string> taken{"time", "abs_seconds"};
|
||||
std::vector<std::string> out;
|
||||
out.reserve(table.columns.size());
|
||||
for (const ExportTable::Column& col : table.columns) {
|
||||
@@ -106,6 +106,14 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
||||
// What is on screen: a pause's frozen view, or the trigger's held event. Both are immutable
|
||||
// snapshots, so the export is the picture the user is looking at either way.
|
||||
std::shared_ptr<const EventSnapshot> snap = model.display_snapshot();
|
||||
// The absolute clock of display x = 0, so `abs_base + time[i]` is the number the stream
|
||||
// stamped sample i with -- **one expression, correct in all four display modes**, which is the
|
||||
// whole reason `stream_at_x0()` exists. `epoch()` is the stream time of the first sample ever
|
||||
// seen (the rings store stamps relative to it) and `stream_at_x0()` is the offset of the
|
||||
// display's origin within that, whether it comes from a snapshot or from the live epoch. The
|
||||
// snapshot branch used to add `trigger_time()` here, which named an unrelated edge on a
|
||||
// *paused* export, or none at all before the first latch.
|
||||
const f64 abs_base = model.epoch() + model.stream_at_x0();
|
||||
if (snap != nullptr) {
|
||||
// The snapshot is immutable and already index-aligned, so the visible window is two
|
||||
// binary searches over its stamps and the same index range for every channel.
|
||||
@@ -115,15 +123,11 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
||||
if (hi <= lo) return table;
|
||||
const std::size_t first = static_cast<std::size_t>(lo - st.begin());
|
||||
const std::size_t n = static_cast<std::size_t>(hi - lo);
|
||||
// The event's x = 0 is the edge, so the stream clock has to be added back for the
|
||||
// second column -- the one case where the visible x is not the stream's own.
|
||||
const f64 edge = model.trigger_time().load(std::memory_order_relaxed);
|
||||
|
||||
table.time.resize(n);
|
||||
table.stream_time.resize(n);
|
||||
table.abs_seconds.resize(n);
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
table.time[i] = st[first + i];
|
||||
table.stream_time[i] = edge + table.time[i];
|
||||
table.abs_seconds[i] = abs_base + table.time[i];
|
||||
}
|
||||
for (const int c : chans) {
|
||||
const std::size_t ch = static_cast<std::size_t>(c);
|
||||
@@ -141,12 +145,12 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
||||
// the same window hold the same samples. The lock is against a structural change
|
||||
// dropping the rings under the copy, which is what the UI thread has to fear here.
|
||||
std::lock_guard<std::mutex> lock(model.mutex());
|
||||
// The axis reads `display_epoch + stamp`, so the window has to be put back on the
|
||||
// stream's clock before the ring is searched. It is 0 in live mode, but reading it is
|
||||
// what keeps this correct rather than merely true today.
|
||||
const f64 epoch = model.display_epoch();
|
||||
// The axis reads `stamp - stream_at_x0()`, so the window has to be put back on the stream's
|
||||
// clock before the ring is searched -- and that offset is a whole window while streaming now,
|
||||
// not the 0 it once was: the live axis is `[-window, 0]` about the newest sample.
|
||||
const f64 s0 = model.stream_at_x0();
|
||||
u64 first = 0, last = 0;
|
||||
model.live_window_locked(view.min - epoch, view.max - epoch, first, last);
|
||||
model.live_window_locked(view.min + s0, view.max + s0, first, last);
|
||||
const u64 n = last - first;
|
||||
if (n == 0) return table;
|
||||
|
||||
@@ -154,10 +158,10 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
||||
std::vector<f64> stamps(static_cast<std::size_t>(n));
|
||||
store.time().copy_from(first, n, stamps.data());
|
||||
table.time.resize(static_cast<std::size_t>(n));
|
||||
table.stream_time.resize(static_cast<std::size_t>(n));
|
||||
table.abs_seconds.resize(static_cast<std::size_t>(n));
|
||||
for (std::size_t i = 0; i < stamps.size(); ++i) {
|
||||
table.stream_time[i] = stamps[i];
|
||||
table.time[i] = epoch + table.stream_time[i];
|
||||
table.time[i] = stamps[i] - s0;
|
||||
table.abs_seconds[i] = abs_base + table.time[i];
|
||||
}
|
||||
for (const int c : chans) {
|
||||
const std::size_t ch = static_cast<std::size_t>(c);
|
||||
@@ -179,7 +183,7 @@ bool write_npy(const std::string& path, const ExportTable& table, std::string& e
|
||||
// `descr` as a list of (name, format) pairs is a *packed* structured dtype -- NumPy
|
||||
// only inserts alignment padding when asked to (`align=True`) -- so the records below
|
||||
// are field widths back to back with no gaps.
|
||||
std::string descr = "[('time', '<f8'), ('stream_time', '<f8')";
|
||||
std::string descr = "[('time', '<f8'), ('abs_seconds', '<f8')";
|
||||
for (const std::string& label : labels) descr += ", ('" + label + "', '<f4')";
|
||||
descr += "]";
|
||||
std::string header = "{'descr': " + descr + ", 'fortran_order': False, 'shape': (" +
|
||||
@@ -211,7 +215,7 @@ bool write_npy(const std::string& path, const ExportTable& table, std::string& e
|
||||
std::size_t at = 0;
|
||||
std::memcpy(record.data() + at, &table.time[i], sizeof(f64));
|
||||
at += sizeof(f64);
|
||||
std::memcpy(record.data() + at, &table.stream_time[i], sizeof(f64));
|
||||
std::memcpy(record.data() + at, &table.abs_seconds[i], sizeof(f64));
|
||||
at += sizeof(f64);
|
||||
for (const ExportTable::Column& col : table.columns) {
|
||||
const f32 v = i < col.values.size() ? col.values[i] : 0.0f;
|
||||
@@ -239,7 +243,7 @@ bool write_csv(const std::string& path, const ExportTable& table, std::string& e
|
||||
if (!opened(out, path, error)) return false;
|
||||
|
||||
const std::vector<std::string> labels = column_labels(table);
|
||||
std::string line = "time,stream_time";
|
||||
std::string line = "time,abs_seconds";
|
||||
for (const std::string& label : labels) line += "," + label;
|
||||
line += "\n";
|
||||
std::fwrite(line.data(), 1, line.size(), out.f);
|
||||
@@ -252,7 +256,7 @@ bool write_csv(const std::string& path, const ExportTable& table, std::string& e
|
||||
line.clear();
|
||||
std::snprintf(buf, sizeof(buf), "%.17g", table.time[i]);
|
||||
line += buf;
|
||||
std::snprintf(buf, sizeof(buf), "%.17g", table.stream_time[i]);
|
||||
std::snprintf(buf, sizeof(buf), "%.17g", table.abs_seconds[i]);
|
||||
line += ",";
|
||||
line += buf;
|
||||
for (const ExportTable::Column& col : table.columns) {
|
||||
|
||||
+16
-7
@@ -24,13 +24,22 @@ struct ExportTable {
|
||||
std::vector<f32> values; ///< `rows()` long, the ring's own `f32`
|
||||
};
|
||||
|
||||
/// The x the plot shows and the crosshair reads: stream seconds in live mode,
|
||||
/// edge-relative seconds in `[-pre, +post]` while an event is held.
|
||||
/// The x the plot shows and the crosshair reads, which is **relative** in every mode: seconds
|
||||
/// before now while streaming (so the newest sample is 0 and the window is `[-window, 0]`),
|
||||
/// seconds from the edge in `[-pre, +post]` while an event is held, and seconds from the
|
||||
/// instant of the freeze on a paused view.
|
||||
std::vector<f64> time;
|
||||
/// The stream's own clock, so two exports taken in different modes line up. Equal to
|
||||
/// `time` in live mode; `time + trigger_time` while an event is held, which is the one
|
||||
/// case where the visible x is not the stream's.
|
||||
std::vector<f64> stream_time;
|
||||
/// The stream's own absolute clock -- the number the time signal carried, which is what the
|
||||
/// source stamped the samples with -- so two exports taken in different modes, or from
|
||||
/// different runs, line up on one timeline.
|
||||
///
|
||||
/// `f64 seconds` rather than the wire's integer, because the integer does not exist in this
|
||||
/// process: `udps_frame_t::values` is a `const double*`, so the client has already widened
|
||||
/// every value by the time acquisition sees it. At Unix-epoch magnitudes an `f64` resolves
|
||||
/// about 240 ns, which is coarser than a 1 MS/s sample period -- so this column is the
|
||||
/// *provenance* of a row and `time` is the column to difference. Its own `np.diff` is
|
||||
/// legitimately not single-valued for that reason.
|
||||
std::vector<f64> abs_seconds;
|
||||
std::vector<Column> columns;
|
||||
|
||||
std::size_t rows() const noexcept { return time.size(); }
|
||||
@@ -45,7 +54,7 @@ struct ExportTable {
|
||||
/// model access. In trigger mode nothing is locked: the `EventSnapshot` is immutable.
|
||||
ExportTable collect_export(ScopeModel& model, RowSet& rows);
|
||||
|
||||
/// Writes a NumPy `.npy` file holding one structured array: `time` and `stream_time` as
|
||||
/// Writes a NumPy `.npy` file holding one structured array: `time` and `abs_seconds` as
|
||||
/// `<f8`, every signal as `<f4` -- the width the ring actually stores, so the file is
|
||||
/// about half the size of an all-`f8` one and invents no precision.
|
||||
///
|
||||
|
||||
@@ -6,9 +6,15 @@ Acquisition runs on its own thread and writes a ring buffer sized from <tt>--buf
|
||||
the drawing thread reads whatever that ring holds when a frame starts, which is why a trace
|
||||
never makes the window stutter.
|
||||
|
||||
The stream is the clock. Every time you read on screen is a timestamp the source sent, never
|
||||
your machine's, so a stalled source freezes the view instead of scrolling it -- and a source
|
||||
whose clock steps still gets the window you asked for.
|
||||
The stream is the clock. Every interval you read on screen is measured between timestamps the
|
||||
source sent, never your machine's, so a stalled source freezes the view instead of scrolling it
|
||||
-- and a source whose clock steps still gets the window you asked for.
|
||||
|
||||
The time axis is <i>relative to now</i>. While streaming it is a fixed <tt>[-window .. 0]</tt>
|
||||
with the newest sample at 0, so x reads as an age: <tt>-1.5s</tt> is a sample a second and a half
|
||||
old. In trigger mode 0 is the event's edge instead, and a paused view keeps the ages it was
|
||||
frozen with. The source's own clock is still there when you need it -- an export writes it as a
|
||||
second column.
|
||||
|
||||
<b>How to reach it</b>
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<b>Rows, layout and drag & drop</b>
|
||||
|
||||
The figure is a stack of rows, 1 to 8 of them, each holding up to 4 traces. Rows share one
|
||||
time axis and one left gutter, so their y labels line up and their windows cannot drift apart.
|
||||
time axis and one pair of side margins, so their y labels line up, their data areas start and
|
||||
end on the same two columns, and their windows cannot drift apart. The left margin holds the y
|
||||
labels; the right one is empty, and is there so the newest tick's label and the newest sample's
|
||||
ink have somewhere to land instead of sitting on the frame.
|
||||
|
||||
<b>How to reach it</b>
|
||||
|
||||
|
||||
@@ -24,6 +24,11 @@ The full table is in the shortcut overview (<tt>Ctrl+/</tt>).
|
||||
zoom, so a 2 s window zoomed into reads 2 s again. The window is more than the axis: it is also
|
||||
the trigger's event span and how the samples are stored, so a wheel notch that redefined it
|
||||
would re-plan the buffer and drop your history.
|
||||
• The axis does not scroll — the <i>data</i> does. A live axis is a fixed <tt>[-window .. 0]</tt>
|
||||
with the newest sample at 0, so the labels stand still and the trace rolls under them. The
|
||||
cursors stand still with the labels (they are positions on screen, so a cursor you placed stays
|
||||
where you put it, over newer data each frame), while a measurement's marks travel with the
|
||||
sample they measured and leave the view when it does.
|
||||
• A live view is clamped to the window you asked for, not to what the ring happens to hold.
|
||||
That is why a buffer that has only just started filling leaves its left-hand side empty
|
||||
instead of squeezing the window down to the first few samples.
|
||||
|
||||
@@ -20,6 +20,8 @@ live ring and puts the window back on the newest data.
|
||||
zoom out is clamped to the snapshot's data.
|
||||
• Pausing is the easiest way to measure a layout or read a cursor precisely, because nothing
|
||||
under the pointer moves between frames.
|
||||
• The status bar says how long you have been paused — wall time, since the stream itself never
|
||||
stopped. It is there so a frozen picture cannot be mistaken for a live one that went quiet.
|
||||
|
||||
<b>Example</b>
|
||||
|
||||
|
||||
@@ -37,6 +37,11 @@ owns a <b>Reset</b> button. Drag a marker to move it. The mode dropdown offers f
|
||||
is drawn larger than a trace's sample dots so it does not read as one more sample.
|
||||
• The time row exists only in the modes that show the vertical pair. <tt>horizontal</tt> has no
|
||||
time row, by design.
|
||||
• A cursor <i>stays where you put it</i> while a live view rolls under it, which is what a bench
|
||||
scope does: its <tt>T0</tt> is an age, so a marker left at <tt>-1.5s</tt> keeps reading the
|
||||
sample a second and a half old as the stream advances. A measurement's marks do the opposite —
|
||||
they travel with the sample they measured — because a cursor is a question you asked and a mark
|
||||
is an answer about one particular sample.
|
||||
|
||||
<b>Example</b>
|
||||
|
||||
|
||||
@@ -17,15 +17,17 @@ the format follows the path's extension.
|
||||
• The stored rate is not always the wire rate. A window long enough to need decimation is stored
|
||||
averaged at a lower rate, and the export carries <i>that</i> — read the status bar's ⓘ popover
|
||||
for the figure before trusting a sample count.
|
||||
• Two time columns. <tt>time</tt> is the display timeline (relative to the trigger edge while an
|
||||
event is held) and <tt>stream_time</tt> is the source's own clock. Both are strictly increasing
|
||||
and evenly spaced; duplicate or backwards stamps would mean the file held two clocks.
|
||||
• Two time columns. <tt>time</tt> is the display timeline — negative ages ending at 0 while
|
||||
streaming, offsets from the trigger edge while an event is held — so it is the x you read a
|
||||
value at on screen. <tt>abs_seconds</tt> is the source's own clock for the same sample, which
|
||||
is what lets two exports taken in different modes line up. Both are strictly increasing and
|
||||
evenly spaced; duplicate or backwards stamps would mean the file held two clocks.
|
||||
• An export taken <i>mid-capture</i> writes the partly filled window that is on screen, which is
|
||||
shorter than the event span. Export outside a capture if you want the whole event.
|
||||
|
||||
<b>Example</b>
|
||||
|
||||
A 2 s window at 10 kS/s over 3 traces writes 20001 rows and 5 columns
|
||||
(<tt>time</tt>, <tt>stream_time</tt>, three signals). The <tt>.npy</tt> is
|
||||
(<tt>time</tt>, <tt>abs_seconds</tt>, three signals). The <tt>.npy</tt> is
|
||||
<tt>64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)</tt> bytes. Read it back with
|
||||
<tt>pd.DataFrame(np.load(path))</tt>.
|
||||
+18
-8
@@ -9,11 +9,19 @@ The bar is always visible under the plot. Click <b>ⓘ</b> for the detail popove
|
||||
|
||||
<b>Worth knowing</b>
|
||||
|
||||
• The badge is one glyph in three colours: lit and dim alternating means data is arriving, and a
|
||||
steady idle colour means it is not. A badge that never blinks is not a working badge.
|
||||
• The state field is one word from a fixed set: <tt>live</tt>, <tt>paused</tt>, <tt>armed</tt>,
|
||||
<tt>capturing</tt>, <tt>event held</tt>, <tt>disarmed</tt>. In trigger mode the bar adds
|
||||
exactly one more field, how long ago the last event was.
|
||||
• The badge is one glyph in four colours: green <i>alternating</i> lit and dim means data is
|
||||
arriving, and a steady colour means it is not. A badge that never blinks is not a working badge.
|
||||
Of the two steady colours, <i>grey</i> is no stream at all or a pipeline you stopped (the
|
||||
tooltip says which), and <i>yellow</i> is a source that answered but has sent no configuration.
|
||||
• The state field is one word from a fixed set: <tt>connecting</tt>, <tt>live</tt>,
|
||||
<tt>paused</tt>, <tt>armed</tt>, <tt>capturing</tt>, <tt>event held</tt>, <tt>disarmed</tt>. Two
|
||||
states add one field each: in trigger mode, how long ago the last event was; while paused, how
|
||||
long you have been paused.
|
||||
• <tt>connecting</tt> means there is no <i>signal list</i> yet — either no session or a session
|
||||
with no configuration behind it. Every other word claims a signal list, so until one arrives
|
||||
there is nothing for them to describe: nothing can be enabled and no data is owed.
|
||||
• The paused figure is <i>wall</i> time, not stream time — the producer never stops while paused,
|
||||
so it answers "how long have I been looking at this picture".
|
||||
• Nothing on the bar may change width with the stream, because the bar sits directly above the
|
||||
plot and a wider label would relayout it. That is why the rate collapses to one figure or a
|
||||
range rather than listing per channel, and why an elapsed time steps to a coarser unit instead
|
||||
@@ -22,9 +30,11 @@ The bar is always visible under the plot. Click <b>ⓘ</b> for the detail popove
|
||||
non-zero count means samples are absent from the buffer, not merely late.
|
||||
• <tt>reconnects</tt> counts sessions re-established with the source. Over unicast a climbing
|
||||
count usually means a second client is taking the source's single session.
|
||||
• The starvation notice — <i>connected, but no new frames in the last second</i> — is that same
|
||||
situation seen from the other side. Pointing the app at a port where nothing answers produces
|
||||
it too.
|
||||
• The starvation notice — <i>connected, but no data is arriving</i> — is that same situation seen
|
||||
from the other side: the signal list is here, so something took the stream away. A source that
|
||||
has sent <i>no</i> configuration gets a different notice, <i>trying to connect</i>, because
|
||||
nothing was taken away — pointing the app at a port where nothing answers is that case, and the
|
||||
usual cause is a streamer that is not running or is serving another port.
|
||||
• <tt>stored</tt> versus <tt>wire</tt> in the popover is the decimation. They differ exactly
|
||||
when the window is too long to store at full rate.
|
||||
|
||||
|
||||
+10
-4
@@ -515,8 +515,9 @@ void ScopeModel::reset_live_history_locked() {
|
||||
}
|
||||
}
|
||||
|
||||
void ScopeModel::publish_pause_view(double lo_s, double hi_s) {
|
||||
void ScopeModel::publish_pause_view(double lo_s, double hi_s, double x_offset) {
|
||||
auto snap = std::make_shared<EventSnapshot>();
|
||||
snap->x_offset = x_offset;
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
BufferStore& src = live();
|
||||
u64 first = 0;
|
||||
@@ -525,10 +526,14 @@ void ScopeModel::publish_pause_view(double lo_s, double hi_s) {
|
||||
const u64 n = last - first;
|
||||
snap->stamps.resize(static_cast<std::size_t>(n));
|
||||
if (n > 0) {
|
||||
// The stamps go in *as the ring holds them*: the stream's own seconds, which is the
|
||||
// coordinate the axis was already reading. Nothing is rebased, so a pause does not move the
|
||||
// dates under the reader the way a trigger event's edge-relative axis deliberately does.
|
||||
// The stamps come out of the ring on the stream's own clock and are rebased onto
|
||||
// `x_offset`, which is the coordinate the axis was *already* reading: a live view is
|
||||
// newest-relative, so leaving them absolute would throw the frozen picture a whole uptime
|
||||
// to the right of the axis at the instant the user pressed pause. Nothing is lost by the
|
||||
// shift -- `x_offset` is exactly what makes the stream's time recoverable, which is what
|
||||
// `stream_at_x0()` reads it for.
|
||||
src.time().copy_from(first, n, snap->stamps.data());
|
||||
for (f64& t : snap->stamps) t -= x_offset;
|
||||
}
|
||||
snap->values.resize(channels_.size());
|
||||
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0});
|
||||
@@ -551,6 +556,7 @@ void ScopeModel::publish_pause_view(double lo_s, double hi_s) {
|
||||
|
||||
void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
|
||||
auto snap = std::make_shared<EventSnapshot>();
|
||||
snap->x_offset = edge; // the stream time x = 0 means, for everything that has to cross back
|
||||
BufferStore& src = live();
|
||||
plot::data::RingBuffer<f64>& tsrc = src.time();
|
||||
|
||||
|
||||
+29
-10
@@ -290,16 +290,17 @@ class ScopeModel {
|
||||
void live_window_locked(double lo_s, double hi_s, u64& first, u64& last);
|
||||
/// The frozen view taken when the user paused, or null while running. A pause is a
|
||||
/// snapshot of the *visible window* in exactly the sense a trigger event is -- an immutable
|
||||
/// series the plot binds to -- and the difference is the anchoring: an event's stamps are
|
||||
/// edge-relative, a pause's are the stream's own, so the axis does not move when the pause
|
||||
/// begins and the reader keeps the times they were already reading.
|
||||
/// series the plot binds to -- and both are anchored the same way: the stamps are the
|
||||
/// coordinates the axis was already reading, with `x_offset` recording which instant that
|
||||
/// x = 0 was. So the axis does not move when the pause begins.
|
||||
std::shared_ptr<const EventSnapshot> pause_view() const noexcept {
|
||||
return pause_view_.load(std::memory_order_acquire);
|
||||
}
|
||||
/// Snapshots the live ring over `[lo_s, hi_s]` (stream seconds, the display epoch already
|
||||
/// removed) as the pause view. The acquisition thread keeps writing, so this is a copy: what
|
||||
/// is frozen is the picture, not the data.
|
||||
void publish_pause_view(double lo_s, double hi_s);
|
||||
/// Snapshots the live ring over `[lo_s, hi_s]` (stream seconds, i.e. the display window put
|
||||
/// back on the stream's clock) as the pause view, rebased onto `x_offset` so its stamps are
|
||||
/// the x the axis is already showing. The acquisition thread keeps writing, so this is a
|
||||
/// copy: what is frozen is the picture, not the data.
|
||||
void publish_pause_view(double lo_s, double hi_s, double x_offset);
|
||||
void clear_pause_view() noexcept { pause_view_.store(nullptr, std::memory_order_release); }
|
||||
|
||||
/// Whether the user paused acquisition. Lives here rather than on `Acquisition` because the
|
||||
@@ -378,12 +379,30 @@ class ScopeModel {
|
||||
return event() != nullptr ? DisplayMode::Held : DisplayMode::Waiting;
|
||||
}
|
||||
|
||||
/// The x origin the plot uses: 0 for "seconds since the stream started", or minus
|
||||
/// the trigger instant while in trigger mode, so the axis reads seconds from the
|
||||
/// event. Applied to the live store's channels.
|
||||
/// The offset the **live** channels report their x with: `display_x = stream_t + epoch`.
|
||||
/// Minus the newest stored sample's stamp while streaming, so the axis reads
|
||||
/// `[-window, 0]` and the newest data is at 0; minus the trigger instant while a capture
|
||||
/// is filling, so the axis reads seconds from the event. It is 0 while a *snapshot* is on
|
||||
/// screen (a held event, a paused view), because then the live channels are bound to
|
||||
/// nothing and the snapshot's own `x_offset` is what says which clock x = 0 is --
|
||||
/// `stream_at_x0()` is the accessor that does not have to know which of the two it is.
|
||||
f64 display_epoch() const noexcept { return display_epoch_.load(std::memory_order_relaxed); }
|
||||
void set_display_epoch(f64 e) noexcept;
|
||||
|
||||
/// The stream time of the display's x = 0, so `stream_t = display_x + stream_at_x0()` and
|
||||
/// `display_x = stream_t - stream_at_x0()` in **every** display mode.
|
||||
///
|
||||
/// One accessor for the two places the offset can live: a snapshot carries its own
|
||||
/// (`EventSnapshot::x_offset`, frozen with the picture), and a live view carries it as
|
||||
/// minus the display epoch. Everything that has to cross between the two clocks goes
|
||||
/// through here -- the export's absolute column, and the pause handler putting a display
|
||||
/// window back on the ring to cut it -- rather than picking the branch itself and getting
|
||||
/// one of the four modes wrong.
|
||||
f64 stream_at_x0() const noexcept {
|
||||
if (std::shared_ptr<const EventSnapshot> snap = display_snapshot()) return snap->x_offset;
|
||||
return -display_epoch();
|
||||
}
|
||||
|
||||
// --- stream state ---------------------------------------------------------
|
||||
StreamStats& stats() noexcept { return stats_; }
|
||||
const StreamStats& stats() const noexcept { return stats_; }
|
||||
|
||||
+102
-44
@@ -30,10 +30,23 @@ constexpr plot::u32 kRulerBColor = 0x8FA8C8u;
|
||||
/// The measurements' own colour, distinct from both cursor rulers so a readout mark cannot be
|
||||
/// mistaken for something the user placed.
|
||||
constexpr plot::u32 kMeasureColor = 0x9BE8A0u;
|
||||
/// The window-start caption's colour: quiet chrome, brighter than a grid line.
|
||||
constexpr plot::u32 kTimeNoteColor = 0xC8C8C8u;
|
||||
constexpr plot::u32 kTriggerColor = 0xFF5B5Bu;
|
||||
|
||||
/// The smallest difference a `TickFormat::Time` label can *write* at the magnitude `t` -- what
|
||||
/// the last digit of a label at that magnitude is worth. `format_time` writes three significant
|
||||
/// figures below a minute (of the SI-prefixed mantissa under a second, of the seconds above it),
|
||||
/// whole seconds through `m:ss`/`h:mm:ss` up to a day, and three figures of days above. So the
|
||||
/// resolution is a step function of the *magnitude* and not of the span -- which is the whole
|
||||
/// reason a relative axis is ever needed, and the reason a span threshold alone gets it wrong in
|
||||
/// both directions.
|
||||
double time_label_resolution(double t) {
|
||||
t = std::fabs(t);
|
||||
if (!std::isfinite(t) || !(t > 0.0)) return 0.0;
|
||||
if (t >= 86400.0) return 86400.0 * std::pow(10.0, std::floor(std::log10(t / 86400.0)) - 2.0);
|
||||
if (t >= 60.0) return 1.0;
|
||||
return std::pow(10.0, std::floor(std::log10(t)) - 2.0);
|
||||
}
|
||||
|
||||
constexpr double kParked = -1.0e12; ///< hides an instrument without removing it
|
||||
/// The measurement marks' stroke: thinner than the chrome that frames the plot (a trace is 1.5,
|
||||
/// the axis lines and the trigger marker `Style::line_width_px`), and broken, so a readout over
|
||||
@@ -112,9 +125,9 @@ bool RowSet::remove_row(int at) {
|
||||
void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
|
||||
r.plot = &p;
|
||||
r.axis_x = r.plot->add_x_axis(
|
||||
{"time", {-1.0, 1.0}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
|
||||
{"", {-1.0, 1.0}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
|
||||
r.axis_y = r.plot->add_y_axis(
|
||||
{"value", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
|
||||
{"", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
|
||||
for (int s = 0; s < kSlotsPerRow; ++s) {
|
||||
const std::size_t i = static_cast<std::size_t>(s);
|
||||
r.plot->add_trace(
|
||||
@@ -155,11 +168,6 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
|
||||
r.plot->set_cursor_stroke(r.measure_v[mi], kMeasureStrokeWidth,
|
||||
plot::Vec2{kMeasureDashOn, kMeasureDashOff});
|
||||
}
|
||||
// The window-start caption for the relative x labels: an annotation, hidden until the
|
||||
// labels are relative. Inside the axes on purpose -- its text changes with the window, and
|
||||
// an axis title of a changing width would relayout the plot every frame.
|
||||
r.time_origin_note = r.plot->add_annotation(
|
||||
plot::Annotation{0.0, 0.0, "", plot::Color::hex(kTimeNoteColor), r.axis_x, r.axis_y, false});
|
||||
r.trigger_marker = r.plot->add_cursor(kParked, plot::Color::hex(kTriggerColor));
|
||||
// A marker, not an instrument: it must sit exactly on the edge's time, so its
|
||||
// snap is Free and the controller never rounds it onto a sample.
|
||||
@@ -191,7 +199,7 @@ void RowSet::rebuild_all() {
|
||||
}
|
||||
figure_->link_x(static_cast<plot::u32>(i), x_group_);
|
||||
}
|
||||
if (y_gutter_px_ > 0.0) apply_y_gutter();
|
||||
apply_gutters();
|
||||
// A new figure starts with no window at all, so the one the rows were showing has to
|
||||
// be restated -- as the pin it was, if an event is on screen: assigning a trace or
|
||||
// changing the row count while a capture is displayed must not throw the view back
|
||||
@@ -509,12 +517,48 @@ bool RowSet::update_time_labels() {
|
||||
const double span = view.span();
|
||||
if (!(span > 0.0) || !std::isfinite(span)) return false;
|
||||
|
||||
// Sub-second windows read relative: `TickFormat::Time` writes three significant digits, so
|
||||
// `12.345671s` and `12.345672s` are the same string and a 1 ms step around a multi-second
|
||||
// time cannot be labelled at all. The band is the switch's hysteresis -- entering below a
|
||||
// second, leaving only above 1.05 -- so a span sitting on the boundary cannot flicker
|
||||
// between two forms while a zoom is nudged.
|
||||
const bool want_relative = relative_x_ ? span < 1.05 : span < 1.0;
|
||||
// A relative axis is the answer to one problem only: a tick step the label form cannot write.
|
||||
// `TickFormat::Time` carries three significant digits, so a 1 ms step around a 12 s stamp
|
||||
// writes `12.3s` on every tick -- there the ticks have to be re-anchored and the anchor named.
|
||||
// The test is therefore the *step against the resolution at the axis' own magnitude*, never the
|
||||
// span: the two part company in both directions, and both cases are real here. A 2 s window at
|
||||
// twenty minutes of uptime is labelled `20:00` throughout and needs the anchor although its
|
||||
// span is wide; a 50 ms trigger window spanning [-25ms, +25ms] is labelled `-20ms .. +20ms`
|
||||
// perfectly well and needs nothing, although its span is narrow. That second case is what made
|
||||
// a triggered sub-second view carry a `t0` caption *and* re-anchor its ticks away from the
|
||||
// edge, which is the one position a triggered axis exists to show.
|
||||
// The 1.05 band is the switch's hysteresis, so a step sitting on the boundary cannot flicker
|
||||
// between two forms while a zoom is nudged, and the `1 - 1e-9` is a slack the *entry* needs for
|
||||
// its own reason: a step read back from two tick values and a resolution from `pow(10, n)`
|
||||
// differ in their last bits even when they are the same number, and `step == res` is a readable
|
||||
// axis (`1.73s, 1.74s, 1.75s` -- one in the last digit is exactly enough), so a decision taken
|
||||
// on those bits picked a different form from one frame to the next.
|
||||
const double mag = std::max(std::fabs(view.min), std::fabs(view.max));
|
||||
const double res = time_label_resolution(mag);
|
||||
// The step the labels are actually written at: the majors the *last* layout produced. They are
|
||||
// a function of the range and the axis' pixel width, not of the label form, so reading them
|
||||
// back here is no feedback loop.
|
||||
double step = 0.0;
|
||||
{
|
||||
const plot::Axis& ax = first.plot->x_axis(first.axis_x);
|
||||
double prev = 0.0;
|
||||
bool have = false;
|
||||
for (const plot::Tick& t : ax.ticks) {
|
||||
if (!t.major) continue;
|
||||
if (have) {
|
||||
step = t.value - prev;
|
||||
break;
|
||||
}
|
||||
prev = t.value;
|
||||
have = true;
|
||||
}
|
||||
}
|
||||
// No ticks yet -- the frames before the first layout. The form is a property of the labels, so
|
||||
// with no labels there is nothing to decide, and keeping the current one is right where
|
||||
// guessing a step from the span is not: a guess that entered the relative form would be
|
||||
// *latched* there by the hysteresis above.
|
||||
const bool want_relative =
|
||||
step > 0.0 ? step < res * (relative_x_ ? 1.05 : 1.0 - 1.0e-9) : relative_x_;
|
||||
const bool changed = want_relative != relative_x_;
|
||||
relative_x_ = want_relative;
|
||||
|
||||
@@ -530,27 +574,25 @@ bool RowSet::update_time_labels() {
|
||||
r.plot->set_x_axis_origin_from_range(r.axis_x, relative_x_);
|
||||
if (!relative_x_) r.plot->set_x_axis_origin(r.axis_x, 0.0);
|
||||
// The caption belongs with the labels, which a figure draws once, on the bottom row (the
|
||||
// one whose x axis is the figure's). Every row gets it parked.
|
||||
// one whose x axis is the figure's). Every other row gets an empty one, which draws nothing.
|
||||
const bool bottom = i == rows_ - 1;
|
||||
if (r.time_origin_note >= r.plot->annotation_count()) continue;
|
||||
plot::Annotation note = r.plot->annotation(r.time_origin_note);
|
||||
note.visible = relative_x_ && bottom;
|
||||
if (note.visible) {
|
||||
// Inside the axes, just above the axis line, at their left edge: the reader adds this
|
||||
// to a tick, so it sits beside the ticks.
|
||||
const plot::Range yr = r.plot->y_range(r.axis_y);
|
||||
note.x = view.min;
|
||||
note.y = yr.min + 0.04 * (yr.span() > 0.0 ? yr.span() : 1.0);
|
||||
char text[64];
|
||||
// The anchor the *drawn* labels were built with: the axis' own origin, which the last
|
||||
// layout set from its range. Reading it back (rather than keeping our own copy) is what
|
||||
// makes the reader's addition exact -- label plus caption is that tick's absolute time.
|
||||
const double shown = r.plot->x_axis_origin(r.axis_x);
|
||||
x_origin_ = shown;
|
||||
std::snprintf(text, sizeof(text), "t0 = %.6g s", shown);
|
||||
note.text = text;
|
||||
if (!relative_x_ || !bottom) {
|
||||
r.plot->set_x_note("");
|
||||
continue;
|
||||
}
|
||||
r.plot->set_annotation(r.time_origin_note, note);
|
||||
char text[64];
|
||||
// The anchor the *drawn* labels were built with: the axis' own origin, which the last
|
||||
// layout set from its range. Reading it back (rather than keeping our own copy) is what
|
||||
// makes the reader's addition exact -- label plus caption is that tick's absolute time.
|
||||
const double shown = r.plot->x_axis_origin(r.axis_x);
|
||||
x_origin_ = shown;
|
||||
std::snprintf(text, sizeof(text), "t0 = %.6g s", shown);
|
||||
// Chrome, not an annotation: an annotation is anchored to a *data* coordinate, so the caption
|
||||
// crawled across the plot as the view panned and drifted as it zoomed -- and it came with the
|
||||
// annotation's own pin cross, which pointed at nothing. `set_x_note()` places it in the axes'
|
||||
// bottom-left corner in pixels, on the legend's backplate, which is where the labels it
|
||||
// qualifies are.
|
||||
r.plot->set_x_note(text);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
@@ -802,10 +844,16 @@ void RowSet::fit_all() {
|
||||
}
|
||||
|
||||
void RowSet::note_y_gutter(double need_px) {
|
||||
double want = std::min(need_px + 6.0, 200.0);
|
||||
// No slack added here, and that is load-bearing: `need_px` is the gutter the row was
|
||||
// *laid out with*, which is already the maximum of its labels and the floor imposed last
|
||||
// frame -- so a few pixels added to it are added again next frame. The `+ 6.0` this used
|
||||
// to carry made the gutter a ratchet that climbed 6 px a frame until it hit the cap
|
||||
// below, which is the whole reason the left margin was `200` of an 870 px viewport (and
|
||||
// the `axes.w changes: 17` beside it). The label padding the reservation below adds is
|
||||
// the slack, once.
|
||||
double want = std::min(need_px, 200.0);
|
||||
// Reserve room for the widest label the formatter can produce, once, with the
|
||||
// font the labels are actually drawn in (the embedded font is monospaced, so a
|
||||
// character count is an exact width).
|
||||
// font the labels are actually drawn in.
|
||||
if (!y_gutter_reserved_) {
|
||||
for (int i = 0; i < rows_; ++i) {
|
||||
Row& r = row(i);
|
||||
@@ -813,10 +861,19 @@ void RowSet::note_y_gutter(double need_px) {
|
||||
const plot::render::TextBackend* text = r.plot->text_backend();
|
||||
if (text == nullptr) continue;
|
||||
const plot::render::Style& style = r.plot->style();
|
||||
const double advance = double(text->measure("0123456789")) / 10.0;
|
||||
const double pad = double(style.device(style.grid_label_pad));
|
||||
// "-1.23e-45" is the longest label `format_si()`'s fallback can print.
|
||||
want = std::max(want, 9.0 * advance + 2.0 * pad + 2.0);
|
||||
// A sign, three significant figures and a one-glyph SI prefix: the widest label
|
||||
// `format_si()` writes for an axis with no unit, measured rather than counted in
|
||||
// characters. Its `%.3g` fallback is wider (`-1.23e-45`) and was what this reserved,
|
||||
// which cost a *third* of the gutter for a label no signal on a `Si` axis produces --
|
||||
// it needs a value past 1e24, and `note_y_gutter()` is a monotone maximum over the
|
||||
// measured need, so one that did appear widens the gutter for good anyway.
|
||||
want = std::max(want, double(text->measure("-1.23m")) + 2.0 * pad + 2.0);
|
||||
// The other side, from the same metrics. An x tick label is *centred* on its tick and
|
||||
// the newest tick sits on the axes' right edge (a live axis is [-window, 0]), so half
|
||||
// the widest time label is what the rightmost one needs to be drawn at all -- and that
|
||||
// is also the band which keeps the newest sample's ink off the frame.
|
||||
right_gutter_px_ = 0.5 * double(text->measure("-1.23ms")) + pad;
|
||||
y_gutter_reserved_ = true;
|
||||
break;
|
||||
}
|
||||
@@ -824,9 +881,10 @@ void RowSet::note_y_gutter(double need_px) {
|
||||
if (want > y_gutter_px_) y_gutter_px_ = want;
|
||||
}
|
||||
|
||||
void RowSet::apply_y_gutter() {
|
||||
if (!figure_ || !(y_gutter_px_ > 0.0)) return;
|
||||
figure_->set_min_y_gutter(static_cast<f32>(y_gutter_px_));
|
||||
void RowSet::apply_gutters() {
|
||||
if (!figure_) return;
|
||||
if (y_gutter_px_ > 0.0) figure_->set_min_y_gutter(static_cast<f32>(y_gutter_px_));
|
||||
figure_->set_right_gutter(static_cast<f32>(right_gutter_px_));
|
||||
}
|
||||
|
||||
void RowSet::set_trigger_marker(double x, bool visible) {
|
||||
|
||||
+31
-17
@@ -106,11 +106,6 @@ class RowSet {
|
||||
/// it keeps its position whatever the cursor toggle says.
|
||||
plot::u32 trigger_marker = 0;
|
||||
|
||||
/// The caption naming the window's start while the x labels are relative: an annotation
|
||||
/// inside the axes, so its text can change every frame without moving the layout (an axis
|
||||
/// *title* would, and it would be a different width each time).
|
||||
plot::u32 time_origin_note = 0;
|
||||
|
||||
/// The measurements' readout marks: a pool created with the plot (a `Plot` cannot add or
|
||||
/// drop a cursor after the fact) and parked unless a measurement is using the slot.
|
||||
std::array<plot::u32, kMeasureMarks> measure_h{};
|
||||
@@ -183,6 +178,14 @@ class RowSet {
|
||||
void set_time_window(double seconds);
|
||||
double time_window() const noexcept { return window_s_; }
|
||||
|
||||
/// The newest x any row's live traces hold, which is what the link group follows -- and, since
|
||||
/// it is reported in the display epoch currently in force, also what the frame tick subtracts
|
||||
/// from that epoch to park the newest sample at x = 0. It must be read **after** `bind()` and
|
||||
/// `acquire()`: it asks the bound series, so before them it answers for the previous frame's
|
||||
/// window, and a frame's production at 1 MS/s over a 50 ms view is a third of the screen.
|
||||
/// False when no row holds a streaming trace.
|
||||
bool live_edge(double& newest) const;
|
||||
|
||||
void set_y_auto(int row, bool on);
|
||||
void set_y_range(int row, double lo, double hi);
|
||||
|
||||
@@ -196,7 +199,13 @@ class RowSet {
|
||||
void place_crossings();
|
||||
CursorMode cursor_mode() const noexcept { return cursor_mode_; }
|
||||
|
||||
/// The two vertical cursors' stream times; shared by every row.
|
||||
/// The two vertical cursors' positions, in the **display** x the axis reads -- not stream
|
||||
/// seconds, which they coincided with only while `display_epoch()` was always 0. A live view
|
||||
/// is newest-relative now, so a cursor left at -1.5 s keeps meaning "1.5 s ago" and the data
|
||||
/// flows under it, which is what a bench scope's roll mode does and is the behaviour that was
|
||||
/// asked for. They are read back out of the plots and written to them unchanged, so there is
|
||||
/// nothing to convert; anything that needs the stream's own clock goes through
|
||||
/// `ScopeModel::stream_at_x0()`. Shared by every row.
|
||||
double cursor_a_x() const noexcept { return cursor_a_x_; }
|
||||
double cursor_b_x() const noexcept { return cursor_b_x_; }
|
||||
/// One row's ruler values, or 0 for a row that does not exist.
|
||||
@@ -364,18 +373,22 @@ class RowSet {
|
||||
/// left and right by a few pixels every frame. Since `set_min_y_gutter()` is a
|
||||
/// floor and the library still takes the larger of the two, a floor smaller than
|
||||
/// the labels cannot stop that; the reservation therefore covers the widest label
|
||||
/// `format_si()` can produce (three significant digits, a sign and a prefix, or the
|
||||
/// `%.3g` fallback) plus the label padding, measured once with the plot's own font.
|
||||
/// Both reservations only ever grow, so the gutter is constant in practice.
|
||||
/// `format_si()` writes for an axis with no unit -- a sign, three significant figures
|
||||
/// and a one-glyph prefix -- plus the label padding, measured once with the plot's own
|
||||
/// font. Both reservations only ever grow, so the gutter is constant in practice.
|
||||
///
|
||||
/// The same metrics fix the **right** gutter, which is a want and not a measurement:
|
||||
/// see `apply_gutters()`.
|
||||
void note_y_gutter(double need_px);
|
||||
|
||||
/// Applies the stable gutter as the figure's floor. Call once per frame, before the
|
||||
/// frame is drawn.
|
||||
/// Applies the stable left gutter as the figure's floor, and the right gutter beside
|
||||
/// it. Call once per frame, before the frame is drawn.
|
||||
///
|
||||
/// It has to be the *figure's* floor and not each row's: `Figure::layout()` clears
|
||||
/// every row's `set_min_y_gutter()` to measure what its own labels need, so a floor
|
||||
/// written onto the rows survives exactly until the next layout.
|
||||
void apply_y_gutter();
|
||||
/// Both have to be the *figure's* and not each row's: `Figure::layout()` clears every
|
||||
/// row's `set_min_y_gutter()` to measure what its own labels need, so a floor written
|
||||
/// onto the rows survives exactly until the next layout, and the rows have to end on
|
||||
/// one column as well as start on one.
|
||||
void apply_gutters();
|
||||
|
||||
/// The palette entry `index`, wrapped. Keyed by the *signal*, so that the colour a
|
||||
/// trace is drawn in is the colour the signal list's chip shows, on every row the
|
||||
@@ -404,8 +417,6 @@ class RowSet {
|
||||
/// at the end of the gesture (`Controller::end_drag()`), and a value read out of the plot
|
||||
/// mid-drag has already been through that act.
|
||||
void place_cursors(Row& r, plot::CursorPlacement placement = plot::CursorPlacement::Snapped);
|
||||
/// The newest x any row's live traces hold, which is what the group follows.
|
||||
bool live_edge(double& newest) const;
|
||||
|
||||
std::unique_ptr<plot::Figure> figure_;
|
||||
/// The one x link group every visible row belongs to. Groups are numbered from 1.
|
||||
@@ -424,6 +435,9 @@ class RowSet {
|
||||
/// gesture to be honoured; one that matches it is just the clamp's own answer arriving back.
|
||||
plot::Range pinned_stated_{0.0, 0.0};
|
||||
double y_gutter_px_ = 0.0;
|
||||
/// The empty band kept to the right of the data, device pixels. Nothing is drawn in it,
|
||||
/// so it is reserved once from the font's metrics and never measured or grown.
|
||||
double right_gutter_px_ = 0.0;
|
||||
bool y_gutter_reserved_ = false;
|
||||
std::array<Row, static_cast<std::size_t>(kMaxRows)> storage_{};
|
||||
CursorMode cursor_mode_ = CursorMode::Vertical;
|
||||
|
||||
@@ -48,6 +48,11 @@ std::string format_glance(const StatusSnapshot& s) {
|
||||
out += " · ";
|
||||
out += s.window_text;
|
||||
out += " window";
|
||||
if (s.paused) {
|
||||
// Directly after the state word's own field rather than at the end, because it qualifies
|
||||
// *that* word: "paused · 10 kS/s · 2 s window · for 12 s" reads as one statement.
|
||||
out += " · for " + format_elapsed(s.paused_for_s);
|
||||
}
|
||||
if (s.trigger_on) {
|
||||
out += " · ";
|
||||
if (s.have_event) {
|
||||
@@ -74,6 +79,7 @@ std::string format_detail(const StatusSnapshot& s) {
|
||||
s.plot1_traces, s.plot1_samples, s.buffer_mb, s.span_s,
|
||||
static_cast<unsigned long long>(s.time_stride));
|
||||
std::string out = buf;
|
||||
if (s.paused) out += " | paused for " + format_elapsed(s.paused_for_s);
|
||||
if (s.trigger_on) {
|
||||
out += s.have_event ? " | last event " + format_elapsed(s.since_event_s) + " ago"
|
||||
: " | no event yet";
|
||||
|
||||
+18
-5
@@ -11,9 +11,12 @@ namespace udpscope {
|
||||
/// two cannot disagree. No GTK here on purpose: this is the one part of the status bar that
|
||||
/// can be reasoned about as text.
|
||||
struct StatusSnapshot {
|
||||
/// One word from the fixed set: "live", "paused", "armed", "capturing", "event held",
|
||||
/// "disarmed". It is a word from a *fixed* set because the bar sits directly above the plot
|
||||
/// and a field whose width tracked the stream would relayout it every frame.
|
||||
/// One word from the fixed set: "connecting", "live", "paused", "armed", "capturing",
|
||||
/// "event held", "disarmed". It is a word from a *fixed* set because the bar sits directly
|
||||
/// above the plot and a field whose width tracked the stream would relayout it every frame --
|
||||
/// so a word added here must be no wider than the widest already in it ("connecting" and
|
||||
/// "event held" are both ten characters). "connecting" is the state before a CONFIG has
|
||||
/// arrived, in which there is no signal list and so nothing any other word here can describe.
|
||||
const char* state_word = "live";
|
||||
/// The long prose the old single-line bar carried, kept for the detail string and the dump.
|
||||
std::string state_detail;
|
||||
@@ -44,10 +47,20 @@ struct StatusSnapshot {
|
||||
bool have_event = false;
|
||||
double since_event_s = 0.0;
|
||||
int reconnects = 0;
|
||||
|
||||
/// How long the view has been frozen, in **wall** seconds, and whether it is. The producer
|
||||
/// never stops while paused -- it keeps filling the live ring, which is what makes the resume
|
||||
/// seamless -- so the stream's own clock says nothing about how long the user has been looking
|
||||
/// at a still picture. Written through `format_elapsed()` like the event age, for the same
|
||||
/// reason: it steps to a coarser unit instead of growing digits, and the bar is directly above
|
||||
/// the plot.
|
||||
bool paused = false;
|
||||
double paused_for_s = 0.0;
|
||||
};
|
||||
|
||||
/// The bar: "live · 10 kS/s · 2 s window", plus " · event 4.2 s ago" in trigger mode and
|
||||
/// nothing else ever. The badge glyph is a separate widget and is not part of this string.
|
||||
/// The bar: "live · 10 kS/s · 2 s window", plus " · event 4.2 s ago" in trigger mode, " · for
|
||||
/// 12 s" while paused, and nothing else ever. The badge glyph is a separate widget and is not
|
||||
/// part of this string.
|
||||
std::string format_glance(const StatusSnapshot& s);
|
||||
|
||||
/// The popover's prose and the exit dump's line, field for field as the old single-line bar
|
||||
|
||||
+313
-124
@@ -137,6 +137,12 @@ struct App {
|
||||
GtkWidget* trigger_holdoff_spin = nullptr;
|
||||
GtkWidget* signal_list = nullptr;
|
||||
GtkWidget* pause_button = nullptr;
|
||||
/// When the pause began, on the **monotonic wall clock**, or 0 while running. The elapsed time
|
||||
/// the status bar shows has to be wall time rather than stream time: the producer never stops
|
||||
/// while paused (it keeps filling the live ring, which is what makes the resume seamless), so
|
||||
/// the stream's own clock advances whether the view is frozen or not and would say nothing
|
||||
/// about how long the user has been looking at a still picture.
|
||||
gint64 pause_started_us = 0;
|
||||
GtkWidget* menu_button = nullptr;
|
||||
GtkWidget* cursors_toggle = nullptr;
|
||||
/// Where the panel controls ended up, decided once at build time: the dump runs after the window
|
||||
@@ -198,6 +204,12 @@ struct App {
|
||||
std::vector<std::string> measure_text;
|
||||
/// The window the current results were computed over, and whether a capture was held then:
|
||||
/// a recomputation is owed when either moves (see `update_measures`).
|
||||
///
|
||||
/// It is kept in **stream** seconds (through `ScopeModel::stream_at_x0()`), not in the display x
|
||||
/// it was read as, because a live view is newest-relative: the axis there is a fixed
|
||||
/// `[-window, 0]` whatever arrives, so "the window moved" is invisible in display coordinates
|
||||
/// and the pane would compute once and then never again -- the readout frozen on the samples
|
||||
/// that happened to be on screen when the pane opened.
|
||||
plot::Range measure_window{};
|
||||
bool measure_window_valid = false;
|
||||
bool measure_was_held = false;
|
||||
@@ -245,13 +257,30 @@ struct App {
|
||||
std::vector<MeasureResult> measure_last_valid;
|
||||
/// The marks the last computation produced: re-placed every frame, which is what makes the
|
||||
/// measurement cursors read-only.
|
||||
///
|
||||
/// A **time** mark's `value` is held in stream seconds, like `measure_window`, and converted to
|
||||
/// the axis' own x by `place_measure_marks()` on the frame it is placed. That is what keeps a
|
||||
/// mark glued to the sample it measured while a live view rolls the data leftwards under it --
|
||||
/// a mark is a *result*, so it travels with its datum, which is the opposite of a cursor (an
|
||||
/// instrument the user put at a position and that stays there). A level mark's `value` is a y
|
||||
/// and has nothing to do with either clock.
|
||||
std::vector<RowSet::MeasureMark> measure_marks;
|
||||
/// `measure_marks` with the time marks converted to display x, rebuilt in place each frame
|
||||
/// rather than allocated: this is on the frame tick's path.
|
||||
std::vector<RowSet::MeasureMark> measure_marks_display;
|
||||
/// What the dump prints for the header and the panels. Captured while the window is alive:
|
||||
/// the dump runs from the destroy handler, where every widget call is a GTK-CRITICAL and
|
||||
/// reads nothing.
|
||||
int dbg_win_btn_w = -1;
|
||||
int dbg_win_label_w = -1;
|
||||
int dbg_bar_w = -1;
|
||||
/// The measurements pane's allocation, for the same reason and more urgently: the dump used
|
||||
/// to ask the widget itself, which is where the two documented `Gtk-CRITICAL`s came from --
|
||||
/// and a critical is the lucky outcome. The pointer is freed memory by then, so a run that
|
||||
/// built and destroyed other windows first (`UDPSCOPE_PROBE_HELP_MS`, three of them) has had
|
||||
/// that memory reused and the read is a **SIGSEGV** in the middle of the dump.
|
||||
int dbg_measures_pane_w = -1;
|
||||
int dbg_measures_pane_h = -1;
|
||||
bool measures_revealed = false;
|
||||
/// The measurement dialog while one is open (null otherwise). A probe needs a handle on it:
|
||||
/// it is a window of its own, so the main window's widget tree does not reach its buttons.
|
||||
@@ -418,11 +447,19 @@ struct App {
|
||||
int dbg_ink_staircase = 0; ///< the filter dropped over three quarters of the points
|
||||
int dbg_ink_short = 0; ///< the ink reached under a quarter of the axis' width
|
||||
double dbg_ink_worst_cover = 1.0;
|
||||
/// The worst distance, in pixels, between where a sample is *drawn* and the x a readout
|
||||
/// quotes for the same sample. `gather()` stages x as an f32 relative to an origin the
|
||||
/// renderer names, while `Plot::crosshair_point()` reads `x_at()` in exact f64, so this is
|
||||
/// the quantisation of that f32 expressed in the unit the user sees it in. Measured on the
|
||||
/// raw levels only, where a gathered point *is* a sample; above them a point is a bucket's
|
||||
/// envelope and has no single sample to be off from.
|
||||
double dbg_ink_worst_offset_px = 0.0;
|
||||
int dbg_ink_raw_checked = 0; ///< frames the figure above was measured over
|
||||
int dbg_ink_reports = 0;
|
||||
/// Frames the badge spent in each `BadgePhase`. Both `lit` and `dim` have to be non-zero
|
||||
/// over a run that acquired anything -- that is what "it blinks" means, and a badge
|
||||
/// stuck lit looks identical to a working one in a screenshot.
|
||||
int dbg_badge_frames[3] = {0, 0, 0};
|
||||
int dbg_badge_frames[4] = {0, 0, 0, 0};
|
||||
int dbg_reset_visible = -1;
|
||||
std::vector<std::uint64_t> last_stored;
|
||||
std::uint64_t dbg_latches_at_disarm = 0;
|
||||
@@ -896,10 +933,14 @@ std::string format_window(double s) {
|
||||
|
||||
/// What the activity badge is saying. `Lit`/`Dim` are the two halves of the blink, so
|
||||
/// the badge is only *blinking* while the pipeline is actually running; a steady badge is
|
||||
/// a stopped one, which is the state worth noticing on a glance.
|
||||
enum class BadgePhase { Lit = 0, Dim = 1, Idle = 2 };
|
||||
/// a stopped one, which is the state worth noticing on a glance. `Connecting` is the
|
||||
/// half-way state -- the socket answered but the stream has sent no CONFIG, so there is no
|
||||
/// signal list and nothing to acquire -- and it is steady yellow rather than green,
|
||||
/// because a blinking green badge over a source that has sent nothing claims a working
|
||||
/// pipeline.
|
||||
enum class BadgePhase { Lit = 0, Dim = 1, Idle = 2, Connecting = 3 };
|
||||
|
||||
/// The badge is one glyph in one of three colours. It must stay the *same glyph*: the
|
||||
/// The badge is one glyph in one of four colours. It must stay the *same glyph*: the
|
||||
/// status row sits above the plot, and a badge that changed width as it blinked would
|
||||
/// move the label beside it and re-lay out the plot twice a second.
|
||||
const char* badge_markup(BadgePhase phase) {
|
||||
@@ -908,6 +949,8 @@ const char* badge_markup(BadgePhase phase) {
|
||||
return "<span foreground=\"#45E045\">\u25CF</span>";
|
||||
case BadgePhase::Dim:
|
||||
return "<span foreground=\"#1C5A1C\">\u25CF</span>";
|
||||
case BadgePhase::Connecting:
|
||||
return "<span foreground=\"#E0B020\">\u25CF</span>";
|
||||
case BadgePhase::Idle:
|
||||
break;
|
||||
}
|
||||
@@ -1093,21 +1136,19 @@ void check_frame_ink(App& app) {
|
||||
const plot::u32 level = spp >= 64.0 ? s->pyramid().level_for(n, spp) : 0u;
|
||||
std::vector<plot::f32>& xy = app.measure_ink_scratch;
|
||||
xy.clear();
|
||||
s->gather(level, first, last, xy);
|
||||
// The same origin the renderer names (`x_range.min`), so the reconstruction below is its
|
||||
// reconstruction: see `plot::data::Series::gather()` for why the caller owns it.
|
||||
s->gather(level, first, last, axis.min, xy);
|
||||
const std::size_t pts = xy.size() / 2;
|
||||
|
||||
std::size_t kept = 0;
|
||||
double lo = 0.0;
|
||||
double hi = 0.0;
|
||||
// Where the filter's drops fall, and how far back each one steps. The renderer reconstructs
|
||||
// every point's absolute x as `gather[i] + series.x0()` **inside** this loop, so an `x0()`
|
||||
// that is not constant for the loop's duration shifts the comparison under itself -- and on a
|
||||
// ring-backed series `x0()` is `time_of(window_base)`, which *clamps to the oldest readable
|
||||
// slot* and therefore advances with the producer. The two readings that separate that from a
|
||||
// genuinely out-of-order gather are the drops' *positions* (spread through the points, or
|
||||
// bunched at the front where the clamp lives) and `back` (one sample's spacing, or a packet's
|
||||
// worth). So the loop is run twice: once the renderer's way, and once with `x0()` read a
|
||||
// single time.
|
||||
// Where the filter's drops fall, and how far back each one steps. The origin is a constant
|
||||
// for the walk's duration *by construction* now -- the renderer reconstructs
|
||||
// `gather[i] + x_range.min`, a local of its own, where it used to re-read `series.x0()` on
|
||||
// every point and a ring-backed `x0()` advances with the producer. So a drop here is a
|
||||
// genuinely out-of-order gather, and `back` says by how much.
|
||||
std::size_t drop_first = 0;
|
||||
std::size_t drop_last = 0;
|
||||
double worst_back = 0.0;
|
||||
@@ -1115,7 +1156,7 @@ void check_frame_ink(App& app) {
|
||||
{
|
||||
double next = std::numeric_limits<double>::infinity();
|
||||
for (std::size_t k = pts; k > 0; --k) {
|
||||
const double x = double(xy[2 * (k - 1)]) + s->x0();
|
||||
const double x = double(xy[2 * (k - 1)]) + axis.min;
|
||||
if (!(x < next)) {
|
||||
worst_back = std::max(worst_back, x - next);
|
||||
if (!any_drop) drop_last = k - 1;
|
||||
@@ -1129,18 +1170,24 @@ void check_frame_ink(App& app) {
|
||||
++kept;
|
||||
}
|
||||
}
|
||||
std::size_t kept_fixed = 0;
|
||||
{
|
||||
const double base = s->x0(); // one read, held for the whole walk
|
||||
double next = std::numeric_limits<double>::infinity();
|
||||
for (std::size_t k = pts; k > 0; --k) {
|
||||
const double x = double(xy[2 * (k - 1)]) + base;
|
||||
if (!(x < next)) continue;
|
||||
next = x;
|
||||
++kept_fixed;
|
||||
}
|
||||
}
|
||||
++app.dbg_ink_checked;
|
||||
// Drawn against quoted, in pixels. At level 0 a gathered pair *is* sample `first + k`, so
|
||||
// this is the one place the two numbers the user compares by eye -- the polyline's position
|
||||
// and the crosshair's readout -- can be differenced. The origin is what decides it: the f32
|
||||
// resolves 2^-24 of whatever span it is relative to, so a view narrower than that fraction
|
||||
// of the *retained window* drew a sample visibly away from the x quoted for it, which is
|
||||
// what "when zooming there is a visible offset between the real data point and the crosshair
|
||||
// selected one" was.
|
||||
if (level == 0 && pts > 0) {
|
||||
const double px_per_x = axes_w / axis.span();
|
||||
double worst = 0.0;
|
||||
for (std::size_t k = 0; k < pts; ++k) {
|
||||
const double drawn = axis.min + double(xy[2 * k]);
|
||||
worst = std::max(worst, std::abs(drawn - s->x_at(first + k)) * px_per_x);
|
||||
}
|
||||
app.dbg_ink_worst_offset_px = std::max(app.dbg_ink_worst_offset_px, worst);
|
||||
++app.dbg_ink_raw_checked;
|
||||
}
|
||||
const double cover = kept >= 2 ? (hi - lo) / axis.span() : 0.0;
|
||||
app.dbg_ink_worst_cover = std::min(app.dbg_ink_worst_cover, cover);
|
||||
const bool collapse = kept < 2;
|
||||
@@ -1155,22 +1202,23 @@ void check_frame_ink(App& app) {
|
||||
if ((collapse || staircase || short_ink) && app.dbg_ink_reports < 40) {
|
||||
++app.dbg_ink_reports;
|
||||
// The series' own answers beside the reconstruction, because the two disagreeing is itself
|
||||
// a reading: `rel` is what `gather()` emitted (relative to the `x0()` *it* read) and `x0`
|
||||
// is the `x0()` read here one call later, so a trace whose ink sits a whole ring-span off
|
||||
// its axis separates into "the stamps moved under us" and "the window is the wrong one".
|
||||
// a reading: `rel` is what `gather()` emitted (relative to `axis.min`, the origin it was
|
||||
// given) while `x0`/`x_at`/`bounds` are the window the series says it holds, so a trace
|
||||
// whose ink sits a whole ring-span off its axis separates into "the stamps moved under us"
|
||||
// and "the window is the wrong one".
|
||||
const plot::Range xb = s->x_bounds();
|
||||
std::fprintf(stderr,
|
||||
"[ink] paint %d %s%s%s mode=%s axis=[%.6f..%.6f] n=%llu idx=[%llu..%llu) "
|
||||
"spp=%.1f level=%u pts=%zu kept=%zu ink=[%.6f..%.6f] cover=%.1f%% "
|
||||
"x0=%.6f x_at=[%.6f..%.6f] bounds=[%.6f..%.6f] rel=[%.6f..%.6f] dx=%.3g "
|
||||
"kept_fixed_x0=%zu drops=[%zu..%zu] back=%.3g\n",
|
||||
"drops=[%zu..%zu] back=%.3g\n",
|
||||
app.dbg_paint_n, collapse ? "collapse " : "", staircase ? "staircase " : "",
|
||||
short_ink ? "short " : "", display_mode_name(app.dbg_display), axis.min, axis.max,
|
||||
(unsigned long long)n, (unsigned long long)first, (unsigned long long)last, spp,
|
||||
level, pts, kept, lo, hi, cover * 100.0, s->x0(), s->x_at(0), s->x_at(n - 1),
|
||||
xb.min, xb.max, pts > 0 ? double(xy[0]) : 0.0,
|
||||
pts > 0 ? double(xy[2 * (pts - 1)]) : 0.0, s->dx(), kept_fixed, drop_first,
|
||||
drop_last, worst_back);
|
||||
pts > 0 ? double(xy[2 * (pts - 1)]) : 0.0, s->dx(), drop_first, drop_last,
|
||||
worst_back);
|
||||
std::fflush(stderr);
|
||||
}
|
||||
}
|
||||
@@ -1965,8 +2013,16 @@ void on_pause_toggled(GtkToggleButton* button, gpointer user) {
|
||||
// arrived while paused instead of a hole.
|
||||
if (RowSet::Row& r0 = app->rows->row(0); r0.plot != nullptr) {
|
||||
const plot::Range view = r0.plot->x_range(r0.axis_x);
|
||||
const double epoch = app->model->display_epoch();
|
||||
app->model->publish_pause_view(view.min - epoch, view.max - epoch);
|
||||
// The axis is in display x and the ring is on the stream's clock, so the window has to be
|
||||
// put back on that clock to be cut -- `stream_at_x0()` is the one accessor that is right in
|
||||
// every mode, including a pause taken while an event is *held*, where the axis is
|
||||
// edge-relative and the old `- display_epoch()` form searched the ring with event
|
||||
// coordinates. The same offset is handed to the snapshot as its `x_offset`, so its stamps
|
||||
// come back rebased onto the x the axis is already showing and the frozen picture does not
|
||||
// move at the instant of the pause.
|
||||
const double s0 = app->model->stream_at_x0();
|
||||
app->model->publish_pause_view(view.min + s0, view.max + s0, s0);
|
||||
app->pause_started_us = g_get_monotonic_time();
|
||||
// Pin on what the snapshot *got*: the cut is clamped to the ring, so it can be narrower than
|
||||
// the view, and pinning the request left the shortfall as an empty left margin.
|
||||
plot::Range pin = view;
|
||||
@@ -1981,6 +2037,7 @@ void on_pause_toggled(GtkToggleButton* button, gpointer user) {
|
||||
} else {
|
||||
app->model->clear_pause_view();
|
||||
app->rows->unpin_pause_window();
|
||||
app->pause_started_us = 0;
|
||||
}
|
||||
gtk_button_set_icon_name(GTK_BUTTON(button), paused ? "media-playback-start-symbolic"
|
||||
: "media-playback-pause-symbolic");
|
||||
@@ -2003,7 +2060,12 @@ void on_trigger_toggled(GtkToggleButton* button, gpointer user) {
|
||||
app->capturing = false;
|
||||
if (!on) {
|
||||
app->model->trigger_time_valid().store(false, std::memory_order_relaxed);
|
||||
app->model->set_display_epoch(0.0); // back to seconds since the stream started
|
||||
// The epoch is *not* written here: the frame tick owns it and states the live answer -- minus
|
||||
// the newest acquired sample -- after it has bound and acquired, which is the only moment the
|
||||
// right number is knowable. This used to set it to 0 ("seconds since the stream started"),
|
||||
// which is no longer a display coordinate anything uses. Nothing is left stale in the
|
||||
// meantime: the group follows the live edge again from here, and `Figure::layout()` re-derives
|
||||
// the range from it on every frame.
|
||||
app->rows->set_time_window(app->rows->time_window()); // and follow the live edge
|
||||
}
|
||||
if (on && signal >= 0) {
|
||||
@@ -2863,6 +2925,19 @@ void open_measure_dialog(App& app, int edit_index) {
|
||||
gtk_window_present(GTK_WINDOW(window));
|
||||
}
|
||||
|
||||
/// Puts `App::measure_marks` on the plots, converting each **time** mark from the stream seconds
|
||||
/// it is stored in to the x the axis is reading this frame. Called every frame -- that is what
|
||||
/// makes the marks read-only (a gesture moves one, and this puts it back) and, since a live axis
|
||||
/// is newest-relative, also what rolls each mark leftwards with the sample it measured.
|
||||
void place_measure_marks(App& app) {
|
||||
const double s0 = app.model->stream_at_x0();
|
||||
app.measure_marks_display = app.measure_marks;
|
||||
for (RowSet::MeasureMark& mk : app.measure_marks_display) {
|
||||
if (!mk.level) mk.value -= s0;
|
||||
}
|
||||
app.rows->set_measure_marks(app.measure_marks_display);
|
||||
}
|
||||
|
||||
/// Recomputes every measurement over the visible window and writes the results into the pane,
|
||||
/// collecting the marks the ones asking to be shown need.
|
||||
///
|
||||
@@ -2877,12 +2952,17 @@ void update_measures(App& app, bool force) {
|
||||
if (app.measure_labels.size() != app.measures.size()) return; // mid-rebuild
|
||||
const RowSet::Row& r0 = app.rows->row(0);
|
||||
if (r0.plot == nullptr) return;
|
||||
// The window on the **stream's** clock, which is the only space in which "it moved" is a
|
||||
// question with an answer: a live axis is newest-relative, so `view` itself is a fixed
|
||||
// `[-window, 0]` that never changes while the data under it does.
|
||||
const double s0 = app.model->stream_at_x0();
|
||||
const plot::Range view = r0.plot->x_range(r0.axis_x);
|
||||
const plot::Range swin{view.min + s0, view.max + s0};
|
||||
const bool held = app.model->display_mode() == DisplayMode::Held;
|
||||
const bool moved = app.measure_window_valid && !held &&
|
||||
std::fabs(view.min - app.measure_window.min) +
|
||||
std::fabs(view.max - app.measure_window.max) >
|
||||
0.02 * std::max(1.0e-12, view.span());
|
||||
std::fabs(swin.min - app.measure_window.min) +
|
||||
std::fabs(swin.max - app.measure_window.max) >
|
||||
0.02 * std::max(1.0e-12, swin.span());
|
||||
// A measurement is a statistic of the visible window, and *a live view moves that window every
|
||||
// frame* -- so "recompute when the window moves" is "recompute every frame", at O(visible
|
||||
// samples) per measurement. That is what stalled the frame tick (0.4 ms -> 13.7 ms, a third of
|
||||
@@ -2905,16 +2985,16 @@ void update_measures(App& app, bool force) {
|
||||
// lag on it is not readable.
|
||||
const bool changed = !app.measure_window_valid || held != app.measure_was_held || moved;
|
||||
if (!force && !changed) {
|
||||
app.rows->set_measure_marks(app.measure_marks);
|
||||
place_measure_marks(app);
|
||||
return;
|
||||
}
|
||||
if (!force && moved && held == app.measure_was_held && !due) {
|
||||
// Nothing to recompute -- but the marks are still re-placed below, which is what makes
|
||||
// them read-only: a gesture moves one on the plot, and this is what puts it back.
|
||||
app.rows->set_measure_marks(app.measure_marks);
|
||||
place_measure_marks(app);
|
||||
return;
|
||||
}
|
||||
app.measure_window = view;
|
||||
app.measure_window = swin;
|
||||
app.measure_window_valid = true;
|
||||
app.measure_was_held = held;
|
||||
app.measure_stamp_us = now_us;
|
||||
@@ -3118,11 +3198,15 @@ void update_measures(App& app, bool force) {
|
||||
const plot::u32 color = measure_color(i);
|
||||
for (const MeasureMarkSpec& spec : mk->marks) {
|
||||
if (!spec.level && !r.valid) continue;
|
||||
marks.push_back({row, spec.level, spec.value, color});
|
||||
// A time mark is stored on the **stream's** clock -- the walks report display x, so `s0`
|
||||
// puts it back -- and `place_measure_marks()` converts it again on every frame it draws.
|
||||
// That is what keeps the mark on its own sample as a live view rolls underneath it; a level
|
||||
// mark is a y and is stored as it comes.
|
||||
marks.push_back({row, spec.level, spec.level ? spec.value : spec.value + s0, color});
|
||||
}
|
||||
}
|
||||
app.measure_marks = std::move(marks);
|
||||
app.rows->set_measure_marks(app.measure_marks);
|
||||
place_measure_marks(app);
|
||||
}
|
||||
|
||||
/// The measurements panel: a header with the `[+]`, and one row per measurement.
|
||||
@@ -4681,24 +4765,29 @@ void report_event_zoom(App& a, const char* what) {
|
||||
const double spp = double(last - first) / axes_w;
|
||||
const plot::u32 level = spp >= 64.0 ? s->pyramid().level_for(n, spp) : 0u;
|
||||
std::vector<plot::f32> xy;
|
||||
s->gather(level, first, last, xy);
|
||||
// `axis.min` is the origin the renderer names, so every reconstruction below is its own --
|
||||
// and at the deep end of this sweep that choice is the whole point: relative to the retained
|
||||
// window instead, the f32's ULP would be a large fraction of these spans.
|
||||
s->gather(level, first, last, axis.min, xy);
|
||||
double ink_lo = 0.0, ink_hi = 0.0;
|
||||
if (xy.size() >= 2) {
|
||||
ink_lo = s->x0() + double(xy[0]);
|
||||
ink_hi = s->x0() + double(xy[xy.size() - 2]);
|
||||
ink_lo = axis.min + double(xy[0]);
|
||||
ink_hi = axis.min + double(xy[xy.size() - 2]);
|
||||
}
|
||||
// The renderer's own increasing-suffix filter, counted rather than applied: it drops every
|
||||
// point that is not strictly older than the one kept after it, which for a *run* of equal x
|
||||
// keeps the last of the run. The stamps here are f32, so once the sample spacing falls below
|
||||
// the f32 step at the stream's magnitude the gathered x has such runs -- and `kept` is how
|
||||
// many distinct positions the polyline is left with. A `kept` far below `pts` is a staircase,
|
||||
// and a `kept` of a handful is a trace with nothing to draw.
|
||||
// the f32 step at the *gather origin's* magnitude the gathered x has such runs -- and `kept`
|
||||
// is how many distinct positions the polyline is left with. A `kept` far below `pts` is a
|
||||
// staircase, and a `kept` of a handful is a trace with nothing to draw. This is the other
|
||||
// reading the origin change buys: the step is now 2^-24 of the span on screen, so a sweep
|
||||
// that used to grind `kept` down as it zoomed keeps every point.
|
||||
std::size_t kept = 0;
|
||||
double worst_back = 0.0; // the largest backwards step, in data units: non-monotonic gather
|
||||
{
|
||||
double next = std::numeric_limits<double>::infinity();
|
||||
for (std::size_t k = xy.size() / 2; k > 0; --k) {
|
||||
const double x = double(xy[2 * (k - 1)]) + s->x0();
|
||||
const double x = double(xy[2 * (k - 1)]) + axis.min;
|
||||
if (!(x < next)) {
|
||||
worst_back = std::max(worst_back, x - next);
|
||||
continue;
|
||||
@@ -4796,6 +4885,11 @@ gboolean tick(gpointer user) {
|
||||
std::fflush(stderr);
|
||||
}
|
||||
}
|
||||
// Captured every frame so the dump has the last live value: see `dbg_measures_pane_w`.
|
||||
if (app->measures_list != nullptr) {
|
||||
app->dbg_measures_pane_w = gtk_widget_get_width(app->measures_list);
|
||||
app->dbg_measures_pane_h = gtk_widget_get_height(app->measures_list);
|
||||
}
|
||||
if (model.generation() != app->signal_generation) rebuild_signal_panels(*app);
|
||||
// A gesture moves the *plot's* cursor; adopting it here is what makes the two
|
||||
// vertical markers one time across every row, and what the pane then reads.
|
||||
@@ -4847,16 +4941,37 @@ gboolean tick(gpointer user) {
|
||||
// UI's own structural changes: the bind takes shared handles into the live
|
||||
// store, and acquire() reads it.
|
||||
std::lock_guard<std::mutex> lock(model.mutex());
|
||||
// Mid-capture the live ring is drawn on the coming event's clock, so its edge sits at
|
||||
// x = 0 exactly as a snapshot's does and the pinned [-pre, +post] axis means the same
|
||||
// thing in both. Every other mode reads stream seconds. The epoch is a consumer-side
|
||||
// offset on the live channels, so it has to be in place before the bind that hands
|
||||
// them to the traces -- and it is written here, under the lock, because a rebuild can
|
||||
// replace the store.
|
||||
const double want_epoch = display == DisplayMode::Capturing ? -cap_edge : 0.0;
|
||||
if (model.display_epoch() != want_epoch) model.set_display_epoch(want_epoch);
|
||||
app->rows->bind(model, display);
|
||||
app->rows->acquire(model);
|
||||
// What x = 0 means, written *after* the bind and the acquire on purpose: the live answer
|
||||
// is minus the newest sample the traces can now report, and asking before they were bound
|
||||
// would put the newest data a frame's production to the right of the axis -- a third of
|
||||
// the screen at 1 MS/s over a 50 ms window. The epoch is only read on the consumer side
|
||||
// (it offsets the live channels' x), so nothing between here and the next frame's bind
|
||||
// depends on it having been set earlier. It is written under the lock because a rebuild
|
||||
// can replace the store it is pushed into.
|
||||
//
|
||||
// Three cases, one rule -- `display_x = stream_t + epoch`:
|
||||
// * streaming, the newest stored sample sits at x = 0, so the axis is a fixed
|
||||
// [-window, 0] and the data flows leftwards through it. A scope's time base is "how
|
||||
// long ago", and the stream's own clock is an uptime nobody reads off an axis.
|
||||
// * mid-capture, the coming event's edge sits at x = 0 exactly as the published
|
||||
// snapshot's will, so the pinned [-pre, +post] means the same thing in both and the
|
||||
// picture does not jump at the latch.
|
||||
// * with a *snapshot* on screen (a held event, a paused view) the live channels are
|
||||
// bound to nothing and the snapshot carries its own `x_offset`, so the epoch is 0 and
|
||||
// `ScopeModel::stream_at_x0()` is what crosses between the clocks.
|
||||
double want_epoch = 0.0;
|
||||
if (display == DisplayMode::Capturing) {
|
||||
want_epoch = -cap_edge;
|
||||
} else if (model.display_snapshot() == nullptr) {
|
||||
// `live_edge()` reports in the epoch currently in force, so subtracting it is minus the
|
||||
// raw stamp: the fixed point is reached in one frame rather than converged on.
|
||||
double newest = 0.0;
|
||||
want_epoch = app->rows->live_edge(newest) ? model.display_epoch() - newest
|
||||
: model.display_epoch();
|
||||
}
|
||||
if (model.display_epoch() != want_epoch) model.set_display_epoch(want_epoch);
|
||||
}
|
||||
|
||||
// The gesture probe's verdicts, one frame after the ask and after this frame's clamp: a
|
||||
@@ -5140,7 +5255,7 @@ gboolean tick(gpointer user) {
|
||||
if (r.plot) gutter = std::max(gutter, double(r.plot->layout().y_axis_width));
|
||||
}
|
||||
app->rows->note_y_gutter(gutter);
|
||||
app->rows->apply_y_gutter();
|
||||
app->rows->apply_gutters();
|
||||
|
||||
// The editor's buttons are placed from the rects this frame was laid out with, so they
|
||||
// follow a resize and the gutter settling without being told.
|
||||
@@ -5343,7 +5458,12 @@ gboolean tick(gpointer user) {
|
||||
}
|
||||
StreamStats& st = model.stats();
|
||||
const char* mode = "live";
|
||||
if (trigger_on) {
|
||||
if (!st.have_config.load()) {
|
||||
// Ahead of every other branch for the reason the glance word is: with no CONFIG there is no
|
||||
// signal list, so "live" (or "armed", which cannot happen without one) would name a pipeline
|
||||
// that has not been described yet.
|
||||
mode = "trying to connect: no configuration, and so no signal list, from the stream yet";
|
||||
} else if (trigger_on) {
|
||||
if (app->capturing) {
|
||||
mode = "trigger: CAPTURING (post-trigger window filling)";
|
||||
} else if (app->event_held) {
|
||||
@@ -5368,14 +5488,26 @@ gboolean tick(gpointer user) {
|
||||
// green light over a dead socket is the one reading that must not happen. The blink is
|
||||
// a 1 Hz square wave off the monotonic clock rather than its own timer, so it cannot
|
||||
// drift away from the frame it is drawn on.
|
||||
// A source that answered CONNECT but has sent no CONFIG is *connecting*, not acquiring:
|
||||
// there is no signal list, so nothing can be enabled and no DATA frame can arrive. It gets
|
||||
// the steady yellow rather than the blinking green, because the green says "the pipeline is
|
||||
// running end to end" and here it has not started.
|
||||
const bool paused = app->acq->paused();
|
||||
const bool active = st.connected.load() && !paused && (!trigger_on || armed);
|
||||
const bool connecting = st.connected.load() && !st.have_config.load();
|
||||
const bool active =
|
||||
st.connected.load() && !connecting && !paused && (!trigger_on || armed);
|
||||
const bool lit = ((g_get_monotonic_time() / (G_TIME_SPAN_SECOND / 2)) & 1) == 0;
|
||||
const BadgePhase phase =
|
||||
!active ? BadgePhase::Idle : (lit ? BadgePhase::Lit : BadgePhase::Dim);
|
||||
const int reason = active ? 0 : !st.connected.load() ? 1 : paused ? 2 : 3;
|
||||
const BadgePhase phase = connecting ? BadgePhase::Connecting
|
||||
: !active ? BadgePhase::Idle
|
||||
: (lit ? BadgePhase::Lit : BadgePhase::Dim);
|
||||
const int reason = active ? 0
|
||||
: !st.connected.load() ? 1
|
||||
: connecting ? 4
|
||||
: paused ? 2
|
||||
: 3;
|
||||
static const char* const kWhy[] = {"acquiring", "waiting for the stream", "paused",
|
||||
"trigger disarmed"};
|
||||
"trigger disarmed",
|
||||
"trying to connect: no configuration from the stream yet"};
|
||||
// Keyed on the reason as well as the phase: three of the reasons share the Idle phase,
|
||||
// so a pause that becomes a disarm has to reach the tooltip.
|
||||
++app->dbg_badge_frames[static_cast<int>(phase)];
|
||||
@@ -5412,8 +5544,16 @@ gboolean tick(gpointer user) {
|
||||
|
||||
// Derive the one-word state for the glance line from the same conditions the mode prose
|
||||
// branches on, so the two cannot disagree.
|
||||
//
|
||||
// `connecting` is first and covers both halves of the handshake -- no socket yet, and a
|
||||
// socket with no CONFIG behind it -- because in neither is there a signal list, and every
|
||||
// other word here claims one. The badge is what distinguishes them (grey against yellow):
|
||||
// the word says what the app is doing, the colour says how far it got. It is no wider than
|
||||
// `event held`, which is what lets it join a set the plot's layout is measured against.
|
||||
const char* state_word = "live";
|
||||
if (display == DisplayMode::Capturing) {
|
||||
if (!st.have_config.load()) {
|
||||
state_word = "connecting";
|
||||
} else if (display == DisplayMode::Capturing) {
|
||||
state_word = "capturing";
|
||||
} else if (display == DisplayMode::Held) {
|
||||
state_word = "event held";
|
||||
@@ -5450,6 +5590,12 @@ gboolean tick(gpointer user) {
|
||||
? double(g_get_monotonic_time() - app->last_latch_us) / 1.0e6
|
||||
: 0.0;
|
||||
snap.reconnects = static_cast<int>(st.reconnects.load(std::memory_order_relaxed));
|
||||
// Paused *and* with a recorded start: the button's handler is what stamps it, so a pause
|
||||
// arriving any other way (there is none today) reports the state without a duration rather
|
||||
// than counting from the epoch.
|
||||
snap.paused = paused && app->pause_started_us != 0;
|
||||
snap.paused_for_s =
|
||||
snap.paused ? double(g_get_monotonic_time() - app->pause_started_us) / 1.0e6 : 0.0;
|
||||
|
||||
const std::string glance = udpscope::format_glance(snap);
|
||||
app->dbg_glance_last = glance;
|
||||
@@ -5582,9 +5728,6 @@ void note_starvation(App& app) {
|
||||
const std::uint64_t reconnects = st.reconnects.load(std::memory_order_relaxed);
|
||||
const bool connected = st.connected.load(std::memory_order_relaxed);
|
||||
|
||||
// No `have_config` here: the losing client of a single-client source need never see a
|
||||
// CONFIG at all (the server answered the first one), and "connected, kicked, nothing
|
||||
// arriving" is the state worth naming either way.
|
||||
// "Connected, but nothing is arriving" is a *rate* fact, not a reconnect one: a source that
|
||||
// answers CONNECT and then goes quiet -- the unicast case where another client took the session --
|
||||
// never reconnects while it is silent, so requiring a reconnect made the notice invisible in
|
||||
@@ -5603,16 +5746,28 @@ void note_starvation(App& app) {
|
||||
// stream was arriving perfectly well.
|
||||
return;
|
||||
}
|
||||
// Two diagnoses, split on `have_config`, because they are not the same situation. A source
|
||||
// that sent a CONFIG and then went quiet is the unicast fight: the signal list is here, so
|
||||
// something took the stream away. One that has sent no CONFIG at all never had the stream to
|
||||
// lose -- there is no signal list, so nothing can be enabled and no DATA frame is owed -- and
|
||||
// naming that "connected, but no data is arriving" blamed a second reader that does not exist.
|
||||
// It is still the same stall test: a CONFIG that arrives promptly leaves no notice at all.
|
||||
const bool have_config = st.have_config.load(std::memory_order_relaxed);
|
||||
app.notice =
|
||||
"connected, but no data is arriving: another reader may hold the source (a unicast "
|
||||
"source serves one client at a time) -- use --multicast to watch from several UIs";
|
||||
have_config
|
||||
? "connected, but no data is arriving: another reader may hold the source (a unicast "
|
||||
"source serves one client at a time) -- use --multicast to watch from several UIs"
|
||||
: "trying to connect: the source answered, but has sent no configuration, so there is "
|
||||
"no signal list yet -- check that the streamer is running and serving this port";
|
||||
app.notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND;
|
||||
if (!app.starve_reported) {
|
||||
app.starve_reported = true;
|
||||
std::fprintf(stderr,
|
||||
"[udpscope] starved: connected (config=%d), reconnects=%llu, no new frames "
|
||||
"in the last second -- a unicast source serves one client at a time\n",
|
||||
st.have_config.load(std::memory_order_relaxed) ? 1 : 0,
|
||||
have_config
|
||||
? "[udpscope] starved: connected (config=1), reconnects=%llu, no new frames "
|
||||
"in the last second -- a unicast source serves one client at a time\n"
|
||||
: "[udpscope] connecting: connected (config=0), reconnects=%llu, no "
|
||||
"configuration from the stream -- there is no signal list to acquire\n",
|
||||
static_cast<unsigned long long>(reconnects));
|
||||
std::fflush(stderr);
|
||||
}
|
||||
@@ -6247,23 +6402,27 @@ gboolean trig_trace_probe(gpointer user) {
|
||||
if (found) break;
|
||||
}
|
||||
|
||||
// Where that slot's samples are *drawn*, reconstructed exactly as the renderer
|
||||
// does it: `Series::gather()` emits x relative to `x0()`, and the renderer adds
|
||||
// `x0()` back before mapping to the screen. So `x0() + gathered` must equal
|
||||
// `x_at()`, and `drawn=` must equal `data=` to the f32's precision.
|
||||
// Where that slot's samples are *drawn*, reconstructed the way the renderer does it:
|
||||
// `Series::gather()` emits x relative to an origin **the caller names**, and the
|
||||
// consumer adds that same origin back before mapping to the screen. So
|
||||
// `origin + gathered` must equal `x_at()`, and `drawn=` must equal `data=` to the
|
||||
// f32's precision. The origin named here is `x0()`, deliberately -- the renderer
|
||||
// passes the visible range's minimum, but this line reads the series' *whole* span,
|
||||
// which is the widest relative value a gather can be asked to stage and so the worst
|
||||
// case for the f32.
|
||||
//
|
||||
// This is the one defect in this area the picture alone shows and no other probe
|
||||
// line does: a series that emits absolute x instead makes the renderer add `x0()`
|
||||
// twice, so the trace lands a whole `x0()` away from the axis it was measured
|
||||
// line does: a series that emits absolute x instead makes the consumer add the origin
|
||||
// twice, so the trace lands a whole origin away from the axis it was measured
|
||||
// against -- off screen entirely once the view is narrower than that shift, while
|
||||
// the crosshair (which reads `x_at()`) goes on reporting correct values.
|
||||
double drawn_lo = 0.0, drawn_hi = 0.0, data_lo = 0.0, data_hi = 0.0;
|
||||
if (shown != nullptr && shown->size() > 0) {
|
||||
const u64 n = shown->size();
|
||||
std::vector<f32> xy;
|
||||
shown->gather(0u, 0u, 1u, xy);
|
||||
shown->gather(0u, 0u, 1u, shown->x0(), xy);
|
||||
const std::size_t head = xy.size();
|
||||
shown->gather(0u, n - 1, n, xy);
|
||||
shown->gather(0u, n - 1, n, shown->x0(), xy);
|
||||
if (head >= 2 && xy.size() >= head + 2) {
|
||||
drawn_lo = shown->x0() + double(xy[0]);
|
||||
drawn_hi = shown->x0() + double(xy[head]);
|
||||
@@ -6603,8 +6762,11 @@ gboolean measure_trace(gpointer user) {
|
||||
line += " " + std::string(measure_type_name(a->measures[i].type)) + "=" +
|
||||
(r.valid ? std::to_string(r.primary) : std::string("--"));
|
||||
}
|
||||
line += " marks=" + std::to_string(a->measure_marks.size());
|
||||
for (const RowSet::MeasureMark& m : a->measure_marks) {
|
||||
// The marks as *placed*, not as stored: a time mark is kept on the stream's clock and converted
|
||||
// for the axis each frame (see `place_measure_marks()`), and it is the converted value that is
|
||||
// comparable with the `view=` printed beside it.
|
||||
line += " marks=" + std::to_string(a->measure_marks_display.size());
|
||||
for (const RowSet::MeasureMark& m : a->measure_marks_display) {
|
||||
line += " " + std::string(m.level ? "level" : "time") + ":" + std::to_string(m.value);
|
||||
}
|
||||
std::fprintf(stderr, "%s\n", line.c_str());
|
||||
@@ -7017,11 +7179,14 @@ void pause_probe_report(App& a, const char* what) {
|
||||
const RowSet::Row& r0 = a.rows->row(0);
|
||||
axis = r0.plot->x_range(r0.axis_x);
|
||||
}
|
||||
// The bar's own string, because "how long have I been paused" is a *status* field and the
|
||||
// elapsed figure is the one part of it that no other line can show: the exit dump's
|
||||
// `status bar:` is read live and the two `mode ...` lines are the live and triggered states.
|
||||
std::fprintf(stderr,
|
||||
"[pause-probe] %-9s paused=%d ring_produced=%zu event_size=%zu "
|
||||
"frozen_x=[%.6f..%.6f] axis=[%.6f..%.6f]\n",
|
||||
"frozen_x=[%.6f..%.6f] axis=[%.6f..%.6f] glance=[%s]\n",
|
||||
what, a.model->paused() ? 1 : 0, ring_produced, event_size, bounds_lo, bounds_hi,
|
||||
double(axis.min), double(axis.max));
|
||||
double(axis.min), double(axis.max), a.dbg_glance_last.c_str());
|
||||
std::fflush(stderr);
|
||||
}
|
||||
|
||||
@@ -7093,13 +7258,13 @@ gboolean export_probe(gpointer user) {
|
||||
const ExportTable table = collect_export(*a->model, *a->rows);
|
||||
std::fprintf(stderr,
|
||||
"[export-probe] mode=%s axis=[%.6f..%.6f] rows=%zu cols=%zu time=[%.6f..%.6f] "
|
||||
"stream=[%.6f..%.6f]\n",
|
||||
"abs=[%.6f..%.6f]\n",
|
||||
a->model->trigger_enabled() && a->model->event() != nullptr ? "event" : "live",
|
||||
axis.min, axis.max, table.rows(), table.columns.size(),
|
||||
table.rows() != 0 ? table.time.front() : 0.0,
|
||||
table.rows() != 0 ? table.time.back() : 0.0,
|
||||
table.rows() != 0 ? table.stream_time.front() : 0.0,
|
||||
table.rows() != 0 ? table.stream_time.back() : 0.0);
|
||||
table.rows() != 0 ? table.abs_seconds.front() : 0.0,
|
||||
table.rows() != 0 ? table.abs_seconds.back() : 0.0);
|
||||
for (const ExportTable::Column& col : table.columns) {
|
||||
std::fprintf(stderr, "[export-probe] column '%s' n=%zu\n", col.name.c_str(), col.values.size());
|
||||
}
|
||||
@@ -7246,18 +7411,25 @@ gboolean timelabels_probe_step(gpointer user) {
|
||||
const RowSet::Row& last = a->rows->row(a->rows->row_count() - 1);
|
||||
std::string ticks;
|
||||
std::string note;
|
||||
// Whether *every* major label on the axis is a different string. That, and not a span
|
||||
// threshold, is what the relative form exists to buy: two adjacent ticks reading `12.3s` are
|
||||
// not labels. It is therefore the one assertion that holds whatever the stream's uptime is --
|
||||
// which decides where the switch actually falls, since the label resolution is a function of
|
||||
// the axis' magnitude.
|
||||
bool distinct = true;
|
||||
if (last.plot != nullptr) {
|
||||
int n = 0;
|
||||
std::vector<std::string> seen;
|
||||
for (const plot::Tick& t : last.plot->x_axis(last.axis_x).ticks) {
|
||||
if (!t.major) continue;
|
||||
if (n++ != 0) ticks += ",";
|
||||
ticks += t.label;
|
||||
if (n >= 4) break;
|
||||
}
|
||||
if (last.time_origin_note < last.plot->annotation_count()) {
|
||||
const plot::Annotation& an = last.plot->annotation(last.time_origin_note);
|
||||
if (an.visible) note = an.text;
|
||||
if (std::find(seen.begin(), seen.end(), t.label) != seen.end()) distinct = false;
|
||||
seen.emplace_back(t.label);
|
||||
if (n++ < 4) {
|
||||
if (n != 1) ticks += ",";
|
||||
ticks += t.label;
|
||||
}
|
||||
}
|
||||
note = last.plot->x_note();
|
||||
}
|
||||
double first_value = 0.0;
|
||||
plot::Range shown{};
|
||||
@@ -7271,9 +7443,10 @@ gboolean timelabels_probe_step(gpointer user) {
|
||||
shown = last.plot->x_range(last.axis_x);
|
||||
}
|
||||
std::fprintf(stderr,
|
||||
"[timelabels-probe] %-12s relative=%d window=%.6g origin=%.6f note=[%s] "
|
||||
"ticks=[%s] first_value=%.6f first_rel=%.6f range=[%.6f..%.6f]\n",
|
||||
what, a->rows->time_labels_relative() ? 1 : 0, a->rows->time_window(),
|
||||
"[timelabels-probe] %-12s relative=%d distinct=%d window=%.6g origin=%.6f "
|
||||
"note=[%s] ticks=[%s] first_value=%.6f first_rel=%.6f range=[%.6f..%.6f]\n",
|
||||
what, a->rows->time_labels_relative() ? 1 : 0, distinct ? 1 : 0,
|
||||
a->rows->time_window(),
|
||||
a->rows->time_labels_origin(), note.c_str(), ticks.c_str(), first_value,
|
||||
first_value - (last.plot != nullptr ? last.plot->x_axis_origin(last.axis_x) : 0.0),
|
||||
shown.min, shown.max);
|
||||
@@ -7557,10 +7730,13 @@ gboolean measures_probe_step(gpointer user) {
|
||||
// A gesture on a measurement's own cursor, placed through the plot exactly as the
|
||||
// controller would: the next frame has to put it back, which is what "read-only" means
|
||||
// here (the library has no flag the hit test would respect).
|
||||
if (!a->measure_marks.empty()) {
|
||||
// The marks as *placed* throughout: the drag writes a plot cursor and the check below reads
|
||||
// one back, so both sides have to be in the plot's own coordinates -- and a time mark is
|
||||
// stored on the stream's clock (see `place_measure_marks()`).
|
||||
if (!a->measure_marks_display.empty()) {
|
||||
RowSet::Row& r0 = a->rows->row(0);
|
||||
if (r0.plot != nullptr) {
|
||||
const RowSet::MeasureMark& mk = a->measure_marks.front();
|
||||
const RowSet::MeasureMark& mk = a->measure_marks_display.front();
|
||||
if (mk.level) {
|
||||
r0.plot->set_cursor_y(r0.measure_h[0], mk.value + 3.0, plot::CursorPlacement::Free);
|
||||
} else {
|
||||
@@ -7573,9 +7749,13 @@ gboolean measures_probe_step(gpointer user) {
|
||||
default: {
|
||||
double restored = -1.0;
|
||||
double want = 0.0;
|
||||
if (!a->measure_marks.empty()) {
|
||||
// Both read *now*: a time mark rolls leftwards with its sample on a live axis, so the value
|
||||
// placed on the most recent frame is the only one the plot's cursor can be expected to hold
|
||||
// -- comparing against the value the drag was launched from would read 300 ms of scroll as a
|
||||
// failure to restore.
|
||||
if (!a->measure_marks_display.empty()) {
|
||||
const RowSet::Row& r0 = a->rows->row(0);
|
||||
const RowSet::MeasureMark& mk = a->measure_marks.front();
|
||||
const RowSet::MeasureMark& mk = a->measure_marks_display.front();
|
||||
want = mk.value;
|
||||
if (r0.plot != nullptr) {
|
||||
restored = mk.level ? r0.plot->cursor(r0.measure_h[0]).y
|
||||
@@ -7584,7 +7764,7 @@ gboolean measures_probe_step(gpointer user) {
|
||||
}
|
||||
std::fprintf(stderr,
|
||||
"[measures-probe] %-14s marks=%zu first=%.6f plot_now=%.6f restored=%d\n",
|
||||
"after drag", a->measure_marks.size(), want, restored,
|
||||
"after drag", a->measure_marks_display.size(), want, restored,
|
||||
std::fabs(restored - want) < 1.0e-9 ? 1 : 0);
|
||||
std::fflush(stderr);
|
||||
measure_dialog_report(*a, "final");
|
||||
@@ -8057,9 +8237,10 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
||||
app.dbg_notrace_frames, app.dbg_notrace_after_bound, app.dbg_notrace_last);
|
||||
std::printf("[udpscope] worst side/pane min width: %d px\n", app.dbg_min_w_worst);
|
||||
std::printf("[udpscope] trace ink: %d paints checked, %d collapsed, %d staircase, %d short, "
|
||||
"worst cover %.1f%%\n",
|
||||
"worst cover %.1f%%, worst drawn-vs-quoted %.4f px over %d raw paints\n",
|
||||
app.dbg_ink_checked, app.dbg_ink_collapse, app.dbg_ink_staircase,
|
||||
app.dbg_ink_short, app.dbg_ink_worst_cover * 100.0);
|
||||
app.dbg_ink_short, app.dbg_ink_worst_cover * 100.0, app.dbg_ink_worst_offset_px,
|
||||
app.dbg_ink_raw_checked);
|
||||
std::printf("[udpscope] blank view frames: %d of %d\n", app.dbg_blank_frames,
|
||||
app.dbg_axes_frames);
|
||||
std::printf("[udpscope] trigger events: %llu total, %llu at the disarm (no event after it "
|
||||
@@ -8213,8 +8394,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
||||
: 0.0);
|
||||
}
|
||||
}
|
||||
std::printf("[udpscope] badge frames: lit=%d dim=%d idle=%d\n", app.dbg_badge_frames[0],
|
||||
app.dbg_badge_frames[1], app.dbg_badge_frames[2]);
|
||||
std::printf("[udpscope] badge frames: lit=%d dim=%d idle=%d connecting=%d\n",
|
||||
app.dbg_badge_frames[0], app.dbg_badge_frames[1], app.dbg_badge_frames[2],
|
||||
app.dbg_badge_frames[3]);
|
||||
std::printf("[udpscope] trigger panel revealed before the probe toggled it: %d; "
|
||||
"signal list items=%d selected=%d; column visible after sidebar "
|
||||
"toggle off=%d\n",
|
||||
@@ -8306,24 +8488,27 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
||||
}
|
||||
{
|
||||
// The x label form: relative to the window's own start (with the caption that names
|
||||
// that start) while the span is sub-second, absolute otherwise. `x_origin_set` is read
|
||||
// back out of the plot, so this is what the axis is actually formatting with.
|
||||
// that start) when the labels could not carry the tick step absolutely, absolute
|
||||
// otherwise. `x_origin_set` is read back out of the plot, so this is what the axis is
|
||||
// actually formatting with, and `note` is the plot's own caption string.
|
||||
const RowSet::Row& rlt = rows.row(rows.row_count() - 1);
|
||||
std::string note;
|
||||
if (rlt.plot != nullptr && rlt.time_origin_note < rlt.plot->annotation_count()) {
|
||||
const plot::Annotation& a = rlt.plot->annotation(rlt.time_origin_note);
|
||||
if (a.visible) note = a.text;
|
||||
}
|
||||
if (rlt.plot != nullptr) note = rlt.plot->x_note();
|
||||
// The labels themselves, from the axis the reader looks at: the assertion is what the
|
||||
// ticks *say*, not only which mode is on.
|
||||
std::string ticks;
|
||||
bool distinct = true;
|
||||
if (rlt.plot != nullptr) {
|
||||
int shown = 0;
|
||||
std::vector<std::string> seen;
|
||||
for (const plot::Tick& t : rlt.plot->x_axis(rlt.axis_x).ticks) {
|
||||
if (!t.major) continue;
|
||||
if (shown++ != 0) ticks += ",";
|
||||
ticks += t.label;
|
||||
if (shown >= 4) break;
|
||||
if (std::find(seen.begin(), seen.end(), t.label) != seen.end()) distinct = false;
|
||||
seen.emplace_back(t.label);
|
||||
if (shown++ < 4) {
|
||||
if (shown != 1) ticks += ",";
|
||||
ticks += t.label;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The labels against the anchor they were built with: the dump runs after the last
|
||||
@@ -8340,18 +8525,23 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
||||
}
|
||||
}
|
||||
}
|
||||
std::printf("[udpscope] time labels: relative=%d origin=%.6f note=[%s] x_origin_set=%.6f "
|
||||
"ticks=[%s] first_rel=%.9f\n",
|
||||
rows.time_labels_relative() ? 1 : 0, rows.time_labels_origin(), note.c_str(),
|
||||
origin_set, ticks.c_str(), first_value - origin_set);
|
||||
std::printf("[udpscope] time labels: relative=%d distinct=%d origin=%.6f note=[%s] "
|
||||
"x_origin_set=%.6f ticks=[%s] first_rel=%.9f\n",
|
||||
rows.time_labels_relative() ? 1 : 0, distinct ? 1 : 0, rows.time_labels_origin(),
|
||||
note.c_str(), origin_set, ticks.c_str(), first_value - origin_set);
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(model.mutex());
|
||||
// `results_for` is the window the numbers describe, `win` the one on screen now: the
|
||||
// drift between them is the assertion that a measurement is a statistic of the *current*
|
||||
// window -- a timer-decoupled recomputation is what used to leave a marker outside it.
|
||||
// Both on the **stream's** clock, which is the space `measure_window` and the time marks
|
||||
// are stored in: a live axis is a fixed `[-window, 0]`, so comparing display x would read
|
||||
// no drift whatever the data did (see `App::measure_window`).
|
||||
const double s0 = model.stream_at_x0();
|
||||
const RowSet::Row& r0 = rows.row(0);
|
||||
const plot::Range win = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x) : plot::Range{};
|
||||
const plot::Range view = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x) : plot::Range{};
|
||||
const plot::Range win{view.min + s0, view.max + s0};
|
||||
const double span = app.measure_window.span();
|
||||
int in_window = 0;
|
||||
int marks = 0;
|
||||
@@ -8388,8 +8578,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
||||
"pane_w=%d pane_h=%d\n",
|
||||
app.measures.size(), app.measures_revealed ? 1 : 0,
|
||||
rows.measure_marks_placed(), app.measure_labels.size(),
|
||||
app.measures_list != nullptr ? gtk_widget_get_width(app.measures_list) : -1,
|
||||
app.measures_list != nullptr ? gtk_widget_get_height(app.measures_list) : -1);
|
||||
app.dbg_measures_pane_w, app.dbg_measures_pane_h);
|
||||
}
|
||||
std::printf("[udpscope] header: window_button_w=%d window_label_w=%d (bar_w=%d)\n",
|
||||
app.dbg_win_btn_w, app.dbg_win_label_w, app.dbg_bar_w);
|
||||
|
||||
Reference in new issue
Block a user