Add object calibration

This commit is contained in:
Ayzen
2026-10-05 17:39:40 +03:00
parent 8f097dfa2e
commit 552cdc688a
33 changed files with 3141 additions and 517 deletions
@@ -17,6 +17,7 @@ from PyQt6.QtWidgets import (
from python_app.gui.runtime.constraints import validate_processing_mode_constraints
from python_app.gui.antenna_geometry_editor import AntennaGeometryEditor
from python_app.models.run_config_model import GprRxGeometryModel, GprTxGeometryModel
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
_GPR_MODES = ("gpr", "legacy_gpr")
@@ -320,6 +321,10 @@ class AppWindowLiveProcessingMixin:
reprocess_current_result=not self._can_reprocess_history(),
)
)
active = getattr(self, "_active_run_config", None)
if active is not None and self._supervisor.is_running():
active.gpr.tx_geometry = [GprTxGeometryModel(**entry) for entry in self._gpr_tx_geometry_input.records()]
active.gpr.rx_geometry = [GprRxGeometryModel(**entry) for entry in self._gpr_rx_geometry_input.records()]
if getattr(self, "_await_processing_revision", False):
self._required_processing_revision = self._processing_settings_revision
@@ -379,6 +384,8 @@ class AppWindowLiveProcessingMixin:
finally:
self._suppress_live_settings_handler = False
self._sync_socket_speed_controls()
if {"tx_geometry", "rx_geometry"} & settings.keys():
self._invalidate_object_calibration_after_geometry_change()
if {"tx_geometry", "rx_geometry", "relative_permittivity"} & settings.keys():
self._await_processing_revision = True
if "processor_mode" in settings:
@@ -395,9 +402,49 @@ class AppWindowLiveProcessingMixin:
def _on_gpr_geometry_changed(self) -> None:
if getattr(self, "_suppress_live_settings_handler", False):
return
self._invalidate_object_calibration_after_geometry_change()
self._await_processing_revision = True
self._on_processing_live_settings_changed()
def _invalidate_object_calibration_after_geometry_change(self) -> None:
"""Disable a correction tied to another antenna layout and reload the preprocessor."""
selected = self._selected_preprocess_sets.get("s21_object_calibration", "")
was_running = self._supervisor.is_running()
active_config = getattr(self, "_active_run_config", None)
active = active_config.preprocess.s21.object_calibration.set_name if was_running and active_config else ""
if not selected and not active:
return
from python_app.workflows.mimo_calibration import validate_for_config
geometry = {"gpr": {
"tx_geometry": self._gpr_tx_geometry_input.records(),
"rx_geometry": self._gpr_rx_geometry_input.records(),
}}
selections = [(selected, self._selected_preprocess_radar_key)]
if active:
selections.append((active, self._radar_key(active_config)))
invalid = set()
for name, radar_key in dict.fromkeys(selections):
if not name:
continue
try:
validate_for_config(self._store.load_object_calibration(radar_key, name), geometry)
except Exception as exc: # noqa: BLE001 - reject unavailable or incompatible saved corrections
invalid.add(name)
self._log_warning(f"Object calibration {name} disabled for changed antenna geometry: {exc}")
if not invalid:
return
resume_seq = getattr(self, "_stop_request_seq", 0) + 1
if active in invalid:
self._stop_run()
if selected in invalid:
self._selected_preprocess_sets["s21_object_calibration"] = ""
self._refresh_preprocess_summary_labels()
if self._preprocess_dialog is not None:
self._preprocess_dialog.set_selected_sets(self._selected_preprocess_sets, emit_signal=False)
if active in invalid and self._pipeline_resume_allowed(resume_seq):
self._start_run()
def _on_processing_live_settings_changed(self, *_args) -> None:
"""Handle live-processing setting changes and trigger redraw when needed."""
# Web-apply sets many widgets at once; their individual change signals are
@@ -4,6 +4,7 @@ from __future__ import annotations
from collections import deque
from contextlib import ExitStack
from copy import deepcopy
import json
from pathlib import Path
@@ -23,12 +24,23 @@ from python_app.orchestration.preprocess_assets import (
class AppWindowConfigProfileIOMixin:
"""Persist and restore full GUI profiles without restarting the app."""
def _write_gui_profile_to_path(self, output_path: Path, *, allow_overwrite: bool = True) -> GuiProfileModel:
"""Serialize current full GUI profile to `output_path` and return the persisted model."""
def _write_gui_profile_to_path(
self, output_path: Path, *, allow_overwrite: bool = True, dataset: bool = False,
) -> GuiProfileModel:
"""Save UI settings, preserving the acquisition provenance for recorded data."""
if not allow_overwrite and output_path.exists():
raise FileExistsError(f"Config profile output already exists: {output_path}")
profile = self._build_gui_profile()
active = getattr(self, "_active_run_config", None)
if dataset and active is not None:
profile.run_config.radar = deepcopy(active.radar)
profile.run_config.input_switch = deepcopy(active.input_switch)
profile.run_config.output_switch = deepcopy(active.output_switch)
profile.run_config.combos = deepcopy(active.combos)
profile.run_config.preprocess = deepcopy(active.preprocess)
profile.run_config.gpr.tx_geometry = deepcopy(active.gpr.tx_geometry)
profile.run_config.gpr.rx_geometry = deepcopy(active.gpr.rx_geometry)
atomic_write_json(output_path, profile.to_dict(), durable=True)
return profile
@@ -206,6 +218,9 @@ class AppWindowConfigProfileIOMixin:
capture sequence or the pipeline is active — the operator must stop first.
Shared by the desktop dialog and the web loader so both honor the same rule.
"""
if getattr(self, "_object_capture_job", None) is not None:
self._show_error("Wait for object-calibration capture to finish")
return False
if self._capture_session is not None:
self._show_error(
"Cannot load config during active capture sequence",
@@ -57,13 +57,16 @@ class AppWindowPipelineMixin:
"""Start acquisition in single-capture mode."""
self._start_run(single_capture=True)
def _start_run(self, *, single_capture: bool = False) -> None:
def _start_run(self, *, single_capture: bool = False, config_override: RunConfigModel | None = None) -> None:
"""Start pipeline processes and ring readers."""
if getattr(self, "_closing", False) or getattr(self, "_close_after_stop", False):
return
if getattr(self, "_stopping_run", False):
self._show_error("Wait for pipeline shutdown before starting again")
return
if config_override is None and getattr(self, "_object_capture_job", None) is not None:
self._show_error("Wait for object-calibration capture to finish")
return
if self._capture_session is not None:
self._show_error(
"Cannot start pipeline during active capture sequence",
@@ -88,7 +91,7 @@ class AppWindowPipelineMixin:
try:
processor_was_running = self._supervisor.is_processor_running()
config = self._build_config()
config = config_override.clone() if config_override is not None else self._build_config()
validate_acquisition_config(config)
run_signature = self._build_run_history_signature(config)
processor_signature = self._build_processor_run_signature(config)
@@ -101,7 +104,8 @@ class AppWindowPipelineMixin:
if not processor_was_running:
self._reset_runtime_history()
self._validate_processing_mode_constraints(config)
if config_override is None:
self._validate_processing_mode_constraints(config)
radar_key = self._radar_key(config)
missing_assets = [
@@ -145,14 +149,12 @@ class AppWindowPipelineMixin:
f"{', ' + combo_preview if combo_preview else ''}, radar_key={radar_key}"
)
if not single_capture:
should_reset_history = (
self._history_run_signature is not None and self._history_run_signature != run_signature
)
if should_reset_history:
if self._history_run_signature is not None and self._history_run_signature != run_signature:
if processor_was_running:
self._reset_runtime_history()
self._log("History reset because run settings changed")
self._history_run_signature = run_signature
self._write_live_processing_config(history_command="clear_all", bump_history_seq=True)
self._log("History reset because run settings changed")
self._history_run_signature = run_signature
# Open the single-capture acquisition window BEFORE the producer can emit:
# any collection the one-shot orchestrator publishes is then strictly newer
@@ -211,6 +213,9 @@ class AppWindowPipelineMixin:
def _apply_radar_settings(self) -> None:
"""Apply current radar settings by preconfiguring native device."""
if getattr(self, "_object_capture_job", None) is not None:
self._show_error("Wait for object-calibration capture to finish")
return
if self._capture_session is not None:
self._show_error(
"Finish or abort capture sequence before applying radar settings",
@@ -5,6 +5,7 @@ from __future__ import annotations
from PyQt6.QtCore import QTimer
from python_app.gui.preprocess_dialog import PreprocessDialog
from python_app.gui.controllers.object_calibration_controller import ObjectCalibrationController
from python_app.gui.trace_png_export import export_trace_png
from python_app.orchestration.preprocess_assets import (
PREPROCESS_ASSET_SPECS,
@@ -124,6 +125,9 @@ class AppWindowPreprocessMixin:
"""Capture, save, and select a temporary S21 reference with current sweep settings."""
if getattr(self, "_closing", False) or getattr(self, "_stopping_run", False):
return
if getattr(self, "_object_capture_job", None) is not None:
self._show_error("Wait for object-calibration capture to finish")
return
if self._capture_session is not None:
self._show_error(
"Cannot capture tmp reference during active capture sequence",
@@ -227,6 +231,9 @@ class AppWindowPreprocessMixin:
dialog.finalize_sequence_requested.connect(self._finalize_capture_sequence)
dialog.abort_sequence_requested.connect(self._abort_capture_sequence)
dialog.create_neutral_sets_requested.connect(self._create_neutral_sets)
self._object_calibration_controller = ObjectCalibrationController(self)
dialog.import_object_calibration_requested.connect(self._object_calibration_controller.import_calibration)
dialog.create_object_calibration_requested.connect(self._object_calibration_controller.open_creation)
dialog.set_neutral_sets_visible(supports_neutral_preprocess_sets(self._defaults_config))
dialog.set_radar_config_summary(
directory_path=self._preprocess_radar_scan_summary.directory_path,
@@ -340,6 +347,9 @@ class AppWindowPreprocessMixin:
"""Start sequential capture session for requested preprocess asset."""
if getattr(self, "_closing", False) or getattr(self, "_stopping_run", False):
return
if getattr(self, "_object_capture_job", None) is not None:
self._show_error("Wait for object-calibration capture to finish")
return
if self._capture_session is not None:
self._show_error("Another capture sequence is already active", details=self._capture_state_details())
return
@@ -410,6 +420,9 @@ class AppWindowPreprocessMixin:
"""Save neutral S21 calibration/reference sets for the current radar settings."""
if getattr(self, "_closing", False) or getattr(self, "_stopping_run", False):
return
if getattr(self, "_object_capture_job", None) is not None:
self._show_error("Wait for object-calibration capture to finish")
return
if self._capture_session is not None:
self._show_error(
"Cannot create neutral sets during active capture sequence",
@@ -71,6 +71,9 @@ class AppWindowRecordingMixin:
path/name. Refuses to start a second recording while one is in progress, and
reports a name clash up front (before any data is collected).
"""
if getattr(self, "_object_capture_job", None) is not None:
self._show_error("Wait for object-calibration capture to finish")
return
if self._recording_active:
self._show_error(
"A recording is already in progress; wait for it to finish before starting another",
@@ -100,7 +103,8 @@ class AppWindowRecordingMixin:
)
# Adjacent config profile, mirroring the manual save (fail if it clashes).
self._write_gui_profile_to_path(
self._snapshot_config_profile_path(writer.directory), allow_overwrite=False
self._snapshot_config_profile_path(writer.directory), allow_overwrite=False,
dataset=self._supervisor.is_running(),
)
except Exception as exc: # noqa: BLE001
if writer is not None:
@@ -148,6 +152,14 @@ class AppWindowRecordingMixin:
shutil.rmtree(writer.directory)
self._log("Disk recording canceled because acquisition did not start")
return
try:
self._write_gui_profile_to_path(
self._snapshot_config_profile_path(writer.directory), dataset=True,
)
except Exception as exc: # noqa: BLE001 - keep any accepted data and the initial profile
self._finalize_recording_on_stop()
self._show_exception("Failed to update disk recording config profile", exc)
return
self._log(f"Disk recording armed: streaming the next {target} measurement(s) to {writer.directory}")
@@ -41,6 +41,9 @@ class AppWindowSnapshotMixin:
def _save_snapshot(self) -> None:
"""Save up to 1000 recent runtime measurements as a numpy snapshot."""
if getattr(self, "_object_capture_job", None) is not None:
self._show_error("Wait for object-calibration capture to finish")
return
self._drain_runtime_rings_for_snapshot()
if getattr(self, "_closing", False):
return
@@ -100,7 +103,7 @@ class AppWindowSnapshotMixin:
)
config_profile_path = self._snapshot_config_profile_path(snapshot_dir)
try:
self._write_gui_profile_to_path(config_profile_path, allow_overwrite=False)
self._write_gui_profile_to_path(config_profile_path, allow_overwrite=False, dataset=True)
except Exception as exc: # noqa: BLE001
self._show_error(
"Snapshot data was saved, but the adjacent config profile could not be written",
@@ -130,6 +133,9 @@ class AppWindowSnapshotMixin:
def _save_vna_history_json(self) -> None:
"""Save one VNA-history JSON per available combo in runtime history."""
if getattr(self, "_object_capture_job", None) is not None:
self._show_error("Wait for object-calibration capture to finish")
return
self._drain_runtime_rings_for_snapshot()
if getattr(self, "_closing", False):
return
@@ -164,7 +170,7 @@ class AppWindowSnapshotMixin:
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)
self._write_gui_profile_to_path(config_profile_path, allow_overwrite=True, dataset=True)
except Exception as exc: # noqa: BLE001
exported_preview = "\n".join(str(path) for path in output_paths[:8])
if len(output_paths) > 8:
@@ -212,6 +218,9 @@ class AppWindowSnapshotMixin:
def _apply_runtime_history_deletion(self, *, remove_last_only: bool) -> None:
"""Apply destructive runtime-history deletion across readers, caches, and processor replay state."""
if getattr(self, "_object_capture_job", None) is not None:
self._show_error("Wait for object-calibration capture to finish")
return
if self._capture_session is not None:
self._show_error(
"Cannot modify runtime history during active capture sequence",
@@ -0,0 +1,140 @@
"""Acquire fresh object-calibration sweeps through the existing native preprocessor."""
from __future__ import annotations
import time
from PyQt6.QtCore import QObject, QTimer, pyqtSignal
from python_app.models.dataset_model import SweepCollection
from python_app.models.run_config_model import ComboModel
class ObjectSweepCapture(QObject):
"""Temporarily acquire the full antenna matrix without applying an object correction."""
finished = pyqtSignal()
def __init__(self, owner, dialog) -> None:
if getattr(owner, "_object_capture_job", None) is not None:
raise RuntimeError("Object-calibration capture is already in progress")
super().__init__(dialog)
owner._object_capture_job = self
self._owner = owner
self._dialog = dialog
self._timer = QTimer(self)
self._timer.timeout.connect(self._poll)
self._done = False
self._paused = False
self._started = False
self._resume = False
self._collections: list[SweepCollection] = []
self._seen: set[tuple[int, int]] = set()
self._stop_seq = getattr(owner, "_stop_request_seq", 0)
QTimer.singleShot(0, self._start)
def _start(self) -> None:
if self._done:
return
owner = self._owner
try:
if not owner._pipeline_resume_allowed(self._stop_seq):
self.cancel()
return
if getattr(owner, "_closing", False) or owner._capture_session is not None or owner._preprocess_capture_busy:
raise RuntimeError("Finish the active capture before taking object-calibration data")
if owner._single_capture_active or getattr(owner, "_recording_active", False):
raise RuntimeError("Finish the single capture or disk recording before taking calibration data")
self._config = owner._build_config()
if self._config.radar.replay.dataset_dir.strip():
raise RuntimeError("Object-calibration capture requires live acquisition; disable archive replay")
self._config.preprocess.s21.object_calibration.set_name = ""
self._config.preprocess.s21.object_calibration.bundle_path = ""
tx = sorted({entry.output_pos for entry in self._config.gpr.tx_geometry})
rx = sorted({entry.input_pos for entry in self._config.gpr.rx_geometry})
if not tx or not rx:
raise RuntimeError("Configure Tx/Rx antenna positions before taking calibration data")
self._config.combos = [ComboModel(input=i, output=o) for o in tx for i in rx]
self._count = owner._ensure_preprocess_dialog().median_sweep_count()
self._resume = owner._supervisor.is_running()
if self._resume:
self._paused = True
owner._stop_run()
self._stop_seq += 1
if not owner._pipeline_resume_allowed(self._stop_seq):
self.cancel()
return
self._source_config = self._config.to_dict()
self._start_ns = time.monotonic_ns()
owner._start_run(config_override=self._config)
self._started = owner._supervisor.is_running()
if not self._started:
raise RuntimeError("Calibration acquisition did not start; check the preprocessing sets and log")
self._dialog.set_status(f"Taking calibration data: 0 / {self._count} sweeps")
self._timer.start(50)
except Exception as exc:
self._finish(error=str(exc))
def _poll(self) -> None:
owner = self._owner
try:
if not owner._pipeline_resume_allowed(self._stop_seq):
self.cancel()
return
if not owner._supervisor.is_running():
raise RuntimeError("Calibration acquisition stopped before all sweeps were received")
for collection in owner._pre_history:
identity = (collection.collection_id, collection.monotonic_ns)
starts = [collection.capture_start_ns, *(trace.capture_start_ns for trace in collection.traces)]
acquired_ns = min((start for start in starts if start > 0), default=collection.monotonic_ns)
if acquired_ns < self._start_ns or identity in self._seen:
continue
self._seen.add(identity)
self._collections.append(collection)
if len(self._collections) == self._count:
self._finish()
return
self._dialog.set_status(f"Taking calibration data: {len(self._collections)} / {self._count} sweeps")
if time.monotonic_ns() - self._start_ns > owner._SINGLE_CAPTURE_TIMEOUT_S * 1e9:
raise TimeoutError("Timed out waiting for preprocessed calibration sweeps")
except Exception as exc:
self._finish(error=str(exc))
def cancel(self) -> None:
"""Cancel this capture and restore the previous acquisition when it is still allowed."""
self._finish(error="Calibration capture cancelled")
def _finish(self, *, error: str | None = None) -> None:
if self._done:
return
self._done = True
self._timer.stop()
owner = self._owner
resume_allowed = owner._pipeline_resume_allowed(self._stop_seq)
try:
if self._started and resume_allowed:
owner._stop_run()
self._stop_seq += 1
if not owner._pipeline_resume_allowed(self._stop_seq):
error = "Calibration capture cancelled"
if error is None:
self._dialog.add_capture(self._collections, self._source_config)
else:
self._dialog.set_status(error)
except Exception as exc:
self._dialog.set_status(f"Calibration capture failed: {exc}")
finally:
if getattr(owner, "_object_capture_job", None) is self:
owner._object_capture_job = None
if self._resume and (self._paused or self._started) and resume_allowed and owner._pipeline_resume_allowed(self._stop_seq):
owner._start_run()
self._dialog.set_capture_pending(False)
self.finished.emit()
self.deleteLater()
def capture_object_sweeps(owner, dialog) -> ObjectSweepCapture:
"""Start an asynchronous capture; the dialog receives sweeps or a visible error."""
capture = ObjectSweepCapture(owner, dialog)
dialog.set_capture_pending(True)
return capture
@@ -0,0 +1,183 @@
"""GUI coordination for importing and estimating optional object calibration."""
from __future__ import annotations
import queue
import threading
from pathlib import Path
from PyQt6.QtCore import QObject, QTimer
from PyQt6.QtWidgets import QFileDialog
from python_app.gui.object_calibration_dialog import ObjectCalibrationDialog
def _calculate(groups, config, settings, cancelled, messages, result) -> None:
"""The worker handles only Python data; every Qt action stays on the GUI thread."""
try:
from python_app.workflows.mimo_calibration import Settings, compute_from_collections
calibration = compute_from_collections(
groups, config, settings=Settings(**settings),
progress=messages.put, cancelled=cancelled.is_set,
)
result.put(("cancelled", None) if cancelled.is_set() else ("ready", calibration))
except InterruptedError:
result.put(("cancelled", None))
except Exception as exc: # noqa: BLE001 - report worker failures on the GUI thread
result.put(("error", exc))
class ObjectCalibrationController(QObject):
"""Own one modeless editor and cancellable calculation for an AppWindow."""
def __init__(self, owner) -> None:
super().__init__(owner)
self._owner = owner
self._dialog: ObjectCalibrationDialog | None = None
self._capture = None
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._poll = QTimer(self)
self._poll.setInterval(100)
self._poll.timeout.connect(self._poll_calculation)
self._save_name = ""
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):
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:
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)
def _capture_finished(self) -> 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:
return
try:
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
self._cancel = threading.Event()
self._messages = queue.Queue()
self._result = queue.Queue(maxsize=1)
self._worker = threading.Thread(
target=_calculate,
args=(groups, config, settings, self._cancel, self._messages, self._result),
name="object-calibration", daemon=True,
)
self._dialog.set_calculating(True)
self._dialog.set_status("Расчёт калибровки…")
self._worker.start()
self._poll.start()
except Exception as exc: # noqa: BLE001
self._worker = None
self._dialog.set_calculating(False)
self._dialog.set_status(f"Ошибка расчёта: {exc}")
self._owner._show_exception("Failed to calculate object calibration", exc)
def _poll_calculation(self) -> None:
if self._dialog is None or getattr(self._owner, "_closing", False):
self._poll.stop()
return
while not self._messages.empty():
self._dialog.set_status(self._messages.get_nowait())
try:
state, payload = 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("Расчёт отменён. Снятые данные сохранены в этом окне.")
return
if state == "error":
self._dialog.set_status(f"Ошибка расчёта: {payload}")
self._owner._show_exception("Failed to calculate object calibration", payload)
return
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}")
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("Отмена расчёта…")
def shutdown(self) -> None:
self.cancel()
self._poll.stop()
if self._dialog is not None:
self._dialog.close()