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:
Martino FerrariandClaude Opus 4.6 committed 2026-10-05 21:49:12 +02:00
1 parent 2f4530c128
commit b313cac46e
18 files changed
+501 -145

No files matched your search

+16 -6
View File
@@ -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
+23 -19
View File
@@ -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
View File
@@ -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.
///
+9 -3
View File
@@ -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>
+5
View File
@@ -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.
+2
View File
@@ -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>
+5
View File
@@ -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>
+6 -4
View File
@@ -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>.
+4 -2
View File
@@ -12,8 +12,10 @@ The bar is always visible under the plot. Click <b>ⓘ</b> for the detail popove
• 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.
<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.
• 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
+10 -4
View File
@@ -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
View File
@@ -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_; }
+15 -3
View File
@@ -178,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);
@@ -191,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.
@@ -399,8 +413,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.
+6
View File
@@ -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";
+12 -2
View File
@@ -44,10 +44,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
+135 -32
View File
@@ -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,7 +257,17 @@ 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.
@@ -1978,8 +2000,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;
@@ -1994,6 +2024,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");
@@ -2016,7 +2047,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) {
@@ -2876,6 +2912,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.
///
@@ -2890,12 +2939,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
@@ -2918,16 +2972,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;
@@ -3131,11 +3185,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.
@@ -4865,16 +4923,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
@@ -5468,6 +5547,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;
@@ -6625,8 +6710,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());
@@ -7039,11 +7127,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);
}
@@ -7115,13 +7206,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());
}
@@ -7587,10 +7678,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 {
@@ -7603,9 +7697,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
@@ -7614,7 +7712,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");
@@ -8384,8 +8482,13 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
// `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;