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
+135
-32
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user