Add object calibration
This commit is contained in:
@@ -791,6 +791,9 @@ class AppWindow(
|
||||
# 0) Stop the GPIO button watcher so a late press cannot start work.
|
||||
self._stop_control_button_watcher()
|
||||
self._resume_pipeline_after_capture = False
|
||||
controller = getattr(self, "_object_calibration_controller", None)
|
||||
if controller is not None:
|
||||
controller.shutdown()
|
||||
# 0) Drop the B-scan replay writer so a pending debounce cannot push into a
|
||||
# ring we are about to tear down.
|
||||
self._bscan_reprocess_timer.stop()
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,293 @@
|
||||
"""Live reflector measurements and target-phase diagnostics for MIMO calibration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
from PyQt6.QtWidgets import (
|
||||
QCheckBox, QComboBox, QDialog, QDoubleSpinBox, QFormLayout, QGroupBox,
|
||||
QHBoxLayout, QHeaderView, QLabel, QLineEdit, QPushButton, QTableWidget, QTableWidgetItem,
|
||||
QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from python_app.models.dataset_model import SweepCollection
|
||||
|
||||
|
||||
class ObjectCalibrationDialog(QDialog):
|
||||
"""Collect full-matrix sweeps, then associate each group with measured XYZ."""
|
||||
|
||||
capture_requested = pyqtSignal()
|
||||
calculate_requested = pyqtSignal()
|
||||
cancel_requested = pyqtSignal()
|
||||
|
||||
def __init__(self, set_name: str, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("MIMO Object Calibration")
|
||||
self.resize(1000, 850)
|
||||
self._captures: list[dict] = []
|
||||
self._config: dict | None = None
|
||||
self._capture_pending = self._calculating = False
|
||||
self._calibration: dict | None = None
|
||||
layout = QVBoxLayout(self)
|
||||
name_row = QHBoxLayout()
|
||||
name_row.addWidget(QLabel("Set name", self))
|
||||
self._set_name_input = QLineEdit(set_name, self)
|
||||
name_row.addWidget(self._set_name_input)
|
||||
layout.addLayout(name_row)
|
||||
explanation = QLabel(
|
||||
"Снимите данные, затем введите физически измеренные координаты центра отражателя: "
|
||||
"X — поперёк, Y — высота над землёй, Z — вдоль земли. "
|
||||
"Обычные S21-калибровка и выбранный референс уже применены; "
|
||||
"существующая MIMO-поправка при съёмке отключена.", self,
|
||||
)
|
||||
explanation.setWordWrap(True)
|
||||
layout.addWidget(explanation)
|
||||
self._table = QTableWidget(0, 5, self)
|
||||
self._table.setHorizontalHeaderLabels(["Набор", "Свипов", "X (м)", "Y (м)", "Z (м)"])
|
||||
horizontal, vertical = self._table.horizontalHeader(), self._table.verticalHeader()
|
||||
assert horizontal is not None and vertical is not None
|
||||
horizontal.setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
||||
vertical.hide()
|
||||
self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||
self._table.itemChanged.connect(self._measurements_changed)
|
||||
layout.addWidget(self._table)
|
||||
actions = QHBoxLayout()
|
||||
self._capture_button = QPushButton("Снять данные", self)
|
||||
self._capture_button.clicked.connect(self.capture_requested.emit)
|
||||
self._remove_button = QPushButton("Удалить выбранный набор", self)
|
||||
self._remove_button.clicked.connect(self._remove_capture)
|
||||
actions.addWidget(self._capture_button)
|
||||
actions.addWidget(self._remove_button)
|
||||
actions.addStretch(1)
|
||||
layout.addLayout(actions)
|
||||
self._settings: dict[str, QDoubleSpinBox | QCheckBox | QComboBox] = {}
|
||||
form = QFormLayout()
|
||||
for key, title, default, low, high in (
|
||||
("sphere_radius_m", "Радиус шара, м (0 — точка)", .16, 0, 100),
|
||||
("antenna_height_m", "Высота начала координат антенн над землёй, м", .92, 0, 100),
|
||||
("pitch_deg", "Наклон радара вниз, °", 10, -90, 90),
|
||||
):
|
||||
form.addRow(title, self._number(key, default, low, high))
|
||||
layout.addLayout(form)
|
||||
advanced = QGroupBox("Параметры расчёта", self)
|
||||
advanced.setFlat(True)
|
||||
advanced.setCheckable(True)
|
||||
advanced.setChecked(False)
|
||||
advanced_layout = QVBoxLayout(advanced)
|
||||
advanced_content = QWidget(advanced)
|
||||
advanced_form = QFormLayout(advanced_content)
|
||||
for key, title, default, low, high in (
|
||||
("f_min", "Нижняя частота оценки, МГц", 2000, 0, 100000),
|
||||
("f_max", "Верхняя частота оценки, МГц", 6000, 0, 100000),
|
||||
("gate_half_m", "Полуширина окна эха по пути, м", .15, .000001, 100),
|
||||
("gate_taper_m", "Плавный край окна, м", .03, .000001, 100),
|
||||
("coarse_range_search_m", "Грубый поиск по пути, ±м (0 — выкл.)", .6, 0, 100),
|
||||
("refine_range_m", "Уточнение координат, ±м", .1, 0, 100),
|
||||
):
|
||||
advanced_form.addRow(title, self._number(key, default, low, high))
|
||||
axes = QWidget(advanced_content)
|
||||
axes_layout = QHBoxLayout(axes)
|
||||
axes_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._refine_axes = {}
|
||||
for axis in "xyz":
|
||||
checkbox = QCheckBox(axis.upper(), axes)
|
||||
checkbox.setChecked(True)
|
||||
checkbox.toggled.connect(self._measurements_changed)
|
||||
self._refine_axes[axis] = checkbox
|
||||
axes_layout.addWidget(checkbox)
|
||||
advanced_form.addRow("Уточнять координаты", axes)
|
||||
phase_model = QComboBox(advanced_content)
|
||||
phase_model.addItems(["const", "linear", "poly3", "raw"])
|
||||
phase_model.setCurrentText("linear")
|
||||
phase_model.currentTextChanged.connect(self._measurements_changed)
|
||||
self._settings["phase_model"] = phase_model
|
||||
advanced_form.addRow("Модель фазы антенны", phase_model)
|
||||
amplitude = QCheckBox("Выравнивать также амплитуды", advanced_content)
|
||||
amplitude.toggled.connect(self._measurements_changed)
|
||||
self._settings["apply_amplitude"] = amplitude
|
||||
advanced_form.addRow(amplitude)
|
||||
advanced_content.hide()
|
||||
advanced.toggled.connect(advanced_content.setVisible)
|
||||
advanced_layout.addWidget(advanced_content)
|
||||
layout.addWidget(advanced)
|
||||
self._target_combo = QComboBox(self)
|
||||
self._target_combo.currentIndexChanged.connect(self._draw_phase_effect)
|
||||
layout.addWidget(self._target_combo)
|
||||
self._phase_plot = pg.PlotWidget(background="#0f141c", enableMenu=False)
|
||||
self._phase_plot.setLabel("left", "Target phase relative to first pair", units="deg")
|
||||
self._phase_plot.setLabel("bottom", "Tx / Rx pair")
|
||||
self._phase_plot.showGrid(x=True, y=True, alpha=.2)
|
||||
self._phase_plot.setMinimumHeight(220)
|
||||
self._phase_plot.addLegend()
|
||||
layout.addWidget(self._phase_plot)
|
||||
self._quality_label = QLabel("Фазы вкладов пар появятся после расчёта.", self)
|
||||
self._quality_label.setWordWrap(True)
|
||||
layout.addWidget(self._quality_label)
|
||||
self._status_label = QLabel("Готово к съёмке", self)
|
||||
self._status_label.setWordWrap(True)
|
||||
layout.addWidget(self._status_label)
|
||||
footer = QHBoxLayout()
|
||||
self._calculate_button = QPushButton("Рассчитать и сохранить", self)
|
||||
self._calculate_button.clicked.connect(self.calculate_requested.emit)
|
||||
self._cancel_button = QPushButton("Отменить операцию", self)
|
||||
self._cancel_button.clicked.connect(self.cancel_requested.emit)
|
||||
close_button = QPushButton("Закрыть", self)
|
||||
close_button.clicked.connect(self.reject)
|
||||
footer.addWidget(self._calculate_button)
|
||||
footer.addWidget(self._cancel_button)
|
||||
footer.addStretch(1)
|
||||
footer.addWidget(close_button)
|
||||
layout.addLayout(footer)
|
||||
self._update_actions()
|
||||
|
||||
def _number(self, key: str, value: float, low: float, high: float) -> QDoubleSpinBox:
|
||||
field = QDoubleSpinBox(self)
|
||||
field.setDecimals(6)
|
||||
field.setRange(low, high)
|
||||
field.setSingleStep(100 if key in {"f_min", "f_max"} else .1 if key == "pitch_deg" else .01)
|
||||
field.setValue(value)
|
||||
field.valueChanged.connect(self._measurements_changed)
|
||||
self._settings[key] = field
|
||||
return field
|
||||
|
||||
def add_capture(self, collections: list[SweepCollection], config: dict) -> None:
|
||||
"""Freeze a captured group before the operator enters its target coordinates."""
|
||||
if not collections:
|
||||
raise ValueError("Не получены свипы отражателя")
|
||||
if self._config is not None and any(
|
||||
config.get(key) != self._config.get(key) for key in ("radar", "gpr", "preprocess")
|
||||
):
|
||||
raise ValueError("Параметры радара, геометрия или препроцессинг изменились между наборами")
|
||||
self._config = copy.deepcopy(config)
|
||||
self._captures.append({"collections": copy.deepcopy(collections)})
|
||||
row = self._table.rowCount()
|
||||
self._table.insertRow(row)
|
||||
for column, value in enumerate((str(row + 1), str(len(collections)), "", "", "")):
|
||||
item = QTableWidgetItem(value)
|
||||
if column < 2:
|
||||
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
self._table.setItem(row, column, item)
|
||||
self._table.setCurrentCell(row, 2)
|
||||
self.set_status("Данные сняты. Введите координаты центра отражателя для нового набора.")
|
||||
self._update_actions()
|
||||
|
||||
def calculation_inputs(self) -> tuple[list[dict], dict, dict]:
|
||||
"""Validate XYZ after capture; expose a frozen recipe to the worker."""
|
||||
if not self._captures or self._config is None:
|
||||
raise ValueError("Сначала снимите данные отражателя")
|
||||
groups = []
|
||||
for row, capture in enumerate(self._captures):
|
||||
try:
|
||||
coordinates = []
|
||||
for column in (2, 3, 4):
|
||||
item = self._table.item(row, column)
|
||||
assert item is not None
|
||||
coordinates.append(float(item.text().strip()))
|
||||
target = tuple(coordinates)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Введите X, Y и Z центра отражателя для набора {row + 1}") from exc
|
||||
if not all(math.isfinite(value) for value in target):
|
||||
raise ValueError(f"Координаты набора {row + 1} должны быть конечными числами")
|
||||
groups.append({**capture, "target_scene_m": target})
|
||||
settings: dict[str, float | bool | str | tuple[str, ...] | tuple[int, ...]] = {
|
||||
key: field.value() * (1e6 if key in {"f_min", "f_max"} else 1) if isinstance(field, QDoubleSpinBox) else
|
||||
field.isChecked() if isinstance(field, QCheckBox) else field.currentText()
|
||||
for key, field in self._settings.items()
|
||||
}
|
||||
settings["refine_axes"] = tuple(axis for axis, field in self._refine_axes.items() if field.isChecked())
|
||||
traces = self._captures[0]["collections"][0].traces
|
||||
settings["input_idx"] = tuple(sorted({trace.combo.input for trace in traces}))
|
||||
settings["output_idx"] = tuple(sorted({trace.combo.output for trace in traces}))
|
||||
return groups, copy.deepcopy(self._config), settings
|
||||
|
||||
def set_name(self) -> str:
|
||||
return self._set_name_input.text().strip()
|
||||
|
||||
def set_capture_pending(self, pending: bool) -> None:
|
||||
self._capture_pending = pending
|
||||
self._update_actions()
|
||||
|
||||
def set_calculating(self, calculating: bool) -> None:
|
||||
self._calculating = calculating
|
||||
self._update_actions()
|
||||
|
||||
def set_status(self, message: str) -> None:
|
||||
self._status_label.setText(message)
|
||||
|
||||
def show_calibration(self, calibration: dict) -> None:
|
||||
self._calibration = calibration
|
||||
self._target_combo.clear()
|
||||
for index, target in enumerate(calibration.get("meta", {}).get("targets", [])):
|
||||
xyz = target.get("nominal_scene_m", [])
|
||||
self._target_combo.addItem(f"Набор {index + 1}: {xyz}")
|
||||
self._draw_phase_effect()
|
||||
|
||||
def _draw_phase_effect(self, *_args) -> None:
|
||||
self._phase_plot.clear()
|
||||
if self._calibration is None:
|
||||
return
|
||||
diagnostics = self._calibration.get("meta", {}).get("diagnostics", [])
|
||||
index = self._target_combo.currentIndex()
|
||||
if not 0 <= index < len(diagnostics):
|
||||
self._quality_label.setText("У этого набора нет диагностики фаз в точке цели.")
|
||||
return
|
||||
diagnostic = diagnostics[index]
|
||||
phases = np.array([diagnostic["phase_before_deg"], diagnostic["phase_after_deg"]]).reshape(2, -1)
|
||||
before, after = (phases - phases[0, 0] + 180) % 360 - 180
|
||||
pairs = [(output, input_pos) for output in self._calibration["tx_output_pos"]
|
||||
for input_pos in self._calibration["rx_input_pos"]]
|
||||
xx = np.arange(len(pairs))
|
||||
for values, color, label in ((before, "#ed7d31", "До"), (after, "#42c7a5", "После")):
|
||||
self._phase_plot.plot(xx, values, pen=pg.mkPen(color, width=1.5), symbol="o",
|
||||
symbolBrush=color, symbolPen=color, name=label)
|
||||
step = max(1, len(pairs) // 16)
|
||||
self._phase_plot.getAxis("bottom").setTicks([[
|
||||
(k, f"o{pairs[k][0]}/i{pairs[k][1]}") for k in range(0, len(pairs), step)
|
||||
]])
|
||||
self._quality_label.setText(
|
||||
f"Согласованность фаз в точке цели: {diagnostic['coherence_before']:.3f} "
|
||||
f"→ {diagnostic['coherence_after']:.3f} (1 — все вклады в фазе)."
|
||||
)
|
||||
|
||||
def _remove_capture(self) -> None:
|
||||
row = self._table.currentRow()
|
||||
if row >= 0:
|
||||
self._captures.pop(row)
|
||||
self._table.removeRow(row)
|
||||
for index in range(self._table.rowCount()):
|
||||
item = self._table.item(index, 0)
|
||||
assert item is not None
|
||||
item.setText(str(index + 1))
|
||||
if not self._captures:
|
||||
self._config = None
|
||||
self._measurements_changed()
|
||||
|
||||
def _measurements_changed(self, *_args) -> None:
|
||||
self._calibration = None
|
||||
if hasattr(self, "_phase_plot"):
|
||||
self._phase_plot.clear()
|
||||
self._target_combo.clear()
|
||||
self._quality_label.setText("Параметры изменены — требуется новый расчёт.")
|
||||
|
||||
def _update_actions(self) -> None:
|
||||
busy = self._capture_pending or self._calculating
|
||||
self._capture_button.setEnabled(not busy)
|
||||
self._remove_button.setEnabled(not busy and bool(self._captures))
|
||||
self._calculate_button.setEnabled(not busy and bool(self._captures))
|
||||
self._cancel_button.setEnabled(busy)
|
||||
self._table.setEnabled(not busy)
|
||||
self._set_name_input.setEnabled(not busy)
|
||||
for field in [*self._settings.values(), *self._refine_axes.values()]:
|
||||
field.setEnabled(not busy)
|
||||
|
||||
def reject(self) -> None:
|
||||
self.cancel_requested.emit()
|
||||
super().reject()
|
||||
|
||||
def closeEvent(self, event) -> None: # noqa: N802
|
||||
self.cancel_requested.emit()
|
||||
super().closeEvent(event)
|
||||
@@ -26,7 +26,9 @@ import pyqtgraph as pg
|
||||
|
||||
from python_app.models.dataset_model import TraceData
|
||||
from python_app.orchestration.preprocess_assets import (
|
||||
CAPTURABLE_PREPROCESS_ASSET_KEYS,
|
||||
PREPROCESS_ASSET_SPECS,
|
||||
REQUIRED_PREPROCESS_ASSET_KEYS,
|
||||
VISIBLE_PREPROCESS_ASSET_KEYS,
|
||||
preprocess_asset_display_name,
|
||||
)
|
||||
@@ -47,6 +49,8 @@ class PreprocessDialog(QDialog):
|
||||
finalize_sequence_requested = pyqtSignal()
|
||||
abort_sequence_requested = pyqtSignal()
|
||||
create_neutral_sets_requested = pyqtSignal()
|
||||
import_object_calibration_requested = pyqtSignal()
|
||||
create_object_calibration_requested = pyqtSignal()
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
"""Initialize window metadata and compose dialog UI."""
|
||||
@@ -165,10 +169,25 @@ class PreprocessDialog(QDialog):
|
||||
for key in keys:
|
||||
combo = QComboBox(group)
|
||||
combo.setPlaceholderText("<not selected>")
|
||||
combo.setCurrentIndex(-1)
|
||||
combo.currentTextChanged.connect(self._emit_selection_changed)
|
||||
combo.setProperty("optional", key not in REQUIRED_PREPROCESS_ASSET_KEYS)
|
||||
self._set_combo_items(combo, [], "")
|
||||
combo.currentIndexChanged.connect(self._emit_selection_changed)
|
||||
self._set_combos[key] = combo
|
||||
form.addRow(self._asset_row_label(key), combo)
|
||||
field = combo
|
||||
if key == "s21_object_calibration":
|
||||
field = QWidget(group)
|
||||
row = QHBoxLayout(field)
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.addWidget(combo, stretch=1)
|
||||
self._import_object_calibration_button = QPushButton("Import…", field)
|
||||
self._import_object_calibration_button.setToolTip("Import an existing cal_*.npy MIMO calibration folder.")
|
||||
self._import_object_calibration_button.clicked.connect(self.import_object_calibration_requested.emit)
|
||||
self._create_object_calibration_button = QPushButton("Create…", field)
|
||||
self._create_object_calibration_button.setToolTip("Calculate a MIMO calibration from reflector measurements.")
|
||||
self._create_object_calibration_button.clicked.connect(self.create_object_calibration_requested.emit)
|
||||
row.addWidget(self._import_object_calibration_button)
|
||||
row.addWidget(self._create_object_calibration_button)
|
||||
form.addRow(self._asset_row_label(key), field)
|
||||
return group
|
||||
|
||||
def _build_sequence_group(self) -> QGroupBox:
|
||||
@@ -199,7 +218,7 @@ class PreprocessDialog(QDialog):
|
||||
def _build_sequence_button_grid(self, parent: QGroupBox) -> QGridLayout:
|
||||
"""Build per-asset capture start buttons."""
|
||||
layout = QGridLayout()
|
||||
for index, key in enumerate(VISIBLE_PREPROCESS_ASSET_KEYS):
|
||||
for index, key in enumerate(CAPTURABLE_PREPROCESS_ASSET_KEYS):
|
||||
button = QPushButton(f"Start {preprocess_asset_display_name(key)}", parent)
|
||||
button.clicked.connect(lambda _checked=False, asset_key=key: self.start_sequence_requested.emit(asset_key))
|
||||
layout.addWidget(button, index // 2, index % 2)
|
||||
@@ -316,7 +335,7 @@ class PreprocessDialog(QDialog):
|
||||
|
||||
def selection_snapshot(self) -> dict[str, str]:
|
||||
"""Return currently selected set names keyed by preprocess asset key."""
|
||||
return {key: self._set_combos[key].currentText().strip() for key in VISIBLE_PREPROCESS_ASSET_KEYS}
|
||||
return {key: self._set_combos[key].currentData() or "" for key in VISIBLE_PREPROCESS_ASSET_KEYS}
|
||||
|
||||
def clear_capture_log(self) -> None:
|
||||
"""Clear capture history text box."""
|
||||
@@ -398,7 +417,7 @@ class PreprocessDialog(QDialog):
|
||||
for key in VISIBLE_PREPROCESS_ASSET_KEYS:
|
||||
combo = self._set_combos[key]
|
||||
with QSignalBlocker(combo):
|
||||
self._set_combo_items(combo, available_sets.get(key, []), combo.currentText().strip())
|
||||
self._set_combo_items(combo, available_sets.get(key, []), combo.currentData() or "")
|
||||
|
||||
def set_selected_sets(self, selected_sets: dict[str, str], *, emit_signal: bool = True) -> None:
|
||||
"""Apply selected set names to all comboboxes and optionally emit update."""
|
||||
@@ -406,13 +425,10 @@ class PreprocessDialog(QDialog):
|
||||
selected_value = selected_sets.get(key, "")
|
||||
combo = self._set_combos[key]
|
||||
with QSignalBlocker(combo):
|
||||
if not selected_value:
|
||||
combo.setCurrentIndex(-1)
|
||||
continue
|
||||
index = combo.findText(selected_value)
|
||||
if index < 0:
|
||||
combo.addItem(selected_value)
|
||||
index = combo.findText(selected_value)
|
||||
index = combo.findData(selected_value)
|
||||
if index < 0 and selected_value:
|
||||
combo.addItem(selected_value, selected_value)
|
||||
index = combo.count() - 1
|
||||
combo.setCurrentIndex(index)
|
||||
if emit_signal:
|
||||
self._emit_selection_changed()
|
||||
@@ -538,12 +554,15 @@ class PreprocessDialog(QDialog):
|
||||
def _set_combo_items(combo: QComboBox, names: list[str], current_text: str) -> None:
|
||||
"""Replace combo contents and keep previous value when still available."""
|
||||
combo.clear()
|
||||
combo.addItems(names)
|
||||
if combo.property("optional"):
|
||||
combo.addItem("Off", "")
|
||||
for name in names:
|
||||
combo.addItem(name, name)
|
||||
if not current_text:
|
||||
combo.setCurrentIndex(-1)
|
||||
combo.setCurrentIndex(combo.findData(""))
|
||||
return
|
||||
index = combo.findText(current_text)
|
||||
index = combo.findData(current_text)
|
||||
if index < 0:
|
||||
combo.addItem(current_text)
|
||||
index = combo.findText(current_text)
|
||||
combo.addItem(current_text, current_text)
|
||||
index = combo.count() - 1
|
||||
combo.setCurrentIndex(index)
|
||||
|
||||
Reference in New Issue
Block a user