"""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 class AppWindowSnapshotMixin: """Saves runtime data snapshots and maintains ring-reader freshness.""" 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") 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, ) 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})" ) except Exception as exc: # noqa: BLE001 self._show_error(f"Failed to save snapshot: {exc}") def _save_vna_history_json(self) -> None: """Save runtime history as vna_system-compatible JSON file.""" 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") return try: last_n = int(self._save_count.value()) input_index = int(self._vna_json_input_index.value()) output_index = int(self._vna_json_output_index.value()) output_root = Path(self._save_path_input.text().strip()).expanduser() output_name = self._save_name_input.text().strip() output_path, summary = self._store.save_runtime_vna_history_json( output_root, output_name, list(self._raw_history), list(self._pre_history), list(self._result_history), last_n, input_index=input_index, output_index=output_index, primary_stage="preprocessed", ) self._log( f"Saved VNA history JSON: {output_path} " f"(sweeps={summary.get('sweep_count', 0)}, " f"raw_records={summary.get('raw_record_count', 0)}, " 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"input={input_index}, " f"output={output_index}, " f"requested_last_n={last_n})" ) except Exception as exc: # noqa: BLE001 self._show_error(f"Failed to save VNA history JSON: {exc}") def _clear_all_runtime_history(self) -> None: """Clear all runtime histories, ring backlogs, and processor replay state.""" if self._capture_session is not None: self._show_error("Cannot clear runtime history during active capture sequence") return resume_acquisition = self._supervisor.is_running() dropped_raw = 0 dropped_pre = 0 dropped_results = 0 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 self._replace_runtime_history(retained_raw=[], retained_pre=[], retained_result=[]) self._bscan_history_floor_collection_id = 0 self._clear_history_mode_caches() # Clear processor-side replay cache so newly rendered B-scan starts clean. self._write_live_processing_config(history_command="clear_all", bump_history_seq=True) if self._supervisor.is_processor_running(): self._drain_results_until_quiet(timeout_s=0.25, poll_s=0.01) if self._result_reader is not None: dropped_results += self._result_reader.drop_all() self._result_history.clear() self._update_history_indicator() self._redraw_after_history_deletion() self._log( "Runtime history fully cleared: " 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_error(f"Failed to clear runtime history: {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 if got_results and (missing_raw or missing_pre) and time.monotonic() < deadline: time.sleep(0.01) continue if got_raw_or_pre and missing_results and time.monotonic() < deadline: time.sleep(0.01) continue if ( self._result_reader is not None and max(raw_count, pre_count) > result_count and time.monotonic() < deadline ): time.sleep(0.01) continue break except Exception as exc: # noqa: BLE001 self._log(f"Snapshot drain warning: {exc}") 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}" )