Stop discarding precomputed FFT data on view switches
The display path hardcoded row_avg_n=0 when checking a file's stored FFT cache, since the main window has no row-averaging control (only the batch-only RowAverageFftOptionsDialog). Once a file was batch-computed with row-averaging, every view switch saw a phantom provenance mismatch and silently launched a full raw recompute, discarding the precomputed data. The display now asks for the stored image at the settings it was actually computed under (bg-sub/pad/row-averaging), rather than the window's live controls, so a stored or already-computed image is always shown once present. Those controls now only affect a first-time compute for an uncached angle or an explicit batch recompute -- never what's already on screen. DC threshold is unaffected: it stays live, since re-masking a cached image is free. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+67
-33
@@ -90,10 +90,13 @@ class SrasViewerWindow(QMainWindow):
|
||||
# computed lazily (with a progress popup) the first time an
|
||||
# angle/threshold combination is viewed — using the cached DC4
|
||||
# image to skip the FFT entirely for masked-out pixels — and
|
||||
# cached per (angle, bg_sub, n_fft, threshold) so revisiting the
|
||||
# same combination is free.
|
||||
# cached per (angle, threshold) so revisiting the same combination
|
||||
# is free. bg-sub/pad are deliberately not part of the key: once an
|
||||
# angle has any FFT image (live or from the file's own stored
|
||||
# cache), it stays displayed regardless of those controls — see
|
||||
# _fft_cache_key.
|
||||
self._dc_cache: dict[tuple[int, int], np.ndarray] = {}
|
||||
self._fft_cache: dict[tuple[int, bool, int | None, float], np.ndarray] = {}
|
||||
self._fft_cache: dict[tuple[int, float], np.ndarray] = {}
|
||||
self._dc_generation: int = 0
|
||||
|
||||
# Angle alignment ("Fusion" menu)
|
||||
@@ -275,7 +278,9 @@ class SrasViewerWindow(QMainWindow):
|
||||
self.chk_bg_sub.setEnabled(False)
|
||||
self.chk_bg_sub.setToolTip(
|
||||
"Subtract the stored background waveform from each CH1 frame\n"
|
||||
"before computing the FFT (v4+ files only)."
|
||||
"before computing the FFT (v4+ files only). Applies to angles\n"
|
||||
"not yet computed and to future batch recomputes — it does not\n"
|
||||
"change an image already shown or already stored in the file."
|
||||
)
|
||||
self.chk_bg_sub.toggled.connect(self._on_bg_sub_toggled)
|
||||
vl.addWidget(self.chk_bg_sub)
|
||||
@@ -622,16 +627,20 @@ class SrasViewerWindow(QMainWindow):
|
||||
self.lbl_frame_warn.setText("\n".join(notes))
|
||||
|
||||
def _cache_mismatch_notes(self) -> list[str]:
|
||||
"""Why the file's stored FFT images can't serve the current view, if
|
||||
they can't. Padding, bg-sub and row-averaging are all baked into the
|
||||
stored numbers, so changing any of them silently sends every angle
|
||||
back through a real FFT — worth saying out loud rather than leaving
|
||||
the user to wonder why a file they batch-computed got slow.
|
||||
"""Informational only: whether the file's stored FFT cache was
|
||||
computed under different bg-sub/pad settings than these controls
|
||||
currently say. The display always shows the stored image as-is
|
||||
regardless (see _stored_fft_image) — these controls only affect a
|
||||
future live compute for an angle with nothing cached yet, or an
|
||||
explicit batch recompute, never what's already on screen.
|
||||
|
||||
row_avg_n is compared against the file's own recorded value (a
|
||||
self-match), so it never contributes a reason here — there's no
|
||||
live control for it to diverge from, and the "Cached images" line
|
||||
above already reports it.
|
||||
|
||||
Asks compute for the reasons rather than restating the accept rule,
|
||||
so a new provenance field can only be added in one place. The display
|
||||
always asks for a raw per-pixel image, since row-averaging is a batch
|
||||
option with no display control.
|
||||
so a new provenance field can only be added in one place.
|
||||
"""
|
||||
s = self._sras
|
||||
if s is None or all(x is None for x in s.precomputed_freq_mhz):
|
||||
@@ -639,11 +648,13 @@ class SrasViewerWindow(QMainWindow):
|
||||
|
||||
reasons = compute.cache_mismatch_reasons(
|
||||
s, n_fft=self._current_n_fft(),
|
||||
apply_bg_sub=self.chk_bg_sub.isChecked(), row_avg_n=0)
|
||||
apply_bg_sub=self.chk_bg_sub.isChecked(),
|
||||
row_avg_n=s.precomputed_row_avg_n)
|
||||
if not reasons:
|
||||
return []
|
||||
return ["! Cached FFT unusable for this view — " + "; ".join(reasons)
|
||||
+ ". FFT angles will recompute."]
|
||||
return ["Note: current bg-sub/pad controls differ from the stored "
|
||||
"cache — " + "; ".join(reasons) + ". Shown as stored; use "
|
||||
"Batch Compute to recompute with these settings."]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Controls
|
||||
@@ -691,11 +702,12 @@ class SrasViewerWindow(QMainWindow):
|
||||
self._on_view_changed()
|
||||
|
||||
def _on_bg_sub_toggled(self):
|
||||
# Background subtraction changes the FFT input, so it genuinely
|
||||
# invalidates the cached raw FFT (the cache key includes it) —
|
||||
# _refresh_display() recomputes only on a miss for the new state.
|
||||
# bg-sub no longer gates the display: it only affects a future live
|
||||
# compute for an angle with nothing cached yet, or an explicit batch
|
||||
# recompute — never what's already shown. Just keep the info panel's
|
||||
# divergence note current.
|
||||
if self._is_fft_mode():
|
||||
self._refresh_display()
|
||||
self._update_scan_info_labels()
|
||||
|
||||
def _on_grating_changed(self):
|
||||
# Grating is a pure post-multiply on the cached frequency image —
|
||||
@@ -889,8 +901,15 @@ class SrasViewerWindow(QMainWindow):
|
||||
return freq_mhz
|
||||
|
||||
def _fft_cache_key(self, angle_idx: int) -> tuple:
|
||||
return (angle_idx, self.chk_bg_sub.isChecked(), self._current_n_fft(),
|
||||
self.spin_threshold_mv.value())
|
||||
"""Keyed by angle and DC threshold only. Once any FFT image exists
|
||||
for an angle this session — live-computed or pulled from the file's
|
||||
own stored cache — it stays the displayed image for that angle
|
||||
regardless of later bg-sub/pad toggles; those only affect a future
|
||||
live compute for an angle with nothing cached yet, or an explicit
|
||||
batch recompute (see _stored_fft_image). Threshold stays in the key
|
||||
because re-masking against it is free and meant to stay interactive
|
||||
(see _on_threshold_changed)."""
|
||||
return (angle_idx, self.spin_threshold_mv.value())
|
||||
|
||||
def _aligned_cache_key(self, angle_idx: int, ch_idx: int) -> tuple:
|
||||
"""Mirrors _fft_cache's key granularity so a stale aligned image is
|
||||
@@ -916,20 +935,35 @@ class SrasViewerWindow(QMainWindow):
|
||||
return cached
|
||||
|
||||
def _stored_fft_image(self, angle_idx: int) -> np.ndarray | None:
|
||||
"""The open file's own stored peak-frequency image for the current
|
||||
view, masked and ready to display, or None if the file has nothing
|
||||
that answers this exact view.
|
||||
"""The open file's own stored peak-frequency image for this angle,
|
||||
masked and ready to display, or None if the file has nothing stored
|
||||
for it.
|
||||
|
||||
Asks for the image at the settings it was actually computed under
|
||||
(sras.precomputed_bg_sub / precomputed_pad_factor /
|
||||
precomputed_row_avg_n) rather than the window's live bg-sub/pad
|
||||
controls, so a stored image is always shown once present — those
|
||||
controls never gate whether it's used, only what a *future* compute
|
||||
produces. See _cache_mismatch_notes for the informational (non-
|
||||
blocking) note when the live controls diverge from what's shown.
|
||||
Only the DC threshold is taken live: re-masking a stored image
|
||||
against it is free, unlike bg-sub/pad/row-averaging which are baked
|
||||
irreversibly into the stored numbers.
|
||||
|
||||
allow_dc_recompute=False keeps this off the I/O path: if the mask
|
||||
would mean reading a whole CH4 channel, this declines and the caller
|
||||
falls through to the background worker, which reaches the same stored
|
||||
image via compute_rf_image and pays for the mask off the GUI thread.
|
||||
"""
|
||||
s = self._sras
|
||||
n_fft = (s.samples_per_frame * s.precomputed_pad_factor
|
||||
if s.precomputed_pad_factor > 1 else None)
|
||||
return compute.cached_rf_image(
|
||||
self._sras, angle_idx,
|
||||
s, angle_idx,
|
||||
dc_threshold_mv=self.spin_threshold_mv.value(),
|
||||
apply_bg_sub=self.chk_bg_sub.isChecked(),
|
||||
n_fft=self._current_n_fft(),
|
||||
apply_bg_sub=s.precomputed_bg_sub,
|
||||
n_fft=n_fft,
|
||||
row_avg_n=s.precomputed_row_avg_n,
|
||||
dc4_mv=self._dc_cache.get((angle_idx, CH4_IDX)),
|
||||
allow_dc_recompute=False)
|
||||
|
||||
@@ -1109,8 +1143,7 @@ class SrasViewerWindow(QMainWindow):
|
||||
ch_idx = self._pending_ch
|
||||
|
||||
if ch_idx in CH1_DERIVED_MODES:
|
||||
self._fft_cache[(angle_idx, self._pending_bg_sub,
|
||||
self._current_n_fft(), self._pending_threshold)] = result
|
||||
self._fft_cache[(angle_idx, self._pending_threshold)] = result
|
||||
img = self._scale_for_display(result, ch_idx)
|
||||
else:
|
||||
img = result
|
||||
@@ -1427,11 +1460,12 @@ class SrasViewerWindow(QMainWindow):
|
||||
self._fft_pad_factor = dlg.get_pad_factor()
|
||||
self._settings.setValue("fft/backend", compute.get_fft_backend())
|
||||
self._settings.setValue("fft/pad_factor", self._fft_pad_factor)
|
||||
# Pad factor changes the FFT bin count, so it genuinely invalidates
|
||||
# the cached raw FFT (part of the cache key) — _refresh_display()
|
||||
# recomputes only on a cache miss.
|
||||
# Pad factor no longer gates the display: it only affects a future
|
||||
# live compute for an angle with nothing cached yet, or an explicit
|
||||
# batch recompute — never what's already shown. Just keep the info
|
||||
# panel's divergence note current.
|
||||
if self._is_fft_mode():
|
||||
self._refresh_display()
|
||||
self._update_scan_info_labels()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
Reference in New Issue
Block a user