481 lines
20 KiB
Python
481 lines
20 KiB
Python
"""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("<not selected>")
|
|
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("<none>", group)
|
|
self._progress_label = QLabel("0 / 0", group)
|
|
self._combo_label = QLabel("<none>", 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("<none>")
|
|
self._progress_label.setText("0 / 0")
|
|
self._combo_label.setText("<none>")
|
|
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("<complete>")
|
|
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)
|