diff --git a/CLAUDE.md b/CLAUDE.md index 77962a5..fa217a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are 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 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 value ring's `produced()`, so publishing before each channel of the latching sample 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 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 `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 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 @@ -239,6 +257,20 @@ far the largest file. 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 — `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 through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via `find_labelled_button()`. The Arm/Disarm button in the trigger panel still has text. diff --git a/CMakeLists.txt b/CMakeLists.txt index 3be140a..8eef129 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -39,6 +39,7 @@ add_executable(udpscope src/trigger.cpp src/acquisition.cpp src/plot_host.cpp + src/export.cpp src/ui.cpp ) diff --git a/README.md b/README.md index 0ad078a..4fbee96 100644 --- a/README.md +++ b/README.md @@ -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/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/export.* the visible range as a table, and the .npy / .csv writers 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 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 -data**, **Cursors**, **Reset cursors** and the **Signal list** toggle — the things -you set up once. The side column and the two panel toggles stay hidden until a +data**, **Cursors**, **Reset cursors**, **Export visible range…** and the +**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 — and the first CONFIG reveals the signal panel (the trigger panel stays behind its 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 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), 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 @@ -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 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 ` #include #include +#include +#include #include #include #include @@ -255,6 +258,14 @@ std::string format_window(double s) { 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 ----------------------------------------------------------- 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(); } +// --- 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 ctx(static_cast(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(user); + auto ctx = std::make_unique(); + 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 ------------------------------------------------------- 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)); + 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"); 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); @@ -2007,6 +2110,52 @@ gboolean pause_probe(gpointer user) { 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(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) { App* a = static_cast(user); // 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")) { g_timeout_add(static_cast(std::atoi(env)), pause_probe, &app); } + if (const char* env = g_getenv("UDPSCOPE_EXPORT_MS")) { + g_timeout_add(static_cast(std::atoi(env)), export_probe, &app); + } // Our own options were consumed by parse_args(); GTK would reject them, so only // the program name is handed to g_application_run().