"""GUI coordination for estimating and saving optional object calibration.""" from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass import logging import queue import threading from PyQt6.QtCore import QObject, QTimer from PyQt6.QtWidgets import QWidget from python_app.gui.controllers.object_calibration_capture import ObjectSweepCapture from python_app.gui.object_calibration_dialog import ObjectCalibrationDialog from python_app.models.dataset_model import SweepCollection from python_app.models.run_config_schema import RunConfigModel from python_app.storage.npz.store import NpzStore from python_app.workflows.object_calibration import validate_object_set logger = logging.getLogger(__name__) @dataclass class _CalculationResult: calibration: tuple[SweepCollection, dict] | None = None error: Exception | None = None cancelled: bool = False def _calculate(groups: list[dict], config: dict, settings: dict, provenance: dict, cancelled: threading.Event, messages: queue.Queue[str], result: queue.Queue[_CalculationResult]) -> None: """Only immutable inputs and queues cross the worker boundary, never Qt objects.""" try: from python_app.workflows.mimo_calibration import Settings, compute_from_collections calibration = compute_from_collections( groups, config, settings=Settings(**settings), provenance=provenance, progress=messages.put, cancelled=cancelled.is_set, ) result.put(_CalculationResult(calibration=calibration, cancelled=cancelled.is_set())) except InterruptedError: result.put(_CalculationResult(cancelled=True)) except Exception as exc: result.put(_CalculationResult(error=exc)) class ObjectCalibrationController(QObject): """Own one modeless editor, acquisition job and cancellable calculation.""" def __init__(self, parent: QWidget, store: NpzStore, build_config: Callable[[], RunConfigModel], capture: Callable[[ObjectCalibrationDialog], ObjectSweepCapture], select_saved: Callable[[str], None], error: Callable[[str, Exception], None]) -> None: super().__init__(parent) self._parent, self._store, self._build_config = parent, store, build_config self._start_capture, self._select_saved, self._error = capture, select_saved, error self.dialog: ObjectCalibrationDialog | None = None self.capture: ObjectSweepCapture | None = None self._closed = False self._worker: threading.Thread | None = None self._cancel = threading.Event() self._messages: queue.Queue[str] = queue.Queue() self._result: queue.Queue[_CalculationResult] = queue.Queue(maxsize=1) self._poll = QTimer(self) self._poll.setInterval(100) self._poll.timeout.connect(self._poll_calculation) self._save_name = "" self._provenance: dict = {} def open_creation(self, set_name: str) -> None: if self._closed: return if self.dialog is None: self.dialog = ObjectCalibrationDialog(set_name, self._parent) self.dialog.capture_requested.connect(self.capture_data) self.dialog.calculate_requested.connect(self._start_calculation) self.dialog.cancel_requested.connect(self.cancel) self.dialog.show() self.dialog.raise_() self.dialog.activateWindow() def capture_data(self) -> None: if self._closed or self.capture is not None or self._worker is not None or self.dialog is None: return try: self.capture = self._start_capture(self.dialog) self.capture.finished.connect(self._capture_finished) except Exception as exc: self.dialog.set_capture_pending(False) self.dialog.set_status(str(exc)) self._error("Failed to capture object-calibration data", exc) def _capture_finished(self) -> None: self.capture = None def _start_calculation(self) -> None: if self._closed or self._worker is not None or self.capture is not None or self.dialog is None: return try: name = self.dialog.set_name() if not name: raise ValueError("Set name is required") groups, config, settings, provenance = self.dialog.calculation_inputs() if name in self._store.list_sets("s21_object_calibration", provenance["radar_key"]): raise FileExistsError(f"Object calibration already exists: {name}") self._provenance, self._save_name = provenance, name self._cancel = threading.Event() self._messages, self._result = queue.Queue(), queue.Queue(maxsize=1) self._worker = threading.Thread( target=_calculate, args=(groups, config, settings, provenance, self._cancel, self._messages, self._result), name="object-calibration", daemon=True, ) self.dialog.set_calculating(True) self.dialog.set_status("Calculating calibration…") self._worker.start() self._poll.start() except Exception as exc: self._worker = None self.dialog.set_calculating(False) self.dialog.set_status(f"Calculation failed: {exc}") self._error("Failed to calculate object calibration", exc) def _poll_calculation(self) -> None: if self.dialog is None or self._closed: self._poll.stop() return while not self._messages.empty(): self.dialog.set_status(self._messages.get_nowait()) try: outcome = self._result.get_nowait() except queue.Empty: return self._poll.stop() self._worker = None self.dialog.set_calculating(False) if self._cancel.is_set() or outcome.cancelled: self.dialog.set_status("Calculation cancelled. Captured data remain in this window.") return if outcome.error is not None: self.dialog.set_status(f"Calculation failed: {outcome.error}") self._error("Failed to calculate object calibration", outcome.error) return assert outcome.calibration is not None try: # Save under captured provenance even when the pending GUI profile changed. radar_key = self._provenance["radar_key"] if self._save_name in self._store.list_sets("s21_object_calibration", radar_key): raise FileExistsError(f"Object calibration already exists: {self._save_name}") collection, metadata = outcome.calibration self._store.save_set("s21_object_calibration", radar_key, self._save_name, collection, metadata=metadata) self.dialog.show_calibration(metadata) status = f"Calibration saved: {self._save_name} (capture radar: {radar_key})." try: current = self._build_config() current_key = current.radar_key() through = self._store.load_set("s21_calibration", current_key, current.preprocess.s21.calibration.set_name) validate_object_set(collection, metadata, current.to_dict(), current_key, through) except Exception as exc: status += f" Not selected for the current configuration: {exc}" else: self._select_saved(self._save_name) status += " Applies on the next Start." self.dialog.set_status(status) logger.info("%s", status) except Exception as exc: self.dialog.set_status(f"Calibration could not be saved: {exc}") self._error("Failed to save object calibration", exc) def cancel(self) -> None: self._cancel.set() if self.capture is not None: self.capture.cancel() if self._worker is not None and self.dialog is not None: self.dialog.set_status("Cancelling calculation…") def shutdown(self) -> None: self._closed = True self.cancel() self._poll.stop() if self.dialog is not None: self.dialog.close()