cleaned up ui added exporter

This commit is contained in:
Martino Ferrari committed 2026-10-01 16:16:17 +02:00
1 parent 8059b90de7
commit ea560ed272
4 files changed
+225 -3

No files matched your search

+32
View File
@@ -45,6 +45,9 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
`UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes. `UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes.
- `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are - `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are
measured on a still picture. measured on a still picture.
- `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.
**Run one self-check at a time.** Two concurrent `xvfb-run -a` instances end up on the **Run one self-check at a time.** Two concurrent `xvfb-run -a` instances end up on the
same display and the second gets no GL context — it dumps `layout over 0 frames` and same display and the second gets no GL context — it dumps `layout over 0 frames` and
@@ -106,6 +109,17 @@ Two lines of the self-check output are assertions in disguise:
is the same class of bug, not rounding: `publish_event()` clamps the slice to every is the same class of bug, not rounding: `publish_event()` clamps the slice to every
value ring's `produced()`, so publishing before each channel of the latching sample value ring's `produced()`, so publishing before each channel of the latching sample
has been pushed silently drops it. has been pushed silently drops it.
- `[export-probe] mode=.. axis=[a..b] rows=N cols=M time=[c..d] stream=[..]` (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 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
either writer.
The probes are the regression net — when changing UI or plot-host behaviour, extend 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. the probe/dump in `src/ui.cpp` rather than adding a separate harness.
@@ -154,6 +168,10 @@ Layers to keep straight:
the live follow (the *group's* — a member's own axis must be `XFollow::Off`, since the live follow (the *group's* — a member's own axis must be `XFollow::Off`, since
`Figure::link_x()` refuses a `Live` member), the left gutter and the crosshair. `Figure::link_x()` refuses a `Live` member), the left gutter and the crosshair.
- `ExportTable` (`src/export.hpp`) — the visible window as columns, collected from the
snapshot or the live rings and handed to the `.npy`/`.csv` writers. Always the stored
samples, never the LOD pyramid's buckets.
`src/ui.cpp` is the GTK4 window (toolbar, trigger panel, signal panel, plot box) plus `src/ui.cpp` is the GTK4 window (toolbar, trigger panel, signal panel, plot box) plus
the `tick()` frame function that binds, updates the trigger state and rewrites the the `tick()` frame function that binds, updates the trigger state and rewrites the
status line. One `plot::gtk4::PlotArea` in figure mode draws the whole figure. It is by status line. One `plot::gtk4::PlotArea` in figure mode draws the whole figure. It is by
@@ -239,6 +257,20 @@ far the largest file.
every value ring.** That is what makes `stamps` and each `values[c]` equal-length every value ring.** That is what makes `stamps` and each `values[c]` equal-length
and index-aligned by construction instead of by two truncations that can disagree — and index-aligned by construction instead of by two truncations that can disagree —
`EventSeries::gather()` indexes both at the same `i`. `EventSeries::gather()` indexes both at the same `i`.
- **`live_window_locked()` is the one live cut, shared by `publish_event()` and the
export.** Both need the same absolute index range over the time ring and every value
ring, or an export and a capture of the same window would hold different samples. Call
it under `mutex()`.
- **A trace's colour is indexed by *signal*, not by trace slot.** `Row::color_index`
stores it and `signal_color_index()` (ui.cpp) computes it from the signal list, so a
signal dropped on two rows is one colour and the signal panel's chip names a trace
unambiguously. `plot::Plot` fixes a colour at `add_trace()`, so changing one needs a
figure rebuild — which `assign()` already forces. A signal on no plot gets
`RowSet::unplotted_color()` (grey) rather than no chip, so the panel's rows stay the
same height.
- **The export is collected at click time, not in the file-dialog callback.** The live
view keeps scrolling while the chooser is up, so a late collection would write a
window the user never saw. The writer is picked by the chosen path's extension.
- **The header bar's buttons are icon-only**, so headless probes must reach them - **The header bar's buttons are icon-only**, so headless probes must reach them
through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via
`find_labelled_button()`. The Arm/Disarm button in the trigger panel still has text. `find_labelled_button()`. The Arm/Disarm button in the trigger panel still has text.
+1
View File
@@ -39,6 +39,7 @@ add_executable(udpscope
src/trigger.cpp src/trigger.cpp
src/acquisition.cpp src/acquisition.cpp
src/plot_host.cpp src/plot_host.cpp
src/export.cpp
src/ui.cpp src/ui.cpp
) )
+40 -3
View File
@@ -44,6 +44,7 @@ src/channel.* ChannelBuffer (a ring pair as a plot::data::Series) and SlotS
src/acquisition.* the UDPStreamer client, the producer thread, frame decoding src/acquisition.* the UDPStreamer client, the producer thread, frame decoding
src/trigger.* the trigger configuration and the producer-side state machine src/trigger.* the trigger configuration and the producer-side state machine
src/plot_host.* the one plot::Figure, its rows' traces and cursors, the x link group src/plot_host.* the one plot::Figure, its rows' traces and cursors, the x link group
src/export.* the visible range as a table, and the .npy / .csv writers
src/ui.* the GTK4 window: toolbar, trigger panel, signal panel, plot box src/ui.* the GTK4 window: toolbar, trigger panel, signal panel, plot box
``` ```
@@ -188,8 +189,8 @@ assumed.
pause/resume, the trigger toggle, the side-panel toggle and an overflow menu. Those pause/resume, the trigger toggle, the side-panel toggle and an overflow menu. Those
four buttons are icons with tooltips; nine text buttons was a wall of words to read four buttons are icons with tooltips; nine text buttons was a wall of words to read
past every time you wanted the one you needed. The overflow menu holds **Fit to past every time you wanted the one you needed. The overflow menu holds **Fit to
data**, **Cursors**, **Reset cursors** and the **Signal list** toggle — the things data**, **Cursors**, **Reset cursors**, **Export visible range…** and the
you set up once. The side column and the two panel toggles stay hidden until a **Signal list** toggle — the things you set up once. The side column and the two panel toggles stay hidden until a
CONFIG arrives — there is nothing to configure before the stream describes itself — CONFIG arrives — there is nothing to configure before the stream describes itself —
and the first CONFIG reveals the signal panel (the trigger panel stays behind its and the first CONFIG reveals the signal panel (the trigger panel stays behind its
own toggle). own toggle).
@@ -239,7 +240,19 @@ latch, which the status line reports.
The signal panel is one line per signal: a colour chip naming the trace it is drawn The signal panel is one line per signal: a colour chip naming the trace it is drawn
as, a checkbox that acquires it, its name, its unit, and a `⋯` menu holding the as, a checkbox that acquires it, its name, its unit, and a `⋯` menu holding the
information popup and the gain/offset calibration popup. A `GtkSwitch` and two information popup and the gain/offset calibration popup.
**The chip's colour belongs to the signal, not to the slot it landed in.** The
palette is indexed by position in the signal list (`signal_color_index()`), so a
signal dropped on two rows is the same colour in both and the chip names a trace
unambiguously; indexing by trace slot made the same signal blue on row 1 and orange
on row 2, and then the chip named neither. A signal that is on no plot — acquired or
not — gets a **grey** chip rather than no chip, so every row of the panel is the same
shape and the column of chips reads as a legend. Because `plot::Plot` fixes a trace's
colour at `add_trace()`, the index is stored on the row (`Row::color_index`) and a
colour change goes through a figure rebuild, which a drop already forces.
A `GtkSwitch` and two
always-visible buttons made a row about twice as tall (46 px against 26 px now), always-visible buttons made a row about twice as tall (46 px against 26 px now),
which is what turned a four-signal stream into a panel you have to scroll; Adwaita's which is what turned a four-signal stream into a panel you have to scroll; Adwaita's
button and list-row minimums, not the widgets themselves, set that height, so the button and list-row minimums, not the widgets themselves, set that height, so the
@@ -342,6 +355,30 @@ the widget keeps its context. A displayed event survives the rebuild because the
pinned `[-pre, +post]` window is restated rather than re-derived from the live pinned `[-pre, +post]` window is restated rather than re-derived from the live
edge. edge.
**Export visible range…** (overflow menu) writes every signal bound to a visible row,
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.
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. |
| `.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
dependency for a feature whose output a three-line reader already handles.
The table is collected when the menu item is clicked, not when the dialog is
answered: a live view keeps scrolling while the file chooser is up. The live cut goes
through `ScopeModel::live_window_locked()`, the same index range `publish_event()`
uses, so an export and a capture of the same window hold the same samples.
## Review findings not (yet) implemented ## Review findings not (yet) implemented
These are known deviations, found by a spec/code review, that are deliberate for These are known deviations, found by a spec/code review, that are deliberate for
+152
View File
@@ -16,6 +16,7 @@
#include "acquisition.hpp" #include "acquisition.hpp"
#include "config.hpp" #include "config.hpp"
#include "export.hpp"
#include "model.hpp" #include "model.hpp"
#include "plot_host.hpp" #include "plot_host.hpp"
@@ -26,6 +27,8 @@
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <cstring> #include <cstring>
#include <ctime>
#include <memory>
#include <mutex> #include <mutex>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -255,6 +258,14 @@ std::string format_window(double s) {
return buf; return buf;
} }
/// A short-lived line in front of the status text: the outcome of an action that the
/// picture does not show by itself (a drop that changed nothing, an export that wrote a
/// file). `tick()` clears it when it expires.
void set_notice(App& app, std::string what, int seconds = 4) {
app.notice = std::move(what);
app.notice_until_us = g_get_monotonic_time() + seconds * G_TIME_SPAN_SECOND;
}
// --- plot widgets ----------------------------------------------------------- // --- plot widgets -----------------------------------------------------------
void on_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer user); void on_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer user);
@@ -864,6 +875,89 @@ void on_cursors_reset(GtkButton*, gpointer user) {
app->rows->reset_cursors(); app->rows->reset_cursors();
} }
// --- export -----------------------------------------------------------------
/// The table travels to the async callback. It is collected when the button is clicked,
/// not when the dialog is answered: a live view keeps scrolling while the file chooser
/// is up, so collecting late would write a window the user never saw.
struct ExportContext {
App* app = nullptr;
ExportTable table;
};
void on_export_response(GObject* source, GAsyncResult* result, gpointer user) {
std::unique_ptr<ExportContext> ctx(static_cast<ExportContext*>(user));
GError* err = nullptr;
GFile* file = gtk_file_dialog_save_finish(GTK_FILE_DIALOG(source), result, &err);
if (file == nullptr) {
// A cancelled dialog is not a failure and says nothing; anything else does.
if (err != nullptr && !g_error_matches(err, GTK_DIALOG_ERROR, GTK_DIALOG_ERROR_DISMISSED)) {
set_notice(*ctx->app, std::string("export failed: ") + err->message);
}
if (err != nullptr) g_error_free(err);
return;
}
char* raw = g_file_get_path(file);
const std::string path = raw != nullptr ? raw : std::string();
g_free(raw);
g_object_unref(file);
if (path.empty()) {
set_notice(*ctx->app, "export failed: that location is not a local file");
return;
}
// The extension picks the writer, so the filter the user chose is honoured by way of
// the name it put in the entry rather than by a second piece of state.
const bool csv = path.size() >= 4 && path.compare(path.size() - 4, 4, ".csv") == 0;
std::string error;
const bool ok = csv ? write_csv(path, ctx->table, error) : write_npy(path, ctx->table, error);
if (!ok) {
set_notice(*ctx->app, "export failed: " + error);
return;
}
set_notice(*ctx->app, "exported " + std::to_string(ctx->table.rows()) + " samples x " +
std::to_string(ctx->table.columns.size()) + " signals to " + path);
}
void on_export_clicked(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
auto ctx = std::make_unique<ExportContext>();
ctx->app = app;
ctx->table = collect_export(*app->model, *app->rows);
if (ctx->table.empty()) {
set_notice(*app, "nothing to export: no signal is plotted in the visible range");
return;
}
GtkFileDialog* dialog = gtk_file_dialog_new();
gtk_file_dialog_set_title(dialog, "Export visible range");
char stamp[32];
const std::time_t now = std::time(nullptr);
std::tm tm{};
localtime_r(&now, &tm);
std::strftime(stamp, sizeof(stamp), "%Y%m%d-%H%M%S", &tm);
gtk_file_dialog_set_initial_name(dialog, (std::string("udpscope-") + stamp + ".npy").c_str());
GListStore* filters = g_list_store_new(GTK_TYPE_FILE_FILTER);
GtkFileFilter* npy = gtk_file_filter_new();
gtk_file_filter_set_name(npy, "NumPy array (*.npy)");
gtk_file_filter_add_pattern(npy, "*.npy");
g_list_store_append(filters, npy);
GtkFileFilter* csv = gtk_file_filter_new();
gtk_file_filter_set_name(csv, "CSV (*.csv)");
gtk_file_filter_add_pattern(csv, "*.csv");
g_list_store_append(filters, csv);
gtk_file_dialog_set_filters(dialog, G_LIST_MODEL(filters));
gtk_file_dialog_set_default_filter(dialog, npy);
g_object_unref(npy);
g_object_unref(csv);
g_object_unref(filters);
gtk_file_dialog_save(dialog, GTK_WINDOW(app->window), nullptr, on_export_response,
ctx.release());
g_object_unref(dialog);
}
// --- right-click menu ------------------------------------------------------- // --- right-click menu -------------------------------------------------------
void on_y_auto_clicked(GtkButton* button, gpointer user) { void on_y_auto_clicked(GtkButton* button, gpointer user) {
@@ -1570,6 +1664,15 @@ GtkWidget* build_toolbar(App& app) {
gtk_box_append(GTK_BOX(menu_box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); gtk_box_append(GTK_BOX(menu_box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
GtkWidget* export_btn = gtk_button_new_with_label("Export visible range...");
gtk_widget_set_tooltip_text(export_btn,
"write every plotted signal over the visible x range, at the "
"stored sample rate, to a .npy or .csv file");
g_signal_connect(export_btn, "clicked", G_CALLBACK(on_export_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), export_btn);
gtk_box_append(GTK_BOX(menu_box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
app.signal_toggle = gtk_toggle_button_new_with_label("Signal list"); app.signal_toggle = gtk_toggle_button_new_with_label("Signal list");
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.signal_toggle), true); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.signal_toggle), true);
g_signal_connect(app.signal_toggle, "toggled", G_CALLBACK(on_signals_toggled), &app); g_signal_connect(app.signal_toggle, "toggled", G_CALLBACK(on_signals_toggled), &app);
@@ -2007,6 +2110,52 @@ gboolean pause_probe(gpointer user) {
return G_SOURCE_REMOVE; return G_SOURCE_REMOVE;
} }
/// Headless self-check for the export: collect the same table the menu item would and
/// write it both ways, skipping only the file chooser. Two numbers are the assertion --
/// `time=` must equal `axis=` (the export is the *visible* range, not the whole ring)
/// and `rows=` must be that span times the stream's real rate, so a table cut from the
/// LOD pyramid or from a stale window shows up as a count far short of it.
gboolean export_probe(gpointer user) {
App* a = static_cast<App*>(user);
const char* env = g_getenv("UDPSCOPE_EXPORT_PATH");
const std::string prefix = env != nullptr ? env : "/tmp/udpscope-export";
const RowSet::Row& r0 = a->rows->row(0);
const plot::Range axis = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x) : plot::Range{0.0, 0.0};
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",
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);
for (const ExportTable::Column& col : table.columns) {
std::fprintf(stderr, "[export-probe] column '%s' n=%zu\n", col.name.c_str(), col.values.size());
}
for (const char* ext : {".npy", ".csv"}) {
const std::string path = prefix + ext;
std::string error;
const bool ok = std::strcmp(ext, ".csv") == 0 ? write_csv(path, table, error)
: write_npy(path, table, error);
long bytes = -1;
if (ok) {
if (std::FILE* f = std::fopen(path.c_str(), "rb"); f != nullptr) {
std::fseek(f, 0, SEEK_END);
bytes = std::ftell(f);
std::fclose(f);
}
}
std::fprintf(stderr, "[export-probe] %s %s bytes=%ld%s%s\n", path.c_str(),
ok ? "written" : "FAILED", bytes, ok ? "" : " error=", ok ? "" : error.c_str());
}
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean exit_probe_step1(gpointer user) { gboolean exit_probe_step1(gpointer user) {
App* a = static_cast<App*>(user); App* a = static_cast<App*>(user);
// Record the panel states before the toggles are exercised. // Record the panel states before the toggles are exercised.
@@ -2055,6 +2204,9 @@ int run_ui(int argc, char** argv, ScopeModel& model, Acquisition& acq, RowSet& r
if (const char* env = g_getenv("UDPSCOPE_PROBE_PAUSE_MS")) { if (const char* env = g_getenv("UDPSCOPE_PROBE_PAUSE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), pause_probe, &app); g_timeout_add(static_cast<guint>(std::atoi(env)), pause_probe, &app);
} }
if (const char* env = g_getenv("UDPSCOPE_EXPORT_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), export_probe, &app);
}
// Our own options were consumed by parse_args(); GTK would reject them, so only // Our own options were consumed by parse_args(); GTK would reject them, so only
// the program name is handed to g_application_run(). // the program name is handed to g_application_run().