diff --git a/sras_viewer/main_window.py b/sras_viewer/main_window.py index 441199d..a9f138d 100644 --- a/sras_viewer/main_window.py +++ b/sras_viewer/main_window.py @@ -1291,6 +1291,10 @@ class SrasViewerWindow(QMainWindow): # need to re-read the CH4 channel from disk. dc4_mv=self._dc_cache.get((angle_idx, CH4_IDX)), is_fft_mode=is_fft, + # Mirror _stored_fft_image: a stored row-averaged cache must stay + # eligible even when this fallback compute is the one that ends + # up serving it (e.g. before DC precompute reaches this angle). + row_avg_n=self._sras.precomputed_row_avg_n, ) if not self._run_worker( Jobs.COMPUTE, worker, diff --git a/sras_workers.py b/sras_workers.py index ff9d130..1454788 100644 --- a/sras_workers.py +++ b/sras_workers.py @@ -119,7 +119,8 @@ class ComputeWorker(CancellableWorker): apply_bg_sub: bool = True, n_fft: int | None = None, dc_threshold_mv: float = 0.0, dc4_mv: np.ndarray | None = None, - is_fft_mode: bool = False): + is_fft_mode: bool = False, + row_avg_n: int = 0): super().__init__() self._sras = sras self._angle = angle_idx @@ -129,6 +130,7 @@ class ComputeWorker(CancellableWorker): self._dc_threshold = dc_threshold_mv self._dc4_mv = dc4_mv self._is_fft_mode = is_fft_mode + self._row_avg_n = row_avg_n def run(self): try: @@ -136,7 +138,8 @@ class ComputeWorker(CancellableWorker): img = compute_rf_image( self._sras, self._angle, dc_threshold_mv=self._dc_threshold, apply_bg_sub=self._apply_bg_sub, n_fft=self._n_fft, - dc4_mv=self._dc4_mv, should_stop=self._stopped) + dc4_mv=self._dc4_mv, should_stop=self._stopped, + row_avg_n=self._row_avg_n) else: img = dc_image_mv(self._sras, self._angle, self._ch, should_stop=self._stopped) @@ -412,7 +415,7 @@ class BatchExportWorker(CancellableWorker): freq_mhz = compute_rf_image( s, angle_idx, dc_threshold_mv=self._dc_threshold_mv, apply_bg_sub=self._apply_bg_sub, n_fft=self._n_fft, - should_stop=self._stopped) + should_stop=self._stopped, row_avg_n=s.precomputed_row_avg_n) for channel in self._channels: if self._stop: diff --git a/tests/test_stored_cache.py b/tests/test_stored_cache.py index f802e53..558baf9 100644 --- a/tests/test_stored_cache.py +++ b/tests/test_stored_cache.py @@ -593,3 +593,67 @@ def test_viewer_batch_row_average_dispatch(tmp_path, monkeypatch, no_fft): finally: win.close() pump(300) + + +def test_compute_worker_fallback_honors_row_avg_n(tmp_path, monkeypatch, no_fft): + """The other half of the row-averaged recompute bug: _stored_fft_image + isn't the only path that can serve a view. When the DC4 mask isn't known + yet without I/O (e.g. background DC precompute hasn't reached this angle), + _stored_fft_image's allow_dc_recompute=False guard bails and + _refresh_display falls through to _start_compute's ComputeWorker instead. + That worker must still ask compute_rf_image for the file's own + row_avg_n -- not silently default to 0 -- so its own internal cache + fast path also serves the stored row-averaged image rather than running + a real, non-averaged FFT.""" + path = tmp_path / "rowavg_fallback.sras" + gen.write(path, n_angles=2, seed=33, samples_per_frame=128) + # Only fft_rowavg -- deliberately no DC block, exactly what "Batch + # Compute Row-Averaged FFT and Store" leaves on disk by itself. + err = cache_file(str(path), "fft_rowavg", True, dc_threshold_mv=-1e9, row_avg_n=5) + assert err == "", err + + app = QApplication.instance() or QApplication([]) # noqa: F841 + win = SrasViewerWindow() + win.show() + + # Keep _dc_cache empty so _stored_fft_image can't resolve a mask without + # I/O and _refresh_display must fall through to _start_compute. + monkeypatch.setattr(type(win), "_start_dc_precompute", lambda self: None) + + dispatched = [] + original_start = type(win)._start_compute + monkeypatch.setattr(type(win), "_start_compute", + lambda self: (dispatched.append(self.spin_angle.value()), + original_start(self))[1]) + try: + win._load_file(str(path)) + assert wait_until(lambda: win._sras is not None), "file loaded" + assert win._sras.precomputed_row_avg_n == 5 + # The initial default view (CH4, DC) has nothing cached either, so + # it dispatches its own one-off DC compute for angle 0 on load -- + # unrelated to this bug. Let that settle, then clear it so no DC4 is + # available for any angle, simulating "background DC precompute + # hasn't reached this angle yet". + assert wait_until(lambda: not win._job_running("compute")), "initial DC view settled" + dispatched.clear() + win._dc_cache.clear() + + no_fft.clear() + win.combo_channel.setCurrentIndex(CH1_IDX) + assert wait_until(lambda: win._current_ch == CH1_IDX + and not win._job_running("compute")), "CH1 displayed" + + assert dispatched == [0], \ + "with no DC cache available yet, the fallback compute must run" + assert not no_fft, \ + f"the fallback's own compute_rf_image call must still hit the " \ + f"stored row-averaged cache internally: {no_fft}" + expected = compute.cached_rf_image( + win._sras, 0, dc_threshold_mv=win.spin_threshold_mv.value(), + apply_bg_sub=win.chk_bg_sub.isChecked(), row_avg_n=5) + assert expected is not None + assert np.allclose(win._current_image, expected, atol=1e-3), \ + "the displayed image is the stored row-averaged one, not a raw recompute" + finally: + win.close() + pump(300)