added data saving feature

This commit is contained in:
Ayzen
2026-06-23 12:19:31 +03:00
parent 716fd0b07a
commit 8db14b9482
20 changed files with 1192 additions and 63 deletions
@@ -336,6 +336,7 @@ class AppWindowConfigProfileIOMixin:
self._legacy_gpr_visible_z_min_m,
self._legacy_gpr_visible_z_max_m,
self._save_count,
self._record_count,
self._save_path_input,
self._save_name_input,
self._adc_project_dir_input,
@@ -522,6 +523,7 @@ class AppWindowConfigProfileIOMixin:
self._legacy_gpr_visible_z_max_m.setValue(float(gui_state.processing.legacy_gpr.visible_z_max_m))
self._save_count.setValue(int(gui_state.data_actions.save_count))
self._record_count.setValue(int(gui_state.data_actions.record_count))
self._save_path_input.setText(str(gui_state.data_actions.save_path))
self._save_name_input.setText(str(gui_state.data_actions.save_name))
@@ -273,6 +273,7 @@ class AppWindowConfigStateBuildersMixin:
save_count=10,
save_path=str(self._project_root / "python_app/data/snapshots"),
save_name="snapshot_manual",
record_count=100,
),
preprocess_dialog=GuiPreprocessDialogStateModel(
set_name="set_001",
@@ -404,6 +405,7 @@ class AppWindowConfigStateBuildersMixin:
save_count=int(self._save_count.value()),
save_path=self._save_path_input.text().strip(),
save_name=self._save_name_input.text().strip(),
record_count=int(self._record_count.value()),
),
preprocess_dialog=GuiPreprocessDialogStateModel(
set_name=self._current_preprocess_set_name(),
@@ -281,6 +281,9 @@ class AppWindowPipelineMixin:
else:
self._supervisor.stop()
self._drain_rings_once_for_history()
# Flush any in-progress disk recording so its buffered (not-yet-full) chunk is
# written rather than lost; the drains above already captured the last data.
self._finalize_recording_on_stop()
keep_results_reader = self._supervisor.is_processor_running()
self._close_readers(keep_results=keep_results_reader)
self._single_capture_active = False
@@ -340,6 +343,7 @@ class AppWindowPipelineMixin:
self._read_all_raw()
self._read_all_preprocessed()
result_latest = self._read_all_results() if self._result_reader is not None else None
self._poll_recording_writer() # finalize a disk recording once its writer drains
self._update_history_indicator()
self._last_reader_error_signature = None
self._reader_error_repeat_count = 0
@@ -550,6 +554,7 @@ class AppWindowPipelineMixin:
if collection is None:
break
self._raw_history.append(collection)
self._record_collection("raw", collection)
latest = collection
# `capture_*_ns` are populated by the C++ sweep_orchestrator with
# wallclocks captured around the actual device read. Pre-orchestrator
@@ -573,6 +578,7 @@ class AppWindowPipelineMixin:
if collection is None:
break
self._pre_history.append(collection)
self._record_collection("preprocessed", collection)
def _read_all_results(self) -> ResultCollection | None:
"""Read available result collections from results ring."""
@@ -585,6 +591,7 @@ class AppWindowPipelineMixin:
break
self._pipeline_metrics.record("processing", int(collection.processing_duration_ns))
record_result_history(self._result_history, collection)
self._record_collection("results", collection)
latest = collection
return latest
@@ -0,0 +1,334 @@
"""Record the next N runtime measurements to disk, in chunks, on a background thread.
The manual "Save Dataset" button persists what is *already* in history (capped at the
GUI history limit). This mixin adds the complementary "Start + Record" action: arm a
recording, then stream the measurements that arrive *after* arming up to the
configured count to disk without ever blocking acquisition.
Key properties:
* **Flat memory.** Measurements are flushed in chunks of :data:`_RECORDING_CHUNK_SIZE`
and freed, and at most :data:`_RECORDING_QUEUE_CHUNKS` chunks are ever in flight, so
RAM does not grow with the recording length.
* **Off the GUI thread.** A single background writer thread does all disk I/O; the GUI
poll tick only hands it chunks. The writes never stall acquisition or the web UI. If
the disk genuinely cannot keep up, the bounded queue applies brief backpressure
rather than growing memory without limit.
* **Aligned at capture.** Each result is paired with its raw/preprocessed collection by
``collection_id`` as it arrives, so no cross-stage buffering is needed.
* **Only new sweeps.** Collections produced before the arm instant are ignored (gated
on ``monotonic_ns``), so a pre-existing ring backlog is not recorded.
* **No double-arm.** Arming is refused while a recording is still in progress.
The on-disk layout is identical to "Save Dataset" (one streaming dataset directory).
"""
from __future__ import annotations
from contextlib import suppress
import logging
from pathlib import Path
import queue
import threading
import time
logger = logging.getLogger(__name__)
# Flush to disk every this many results, then drop them from memory.
_RECORDING_CHUNK_SIZE = 200
# Max chunks queued for the writer thread before the producer applies backpressure;
# bounds in-flight memory to roughly this many chunks of measurements.
_RECORDING_QUEUE_CHUNKS = 8
# Safety bound on raw/preprocessed collections still waiting for their result (normally
# a handful). If the processor stalls, the oldest are dropped so the maps stay bounded.
_RECORDING_MAX_PENDING = 512
class AppWindowRecordingMixin:
"""Stream the next N arriving measurements to a snapshot dataset on a worker thread."""
def _init_recording_state(self) -> None:
"""Initialise the (idle) disk-recording state. Called once from ``__init__``."""
self._recording_active = False
self._recording_collecting = False
self._recording_target = 0
self._recording_since_ns = 0
self._recording_enqueued = 0
self._recording_writer = None
self._recording_queue: queue.Queue | None = None
self._recording_thread: threading.Thread | None = None
self._recording_stop = threading.Event()
self._recording_write_error: str | None = None
self._recording_pending_raw: dict[int, object] = {}
self._recording_pending_pre: dict[int, object] = {}
self._recording_chunk_raw: list = []
self._recording_chunk_pre: list = []
self._recording_chunk_results: list = []
# -- arming ---------------------------------------------------------------
def _start_run_with_recording(self) -> None:
"""Arm a streamed recording of the next N measurements, starting the run if stopped.
``N`` comes from the record-count field; the destination is the shared save
path/name. Refuses to start a second recording while one is in progress, and
reports a name clash up front (before any data is collected).
"""
if self._recording_active:
self._show_error(
"A recording is already in progress; wait for it to finish before starting another",
details=f"progress={self._recording_collected_count()}/{self._recording_target}",
)
return
target = int(self._record_count.value())
destination = self._snapshot_destination_dir()
if destination.exists():
self._show_error(
"Recording destination already exists; choose a new name or path",
details=f"destination={destination}",
)
return
try:
writer = self._store.create_snapshot_stream(
Path(self._save_path_input.text().strip()).expanduser(),
self._save_name_input.text().strip(),
name_prefix=self._radar_config_name_prefix(),
)
# Adjacent config profile, mirroring the manual save (fail if it clashes).
self._write_gui_profile_to_path(
self._snapshot_config_profile_path(writer.directory), allow_overwrite=False
)
except Exception as exc: # noqa: BLE001
self._show_exception("Failed to start disk recording", exc)
return
self._recording_writer = writer
self._recording_target = target
self._recording_since_ns = time.monotonic_ns()
self._recording_enqueued = 0
self._recording_write_error = None
self._recording_pending_raw = {}
self._recording_pending_pre = {}
self._recording_chunk_raw = []
self._recording_chunk_pre = []
self._recording_chunk_results = []
self._recording_queue = queue.Queue(maxsize=_RECORDING_QUEUE_CHUNKS)
self._recording_stop = threading.Event()
self._recording_thread = threading.Thread(
target=self._recording_writer_loop,
args=(writer, self._recording_queue, self._recording_stop),
name="disk-recorder",
daemon=True,
)
self._recording_thread.start()
self._recording_active = True
self._recording_collecting = True
if not self._supervisor.is_running():
self._start_run()
self._log(f"Disk recording armed: streaming the next {target} measurement(s) to {writer.directory}")
# -- background writer ----------------------------------------------------
def _recording_writer_loop(
self, writer, work_queue: queue.Queue, stop: threading.Event
) -> None:
"""Write queued chunks to disk until a sentinel, a stop request, or an error.
Runs on a daemon thread; touches only the writer (file I/O) and logging never
Qt. A write failure is recorded for the GUI thread to surface and ends the loop.
"""
while not stop.is_set():
try:
chunk = work_queue.get(timeout=0.2)
except queue.Empty:
continue
if chunk is None: # sentinel: target reached, all chunks drained
return
raw, preprocessed, results = chunk
try:
writer.append(raw, preprocessed, results)
except Exception as exc: # noqa: BLE001 - reported to the GUI thread
logger.exception("Disk recording chunk write failed")
self._recording_write_error = f"{type(exc).__name__}: {exc}"
return
# -- capture (called from the ring-read hooks on the poll tick) -----------
def _record_collection(self, stage: str, collection) -> None:
"""Stream one arriving collection while recording; hand chunks to the writer thread.
``stage`` is ``"raw"``, ``"preprocessed"`` or ``"results"``. Raw/preprocessed
collections are held by ``collection_id`` until their result arrives; the result
pairs them and appends an aligned measurement to the current chunk.
"""
if not self._recording_collecting:
return
produced_ns = int(getattr(collection, "monotonic_ns", 0) or 0)
if produced_ns and produced_ns < self._recording_since_ns:
return
if stage == "raw":
self._stash_pending(self._recording_pending_raw, collection)
elif stage == "preprocessed":
self._stash_pending(self._recording_pending_pre, collection)
elif stage == "results":
self._record_result(collection)
@staticmethod
def _stash_pending(pending: dict, collection) -> None:
"""Hold a raw/preprocessed collection by id until its result arrives (bounded)."""
pending[int(collection.collection_id)] = collection
while len(pending) > _RECORDING_MAX_PENDING:
pending.pop(next(iter(pending))) # drop oldest; its result never came
def _record_result(self, result) -> None:
"""Pair a result with its raw/preprocessed, buffer it, and flush/finish as needed."""
if self._recording_collected_count() >= self._recording_target:
return # already have the full target accepted
collection_id = int(result.collection_id)
raw = self._recording_pending_raw.pop(collection_id, None)
preprocessed = self._recording_pending_pre.pop(collection_id, None)
if raw is not None:
self._recording_chunk_raw.append(raw)
if preprocessed is not None:
self._recording_chunk_pre.append(preprocessed)
self._recording_chunk_results.append(result)
if len(self._recording_chunk_results) >= _RECORDING_CHUNK_SIZE:
if not self._flush_recording_chunk():
return
if self._recording_collected_count() >= self._recording_target:
if not self._flush_recording_chunk():
return
self._stop_collecting()
def _flush_recording_chunk(self) -> bool:
"""Hand the buffered chunk to the writer thread. Returns ``False`` if it aborted."""
if not self._recording_chunk_results:
return True
if self._recording_write_error is not None or not self._recording_thread.is_alive():
self._abort_recording_after_write_error() # never block on a dead consumer
return False
chunk = (self._recording_chunk_raw, self._recording_chunk_pre, self._recording_chunk_results)
self._recording_enqueued += len(self._recording_chunk_results)
self._recording_chunk_raw = []
self._recording_chunk_pre = []
self._recording_chunk_results = []
self._recording_queue.put(chunk) # brief backpressure only if the disk lags
return True
def _stop_collecting(self) -> None:
"""Stop accepting measurements and tell the writer to drain and exit."""
self._recording_collecting = False
with suppress(Exception):
self._recording_queue.put_nowait(None) # sentinel after the last chunk
# -- completion / teardown (GUI thread) -----------------------------------
def _poll_recording_writer(self) -> None:
"""Finalize a recording once the writer thread has drained (or failed).
Called every GUI poll tick. Cheap no-op while idle or still writing.
"""
if not self._recording_active:
return
if self._recording_write_error is not None:
self._abort_recording_after_write_error()
return
if not self._recording_collecting and not self._recording_thread.is_alive():
directory = self._recording_writer.directory if self._recording_writer is not None else "?"
written = self._recording_enqueued
self._reset_recording_state()
self._log(f"Disk recording complete: {written} measurement(s) written to {directory}")
def _finalize_recording_on_stop(self) -> None:
"""Flush the partial chunk and finish the recording when the run is stopped.
Pressing Stop mid-recording writes everything collected so far including a
not-yet-full chunk to disk and ends the recording, so no buffered measurement
is lost. The dataset then holds exactly what was seen before Stop. Called from
``_stop_run`` after its ring drains, so the last in-flight measurements are
already captured. The writer is joined briefly (bounded, like the stop drains)
so completion is deterministic without waiting on a later poll tick.
"""
if not self._recording_active or not self._recording_collecting:
return
if not self._flush_recording_chunk():
return # writer already failed and was surfaced/reset
self._stop_collecting()
thread = self._recording_thread
directory = self._recording_writer.directory if self._recording_writer is not None else "?"
if thread is not None:
thread.join(timeout=3.0)
write_error = self._recording_write_error
written = self._recording_enqueued
self._reset_recording_state()
if write_error is not None:
self._show_error(
"Disk recording stopped with a write error",
details=f"dataset={directory}\nerror={write_error}",
)
else:
self._log(f"Disk recording stopped: {written} measurement(s) written to {directory}")
def _abort_recording_after_write_error(self) -> None:
"""Surface a writer-thread failure on the GUI thread and disarm."""
message = self._recording_write_error or "unknown error"
directory = self._recording_writer.directory if self._recording_writer is not None else "?"
self._reset_recording_state()
self._show_error(
"Disk recording failed and was stopped",
details=f"dataset={directory}\nerror={message}",
)
def _reset_recording_state(self) -> None:
"""Disarm recording, stop the writer thread, and release all buffers."""
self._recording_stop.set()
if self._recording_queue is not None:
with suppress(Exception):
self._recording_queue.put_nowait(None)
self._recording_active = False
self._recording_collecting = False
self._recording_writer = None
self._recording_queue = None
self._recording_thread = None
self._recording_write_error = None
self._recording_enqueued = 0
self._recording_pending_raw = {}
self._recording_pending_pre = {}
self._recording_chunk_raw = []
self._recording_chunk_pre = []
self._recording_chunk_results = []
def _shutdown_recording(self) -> None:
"""Stop the writer thread on app teardown, briefly joining so it is not orphaned."""
thread = self._recording_thread
self._recording_stop.set()
if self._recording_queue is not None:
with suppress(Exception):
self._recording_queue.put_nowait(None)
if thread is not None and thread.is_alive():
thread.join(timeout=2.0)
self._reset_recording_state()
# -- status ---------------------------------------------------------------
def _recording_collected_count(self) -> int:
"""Measurements accepted so far (handed to the writer + still buffered)."""
return self._recording_enqueued + len(self._recording_chunk_results)
def _recording_status(self) -> dict:
"""A compact snapshot of recording progress for the status feed / web UI."""
return {
"active": self._recording_active,
"collected": self._recording_collected_count(),
"target": self._recording_target,
}
@@ -39,23 +39,55 @@ class AppWindowSnapshotMixin:
return output_dir / "config_profile.json"
def _save_snapshot(self) -> None:
"""Save runtime snapshot in numpy-directory format."""
"""Save the last-N runtime measurements as a numpy snapshot (the "Save Dataset" button)."""
self._drain_runtime_rings_for_snapshot()
if not self._raw_history and not self._pre_history and not self._result_history:
self._show_error("No runtime data is available for save", details=self._runtime_history_details())
return
self._write_snapshot_dataset(
list(self._raw_history),
list(self._pre_history),
list(self._result_history),
int(self._save_count.value()),
)
def _snapshot_destination_dir(self) -> Path:
"""The directory a save/record would create now, from the current path/name fields.
Used to surface a name clash *before* doing work (manual save and the disk
recorder both create this directory and fail if it already exists).
"""
output_root = Path(self._save_path_input.text().strip()).expanduser()
return self._store.snapshot_directory(
output_root,
self._save_name_input.text().strip(),
name_prefix=self._radar_config_name_prefix(),
)
def _write_snapshot_dataset(
self,
raw_history: list,
preprocessed_history: list,
result_history: list,
last_n: int,
) -> None:
"""Save the given aligned histories as a numpy snapshot + adjacent config profile.
Shared by the manual "Save Dataset" button (which passes the runtime history)
and the on-the-fly disk recorder (which passes the freshly recorded buffers),
so both produce byte-identical dataset layouts and identical error reporting.
"""
try:
last_n = int(self._save_count.value())
output_root = Path(self._save_path_input.text().strip()).expanduser()
snapshot_name = self._save_name_input.text().strip()
snapshot_dir, summary = self._store.save_runtime_snapshot_numpy(
output_root,
snapshot_name,
list(self._raw_history),
list(self._pre_history),
list(self._result_history),
raw_history,
preprocessed_history,
result_history,
last_n,
name_prefix=self._radar_config_name_prefix(),
)
@@ -18,15 +18,37 @@ from __future__ import annotations
import base64
import contextlib
import os
import threading
import time
from pathlib import Path
from PyQt6.QtCore import QBuffer, QIODevice, QObject, pyqtSignal
from python_app.gui.controllers.app_window_config.live_processing_mixin import web_apply_field_names
from python_app.webui.controller import WebActionError
_WEBUI_PORT_ENV = "RADAR_SYSTEM_WEBUI_PORT"
_DEFAULT_PORT = 8080
# Upper bound on how long a browser control call waits for the GUI thread to run and
# report the action. Comfortably above a real save/start, but bounded so a wedged GUI
# thread surfaces as an error instead of hanging the HTTP worker forever.
_WEB_ACTION_TIMEOUT_S = 30.0
class _WebActionCall:
"""One synchronous web action: the GUI thread fills the result, the web thread waits.
The web (uvicorn) thread emits a control signal carrying this object and blocks on
:attr:`done`; the GUI thread runs the desktop action, records any surfaced error in
:attr:`error`, and sets the event. This turns the fire-and-forget signal bridge into
a request/response so failures reach the browser.
"""
__slots__ = ("done", "error")
def __init__(self) -> None:
self.done = threading.Event()
self.error: str | None = None
# Headless has no shown window, so give the offscreen window a usable size for the
# grabbed plot. In GUI mode the user's real (shown) window size is used as-is.
_HEADLESS_PLOT_SIZE = (1600, 900)
@@ -66,14 +88,18 @@ class AppWindowWebController(QObject):
place, so the web thread reads a consistent value without locking.
"""
start_requested = pyqtSignal()
stop_requested = pyqtSignal()
remove_last_requested = pyqtSignal()
single_capture_requested = pyqtSignal()
capture_requested = pyqtSignal()
# Control signals carry a trailing _WebActionCall the GUI slot fills in, so the web
# thread can block on the real outcome. apply_settings stays fire-and-forget: it is
# validated up front and returns the live schema, not a pass/fail.
start_requested = pyqtSignal(object)
stop_requested = pyqtSignal(object)
remove_last_requested = pyqtSignal(object)
single_capture_requested = pyqtSignal(object)
capture_requested = pyqtSignal(object)
start_recording_requested = pyqtSignal(str, str, int, object)
load_config_requested = pyqtSignal(str, object)
save_dataset_requested = pyqtSignal(str, str, object)
apply_settings_requested = pyqtSignal(dict)
load_config_requested = pyqtSignal(str)
save_dataset_requested = pyqtSignal(str, str)
def __init__(self, run_configs_dir: Path, parent: QObject | None = None) -> None:
super().__init__(parent)
@@ -109,20 +135,38 @@ class AppWindowWebController(QObject):
# -- WebController controls (web thread -> Qt main thread) ---------------
def _dispatch(self, signal, *args) -> None:
"""Emit a control signal and block until the GUI thread reports the outcome.
Runs on the web worker thread (the routes call this via a thread pool, so the
event loop is never blocked). Raises :class:`WebActionError` if the desktop
action surfaced an error or did not finish within the timeout.
"""
call = _WebActionCall()
signal.emit(*args, call)
if not call.done.wait(_WEB_ACTION_TIMEOUT_S):
raise WebActionError("The desktop did not complete the action in time")
if call.error is not None:
raise WebActionError(call.error)
def start(self) -> None:
self.start_requested.emit()
self._dispatch(self.start_requested)
def stop(self) -> None:
self.stop_requested.emit()
self._dispatch(self.stop_requested)
def single_capture(self) -> None:
self.single_capture_requested.emit()
self._dispatch(self.single_capture_requested)
def capture_tmp_reference(self) -> None:
self.capture_requested.emit()
self._dispatch(self.capture_requested)
def remove_last_measurement(self) -> None:
self.remove_last_requested.emit()
self._dispatch(self.remove_last_requested)
def start_recording(self, path: str, name: str, count: int) -> None:
"""Arm a run + disk recording of the next ``count`` measurements (the desktop button)."""
self._dispatch(self.start_recording_requested, path, name, int(count))
def apply_live_settings(self, fields: dict) -> dict:
unknown = set(fields) - _LIVE_FIELD_NAMES
@@ -135,16 +179,16 @@ class AppWindowWebController(QObject):
"""Request loading the run-config named ``name`` (the desktop "Load Config" action).
Validates the name against the directory here on the web thread so an invalid
or unsafe name fails the HTTP request immediately instead of silently doing nothing
on the Qt side; the actual load runs through the queued signal.
or unsafe name fails the HTTP request immediately; the load itself then runs
synchronously on the Qt side and any load error is surfaced too.
"""
if _safe_run_config_path(self._run_configs_dir, name) is None:
raise ValueError(f"Unknown run config: {name}")
self.load_config_requested.emit(name)
self._dispatch(self.load_config_requested, name)
def save_dataset(self, path: str, name: str) -> None:
"""Save the runtime dataset to ``path``/``name`` (the desktop "Save Dataset" button)."""
self.save_dataset_requested.emit(path, name)
self._dispatch(self.save_dataset_requested, path, name)
class AppWindowWebMixin:
@@ -168,15 +212,40 @@ class AppWindowWebMixin:
# The web picker browses this directory; ensure it exists on fresh deploys.
self._run_configs_dir.mkdir(parents=True, exist_ok=True)
# Capture slot for errors a web-triggered action surfaces (None = no web
# action in flight). Read default-safe by `_show_error`/`_show_exception`.
self._web_action_error_capture: list[str] | None = None
controller = AppWindowWebController(self._run_configs_dir, parent=self)
controller.start_requested.connect(self._start_run)
controller.stop_requested.connect(self._stop_run)
controller.single_capture_requested.connect(self._start_single_capture)
controller.capture_requested.connect(self._capture_tmp_reference)
controller.remove_last_requested.connect(self._remove_last_runtime_history)
# Each control signal carries a _WebActionCall the wrapper finalizes, so the
# browser learns whether the desktop action actually succeeded.
controller.start_requested.connect(
lambda call: self._run_web_action(call, self._start_run)
)
controller.stop_requested.connect(
lambda call: self._run_web_action(call, self._stop_run)
)
controller.single_capture_requested.connect(
lambda call: self._run_web_action(call, self._start_single_capture)
)
controller.capture_requested.connect(
lambda call: self._run_web_action(call, self._capture_tmp_reference)
)
controller.remove_last_requested.connect(
lambda call: self._run_web_action(call, self._remove_last_runtime_history)
)
controller.start_recording_requested.connect(
lambda path, name, count, call: self._run_web_action(
call, self._start_web_recording, path, name, count
)
)
controller.load_config_requested.connect(
lambda name, call: self._run_web_action(call, self._load_web_config, name)
)
controller.save_dataset_requested.connect(
lambda path, name, call: self._run_web_action(call, self._save_web_dataset, path, name)
)
controller.apply_settings_requested.connect(self._apply_web_live_settings)
controller.load_config_requested.connect(self._load_web_config)
controller.save_dataset_requested.connect(self._save_web_dataset)
self._web_controller = controller
self._web_update_snapshot() # seed snapshots before the first request
@@ -190,6 +259,27 @@ class AppWindowWebMixin:
self._web_controller = None
self._web_server = None
def _run_web_action(self, call: _WebActionCall, action, *args) -> None:
"""Run a web-triggered desktop action on the GUI thread, capturing its outcome.
Errors the action reports through ``_show_error``/``_show_exception`` are
captured into the call (and still logged/shown on the desktop) instead of
vanishing from the browser's view. Re-entrancy-safe: a nested action — e.g. a
modal error dialog pumping the event loop in GUI mode saves and restores the
capture slot, so each action only sees its own first error.
"""
previous_capture = self._web_action_error_capture
capture: list[str] = []
self._web_action_error_capture = capture
try:
action(*args)
call.error = capture[0] if capture else None
except Exception as exc: # noqa: BLE001 - handlers self-report; this is a backstop
call.error = self._exception_summary(exc)
finally:
self._web_action_error_capture = previous_capture
call.done.set()
def _load_web_config(self, name: str) -> None:
"""Load a run config chosen in the browser through the shared desktop load path.
@@ -216,6 +306,20 @@ class AppWindowWebMixin:
self._save_name_input.setText(name)
self._save_snapshot()
def _start_web_recording(self, path: str, name: str, count: int) -> None:
"""Arm disk recording from the browser via the same handler as the desktop button.
The save path/name fields are mirrored exactly like ``_save_web_dataset`` (blank
path keeps the configured destination), the record count is applied to the shared
spinbox, then the unchanged desktop arming action runs.
"""
if path.strip():
self._save_path_input.setText(path)
self._save_name_input.setText(name)
if count >= 1:
self._record_count.setValue(min(count, self._record_count.maximum()))
self._start_run_with_recording()
def _web_update_snapshot(self) -> None:
"""Refresh the snapshots the bridge serves (called on the Qt poll tick).
@@ -237,6 +341,9 @@ class AppWindowWebMixin:
# Current save path/name, so the web fields can prefill the desktop values.
"save_path": self._save_path_input.text(),
"save_name": self._save_name_input.text(),
# Default record count + live disk-recording progress for the web UI.
"record_count": int(self._record_count.value()),
"recording": self._recording_status(),
# Per-stage capture counts, identical to the desktop history
# label (raw -> preprocessed -> results), mirrored to the browser.
"raw_count": len(self._raw_history),
@@ -37,10 +37,20 @@ def build_data_actions_group(owner) -> QGroupBox:
capture_tmp_reference_button = QPushButton("Capture Tmp Reference")
capture_tmp_reference_button.clicked.connect(owner._capture_tmp_reference)
capture_tmp_reference_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
record_button = QPushButton("Start + Record to Disk")
record_button.setToolTip(
"Start the run (if stopped) and save the next N measurements to the path/name below."
)
record_button.clicked.connect(owner._start_run_with_recording)
record_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
owner._save_count = QSpinBox()
owner._save_count.setMinimum(1)
owner._save_count.setMaximum(10_000)
owner._save_count.setValue(int(data_defaults.save_count))
owner._record_count = QSpinBox()
owner._record_count.setMinimum(1)
owner._record_count.setMaximum(1_000_000)
owner._record_count.setValue(int(data_defaults.record_count))
button_grid = QGridLayout()
button_grid.setHorizontalSpacing(8)
@@ -49,6 +59,7 @@ def build_data_actions_group(owner) -> QGroupBox:
button_grid.addWidget(save_vna_json_button, 0, 1)
button_grid.addWidget(remove_last_button, 1, 0)
button_grid.addWidget(capture_tmp_reference_button, 1, 1)
button_grid.addWidget(record_button, 2, 0, 1, 2)
button_grid.setColumnStretch(0, 1)
button_grid.setColumnStretch(1, 1)
layout.addLayout(button_grid)
@@ -60,6 +71,13 @@ def build_data_actions_group(owner) -> QGroupBox:
count_row.addStretch(1)
layout.addLayout(count_row)
record_count_row = QHBoxLayout()
record_count_row.setSpacing(8)
record_count_row.addWidget(QLabel("Number of Sweeps to Record"))
record_count_row.addWidget(owner._record_count)
record_count_row.addStretch(1)
layout.addLayout(record_count_row)
path_row = QHBoxLayout()
path_row.setSpacing(8)
owner._save_path_input = QLineEdit(str(data_defaults.save_path))