Rework radar system

This commit is contained in:
Ayzen
2026-10-06 12:32:26 +03:00
parent 552cdc688a
commit 5bb86b7165
299 changed files with 13537 additions and 27763 deletions
@@ -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()