.. _uz_dataviewer_roadmap:
=======
Roadmap
=======
Developer reference for open ideas and shipped features of ``uz_dataviewer``.
For usage see :doc:`uz_dataviewer_usage`; for internals see :doc:`uz_dataviewer_architecture`.
The table below is the index of open work; the sections after it hold the detailed analysis for the larger items (FFT, cloud logs, node-graph follow-ups).
Open ideas
==========
.. list-table::
:header-rows: 1
:widths: 22 12 44 22
* - Idea
- Area
- Status
- Detail
* - **FFT as a plot type** (vs. the current dedicated window)
- Plots
- Deferred. The window is intentional: per-frame transforms of a large window would drop
fps, while explicit *Compute* stays responsive. Revisit only if an inline spectrum cell
is needed.
- :ref:`uz_dataviewer_design_decisions`
* - **Polars for load/perf**
- Loader
- Idea. Measure first; the hot paths (Arrow parse, NumPy pyramid/FFT) are already native,
so the gain may be small.
- —
* - **3D flux maps from ``pyuzlib``**
- Plots
- Idea. Plot machine data as 3D flux maps. Larger feature; no design yet.
- —
* - **ILA-mode (logic viewer)**
- Loader/Plots
- Idea. Read ILA data (CSV or native) and show it as a logic-analyzer view.
- —
* - **Node graph edges & polish** (input order, FFT x-axis label, unit propagation,
name collisions, source-on-derived chaining)
- Nodes
- Open follow-ups, all low / low-medium severity; not core-path bugs.
- :ref:`uz_dataviewer_node_followups`
* - **Fixed FFT bin count** (resample / Welch cap)
- FFT
- Idea. A user-set cap so huge windows compute cheaply. Trade-offs analysed below.
- :ref:`uz_dataviewer_fixed_bin_count`
* - **Move one-time ``rfft`` off the render thread**
- FFT
- Idea. Removes the ~0.5 s *Compute* freeze on native (web has no worker threads).
- :ref:`uz_dataviewer_fft_deferred`
* - **Cloud logs (authenticated file store)**
- Loader
- Proposed. Serve logs from a PocketBase file store for native + web; the client
downloads and loads them through the existing pipeline (decimation stays client-side,
local path untouched).
- :ref:`uz_dataviewer_remote_data`
* - **Large logs in the web build (out-of-core)**
- Loader
- Analysis. wasm32's ~4 GB heap blocks big logs in the browser; out-of-core via an
embedded DB or on-disk pyramid on OPFS is the fix (also removes the native RAM bound).
Deferred. The **native** lean-loading path is **shipped** (float32 channels,
large-Parquet streaming, CSV size guard, CSV→Parquet ``convert``); only the
web/out-of-core route remains.
- :ref:`uz_dataviewer_web_large_logs`
.. _uz_dataviewer_fft_deferred:
FFT — deferred analysis
=======================
Background measurements (15M-sample window, one core):
- One-time ``rfft`` + magnitude on *Compute*: **~512 ms** → ~7.5M bins (60 MB float32).
- Per-frame plotting (pyramid-backed ``decimate_range``): **~0.3 ms**.
Moving the one-time ``rfft`` off the render thread (reusing the existing ``ThreadPoolExecutor``) would remove the *Compute* freeze on native.
It is independent of the bin-count work below and is a no-op on web (no worker threads).
Power-of-2 padding
------------------
15M is 5-smooth, so NumPy's pocketfft factors it well; the cost is mostly size, not factoring.
Padding *up* to the next power-of-2 (16.7M) adds more points, bins, and memory — not a speedup.
Zero-padding only sinc-interpolates the spectrum (cosmetic, no new resolution).
Rounding the FFT length *down* to the nearest 5-smooth number avoids large prime factors, but is a micro-optimisation relevant only for pathological window sizes.
**Decision: no padding knob.**
.. _uz_dataviewer_fixed_bin_count:
Fixed bin count (resample / Welch)
----------------------------------
Goal: a user-set cap on bin count so large windows compute cheaply.
.. list-table::
:header-rows: 1
:widths: 22 18 18 20 22
* - Mode
- Bins
- Δf
- Max freq
- Trade-off
* - **Resample to N_fft**
- fixed N_fft/2+1
- 1/T — fine, kept
- lowered to N_fft/(2T)
- needs anti-alias low-pass; loses high-freq content
* - **Welch (N_seg segments, averaged)**
- fixed N_seg/2+1
- coarser (fs/N_seg)
- full fs/2, kept
- smoother PSD; pure NumPy
Key relation: ``Δf = fs/N = 1/T``, so resampling changes only the frequency *range*, not the *resolution*.
Welch keeps the full range and reduces variance by averaging periodograms.
Implementation: a new ``fft_max_bins(n)`` command (default ``0`` = full resolution, current behavior).
Resample mode needs an anti-alias FIR (NumPy, not scipy, to keep native + web on one dependency-free path); Welch is pure NumPy.
Touch-points: ``analysis.compute_fft``, ``panels/fft.py``, ``panels/analysis.py``, ``commands.py``, session round-trip, usage / architecture docs.
Open decisions before building:
#. Which mode(s) to expose — resample (keeps Δf, loses high freq), Welch (keeps full range, coarser, smoother), or both. Proposed default: Welch.
#. Anti-alias implementation — a NumPy FIR/polyphase decimator (consistent with dropping ``tsdownsample``), or add ``scipy`` (installed locally but not in requirements, and a heavy Pyodide wheel). Proposed: NumPy decimator.
#. Moving the one-time ``rfft`` off the render thread — independent of the above; reuses the existing ``ThreadPoolExecutor``. No-op on web.
.. _uz_dataviewer_remote_data:
Cloud logs via an authenticated file store (proposal)
=====================================================
.. note::
Status: **proposed, not built.**
Design / feasibility note, not a spec for existing code.
Goal: central, authenticated access to logs for both native and web builds, so analysts can open recordings without copying multi-gigabyte files by hand.
Note that a ">8 GB log" is the **CSV text** size; the same record is only ~15M rows ≈ ~1.3 GB of numeric data, which fits the web build's ~1.5 GB wasm-heap budget (see :doc:`uz_dataviewer_native_vs_web`).
Such a log already loads at full resolution in the browser and trivially on native.
The goal is **access**, not raising the memory ceiling, so the design stays small.
Constraints
-----------
- **Decimation stays client-side and unchanged** — the in-memory min/max ``Pyramid`` (``downsample.py``) does the work; the backend does no decimation.
- **The local-file path is untouched** — opening a file from disk behaves exactly as now.
- **One simple backend** — no second store, no server-side compute.
Approach
--------
Store each log as a **Parquet file** in the cloud; the client downloads it and loads it through the existing loader, producing an ordinary in-memory ``Run``.
Cloud access is purely additive and converges onto the current code path.
Backend — `PocketBase `_, a single Go binary (SQLite + auth + REST + file storage), used only as an authenticated file store:
- A ``runs`` collection: metadata (``label``, recorded date, notes, ``t_min``/``t_max``, channel list) **plus a Parquet file attachment** per run.
- Built-in auth gates who can list and download runs.
- **Store logs as file attachments, not per-sample records.** A 15M-row log as a Parquet attachment is a few-hundred-MB download; the same data as per-sample records would be 15M+ rows, far outside SQLite's comfort zone (and PocketBase has no server-side aggregation).
- **Ingest** is an offline script: convert a log (CSV/Parquet) to compact Parquet and upload it with metadata. No live/growing-log support in v1.
Client — "Open from cloud" reuses the existing loader:
#. **Authenticate** against the PocketBase instance (token / OAuth2).
#. **List** runs from the ``runs`` collection (shown in Navigation, like local runs).
#. **Download** the chosen Parquet into the Pyodide FS (web) or a temp file (native).
#. Hand it to the existing ``loader.load_file`` via ``AppState.request_load`` (``state.py``, ``loader.py``).
From step 4 on it is a normal ``Run`` with the lazily-built client ``Pyramid``, identical to a local file.
It is scriptable via ``connect(url[, token])`` + ``open_cloud(run_id)`` in ``commands.py``.
Transport is gated on ``webbridge.IS_WEB``: web uses ``js.fetch`` to pull the file into the FS then loads it; native uses ``urllib``/``requests`` on the loader thread.
Limit and future hook
---------------------
"Download then load" is still bounded by the **wasm heap on web**: a log whose *numeric* size exceeds ~1.5 GB won't fit in a browser tab (native has no such limit).
The extension, if logs ever outgrow browser memory, is **range-streaming** — fetch only the visible window and decimate it client-side (a thin range API + an async per-window cache in the plot path).
That is deliberately out of scope here and shares plumbing with the web out-of-core work (:ref:`uz_dataviewer_web_large_logs`).
Rough effort (small–medium): PocketBase schema + ingest script; a client cloud module (authenticate, list, download-to-FS, hand to ``request_load``); ``connect`` / ``open_cloud`` commands + a Navigation "Open from cloud…" entry.
Auth / multi-user is free from PocketBase.
.. _uz_dataviewer_web_large_logs:
Large logs in the web build (out-of-core)
=========================================
.. note::
Status: **analysis / design note, not built.**
Separate from the cloud-logs feature (:ref:`uz_dataviewer_remote_data`) — this is only about why the *browser* build can't open very large logs and what could fix it.
Native large logs are already handled by lean loading (see :ref:`uz_dataviewer_loader_large_logs`); this section is the harder web / larger-than-RAM case.
The problem
-----------
Example: a ~4.6 GB CSV, 21 columns (``time`` + 20 channels), ~55M rows.
Parsed to numbers that is ``time`` float64 (8 B) + 20 channels float32 (80 B) = 88 B/row × 55M ≈ **~4.8 GB**.
The web build cannot hold that, so today it decimates ``1:stride`` on load (a lossy overview) or the tab runs out of memory; native opens it fine using the PC's RAM.
The wasm32 ceiling
------------------
Pyodide runs in wasm32's single ~4 GiB linear memory (~2–3 GB usable), and everything Python touches lives there — numpy arrays, parse buffers, the CSV text, all Python objects (the basics are in :ref:`uz_dataviewer_native_web_memory`).
The loader materialises the whole dataset and builds the pyramid in that same memory, so ~4.8 GB doesn't fit.
This cap is on linear memory (RAM), not on what the tab can store overall — which is why disk-backed storage (OPFS) can help even in the same tab.
Mitigations
-----------
.. list-table::
:header-rows: 1
:widths: 40 25 35
* - Option
- Helps?
- Cost
* - **Decimate-on-load** (current)
- Loads a coarse *overview* only — no full-res zoom
- already done; lossy
* - **Shrink the footprint** (load only selected channels; store ``y`` as ``float16``/``int16``)
- ~2–4× more headroom
- cheap, partial; precision loss
* - **Out-of-core via OPFS** (embedded DB or on-disk pyramid)
- **Real fix** — data on disk, not in the 4 GB RAM
- significant change
* - **wasm64 / memory64**
- Raises the address space past 4 GB
- needs a memory64 Pyodide + imgui_bundle build and broad browser support; not practical yet
* - **Web Workers / SharedArrayBuffer**
- **No** — still 32-bit linear memory per module
- improves responsiveness, not capacity
Out-of-core via OPFS
--------------------
A browser tab has two separate memory pools, and the 4 GB cap applies to only one:
#. **wasm linear memory (RAM)** — the single ~4 GB buffer. This is the wall.
#. **OPFS (Origin Private File System)** — large, persistent, disk-backed storage for the origin, not part of linear memory.
An embedded database compiled to wasm (DuckDB-wasm, or SQLite-wasm with the OPFS VFS) keeps its tables in OPFS (disk) and queries out-of-core: it pages only the data it needs into a bounded buffer, scans, frees it, and returns a small result (~``max_points`` per signal).
So the full dataset lives on disk while only a bounded working set ever occupies RAM — the dataset can be far larger than 4 GB even in the same tab.
This only works if the app **never materialises the whole dataset in RAM** (today it does the equivalent of ``SELECT *`` into numpy).
Out-of-core means querying the visible window per view: zoomed in → a range query (few rows, exact); zoomed out → a min/max-per-bucket query for the overview; fetched async + cached, debounced on pan/zoom settle.
Engines
-------
- **Native is also bound by RAM today** — the loader reads the whole log into memory; native just has a far higher ceiling (the PC's RAM). The load *peak* is already handled (lean loading, :ref:`uz_dataviewer_loader_large_logs`); the *resident* bound is what out-of-core removes.
- **pandas** is in-memory only; chunked reads give no random-access "visible window" model.
- **Polars** (lazy + streaming over Parquet, with predicate/projection pushdown) and **DuckDB over Parquet** are genuinely out-of-core.
- **Where it runs matters.** On native, Polars/DuckDB over Parquet is the easy path and would lift the native RAM bound too. In the browser the Python builds aren't set up for Pyodide + OPFS, so the practical engine is **DuckDB-wasm** reading from OPFS, or our own on-disk pyramid in OPFS.
Two approaches (not chosen)
---------------------------
**A. DuckDB-wasm + OPFS.** Store the log as Parquet/tables in OPFS; do range filters and min/max-per-bucket in SQL. Least bespoke code, but adds a large second wasm runtime (tens of MB to download) and moves the decimation into the DB.
**B. On-disk pyramid in OPFS (no DB).** A one-time streaming ingest reads the file in chunks (never holding it all in RAM) and writes columnar data plus our min/max ``Pyramid`` levels into OPFS; the client then reads small levels/ranges per view. Keeps our decimation and needs no heavy dependency, but is more bespoke out-of-core code.
Both need a one-time ingest pass into OPFS, and both share the in-app **"windowed run"** abstraction (a run whose arrays are fetched per visible window, async + cached) — the same plumbing the cloud feature would use, only the source differs.
Scope
-----
Substantial: a new run type in ``model.py`` and a shift in ``panels/plots.py`` from per-frame in-memory decimation to an async windowed fetch + cache.
The native path is unaffected.
The A-vs-B choice is deferred until this is scheduled.
.. _uz_dataviewer_node_followups:
Nodes — known limitations & follow-ups
======================================
From a review of the node-graph feature (engine in ``nodes.py`` / ``transforms.py``, canvas in ``panels/nodes.py``).
The review fixed three items first: transitive staleness (a downstream node reads ``(stale)`` when an upstream changes), op-aware input-pin count on ``math`` nodes, and skip-fresh evaluation (``node_eval`` only recomputes stale nodes).
The items below are edges and polish, not core-path bugs.
A. **Binary math input order is implicit (connection order).** ``A-B`` / ``A/B`` take ``inputs[0]`` and ``inputs[1]`` in the order the links were *created*, not by which input pin (``in1``/``in2``) the user dropped onto.
The editor reports the target slot in ``query_new_link``, but ``NodesPanel._try_link`` ignores it and ``node_link`` just appends.
Severity low–medium (wrong order silently swaps A and B).
Fix: decode the dropped slot and have ``node_link`` place the source at that index (a small command-grammar change to carry an optional slot).
B. **FFT-derived run's x-axis is frequency but plots label it "time".** An ``fft`` node's derived run uses ``time = freqs``, so dropping it into a time-series cell shows a spectrum under an x-axis labelled "time", and **Link X** would couple a frequency axis to time axes.
Severity low (cosmetic / semantic).
Fix: tag derived runs with an x-axis kind/label; have ``PlotsPanel`` honour it and exclude frequency-axis runs from link-X.
C. **Units are not propagated to derived signals.** Every derived signal is created with unit ``""``.
For ``filter`` and ``offset`` the physical unit is unchanged and could be carried over; ``scale``/``derivative``/``integral`` change it.
Severity low.
Fix: pass the input unit through for unit-preserving ops in ``nodes._compute`` / ``upsert_derived_run``.
D. **Name / label collisions.** Derived runs are labelled by node name (``node_`` by default).
If a loaded file is named like a node, or two nodes share a label, ``_label_to_run_id`` resolves to the first match; ``node_rename`` checks other node names but not loaded run labels.
Severity low (needs a deliberate clash).
Fix: validate node names against ``registry`` labels too and reject duplicates.
E. **Source-on-derived chaining is fragile across restore/script.** A source node wrapping *another node's* derived run works at runtime (the run id is valid) but may not survive save/restore or ``.uzscript`` replay, because the derived run does not exist until its producing node is evaluated.
Chaining is meant to go through ``node_link`` (transform → transform).
Severity low.
Fix: resolve such source refs lazily at eval time, or block creating a source from a derived run and steer users to ``node_link``.
Shipped
=======
Items from the original idea list now implemented (see :doc:`uz_dataviewer_usage` / :doc:`uz_dataviewer_architecture`):
- **Scriptable command API** — every click is a logged, replayable command; unified grammar.
- **Command console** — the bottom log shows commands and accepts command input, with completion and history.
- **Zoom/gesture echo** — e.g. a zoom emits ``set_x_lim(plot_1, min, max)`` to the log.
- **Save / restore state** — JSON snapshot and an editable, replayable ``.uzscript``.
- **Nodes** — drag a signal into a canvas, apply a transform, get a new draggable signal; scriptable (``node_*``) and extensible via **plugins** (:doc:`uz_dataviewer_plugins`).
- **More plot types** — XY plot; **Histogram** (a dedicated window, like FFT).
- **Show samples** — per-sample markers on line plots.
- **Spy** — drag a rectangle; an inset below shows only that region.
- **MaterialFlat theme.**
- **Pyodide/web downsampling** — no ``tsdownsample`` wheel needed: the decimator is a pure-NumPy min/max pyramid, so native and web share one code path.
- **Native lean loading** — float32-at-parse, large-Parquet streaming, CSV size guard, and a CSV→Parquet ``convert`` command/CLI (see :ref:`uz_dataviewer_loader_large_logs`).