"""Dialog for preprocess set selection and sequential capture.""" from __future__ import annotations from PyQt6.QtCore import QSignalBlocker, pyqtSignal from PyQt6.QtWidgets import ( QCheckBox, QComboBox, QDialog, QFileDialog, QFormLayout, QGridLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QPlainTextEdit, QPushButton, QScrollArea, QVBoxLayout, QWidget, ) import numpy as np import pyqtgraph as pg from python_app.models.dataset_model import TraceData from python_app.orchestration.preprocess_assets import ( PREPROCESS_ASSET_SPECS, VISIBLE_PREPROCESS_ASSET_KEYS, preprocess_asset_display_name, ) class PreprocessDialog(QDialog): """Standalone dialog for preprocess set selection and capture workflows.""" refresh_requested = pyqtSignal() selection_changed = pyqtSignal() radar_config_dir_changed = pyqtSignal() multi_radar_option_changed = pyqtSignal() start_sequence_requested = pyqtSignal(str) capture_next_requested = pyqtSignal() capture_all_requested = pyqtSignal() undo_last_requested = pyqtSignal() finalize_sequence_requested = pyqtSignal() abort_sequence_requested = pyqtSignal() create_kamil_adc_neutral_sets_requested = pyqtSignal() def __init__(self, parent=None) -> None: """Initialize window metadata and compose dialog UI.""" super().__init__(parent) self._set_combos: dict[str, QComboBox] = {} self._preview_plot: pg.PlotWidget | None = None self._preview_placeholder: QLabel | None = None self._preview_host_layout: QVBoxLayout | None = None self._preview_plot_unavailable = False self._init_window() self._build_ui() def _init_window(self) -> None: """Set static window properties.""" self.setWindowTitle("Preprocessing Setup") self.resize(1120, 820) def _build_ui(self) -> None: """Build root dialog layout and all sections.""" root_layout = QVBoxLayout(self) scroll_area = QScrollArea(self) scroll_area.setWidgetResizable(True) content_widget = QWidget(scroll_area) content_layout = QVBoxLayout(content_widget) content_layout.addWidget(self._build_sets_group()) content_layout.addWidget(self._build_sequence_group()) self._build_status_line(content_layout) self._build_preview_plot(content_layout) content_layout.addStretch(1) scroll_area.setWidget(content_widget) root_layout.addWidget(scroll_area) def _build_sets_group(self) -> QGroupBox: """Build set-management controls used for preprocessing snapshots.""" group = QGroupBox("Preprocess Sets", self) layout = QVBoxLayout(group) header_row = QHBoxLayout() self._set_name_input = QLineEdit("set_001", group) refresh_button = QPushButton("Refresh Sets", group) refresh_button.clicked.connect(self.refresh_requested.emit) self._kamil_adc_neutral_sets_button = QPushButton("Create Neutral S21 Sets", group) self._kamil_adc_neutral_sets_button.setToolTip( "Save S21 calibration=1 and S21 reference=0 for the current Kamil ADC settings." ) self._kamil_adc_neutral_sets_button.clicked.connect( self.create_kamil_adc_neutral_sets_requested.emit ) self._kamil_adc_neutral_sets_button.setVisible(False) header_row.addWidget(QLabel("Set name")) header_row.addWidget(self._set_name_input, stretch=1) header_row.addWidget(self._kamil_adc_neutral_sets_button) header_row.addWidget(refresh_button) layout.addLayout(header_row) layout.addWidget(self._build_radar_config_group(group)) layout.addWidget(self._build_selector_group("S21", VISIBLE_PREPROCESS_ASSET_KEYS, group)) return group def _build_radar_config_group(self, parent: QGroupBox) -> QGroupBox: """Build radar-config directory controls for multi-radar preprocessing.""" group = QGroupBox("Radar Config Variants", parent) layout = QVBoxLayout(group) dir_row = QHBoxLayout() self._radar_config_dir_input = QLineEdit(group) self._radar_config_dir_input.setPlaceholderText("Directory with radar JSON configs") self._radar_config_dir_input.editingFinished.connect(self.radar_config_dir_changed.emit) browse_button = QPushButton("Browse...", group) browse_button.clicked.connect(self._choose_radar_config_dir) dir_row.addWidget(QLabel("Directory")) dir_row.addWidget(self._radar_config_dir_input, stretch=1) dir_row.addWidget(browse_button) layout.addLayout(dir_row) self._radar_config_summary_label = QLabel("No radar config directory selected.", group) self._radar_config_summary_label.setWordWrap(True) layout.addWidget(self._radar_config_summary_label) self._use_all_radar_configs_checkbox = QCheckBox( "Capture current combo for all found radar configs", group, ) self._use_all_radar_configs_checkbox.toggled.connect(self.multi_radar_option_changed.emit) layout.addWidget(self._use_all_radar_configs_checkbox) return group def _build_selector_group(self, title: str, keys: tuple[str, ...], parent: QGroupBox) -> QGroupBox: """Build one selector subgroup for a channel family.""" group = QGroupBox(title, parent) form = QFormLayout(group) form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) for key in keys: combo = QComboBox(group) combo.setPlaceholderText("") combo.setCurrentIndex(-1) combo.currentTextChanged.connect(self._emit_selection_changed) self._set_combos[key] = combo form.addRow(self._asset_row_label(key), combo) return group def _build_sequence_group(self) -> QGroupBox: """Build sequential-capture controls and capture log.""" group = QGroupBox("Sequential Capture (Fill Full N*M)", self) layout = QGridLayout(group) self._active_kind_label = QLabel("", group) self._progress_label = QLabel("0 / 0", group) self._combo_label = QLabel("", group) layout.addWidget(QLabel("Active type"), 0, 0) layout.addWidget(self._active_kind_label, 0, 1) layout.addWidget(QLabel("Progress"), 1, 0) layout.addWidget(self._progress_label, 1, 1) layout.addWidget(QLabel("Current combo"), 2, 0) layout.addWidget(self._combo_label, 2, 1) layout.addLayout(self._build_sequence_button_grid(group), 3, 0, 1, 2) layout.addLayout(self._build_sequence_action_row(group), 4, 0, 1, 2) self._capture_log = QPlainTextEdit(group) self._capture_log.setReadOnly(True) self._capture_log.setPlaceholderText("Capture history per combo") self._capture_log.setMinimumHeight(180) layout.addWidget(self._capture_log, 5, 0, 1, 2) return group 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): 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) return layout def _build_sequence_action_row(self, parent: QGroupBox) -> QHBoxLayout: """Build capture/abort row for active sequence control.""" self._capture_next_button = QPushButton("Capture Current Combo", parent) self._capture_next_button.clicked.connect(self.capture_next_requested.emit) self._capture_next_button.setEnabled(False) self._capture_all_button = QPushButton("Capture All Remaining", parent) self._capture_all_button.clicked.connect(self.capture_all_requested.emit) self._capture_all_button.setEnabled(False) self._undo_last_button = QPushButton("Undo Last Capture", parent) self._undo_last_button.clicked.connect(self.undo_last_requested.emit) self._undo_last_button.setEnabled(False) self._save_sequence_button = QPushButton("Save Captured Set", parent) self._save_sequence_button.clicked.connect(self.finalize_sequence_requested.emit) self._save_sequence_button.setEnabled(False) self._abort_button = QPushButton("Abort Sequence", parent) self._abort_button.clicked.connect(self.abort_sequence_requested.emit) self._abort_button.setEnabled(False) layout = QHBoxLayout() layout.addWidget(self._capture_next_button) layout.addWidget(self._capture_all_button) layout.addWidget(self._undo_last_button) layout.addWidget(self._save_sequence_button) layout.addWidget(self._abort_button) layout.addStretch(1) return layout def _build_status_line(self, root_layout: QVBoxLayout) -> None: """Build one-line status output for dialog operations.""" self._status_label = QLabel("Ready", self) root_layout.addWidget(self._status_label) def _build_preview_plot(self, root_layout: QVBoxLayout) -> None: """Build lazy preview host used after each successful capture.""" host = QWidget(self) layout = QVBoxLayout(host) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) placeholder = QLabel("Preview will appear after the first successful capture.", host) placeholder.setWordWrap(True) placeholder.setMinimumHeight(320) layout.addWidget(placeholder) self._preview_host_layout = layout self._preview_placeholder = placeholder root_layout.addWidget(host) def set_name(self) -> str: """Return requested target set name.""" return self._set_name_input.text().strip() def set_set_name(self, value: str) -> None: """Replace requested target set name.""" self._set_name_input.setText(value) def radar_config_dir(self) -> str: """Return configured radar-config directory path.""" return self._radar_config_dir_input.text().strip() def set_radar_config_dir(self, value: str) -> None: """Replace configured radar-config directory path.""" self._radar_config_dir_input.setText(value) def use_all_radar_configs(self) -> bool: """Return whether multi-radar capture mode is enabled.""" return bool(self._use_all_radar_configs_checkbox.isChecked()) def set_use_all_radar_configs(self, enabled: bool) -> None: """Update multi-radar capture checkbox state.""" self._use_all_radar_configs_checkbox.setChecked(bool(enabled)) def set_radar_config_summary( self, *, directory_path: str, json_file_count: int, valid_variant_count: int, skipped_file_count: int, duplicate_variant_count: int, ) -> None: """Update one-line radar-config scan summary.""" if not directory_path: self._radar_config_summary_label.setText("No radar config directory selected.") return self._radar_config_summary_label.setText( f"Directory: {directory_path} | " f"json={json_file_count}, valid={valid_variant_count}, " f"skipped={skipped_file_count}, duplicates={duplicate_variant_count}" ) 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} def clear_capture_log(self) -> None: """Clear capture history text box.""" self._capture_log.clear() def set_capture_log_entries(self, entries: list[str]) -> None: """Replace capture history text with provided rows.""" self._capture_log.setPlainText("\n".join(entries)) def append_capture_log_entry( self, *, kind: str, captured_count: int, total_count: int, input_pos: int, output_pos: int, extra_details: str = "", ) -> None: """Append one capture progress row to dialog log.""" self._capture_log.appendPlainText( f"{kind}: {captured_count}/{total_count} | " f"input={input_pos} output={output_pos}{extra_details}" ) def set_capture_state( self, *, kind: str | None, captured_count: int, total_count: int, next_input: int | None, next_output: int | None, can_undo: bool, can_finalize: bool, can_capture_all: bool, variant_count: int = 1, ) -> None: """Update sequence progress/status widgets.""" if kind is None: self._active_kind_label.setText("") self._progress_label.setText("0 / 0") self._combo_label.setText("") self._capture_next_button.setEnabled(False) self._capture_all_button.setEnabled(False) self._undo_last_button.setEnabled(False) self._save_sequence_button.setEnabled(False) self._abort_button.setEnabled(False) self._capture_all_button.setText("Capture All Remaining") return active_label = preprocess_asset_display_name(kind) if kind in PREPROCESS_ASSET_SPECS else kind self._active_kind_label.setText(active_label) self._progress_label.setText(f"{captured_count} / {total_count}") self._undo_last_button.setEnabled(bool(can_undo)) self._save_sequence_button.setEnabled(bool(can_finalize)) self._capture_all_button.setEnabled(bool(can_capture_all)) self._abort_button.setEnabled(True) self._capture_all_button.setText("Capture All Remaining") if next_input is None or next_output is None: self._combo_label.setText("") self._capture_next_button.setEnabled(False) else: combo_text = f"input={next_input}, output={next_output}" if int(variant_count) > 1: combo_text += f" | radar configs={int(variant_count)}" self._combo_label.setText(combo_text) self._capture_next_button.setEnabled(True) def set_available_sets(self, available_sets: dict[str, list[str]]) -> None: """Replace combo-box choices for all preprocess assets.""" 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()) 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.""" for key in VISIBLE_PREPROCESS_ASSET_KEYS: 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) combo.setCurrentIndex(index) if emit_signal: self._emit_selection_changed() def set_status(self, message: str) -> None: """Set short human-readable status line.""" self._status_label.setText(message) def set_kamil_adc_neutral_sets_visible(self, visible: bool) -> None: """Show Kamil ADC neutral-set shortcut only in the matching radar mode.""" self._kamil_adc_neutral_sets_button.setVisible(bool(visible)) self._kamil_adc_neutral_sets_button.setEnabled(bool(visible)) def reset_preview(self) -> None: """Clear preview surface and restore default empty-state text when possible.""" if self._preview_plot is not None: self._preview_plot.clear() self._preview_plot.setTitle("") if self._preview_placeholder is not None: self._preview_placeholder.setText("Preview will appear after the first successful capture.") def draw_last_trace(self, trace: TraceData, title: str, *, channel: str) -> None: """Draw the latest captured sweep trace for the requested channel in dB scale.""" samples = trace.s11 if channel == "s11" else trace.s21 magnitude_db = 20.0 * np.log10(np.maximum(np.abs(samples), 1e-12)) if self._ensure_preview_plot(): assert self._preview_plot is not None self._preview_plot.clear() self._preview_plot.plot( trace.frequency_hz, magnitude_db, pen=pg.mkPen("#4cc9f0", width=1.8), ) elif self._preview_placeholder is not None: self._preview_placeholder.setText( f"{title}\n" f"input={trace.combo.input_pos}, output={trace.combo.output_pos}, " f"points={trace.frequency_hz.size}\n" f"Preview plot is unavailable on this PyQtGraph/PyQt6 build." ) combo = trace.combo self._status_label.setText( f"{title}: input={combo.input_pos}, output={combo.output_pos}, points={trace.frequency_hz.size}" ) def _ensure_preview_plot(self) -> bool: """Create preview plot lazily and keep a text fallback when unavailable.""" if self._preview_plot is not None: return True if self._preview_plot_unavailable: return False if self._preview_host_layout is None: return False try: plot = pg.PlotWidget(background="#101418", enableMenu=False) plot.showGrid(x=True, y=True, alpha=0.2) plot.setLabel("bottom", "Frequency", units="Hz") plot.setLabel("left", "Magnitude", units="dB") plot.setMinimumHeight(320) except Exception: self._preview_plot_unavailable = True return False if self._preview_placeholder is not None: self._preview_host_layout.removeWidget(self._preview_placeholder) self._preview_placeholder.deleteLater() self._preview_placeholder = None self._preview_plot = plot self._preview_host_layout.addWidget(plot) return True def _emit_selection_changed(self) -> None: """Emit current selection snapshot change.""" self.selection_changed.emit() def _choose_radar_config_dir(self) -> None: """Pick radar-config directory and notify controller code.""" selected_dir = QFileDialog.getExistingDirectory( self, "Select Radar Config Directory", self.radar_config_dir(), ) if not selected_dir: return self._radar_config_dir_input.setText(selected_dir) self.radar_config_dir_changed.emit() @staticmethod def _asset_row_label(key: str) -> str: """Return short selector label for one preprocess asset.""" return preprocess_asset_display_name(key) @staticmethod 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 not current_text: combo.setCurrentIndex(-1) return index = combo.findText(current_text) if index < 0: combo.addItem(current_text) index = combo.findText(current_text) combo.setCurrentIndex(index)