added data saving feature
This commit is contained in:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user