cleaned up ui added exporter
This commit is contained in:
1 parent
8059b90de7
commit
ea560ed272
4 files changed
+225
-3
No files matched your search
@@ -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.
|
||||
|
||||
@@ -39,6 +39,7 @@ add_executable(udpscope
|
||||
src/trigger.cpp
|
||||
src/acquisition.cpp
|
||||
src/plot_host.cpp
|
||||
src/export.cpp
|
||||
src/ui.cpp
|
||||
)
|
||||
|
||||
|
||||
@@ -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 `<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
|
||||
|
||||
These are known deviations, found by a spec/code review, that are deliberate for
|
||||
|
||||
+152
@@ -16,6 +16,7 @@
|
||||
|
||||
#include "acquisition.hpp"
|
||||
#include "config.hpp"
|
||||
#include "export.hpp"
|
||||
#include "model.hpp"
|
||||
#include "plot_host.hpp"
|
||||
|
||||
@@ -26,6 +27,8 @@
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -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<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 -------------------------------------------------------
|
||||
|
||||
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<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) {
|
||||
App* a = static_cast<App*>(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<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
|
||||
// the program name is handed to g_application_run().
|
||||
|
||||
Reference in new issue
Block a user