Rework radar system
This commit is contained in:
@@ -1,183 +1,181 @@
|
||||
"""GUI coordination for importing and estimating optional object calibration."""
|
||||
"""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 pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QObject, QTimer
|
||||
from PyQt6.QtWidgets import QFileDialog
|
||||
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__)
|
||||
|
||||
|
||||
def _calculate(groups, config, settings, cancelled, messages, result) -> None:
|
||||
"""The worker handles only Python data; every Qt action stays on the GUI thread."""
|
||||
@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),
|
||||
groups, config, settings=Settings(**settings), provenance=provenance,
|
||||
progress=messages.put, cancelled=cancelled.is_set,
|
||||
)
|
||||
result.put(("cancelled", None) if cancelled.is_set() else ("ready", calibration))
|
||||
result.put(_CalculationResult(calibration=calibration, cancelled=cancelled.is_set()))
|
||||
except InterruptedError:
|
||||
result.put(("cancelled", None))
|
||||
except Exception as exc: # noqa: BLE001 - report worker failures on the GUI thread
|
||||
result.put(("error", exc))
|
||||
result.put(_CalculationResult(cancelled=True))
|
||||
except Exception as exc:
|
||||
result.put(_CalculationResult(error=exc))
|
||||
|
||||
|
||||
class ObjectCalibrationController(QObject):
|
||||
"""Own one modeless editor and cancellable calculation for an AppWindow."""
|
||||
"""Own one modeless editor, acquisition job and cancellable calculation."""
|
||||
|
||||
def __init__(self, owner) -> None:
|
||||
super().__init__(owner)
|
||||
self._owner = owner
|
||||
self._dialog: ObjectCalibrationDialog | None = None
|
||||
self._capture = None
|
||||
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 = queue.Queue()
|
||||
self._result: queue.Queue = queue.Queue(maxsize=1)
|
||||
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 import_calibration(self) -> None:
|
||||
"""Validate and copy a legacy cal_*.npy folder into the app's own store."""
|
||||
if getattr(self._owner, "_closing", False):
|
||||
def open_creation(self, set_name: str) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
if getattr(self._owner, "_object_capture_job", None) is not None or self._worker is not None:
|
||||
self._owner._show_error("Wait for the object-calibration operation to finish")
|
||||
return
|
||||
panel = self._owner._ensure_preprocess_dialog()
|
||||
name = panel.set_name()
|
||||
if not name:
|
||||
self._owner._show_error("Set name is required")
|
||||
return
|
||||
directory = QFileDialog.getExistingDirectory(panel, "Import MIMO calibration")
|
||||
if not directory:
|
||||
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:
|
||||
from python_app.workflows.mimo_calibration import load_calibration, validate_for_config
|
||||
|
||||
config = self._owner._build_config()
|
||||
calibration = load_calibration(Path(directory))
|
||||
validate_for_config(calibration, config.to_dict())
|
||||
radar_key = self._owner._radar_key(config)
|
||||
self._owner._store.save_object_calibration(radar_key, name, calibration)
|
||||
self._select_saved(name)
|
||||
panel.set_status(f"Object calibration imported: {name}. Applies on the next Start.")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._owner._show_exception("Failed to import object calibration", exc)
|
||||
|
||||
def open_creation(self) -> None:
|
||||
if getattr(self._owner, "_closing", False):
|
||||
return
|
||||
if self._dialog is None:
|
||||
self._dialog = ObjectCalibrationDialog(self._owner._ensure_preprocess_dialog().set_name(), self._owner)
|
||||
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 (getattr(self._owner, "_closing", False) or self._capture is not None
|
||||
or self._worker is not None or self._dialog is None):
|
||||
return
|
||||
from python_app.gui.controllers.object_calibration_capture import capture_object_sweeps
|
||||
|
||||
try:
|
||||
self._capture = capture_object_sweeps(self._owner, self._dialog)
|
||||
self._capture.finished.connect(self._capture_finished)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._dialog.set_capture_pending(False)
|
||||
self._dialog.set_status(str(exc))
|
||||
self._owner._show_exception("Failed to capture object-calibration data", exc)
|
||||
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
|
||||
self.capture = None
|
||||
|
||||
def _start_calculation(self) -> None:
|
||||
if getattr(self._owner, "_closing", False) or self._worker is not None or self._capture is not None or self._dialog is 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()
|
||||
name = self.dialog.set_name()
|
||||
if not name:
|
||||
raise ValueError("Set name is required")
|
||||
groups, config, settings = self._dialog.calculation_inputs()
|
||||
self._save_name = name
|
||||
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 = queue.Queue()
|
||||
self._result = queue.Queue(maxsize=1)
|
||||
self._messages, self._result = queue.Queue(), queue.Queue(maxsize=1)
|
||||
self._worker = threading.Thread(
|
||||
target=_calculate,
|
||||
args=(groups, config, settings, self._cancel, self._messages, self._result),
|
||||
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("Расчёт калибровки…")
|
||||
self.dialog.set_calculating(True)
|
||||
self.dialog.set_status("Calculating calibration…")
|
||||
self._worker.start()
|
||||
self._poll.start()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
self._worker = None
|
||||
self._dialog.set_calculating(False)
|
||||
self._dialog.set_status(f"Ошибка расчёта: {exc}")
|
||||
self._owner._show_exception("Failed to calculate object calibration", exc)
|
||||
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 getattr(self._owner, "_closing", False):
|
||||
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())
|
||||
self.dialog.set_status(self._messages.get_nowait())
|
||||
try:
|
||||
state, payload = self._result.get_nowait()
|
||||
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 state == "cancelled":
|
||||
self._dialog.set_status("Расчёт отменён. Снятые данные сохранены в этом окне.")
|
||||
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 state == "error":
|
||||
self._dialog.set_status(f"Ошибка расчёта: {payload}")
|
||||
self._owner._show_exception("Failed to calculate object calibration", payload)
|
||||
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:
|
||||
from python_app.workflows.mimo_calibration import validate_for_config
|
||||
|
||||
current_config = self._owner._build_config()
|
||||
validate_for_config(payload, current_config.to_dict())
|
||||
radar_key = self._owner._radar_key(current_config)
|
||||
self._owner._store.save_object_calibration(radar_key, self._save_name, payload)
|
||||
self._select_saved(self._save_name)
|
||||
self._owner._ensure_preprocess_dialog().set_set_name(self._save_name)
|
||||
self._dialog.show_calibration(payload)
|
||||
self._dialog.set_status(f"Калибровка сохранена: {self._save_name}. Применится при следующем Start.")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._dialog.set_status(f"Калибровка не сохранена: {exc}")
|
||||
self._owner._show_exception("Failed to save object calibration", exc)
|
||||
|
||||
def _select_saved(self, name: str) -> None:
|
||||
self._owner._selected_preprocess_sets["s21_object_calibration"] = name
|
||||
self._owner._refresh_sets()
|
||||
self._owner._log(f"Selected object calibration: {name}")
|
||||
# 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("Отмена расчёта…")
|
||||
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()
|
||||
if self.dialog is not None:
|
||||
self.dialog.close()
|
||||
|
||||
Reference in New Issue
Block a user