"""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 runtime snapshot in numpy-directory format.""" 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()) 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), 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}" )