362 lines
16 KiB
Python
362 lines
16 KiB
Python
"""Snapshot and ring-drain helper mixin for the main GUI window."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
import time
|
|
|
|
from PyQt6.QtWidgets import QFileDialog
|
|
from python_app.gui.runtime.history import record_result_history, remove_last_aligned_histories
|
|
from python_app.storage.npz.paths import radar_config_filename_prefix
|
|
|
|
|
|
class AppWindowSnapshotMixin:
|
|
"""Saves runtime data snapshots and maintains ring-reader freshness."""
|
|
|
|
def _radar_config_name_prefix(self) -> str:
|
|
"""Filename prefix encoding the current radar setup (model, sweep span, points, power).
|
|
|
|
Prepended to every saved dataset name so the file records the configuration it was
|
|
captured with — identical for desktop and web saves, which share these handlers.
|
|
"""
|
|
radar = self._build_config().radar
|
|
return radar_config_filename_prefix(
|
|
radar.model,
|
|
radar.sweep.start_hz,
|
|
radar.sweep.stop_hz,
|
|
radar.sweep.points,
|
|
radar.sweep.power_dbm,
|
|
)
|
|
|
|
@staticmethod
|
|
def _snapshot_config_profile_path(snapshot_dir: Path) -> Path:
|
|
"""Return companion config-profile path inside a saved snapshot directory."""
|
|
return snapshot_dir / "config_profile.json"
|
|
|
|
@staticmethod
|
|
def _vna_json_config_profile_path(output_dir: Path) -> Path:
|
|
"""Return companion config-profile path for one VNA-history JSON export batch."""
|
|
return output_dir / "config_profile.json"
|
|
|
|
def _save_snapshot(self) -> None:
|
|
"""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:
|
|
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,
|
|
raw_history,
|
|
preprocessed_history,
|
|
result_history,
|
|
last_n,
|
|
name_prefix=self._radar_config_name_prefix(),
|
|
)
|
|
config_profile_path = self._snapshot_config_profile_path(snapshot_dir)
|
|
try:
|
|
self._write_gui_profile_to_path(config_profile_path, allow_overwrite=False)
|
|
except Exception as exc: # noqa: BLE001
|
|
self._show_error(
|
|
"Snapshot data was saved, but the adjacent config profile could not be written",
|
|
details=(
|
|
f"snapshot_dir={snapshot_dir}\n"
|
|
f"config_profile_path={config_profile_path}\n\n"
|
|
f"{self._exception_details(exc)}"
|
|
),
|
|
)
|
|
return
|
|
self._log(
|
|
f"Saved numpy snapshot: {snapshot_dir} "
|
|
f"(raw={summary.get('raw_count', 0)}, "
|
|
f"preprocessed={summary.get('preprocessed_count', 0)}, "
|
|
f"results={summary.get('result_count', 0)}, "
|
|
f"mode={summary.get('selection_mode', 'unknown')}, "
|
|
f"anchor={summary.get('anchor_stage', 'unknown')}, "
|
|
f"aligned_keys={summary.get('aligned_key_count', 0)}, "
|
|
f"raw_missing={summary.get('raw_missing_count', 0)}, "
|
|
f"pre_missing={summary.get('preprocessed_missing_count', 0)}, "
|
|
f"result_missing={summary.get('result_missing_count', 0)}, "
|
|
f"requested_last_n={last_n}, "
|
|
f"config_profile={config_profile_path})"
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
self._show_exception("Failed to save snapshot", exc)
|
|
|
|
def _save_vna_history_json(self) -> None:
|
|
"""Save one VNA-history JSON per available combo in runtime history."""
|
|
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
|
|
|
|
try:
|
|
last_n = int(self._save_count.value())
|
|
channel = "s21"
|
|
output_root = Path(self._save_path_input.text().strip()).expanduser()
|
|
output_name = self._save_name_input.text().strip()
|
|
output_paths, summary = self._store.save_runtime_vna_history_json_batch(
|
|
output_root,
|
|
output_name,
|
|
list(self._raw_history),
|
|
list(self._pre_history),
|
|
list(self._result_history),
|
|
last_n,
|
|
channel=channel,
|
|
primary_stage="preprocessed",
|
|
name_prefix=self._radar_config_name_prefix(),
|
|
)
|
|
output_stem = str(summary.get("output_stem", "")).strip()
|
|
if not output_stem:
|
|
raise RuntimeError("VNA history JSON export did not report output_stem for config companion save")
|
|
output_dir = Path(str(summary.get("output_dir", "")).strip() or output_paths[0].parent)
|
|
|
|
config_profile_path = self._vna_json_config_profile_path(output_dir)
|
|
try:
|
|
# Snapshot and VNA JSON exports intentionally share the same output directory stem.
|
|
# Refresh the companion GUI profile in place so a prior dataset save does not block JSON export.
|
|
self._write_gui_profile_to_path(config_profile_path, allow_overwrite=True)
|
|
except Exception as exc: # noqa: BLE001
|
|
exported_preview = "\n".join(str(path) for path in output_paths[:8])
|
|
if len(output_paths) > 8:
|
|
exported_preview += "\n..."
|
|
self._show_error(
|
|
"VNA history JSON files were saved, but the adjacent config profile could not be written",
|
|
details=(
|
|
f"output_root={output_root}\n"
|
|
f"output_dir={output_dir}\n"
|
|
f"config_profile_path={config_profile_path}\n"
|
|
f"saved_json_files={len(output_paths)}\n"
|
|
f"{exported_preview}\n\n"
|
|
f"{self._exception_details(exc)}"
|
|
),
|
|
)
|
|
return
|
|
combos = summary.get("combos", [])
|
|
combo_preview = ", ".join(f"in{input_pos}/out{output_pos}" for input_pos, output_pos in combos[:6])
|
|
if len(combos) > 6:
|
|
combo_preview += ", ..."
|
|
self._log(
|
|
f"Saved VNA history JSON batch: files={len(output_paths)} "
|
|
f"(combos={summary.get('combo_count', 0)}"
|
|
f"{', ' + combo_preview if combo_preview else ''}, "
|
|
f"preprocessed_records={summary.get('preprocessed_record_count', 0)}, "
|
|
f"raw={summary.get('raw_count', 0)}, "
|
|
f"preprocessed={summary.get('preprocessed_count', 0)}, "
|
|
f"results={summary.get('result_count', 0)}, "
|
|
f"mode={summary.get('selection_mode', 'unknown')}, "
|
|
f"anchor={summary.get('anchor_stage', 'unknown')}, "
|
|
f"channel={channel}, "
|
|
f"requested_last_n={last_n}, "
|
|
f"config_profile={config_profile_path})"
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
self._show_exception("Failed to save VNA history JSON", exc)
|
|
|
|
def _remove_last_runtime_history(self) -> None:
|
|
"""Remove the newest runtime measurement from all stages and processor replay state."""
|
|
self._apply_runtime_history_deletion(remove_last_only=True)
|
|
|
|
def _clear_all_runtime_history(self) -> None:
|
|
"""Clear all runtime histories, ring backlogs, and processor replay state."""
|
|
self._apply_runtime_history_deletion(remove_last_only=False)
|
|
|
|
def _apply_runtime_history_deletion(self, *, remove_last_only: bool) -> None:
|
|
"""Apply destructive runtime-history deletion across readers, caches, and processor replay state."""
|
|
if self._capture_session is not None:
|
|
self._show_error(
|
|
"Cannot modify runtime history during active capture sequence",
|
|
details=f"{self._capture_state_details()}\n\n{self._runtime_history_details()}",
|
|
)
|
|
return
|
|
|
|
resume_acquisition = self._supervisor.is_running()
|
|
dropped_raw = 0
|
|
dropped_pre = 0
|
|
dropped_results = 0
|
|
history_command = "remove_last" if remove_last_only else "clear_all"
|
|
action_label = "last runtime measurement removed" if remove_last_only else "runtime history fully cleared"
|
|
error_action = "remove last runtime measurement" if remove_last_only else "clear runtime history"
|
|
|
|
try:
|
|
if resume_acquisition:
|
|
self._stop_run()
|
|
|
|
dropped_raw = self._raw_reader.drop_all() if self._raw_reader is not None else 0
|
|
dropped_pre = self._pre_reader.drop_all() if self._pre_reader is not None else 0
|
|
dropped_results = self._result_reader.drop_all() if self._result_reader is not None else 0
|
|
|
|
if remove_last_only:
|
|
retained_raw, retained_pre, retained_result = remove_last_aligned_histories(
|
|
list(self._raw_history),
|
|
list(self._pre_history),
|
|
list(self._result_history),
|
|
)
|
|
else:
|
|
retained_raw = []
|
|
retained_pre = []
|
|
retained_result = []
|
|
|
|
self._replace_runtime_history(
|
|
retained_raw=retained_raw,
|
|
retained_pre=retained_pre,
|
|
retained_result=retained_result,
|
|
)
|
|
self._bscan_history_floor_collection_id = 0
|
|
self._clear_history_mode_caches()
|
|
|
|
self._write_live_processing_config(history_command=history_command, bump_history_seq=True)
|
|
|
|
if self._supervisor.is_processor_running():
|
|
self._drain_results_until_quiet(timeout_s=0.35, poll_s=0.01)
|
|
if self._result_reader is not None:
|
|
dropped_results += self._result_reader.drop_all()
|
|
|
|
self._update_history_indicator()
|
|
self._redraw_after_history_deletion()
|
|
self._log(
|
|
f"{action_label}: "
|
|
f"dropped raw={dropped_raw}, "
|
|
f"preprocessed={dropped_pre}, "
|
|
f"results={dropped_results}"
|
|
)
|
|
|
|
if resume_acquisition:
|
|
self._start_run()
|
|
except Exception as exc: # noqa: BLE001
|
|
self._show_exception(f"Failed to {error_action}", exc)
|
|
|
|
def _browse_save_path(self) -> None:
|
|
"""Open directory picker for snapshot output path."""
|
|
selected = QFileDialog.getExistingDirectory(
|
|
self,
|
|
"Select Snapshot Directory",
|
|
self._save_path_input.text().strip() or str(self._project_root),
|
|
)
|
|
if selected:
|
|
self._save_path_input.setText(selected)
|
|
|
|
def _drain_runtime_rings_for_snapshot(self) -> None:
|
|
"""Drain readers before snapshot to reduce partial-history races."""
|
|
if self._raw_reader is None and self._pre_reader is None and self._result_reader is None:
|
|
return
|
|
try:
|
|
start_raw_count = len(self._raw_history)
|
|
start_pre_count = len(self._pre_history)
|
|
start_result_count = len(self._result_history)
|
|
deadline = time.monotonic() + 1.2
|
|
|
|
while True:
|
|
progress = False
|
|
|
|
if self._raw_reader is not None:
|
|
for _ in range(self._max_pop_per_snapshot_drain):
|
|
collection = self._raw_reader.pop_raw_collection()
|
|
if collection is None:
|
|
break
|
|
self._raw_history.append(collection)
|
|
progress = True
|
|
|
|
if self._pre_reader is not None:
|
|
for _ in range(self._max_pop_per_snapshot_drain):
|
|
collection = self._pre_reader.pop_preprocessed_collection()
|
|
if collection is None:
|
|
break
|
|
self._pre_history.append(collection)
|
|
progress = True
|
|
|
|
if self._result_reader is not None:
|
|
for _ in range(self._max_pop_per_snapshot_drain):
|
|
collection = self._result_reader.pop_result_collection()
|
|
if collection is None:
|
|
break
|
|
record_result_history(self._result_history, collection)
|
|
progress = True
|
|
|
|
if progress:
|
|
continue
|
|
|
|
raw_count = len(self._raw_history)
|
|
pre_count = len(self._pre_history)
|
|
result_count = len(self._result_history)
|
|
|
|
missing_raw = self._raw_reader is not None and raw_count == start_raw_count
|
|
missing_pre = self._pre_reader is not None and pre_count == start_pre_count
|
|
missing_results = self._result_reader is not None and result_count == start_result_count
|
|
got_results = result_count > start_result_count
|
|
got_raw_or_pre = raw_count > start_raw_count or pre_count > start_pre_count
|
|
|
|
# Event-loop-friendly waits so the keepalive/watchdog timers and
|
|
# queued signals keep firing instead of freezing the headless daemon.
|
|
if got_results and (missing_raw or missing_pre) and time.monotonic() < deadline:
|
|
self._pump_events_during_drain(0.01)
|
|
continue
|
|
if got_raw_or_pre and missing_results and time.monotonic() < deadline:
|
|
self._pump_events_during_drain(0.01)
|
|
continue
|
|
if (
|
|
self._result_reader is not None
|
|
and max(raw_count, pre_count) > result_count
|
|
and time.monotonic() < deadline
|
|
):
|
|
self._pump_events_during_drain(0.01)
|
|
continue
|
|
break
|
|
except Exception as exc: # noqa: BLE001
|
|
self._log_exception("Snapshot drain warning", exc, level="WARN")
|
|
|
|
def _drop_pending_ring_payloads(self, *, include_results: bool = True) -> None:
|
|
"""Drop unread payloads from active readers."""
|
|
dropped_raw = self._raw_reader.drop_all() if self._raw_reader is not None else 0
|
|
dropped_pre = self._pre_reader.drop_all() if self._pre_reader is not None else 0
|
|
dropped_results = 0
|
|
if include_results and self._result_reader is not None:
|
|
dropped_results = self._result_reader.drop_all()
|
|
|
|
if dropped_raw or dropped_pre or dropped_results:
|
|
self._log(
|
|
"Single capture ring reset: "
|
|
f"dropped raw={dropped_raw}, "
|
|
f"preprocessed={dropped_pre}, "
|
|
f"results={dropped_results}"
|
|
)
|