"""FFT option dialogs. Angle alignment used to live here too, as ManualAlignmentDialog; it is now the alignment wizard's first page (see align_wizard.py), which needed the same mask-overlay editor plus the crop and export steps. """ from PyQt6.QtWidgets import ( QButtonGroup, QDialog, QDialogButtonBox, QGroupBox, QHBoxLayout, QLabel, QRadioButton, QSpinBox, QVBoxLayout, ) from sras_compute import PYFFTW_AVAILABLE from .common import _CSS_HINT, _make_dspin # --------------------------------------------------------------------------- # FFT Options dialog # --------------------------------------------------------------------------- class FftOptionsDialog(QDialog): """Configure FFT backend and zero-padding. Changes take effect only when the user clicks Apply. Cancel discards all pending edits. The live 'frequency resolution' label updates as the user adjusts the pad factor so they can see the trade-off before committing. """ def __init__(self, parent=None, *, current_backend: str, current_pad_factor: int, samples_per_frame: int | None, sample_rate_hz: float | None, grating_um: float): super().__init__(parent) self.setWindowTitle("FFT Options") self.setModal(True) self.setMinimumWidth(380) self._samples_per_frame = samples_per_frame self._sample_rate_hz = sample_rate_hz self._grating_um = grating_um layout = QVBoxLayout(self) # ---- Backend --------------------------------------------------- grp_backend = QGroupBox("FFT Backend") bl = QVBoxLayout(grp_backend) self._btn_scipy = QRadioButton("SciPy FFT (pocketfft) (always available)") self._btn_pyfftw = QRadioButton( "pyFFTW (faster for large arrays)" if PYFFTW_AVAILABLE else "pyFFTW (not installed — run: pip install pyfftw)") self._btn_pyfftw.setEnabled(PYFFTW_AVAILABLE) self._backend_group = QButtonGroup(self) self._backend_group.addButton(self._btn_scipy, id=0) self._backend_group.addButton(self._btn_pyfftw, id=1) if current_backend == "pyfftw" and PYFFTW_AVAILABLE: self._btn_pyfftw.setChecked(True) else: self._btn_scipy.setChecked(True) bl.addWidget(self._btn_scipy) bl.addWidget(self._btn_pyfftw) layout.addWidget(grp_backend) # ---- Zero-padding ---------------------------------------------- grp_zp = QGroupBox("Zero-Padding") zl = QVBoxLayout(grp_zp) pad_row = QHBoxLayout() pad_row.addWidget(QLabel("Pad factor:")) self._spin_pad = QSpinBox() self._spin_pad.setRange(1, 256) self._spin_pad.setValue(max(1, current_pad_factor)) self._spin_pad.setToolTip( "Multiply the waveform length by this factor via zero-padding\n" "before computing the FFT.\n" "1 = no padding (natural length).\n" "Powers of 2 (2, 4, 8 …) give the best performance." ) self._spin_pad.valueChanged.connect(self._update_info) pad_row.addWidget(self._spin_pad) zl.addLayout(pad_row) self._lbl_nfft = QLabel() self._lbl_freq_res = QLabel() self._lbl_vel_res = QLabel() for lbl in (self._lbl_nfft, self._lbl_freq_res, self._lbl_vel_res): lbl.setStyleSheet(_CSS_HINT) zl.addWidget(lbl) layout.addWidget(grp_zp) # ---- Buttons --------------------------------------------------- buttons = QDialogButtonBox() buttons.addButton("Apply", QDialogButtonBox.ButtonRole.AcceptRole ).clicked.connect(self.accept) buttons.addButton("Cancel", QDialogButtonBox.ButtonRole.RejectRole ).clicked.connect(self.reject) layout.addWidget(buttons) self._update_info() def _update_info(self): spf = self._samples_per_frame sr = self._sample_rate_hz pad = self._spin_pad.value() if spf is None or sr is None: self._lbl_nfft.setText("Load a file to preview FFT parameters.") self._lbl_freq_res.setText("") self._lbl_vel_res.setText("") return n_fft = spf * pad freq_res_hz = sr / n_fft freq_res_mhz = freq_res_hz / 1e6 # v (m/s) = freq (MHz) × grating (µm) vel_res_ms = freq_res_mhz * self._grating_um self._lbl_nfft.setText(f"FFT points: {spf} × {pad} = {n_fft:,}") self._lbl_freq_res.setText( f"Frequency bin: {freq_res_mhz:.4f} MHz ({freq_res_hz / 1e3:.2f} kHz)") self._lbl_vel_res.setText( f"Velocity bin: {vel_res_ms:.3f} m/s " f"(at grating = {self._grating_um:.2f} µm)") def get_backend(self) -> str: return "pyfftw" if self._btn_pyfftw.isChecked() and PYFFTW_AVAILABLE else "scipy" def get_pad_factor(self) -> int: return max(1, self._spin_pad.value()) # --------------------------------------------------------------------------- # Row-Averaged FFT Options dialog # --------------------------------------------------------------------------- class RowAverageFftOptionsDialog(QDialog): """Configure the same-row, distance-weighted neighbor averaging applied to each pixel's CH1 waveform before 'Batch Compute Row-Averaged FFT and Store' re-runs the FFT peak search — a same-row SNR cleanup pass, never mixing across rows/Y (see sras_compute._row_average_waveforms). Unlike the plain FFT batch action (which stores unmasked and defers masking to display time), the DC threshold here is required up front: it decides which same-row neighbors are eligible to contribute to a pixel's average, so it can't be deferred. Changes take effect only when the user clicks Apply. Cancel discards all pending edits. """ def __init__(self, parent=None, *, current_n: int, current_threshold_mv: float, pixel_x_mm: float | None): super().__init__(parent) self.setWindowTitle("Row-Averaged FFT Options") self.setModal(True) self.setMinimumWidth(380) self._pixel_x_mm = pixel_x_mm layout = QVBoxLayout(self) # ---- Neighbor window --------------------------------------------- grp_window = QGroupBox("Same-Row Neighbor Window") wl = QVBoxLayout(grp_window) n_row = QHBoxLayout() n_row.addWidget(QLabel("Neighbor half-width (n):")) self._spin_n = QSpinBox() self._spin_n.setRange(1, 50) self._spin_n.setValue(max(1, current_n)) self._spin_n.setToolTip( "Each pixel's CH1 waveform is averaged with up to n same-row\n" "neighbors on each side, distance-weighted (Gaussian) and\n" "counting only neighbors that already pass the DC threshold\n" "below. Never mixes across rows/Y.") self._spin_n.valueChanged.connect(self._update_info) n_row.addWidget(self._spin_n) wl.addLayout(n_row) self._lbl_width = QLabel() self._lbl_width.setStyleSheet(_CSS_HINT) wl.addWidget(self._lbl_width) layout.addWidget(grp_window) # ---- DC threshold ------------------------------------------------ grp_thr = QGroupBox("Neighbor Validity") tl = QVBoxLayout(grp_thr) thr_row = QHBoxLayout() thr_row.addWidget(QLabel("DC threshold:")) self._spin_threshold = _make_dspin(-500.0, 500.0, 3, suffix=" mV", value=current_threshold_mv, step=0.025) self._spin_threshold.setToolTip( "A same-row neighbor only contributes to a pixel's average if\n" "its own CH4 signal is at or above this threshold -- the same\n" "test used for RF mask display. A pixel below threshold stays\n" "masked, exactly as today; it is never rescued by its neighbors.") thr_row.addWidget(self._spin_threshold) tl.addLayout(thr_row) layout.addWidget(grp_thr) # ---- Buttons ----------------------------------------------------- buttons = QDialogButtonBox() buttons.addButton("Apply", QDialogButtonBox.ButtonRole.AcceptRole ).clicked.connect(self.accept) buttons.addButton("Cancel", QDialogButtonBox.ButtonRole.RejectRole ).clicked.connect(self.reject) layout.addWidget(buttons) self._update_info() def _update_info(self): n = self._spin_n.value() if self._pixel_x_mm is None: self._lbl_width.setText("Load a file to preview the window's physical width.") return width_um = 2 * n * self._pixel_x_mm * 1e3 self._lbl_width.setText( f"Window: ±{n} px = {width_um:.2f} µm full width " f"(pixel pitch {self._pixel_x_mm * 1e3:.3g} µm)") def get_half_width(self) -> int: return self._spin_n.value() def get_threshold_mv(self) -> float: return self._spin_threshold.value()