feat: the x axis is relative to now, and an export carries both clocks
Three asked-for changes, one commit because they are one coordinate system. The live axis is a fixed [-window, 0] with the newest sample at 0, so x reads as an age and the labels stand still while the trace rolls under them. That makes the display clock distinct from the stream clock for the first time, so `ScopeModel::stream_at_x0()` becomes the single conversion between them (`display_x = stream_t - stream_at_x0()` in all four modes) and a snapshot carries its own origin in `EventSnapshot::x_offset`. The pause view is now rebased like an event's, reversing an earlier deliberate choice: a frozen absolute-stamped picture would sit a whole uptime right of a newest-relative axis. That also fixed a latent bug, the export adding `trigger_time()` to a *paused* view's stamps. The status bar says how long you have been paused -- wall time, since the producer never stops, through `format_elapsed()` so the field cannot widen. The export writes two time columns: `time`, the axis as displayed, and `abs_seconds`, the stream's own clock for the same sample. f64 seconds and not the wire's integer, because `udps_frame_t::values` is a `const double*` and the integer never exists in this process. Cursors and measurement marks move in opposite directions under the rolling axis, deliberately: a cursor is a position on screen and stays put, a mark names a sample and travels with it. `App::measure_window` is stream-clocked for a sharper reason -- a display-coordinate live window never moves, so the "the window moved" recompute would never fire again. Moves the deps/plotter pin to 58473db, which stops a time label's mantissa reaching its own radix: a one-second tick differenced off a live axis read `1e+3ms`. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
2f4530c128
commit
b313cac46e
18 files changed
+501
-145
No files matched your search
@@ -63,16 +63,66 @@ 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.
|
||||
|
||||
## 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**
|
||||
@@ -103,8 +153,11 @@ annotation's anchor is a *data* coordinate, so the caption crawled across the pl
|
||||
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 copies out —
|
||||
the cursors' `T0`/`T1`/`dT`, a measurement, an export — stays in absolute stream seconds.
|
||||
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
|
||||
@@ -1107,6 +1160,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
|
||||
@@ -1133,6 +1196,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.
|
||||
|
||||
@@ -1409,16 +1473,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
|
||||
|
||||
Reference in new issue
Block a user