"""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 QSignalBlocker, QRectF, Qt, pyqtSignal from PyQt6.QtWidgets import ( QCheckBox, QComboBox, QDialog, QDoubleSpinBox, QFormLayout, QGroupBox, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QPushButton, QTableWidget, QTableWidgetItem, QToolButton, 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._provenance: 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( "Capture data, then enter the measured reflector centre: " "X across the array, Y above the ground, Z along the ground. " "S21 through calibration and the selected reference are already applied; " "object calibration is disabled during capture. Only antenna pairs with configured " "Tx/Rx geometry are calibrated; other acquired pairs remain unchanged.", self, ) explanation.setWordWrap(True) layout.addWidget(explanation) self._table = QTableWidget(0, 5, self) self._table.setHorizontalHeaderLabels(["Target", "Sweeps", "X (m)", "Y (m)", "Z (m)"]) 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) layout.addWidget(self._table) actions = QHBoxLayout() self._capture_button = QPushButton("Capture data", self) self._capture_button.clicked.connect(self.capture_requested.emit) self._remove_button = QPushButton("Remove selected target", 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", "Sphere radius, m (0 = point)", .16, 0, 100), ("antenna_height_m", "Antenna origin height above ground, m", .92, 0, 100), ("pitch_deg", "Radar downward tilt, °", 10, -90, 90), ): form.addRow(title, self._number(key, default, low, high)) layout.addLayout(form) # A disclosure header: only the header shows until the operator opens it. advanced_toggle = QToolButton(self) advanced_toggle.setText("Calculation settings") advanced_toggle.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) advanced_toggle.setArrowType(Qt.ArrowType.RightArrow) advanced_toggle.setAutoRaise(True) advanced = QGroupBox(self) advanced_form = QFormLayout(advanced) for key, title, default, low, high in ( ("f_min", "Estimation band start, MHz", 2000, 0, 100000), ("f_max", "Estimation band stop, MHz", 6000, 0, 100000), ("gate_half_m", "Echo path gate half-width, m", .15, .000001, 100), ("gate_taper_m", "Gate taper width, m", .03, .000001, 100), ("coarse_range_search_m", "Coarse path search, ±m (0 = off)", .6, 0, 100), ("refine_range_m", "Coordinate refinement, ±m", .1, 0, 100), ): advanced_form.addRow(title, self._number(key, default, low, high)) axes = QWidget(advanced) 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) self._refine_axes[axis] = checkbox axes_layout.addWidget(checkbox) axes_layout.addStretch(1) advanced_form.addRow("Refine coordinates", axes) phase_model = QComboBox(advanced) phase_model.addItems(["const", "linear", "poly3", "raw"]) phase_model.setCurrentText("linear") self._settings["phase_model"] = phase_model advanced_form.addRow("Antenna phase model", phase_model) amplitude = QCheckBox("Equalize amplitudes", advanced) self._settings["apply_amplitude"] = amplitude advanced_form.addRow(amplitude) advanced.hide() def toggle_advanced() -> None: shown = advanced.isHidden() advanced_toggle.setArrowType(Qt.ArrowType.DownArrow if shown else Qt.ArrowType.RightArrow) advanced.setVisible(shown) advanced_toggle.clicked.connect(toggle_advanced) layout.addWidget(advanced_toggle) layout.addWidget(advanced) self._target_combo = QComboBox(self) layout.addWidget(self._target_combo) self._phase_view = pg.GraphicsLayoutWidget(parent=self) self._phase_view.setBackground("#0f141c") self._phase_view.setMinimumHeight(270) self._phase_plots = [] self._phase_images = [] self._phase_text = [] for column, title in enumerate(("Before", "After")): plot = self._phase_view.ci.addPlot(row=0, col=column, title=title, enableMenu=False) plot.setLabel("bottom", "Rx") plot.setLabel("left", "Tx") plot.getViewBox().setAspectLocked(True) plot.getViewBox().invertY(True) plot.getViewBox().setMouseEnabled(x=False, y=False) image = pg.ImageItem(axisOrder="row-major") plot.addItem(image) self._phase_plots.append(plot) self._phase_images.append(image) self._phase_colorbar = pg.ColorBarItem( values=(-180, 180), colorMap=pg.colormap.get("CET-C2"), label="Phase (deg)", interactive=False, colorMapMenu=False, ) self._phase_colorbar.setImageItem(self._phase_images) self._phase_colorbar.axis.setTicks([[(value, str(value)) for value in (-180, -90, 0, 90, 180)]]) self._phase_view.ci.addItem(self._phase_colorbar, row=0, col=2) layout.addWidget(self._phase_view) self._quality_label = QLabel("Pair phases at the target will appear after calculation.", self) self._quality_label.setWordWrap(True) layout.addWidget(self._quality_label) self._status_label = QLabel("Ready to capture", self) self._status_label.setWordWrap(True) layout.addWidget(self._status_label) footer = QHBoxLayout() self._calculate_button = QPushButton("Calculate and save", self) self._calculate_button.clicked.connect(self.calculate_requested.emit) self._cancel_button = QPushButton("Cancel operation", self) self._cancel_button.clicked.connect(self.cancel_requested.emit) close_button = QPushButton("Close", 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._table.itemChanged.connect(self._measurements_changed) self._target_combo.currentIndexChanged.connect(self._draw_phase_effect) for field in [*self._settings.values(), *self._refine_axes.values()]: signal = (field.valueChanged if isinstance(field, QDoubleSpinBox) else field.toggled if isinstance(field, QCheckBox) else field.currentTextChanged) signal.connect(self._measurements_changed) 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) self._settings[key] = field return field def add_capture(self, collections: list[SweepCollection], config: dict, provenance: dict) -> None: """Freeze a captured group before the operator enters its target coordinates.""" if not collections: raise ValueError("No reflector sweeps were captured") if self._config is not None and ( any(config.get(key) != self._config.get(key) for key in ("radar", "gpr", "preprocess")) or config.get("run", {}).get("combos") != self._config.get("run", {}).get("combos") or provenance != self._provenance ): raise ValueError("Radar settings, geometry, channels or preprocessing changed between captures") if self._config is None: self._config = copy.deepcopy(config) self._provenance = copy.deepcopy(provenance) self._captures.append({"collections": copy.deepcopy(collections)}) row = self._table.rowCount() with QSignalBlocker(self._table): 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._measurements_changed() self.set_status("Data captured. Enter the reflector centre coordinates for the new target.") self._update_actions() def calculation_inputs(self) -> tuple[list[dict], dict, dict, dict]: """Validate XYZ after capture; expose a frozen recipe to the worker.""" if not self._captures or self._config is None or self._provenance is None: raise ValueError("Capture reflector data first") 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"Enter X, Y and Z of the reflector centre for target {row + 1}") from exc if not all(math.isfinite(value) for value in target): raise ValueError(f"Target {row + 1} coordinates must be finite numbers") 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()) geometry = self._config["gpr"] settings["input_idx"] = tuple(sorted(entry["input_pos"] for entry in geometry["rx_geometry"])) settings["output_idx"] = tuple(sorted(entry["output_pos"] for entry in geometry["tx_geometry"])) return groups, copy.deepcopy(self._config), settings, copy.deepcopy(self._provenance) 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, metadata: dict) -> None: """Display phase diagnostics stored with the calculated calibration set.""" self._calibration = metadata with QSignalBlocker(self._target_combo): self._target_combo.clear() for index, target in enumerate(metadata["targets"]): xyz = target["nominal_scene_m"] self._target_combo.addItem(f"Target {index + 1}: {xyz}") self._draw_phase_effect() def _clear_phase_effect(self) -> None: for image in self._phase_images: image.clear() for plot, text in self._phase_text: plot.removeItem(text) self._phase_text.clear() for plot, title in zip(self._phase_plots, ("Before", "After")): plot.setTitle(title) def _draw_phase_effect(self, *_args) -> None: """Render notebook cell 5: Tx-by-Rx phase maps relative to each matrix sum.""" self._clear_phase_effect() if self._calibration is None: return diagnostics = self._calibration["diagnostics"] index = self._target_combo.currentIndex() if not 0 <= index < len(diagnostics): self._quality_label.setText("This target has no phase diagnostics.") return diagnostic = diagnostics[index] tx, rx = self._calibration["tx_output_pos"], self._calibration["rx_input_pos"] for key, plot, image in zip(("before", "after"), self._phase_plots, self._phase_images): matrix = np.asarray(diagnostic[f"phase_{key}_deg"], dtype=float) image.setImage(matrix, autoLevels=False, levels=(-180, 180)) image.setRect(QRectF(-.5, -.5, len(rx), len(tx))) plot.getAxis("bottom").setTicks([[(i, f"Rx{port}") for i, port in enumerate(rx)]]) plot.getAxis("left").setTicks([[(i, f"Tx{port}") for i, port in enumerate(tx)]]) plot.setRange(xRange=(-.5, len(rx) - .5), yRange=(-.5, len(tx) - .5), padding=0) plot.setTitle(f"{key.title()}: pair phase at target (STD {diagnostic[f'phase_std_{key}_deg']:.0f}°)") for (row, column), value in np.ndenumerate(matrix): text = pg.TextItem(f"{value:+.0f}", color="w", anchor=(.5, .5), fill=pg.mkBrush(0, 0, 0, 90)) text.setPos(column, row) plot.addItem(text) self._phase_text.append((plot, text)) self._quality_label.setText("Each matrix shows phase relative to its own coherent sum, in degrees.") def _remove_capture(self) -> None: row = self._table.currentRow() if row >= 0: self._captures.pop(row) with QSignalBlocker(self._table): 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._provenance = None self._measurements_changed() self._update_actions() def _measurements_changed(self, *_args) -> None: self._calibration = None with QSignalBlocker(self._target_combo): self._target_combo.clear() self._clear_phase_effect() self._quality_label.setText("Measurements or settings changed. Recalculate to update the phase maps.") 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()