From 1a8187138f2d0968a538c259053bf95ff8206ae7 Mon Sep 17 00:00:00 2001 From: Thomas Ales Date: Thu, 6 Aug 2026 21:20:35 -0500 Subject: [PATCH] pre merge cleanup --- sras_compute.py | 103 +++++++++++++++++++++++++--------- sras_format.py | 55 ++++++++++++++----- sras_viewer/dialogs.py | 19 ++++++- sras_viewer/main_window.py | 110 ++++++++++++++++++++++++++++++++++--- sras_workers.py | 15 +++-- tests/test_gui.py | 47 +++++++++++++++- 6 files changed, 294 insertions(+), 55 deletions(-) diff --git a/sras_compute.py b/sras_compute.py index 9120959..6faca3c 100644 --- a/sras_compute.py +++ b/sras_compute.py @@ -18,7 +18,9 @@ import numpy as np import scipy.fft as scipy_fft import scipy.ndimage as scipy_ndimage -from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, SrasFile, adc_to_mv +from sras_format import ( + CH1_IDX, CH3_IDX, CH4_IDX, PAD_FACTOR_MAX, SrasFile, adc_to_mv, +) # --------------------------------------------------------------------------- # FFT backend @@ -420,6 +422,56 @@ def dc_image_mv(sras: SrasFile, angle_idx: int, ch_idx: int, *sras.cal(ch_idx)) +def cached_rf_image(sras: SrasFile, angle_idx: int, + dc_threshold_mv: float | None, + apply_bg_sub: bool = True, + n_fft: int | None = None, + dc4_mv: np.ndarray | None = None, + allow_dc_recompute: bool = True) -> np.ndarray | None: + """The stored peak-frequency image for one angle (v5 PREC or v7 CACH), + masked and ready to display — or None if no stored image can serve these + settings and a real FFT is needed. + + Stored images are unmasked, so they serve any threshold — but they carry + one bg-sub state and one zero-padding, and a view asking for either of the + others is asking for different numbers, not a different rendering of the + same ones. Padding is the sharp edge: a pad-10 view resolves peaks on a + 10x finer bin grid, so handing back a pad-1 image would quietly undo the + setting. v5 PREC and CACH v1 tails record no pad and are natural + resolution by construction. + + Cheap enough to call on the GUI thread — a copy plus a comparison — + *except* when the CH4 mask has to be read from disk to build it. Pass + *allow_dc_recompute* False to return None in that case instead, and leave + the whole-channel read to a worker. + """ + cached_freq = sras.precomputed_freq_mhz[angle_idx] + # n_fft None means "no padding", i.e. exactly samples_per_frame points. + want_n_fft = n_fft if n_fft is not None else sras.samples_per_frame + if (cached_freq is None + or want_n_fft != sras.precomputed_pad_factor * sras.samples_per_frame + or sras.precomputed_bg_sub != (apply_bg_sub and sras.background is not None)): + return None + + if dc_threshold_mv is None: + return cached_freq.copy() + + # DC4 mask, in priority order: stored DC block, caller-supplied image, + # or a fresh (cheap — no FFT) recompute. + dc4_img = sras.cached_dc_mv(angle_idx, CH4_IDX) + if dc4_img is None: + dc4_img = dc4_mv + if dc4_img is None: + if not allow_dc_recompute: + return None + dc4_img = adc_to_mv(compute_dc_image(sras, angle_idx, CH4_IDX), + *sras.cal(CH4_IDX)) + + freq_img = cached_freq.copy() + freq_img[dc4_img < dc_threshold_mv] = 0.0 + return freq_img + + def compute_rf_image(sras: SrasFile, angle_idx: int, dc_threshold_mv: float | None, apply_bg_sub: bool = True, @@ -455,28 +507,20 @@ def compute_rf_image(sras: SrasFile, angle_idx: int, Fast path: if the file has a precomputed peak-frequency image for this angle (v5 PREC or v7 CACH), zero-padding is off, and the bg-sub flag - matches, the stored image is used directly — no FFT is run. + matches, the stored image is used directly — no FFT is run. That test is + cached_rf_image, which the viewer also applies before it ever dispatches + a compute at all. """ - n_rows, n_frames = sras.image_shape(angle_idx) - data = sras.data[angle_idx] - # ---- Fast path: precomputed image (v5 PREC or v7 CACH) ---------------- - cached_freq = sras.precomputed_freq_mhz[angle_idx] - if (cached_freq is not None - and n_fft is None # no custom zero-padding - and sras.precomputed_bg_sub == (apply_bg_sub and sras.background is not None)): - freq_img = cached_freq.copy() - if dc_threshold_mv is not None: - # DC4 mask, in priority order: already-cached DC block, caller- - # supplied image, or a fresh (cheap — no FFT) recompute. - dc4_img = sras.cached_dc_mv(angle_idx, CH4_IDX) - if dc4_img is None: - dc4_img = dc4_mv if dc4_mv is not None else adc_to_mv( - compute_dc_image(sras, angle_idx, CH4_IDX), *sras.cal(CH4_IDX)) - freq_img[dc4_img < dc_threshold_mv] = 0.0 - return freq_img + cached = cached_rf_image(sras, angle_idx, dc_threshold_mv, + apply_bg_sub=apply_bg_sub, n_fft=n_fft, + dc4_mv=dc4_mv) + if cached is not None: + return cached # ---- Chunked FFT path -------------------------------------------------- + n_rows, n_frames = sras.image_shape(angle_idx) + data = sras.data[angle_idx] exact = exact or _FFT_EXACT_ENV spf = sras.samples_per_frame n_len = n_fft if n_fft is not None else spf @@ -589,17 +633,19 @@ def compute_rf_image(sras: SrasFile, angle_idx: int, # --------------------------------------------------------------------------- def cache_file(path: str, mode: str, apply_bg_sub: bool, - fft_backend: str = "scipy", max_workers: int = 0) -> str: + fft_backend: str = "scipy", max_workers: int = 0, + pad_factor: int = 1) -> str: """Compute and store DC or FFT images for every angle of one file, converting v6 → v7 in place. Returns "" on success or an error message. Module-level and picklable so it can run in a ProcessPoolExecutor. The - FFT backend and worker cap are passed explicitly because module globals - do not survive a spawn. + FFT backend, worker cap and pad factor are passed explicitly because + module globals do not survive a spawn. - The stored FFT cache is always natural-resolution (pad 1): the v7 SFFT - block records no pad factor, and padded views compute live fast enough - (see _peak_bins_zoom) that caching them is not worth a format change. + *pad_factor* is stored alongside the images: a cache is only usable by a + view asking for the same padding, so caching at the viewer's own setting + is the difference between a batch that pays off and one that can never be + read back (see cached_rf_image). """ global _MAX_WORKERS try: @@ -630,15 +676,18 @@ def cache_file(path: str, mode: str, apply_bg_sub: bool, sras.write_v7_cache(new_dc3_mv=dc3, new_dc4_mv=dc4) else: effective_bg = apply_bg_sub and sras.background is not None + pad = max(1, min(PAD_FACTOR_MAX, int(pad_factor))) + n_fft = None if pad <= 1 else sras.samples_per_frame * pad # dc_threshold_mv=None: store unmasked images and mask at display # time (same convention as v5's PREC block). Skipping the mask # also skips reading CH4 entirely. The FFT path parallelises # internally over blocks, so angles run one at a time with the # full budget. freq = [compute_rf_image(sras, a, dc_threshold_mv=None, - apply_bg_sub=effective_bg) + apply_bg_sub=effective_bg, n_fft=n_fft) for a in range(n)] - sras.write_v7_cache(new_freq_mhz=freq, new_bg_sub=effective_bg) + sras.write_v7_cache(new_freq_mhz=freq, new_bg_sub=effective_bg, + new_pad_factor=pad) return "" except Exception as exc: return str(exc) diff --git a/sras_format.py b/sras_format.py index 46d8003..720d03c 100644 --- a/sras_format.py +++ b/sras_format.py @@ -60,7 +60,12 @@ PREC_FLAG_BG_SUB = 0x01 CACH_MAGIC = b"CACH" CACH_HDR_FMT = ">4sBB" # magic, cach_version, block_flags CACH_HDR_SIZE = struct.calcsize(CACH_HDR_FMT) -CACH_VERSION = 1 +# v2 added the SFFT pad factor. Writers always emit v2; readers still accept +# v1, whose FFT block is natural-resolution by construction. An older reader +# meeting a v2 tail rejects the whole section on the version check and +# recomputes — no cache, but no misreading either. +CACH_VERSION = 2 +CACH_VERSION_MIN = 1 CACH_FLAG_DC = 0x01 CACH_FLAG_FFT = 0x02 @@ -69,9 +74,12 @@ SDCB_HDR_FMT = ">4sBH" # magic, reserved, n_stored SDCB_HDR_SIZE = struct.calcsize(SDCB_HDR_FMT) SFFT_MAGIC = b"SFFT" -SFFT_HDR_FMT = ">4sBH" # magic, flags, n_stored +SFFT_HDR_FMT_V1 = ">4sBH" # magic, flags, n_stored +SFFT_HDR_FMT = ">4sBHH" # magic, flags, n_stored, pad_factor SFFT_HDR_SIZE = struct.calcsize(SFFT_HDR_FMT) SFFT_FLAG_BG_SUB = 0x01 +# The viewer clamps its pad factor to this, and the field is a uint16. +PAD_FACTOR_MAX = 256 # Fixed channel indices into the .sras data array (CH1=RF, CH3/CH4=Bias DC) CH1_IDX, CH3_IDX, CH4_IDX = 0, 1, 2 @@ -169,7 +177,9 @@ class SrasFile: ``precomputed_dc3_mv`` / ``precomputed_dc4_mv`` / ``precomputed_freq_mhz``, always as ragged per-angle lists (``list[np.ndarray | None]``, one entry per angle, ``None`` where that angle was never stored) regardless of - source version. + source version. The settings the FFT images were computed under travel + with them as ``precomputed_bg_sub`` / ``precomputed_pad_factor``, since a + stored image is only usable by a view asking for the same two. """ def __init__(self, path: str): @@ -226,6 +236,9 @@ class SrasFile: self.precomputed_dc4_mv: list[np.ndarray | None] = [None] * n_angles self.precomputed_dc3_mv: list[np.ndarray | None] = [None] * n_angles self.precomputed_bg_sub: bool = False + # Zero-padding the stored FFT images were computed at; 1 = natural + # resolution, which is all a v5 PREC or CACH v1 tail can hold. + self.precomputed_pad_factor: int = 1 def cached_dc_mv(self, angle_idx: int, ch_idx: int) -> np.ndarray | None: """A stored DC image (already in mV) for (angle, channel), or None.""" @@ -496,16 +509,18 @@ class SrasFile: return end def _read_cache_block(self, f, hdr_fmt: str, magic: bytes, - stores: list[list]) -> int | None: + stores: list[list]) -> tuple | None: """Read one CACH sub-block header, then its per-angle image entries into *stores* (one list per image the block stores per angle). - Returns the header's flags byte, or None if the block is malformed. + Every sub-block header is (magic, flags, n_stored, *extras). Returns + everything after the magic, so a caller that has extras knows how to + read them, or None if the block is malformed. """ raw = f.read(struct.calcsize(hdr_fmt)) if len(raw) < struct.calcsize(hdr_fmt): return None - block_magic, flags, n_stored = struct.unpack(hdr_fmt, raw) + block_magic, flags, n_stored, *extras = struct.unpack(hdr_fmt, raw) if block_magic != magic: return None for _ in range(n_stored): @@ -515,7 +530,7 @@ class SrasFile: shape = self.image_shape(angle_idx) for store in stores: store[angle_idx] = _read_f32_image(f, shape) - return flags + return (flags, *extras) def _parse_cach_section(self, offset: int): """Parse the v7 CACH tail that holds precomputed DC/FFT images.""" @@ -525,7 +540,8 @@ class SrasFile: if len(header_raw) < CACH_HDR_SIZE: return magic, cach_version, block_flags = struct.unpack(CACH_HDR_FMT, header_raw) - if magic != CACH_MAGIC or cach_version != CACH_VERSION: + if (magic != CACH_MAGIC + or not CACH_VERSION_MIN <= cach_version <= CACH_VERSION): return if block_flags & CACH_FLAG_DC: @@ -535,17 +551,23 @@ class SrasFile: return if block_flags & CACH_FLAG_FFT: - flags = self._read_cache_block( - f, SFFT_HDR_FMT, SFFT_MAGIC, [self.precomputed_freq_mhz]) - if flags is None: + # v1 has no pad field: those images are natural-resolution. + hdr_fmt = SFFT_HDR_FMT if cach_version >= 2 else SFFT_HDR_FMT_V1 + header = self._read_cache_block( + f, hdr_fmt, SFFT_MAGIC, [self.precomputed_freq_mhz]) + if header is None: return + flags, *extras = header self.precomputed_bg_sub = bool(flags & SFFT_FLAG_BG_SUB) + self.precomputed_pad_factor = ( + min(max(1, extras[0]), PAD_FACTOR_MAX) if extras else 1) def write_v7_cache(self, *, new_dc3_mv: list[np.ndarray | None] | None = None, new_dc4_mv: list[np.ndarray | None] | None = None, new_freq_mhz: list[np.ndarray | None] | None = None, - new_bg_sub: bool | None = None): + new_bg_sub: bool | None = None, + new_pad_factor: int | None = None): """Store computed DC and/or FFT images into this file's CACH tail, in place, converting a v6 source to v7 (or updating an existing v7 file). Only the block(s) passed in are recomputed; whichever block @@ -565,6 +587,11 @@ class SrasFile: final_dc4 = new_dc4_mv if new_dc4_mv is not None else self.precomputed_dc4_mv final_freq = new_freq_mhz if new_freq_mhz is not None else self.precomputed_freq_mhz final_bg_sub = new_bg_sub if new_bg_sub is not None else self.precomputed_bg_sub + final_pad = (new_pad_factor if new_pad_factor is not None + else self.precomputed_pad_factor) + if not 1 <= final_pad <= PAD_FACTOR_MAX: + raise ValueError( + f"pad factor {final_pad} outside 1..{PAD_FACTOR_MAX}") dc_entries = [a for a in range(self.n_angles) if final_dc3[a] is not None] fft_entries = [a for a in range(self.n_angles) if final_freq[a] is not None] @@ -584,7 +611,8 @@ class SrasFile: if fft_entries: fft_flags = SFFT_FLAG_BG_SUB if final_bg_sub else 0 - payload += struct.pack(SFFT_HDR_FMT, SFFT_MAGIC, fft_flags, len(fft_entries)) + payload += struct.pack(SFFT_HDR_FMT, SFFT_MAGIC, fft_flags, + len(fft_entries), final_pad) for a in fft_entries: payload += struct.pack(">H", a) payload += final_freq[a].astype(">f4").tobytes() @@ -612,6 +640,7 @@ class SrasFile: self.precomputed_dc4_mv = final_dc4 self.precomputed_freq_mhz = final_freq self.precomputed_bg_sub = final_bg_sub + self.precomputed_pad_factor = final_pad # ------------------------------------------------------------------ # Axes helpers diff --git a/sras_viewer/dialogs.py b/sras_viewer/dialogs.py index 5589698..3fb85ef 100644 --- a/sras_viewer/dialogs.py +++ b/sras_viewer/dialogs.py @@ -48,7 +48,8 @@ class FftOptionsDialog(QDialog): current_pad_factor: int, samples_per_frame: int | None, sample_rate_hz: float | None, - grating_um: float): + grating_um: float, + cached_pad_factor: int | None = None): super().__init__(parent) self.setWindowTitle("FFT Options") self.setModal(True) @@ -57,6 +58,10 @@ class FftOptionsDialog(QDialog): self._samples_per_frame = samples_per_frame self._sample_rate_hz = sample_rate_hz self._grating_um = grating_um + # The pad the open file's stored FFT cache was computed at, if it has + # one — picking anything else here makes that cache unreadable, which + # is worth saying before Apply rather than after. + self._cached_pad_factor = cached_pad_factor layout = QVBoxLayout(self) @@ -109,6 +114,11 @@ class FftOptionsDialog(QDialog): lbl.setStyleSheet(_CSS_HINT) zl.addWidget(lbl) + self._lbl_cache = QLabel() + self._lbl_cache.setWordWrap(True) + self._lbl_cache.setStyleSheet(_CSS_WARN) + zl.addWidget(self._lbl_cache) + layout.addWidget(grp_zp) # ---- Buttons --------------------------------------------------- @@ -126,6 +136,13 @@ class FftOptionsDialog(QDialog): sr = self._sample_rate_hz pad = self._spin_pad.value() + self._lbl_cache.setText( + "" if self._cached_pad_factor in (None, pad) else + f"! This file's stored FFT cache was computed at pad " + f"{self._cached_pad_factor}x, so at {pad}x it can't be used and " + f"CH1/Velocity will recompute. Re-run Convert -> Batch Compute FFT " + f"to cache at {pad}x.") + if spf is None or sr is None: self._lbl_nfft.setText("Load a file to preview FFT parameters.") self._lbl_freq_res.setText("") diff --git a/sras_viewer/main_window.py b/sras_viewer/main_window.py index 07c3c71..5cc0809 100644 --- a/sras_viewer/main_window.py +++ b/sras_viewer/main_window.py @@ -79,11 +79,15 @@ class SrasViewerWindow(QMainWindow): # Display-only settings (colormap, grating) never trigger a # recompute — they're applied to cached data on redraw. DC images - # (CH3/CH4) are cheap and precomputed for every angle in the - # background right after load. CH1/Velocity FFT images are - # 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 + # (CH3/CH4) are cheap: they come straight from the file's stored + # cache when Batch Compute DC has been run for it (see + # _stored_dc_image), and are otherwise precomputed for every angle + # in the background right after load. CH1/Velocity FFT images come + # from the file's own stored cache when Batch Compute FFT has been + # run for it (see _stored_fft_image), and are otherwise 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. Either way the result is # cached per (angle, bg_sub, n_fft, threshold) so revisiting the # same combination is free. self._dc_cache: dict[tuple[int, int], np.ndarray] = {} @@ -219,7 +223,14 @@ class SrasViewerWindow(QMainWindow): self.spin_angle.setRange(0, 0) self.spin_angle.setEnabled(False) self.spin_angle.setMinimumWidth(64) - self.spin_angle.editingFinished.connect(self._on_view_changed) + # Keyboard tracking off is what makes valueChanged safe to act on: it + # stops the signal firing per keystroke, so typing "12" is one change + # to angle 12 (on Return or focus-out) rather than a trip to angle 1 + # first. Arrow clicks and the wheel still emit immediately, which + # editingFinished alone did not — that is why stepping the angle used + # to leave the plot on the previous one until the box lost focus. + self.spin_angle.setKeyboardTracking(False) + self.spin_angle.valueChanged.connect(self._on_view_changed) self.lbl_angle_deg = QLabel("—") angle_field = QWidget() ar = QHBoxLayout(angle_field) @@ -598,12 +609,32 @@ class SrasViewerWindow(QMainWindow): n_fft = sum(1 for x in s.precomputed_freq_mhz if x is not None) if n_dc or n_fft: bg_note = " (bg-sub)" if s.precomputed_bg_sub else " (no bg-sub)" + pad_note = (f", pad {s.precomputed_pad_factor}x" + if n_fft and s.precomputed_pad_factor > 1 else "") notes.append( f"Cached images: DC {n_dc}/{s.n_angles} angles, " - f"FFT {n_fft}/{s.n_angles} angles{bg_note if n_fft else ''} " + f"FFT {n_fft}/{s.n_angles} angles" + f"{bg_note + pad_note if n_fft else ''} " "— display is instant for cached angles") elif s.version == 7: notes.append("v7 format: no cache blocks stored yet") + + # A stored FFT cache the current settings can't read back is the one + # failure here with no other symptom: the images are right there in + # the file and every angle still recomputes. Say why. + if n_fft: + reasons = [] + if s.precomputed_pad_factor != self._fft_pad_factor: + reasons.append(f"stored at pad {s.precomputed_pad_factor}x, " + f"FFT Options is set to {self._fft_pad_factor}x") + if s.precomputed_bg_sub != (self.chk_bg_sub.isChecked() + and s.background is not None): + reasons.append("stored with bg-sub " + f"{'on' if s.precomputed_bg_sub else 'off'}") + if reasons: + notes.append(f"! Cached FFT unusable as configured ({'; '.join(reasons)})" + " — CH1/Velocity will recompute. Re-run Convert -> " + "Batch Compute FFT to cache at the current settings.") self.lbl_frame_warn.setText("\n".join(notes)) # ------------------------------------------------------------------ @@ -656,6 +687,9 @@ class SrasViewerWindow(QMainWindow): # 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. + # It can also decide whether the file's stored cache is readable, so + # the info panel's verdict on that has to be redrawn with it. + self._update_scan_info_labels() if self._is_fft_mode(): self._refresh_display() @@ -838,6 +872,14 @@ class SrasViewerWindow(QMainWindow): return None return self._sras.samples_per_frame * self._fft_pad_factor + def _cached_fft_pad_factor(self) -> int | None: + """The pad factor the open file's stored FFT images were computed at, + or None if it has none stored.""" + if self._sras is None or not any(x is not None + for x in self._sras.precomputed_freq_mhz): + return None + return self._sras.precomputed_pad_factor + def _is_fft_mode(self) -> bool: """Is the selected channel an FFT-derived (CH1/Velocity) mode?""" return (self._sras is not None @@ -877,6 +919,45 @@ class SrasViewerWindow(QMainWindow): self._aligned_cache[key] = cached return cached + def _stored_fft_image(self, angle_idx: int) -> np.ndarray | None: + """The file's own batch-computed FFT image for this angle, masked for + the current threshold — or None if it can't serve the current settings. + + Batch Compute FFT stores a peak-frequency image per angle in the file + itself, and the whole point of paying for that once is that display is + then instant. Without this check the viewer only ever found the stored + image deep inside ComputeWorker, so every angle change after a batch + still dispatched a background job behind a "Computing FFT…" popup for + an image that was already sitting on disk. + """ + img = compute.cached_rf_image( + self._sras, angle_idx, + dc_threshold_mv=self.spin_threshold_mv.value(), + apply_bg_sub=self.chk_bg_sub.isChecked(), + n_fft=self._current_n_fft(), + dc4_mv=self._dc_cache.get((angle_idx, CH4_IDX)), + # Masking is a copy plus a comparison, but *reading* CH4 to build + # the mask is a whole-channel read — that case belongs on a worker, + # so it returns None here and falls through to _start_compute. + allow_dc_recompute=False) + if img is not None: + self._fft_cache[self._fft_cache_key(angle_idx)] = img + return img + + def _stored_dc_image(self, angle_idx: int, ch_idx: int) -> np.ndarray | None: + """The file's own batch-computed DC image for (angle, channel), if it + has one — the CH3/CH4 half of _stored_fft_image. + + The worker this saves would have handed back the very same stored + array (dc_image_mv prefers it over recomputing), so all it ever cost + was a thread and a progress popup — but that is exactly what made + "Batch Compute DC and Store" look like it had done nothing. + """ + img = self._sras.cached_dc_mv(angle_idx, ch_idx) + if img is not None: + self._dc_cache[(angle_idx, ch_idx)] = img + return img + def _refresh_display(self): """Show the image for the current angle/channel/threshold, using cached data whenever possible and only falling back to a background @@ -888,12 +969,16 @@ class SrasViewerWindow(QMainWindow): if ch_idx in CH1_DERIVED_MODES: raw = self._fft_cache.get(self._fft_cache_key(angle_idx)) + if raw is None: + raw = self._stored_fft_image(angle_idx) if raw is not None: self._show_image_now(self._scale_for_display(raw, ch_idx), angle_idx, ch_idx) return else: cached = self._dc_cache.get((angle_idx, ch_idx)) + if cached is None: + cached = self._stored_dc_image(angle_idx, ch_idx) if cached is not None: self._show_image_now(cached, angle_idx, ch_idx) return @@ -1163,7 +1248,11 @@ class SrasViewerWindow(QMainWindow): return self._batch_errors = [] - worker = BatchCacheWorker(paths, mode, self.chk_bg_sub.isChecked()) + # Cache at the pad factor currently configured, not a fixed pad 1 — + # a cache stored at any other padding is one this viewer can never + # read back (see _stored_fft_image). + worker = BatchCacheWorker(paths, mode, self.chk_bg_sub.isChecked(), + self._fft_pad_factor) started = self._run_worker( Jobs.BATCH, worker, connect=( @@ -1338,6 +1427,7 @@ class SrasViewerWindow(QMainWindow): samples_per_frame=self._sras.samples_per_frame if self._sras else None, sample_rate_hz=self._sras.sample_rate_hz if self._sras else None, grating_um=self.spin_grating_um.value(), + cached_pad_factor=self._cached_fft_pad_factor(), ) if dlg.exec() != QDialog.DialogCode.Accepted: return @@ -1347,7 +1437,9 @@ class SrasViewerWindow(QMainWindow): 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. + # recomputes only on a cache miss. It also decides whether the file's + # stored cache can be read back at all, which the info panel reports. + self._update_scan_info_labels() if self._is_fft_mode(): self._refresh_display() diff --git a/sras_workers.py b/sras_workers.py index 4a75dd8..f12e99a 100644 --- a/sras_workers.py +++ b/sras_workers.py @@ -189,7 +189,10 @@ class BatchCacheWorker(QObject): *mode* is ``"dc"`` (CH3/CH4 mean images) or ``"fft"`` (CH1 peak-frequency images, unmasked — masking is applied at display time, same as v5's PREC - convention). + convention). FFT images are computed and stored at *pad_factor*, the + viewer's own zero-padding setting: a stored image only serves a view + asking for the same padding, so caching at any other one produces a file + the viewer will never read back. Files are processed one per subprocess: they are fully independent, each opens its own memmap and writes only its own bytes, and only path strings @@ -201,11 +204,13 @@ class BatchCacheWorker(QObject): file_done = pyqtSignal(str, str) finished = pyqtSignal() - def __init__(self, paths: list[str], mode: str, apply_bg_sub: bool): + def __init__(self, paths: list[str], mode: str, apply_bg_sub: bool, + pad_factor: int = 1): super().__init__() self._paths = paths self._mode = mode self._apply_bg_sub = apply_bg_sub + self._pad_factor = pad_factor def _report(self, path: str, err: str, done: int, total: int): self.file_done.emit(path, err) @@ -230,7 +235,8 @@ class BatchCacheWorker(QObject): with ProcessPoolExecutor(max_workers=n_procs) as executor: futures = { executor.submit(cache_file, p, self._mode, self._apply_bg_sub, - compute.get_fft_backend(), per_proc_workers): p + compute.get_fft_backend(), per_proc_workers, + self._pad_factor): p for p in paths } for fut in as_completed(futures): @@ -254,7 +260,8 @@ class BatchCacheWorker(QObject): try: err = cache_file(path, self._mode, self._apply_bg_sub, compute.get_fft_backend(), - compute.default_max_workers()) + compute.default_max_workers(), + self._pad_factor) except Exception as exc: err = str(exc) done += 1 diff --git a/tests/test_gui.py b/tests/test_gui.py index ada0b73..3c5f73b 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -94,11 +94,18 @@ def test_dc_precompute_all_angles(ctx): def test_angle_switching_from_cache(ctx): + """Setting the angle must redraw on its own — no nudge from the test. + + The spinbox used to be wired to editingFinished alone, so stepping it with + the arrows changed the number and left the plot on the previous angle until + the box happened to lose focus. + """ win, s = ctx.win, ctx.s for a in range(s.n_angles): win.spin_angle.setValue(a) - win._on_view_changed() pump(60) + assert win._current_angle == a, \ + f"angle {a} redrawn from the spinbox alone, showing {win._current_angle}" expected = win._sras.image_shape(a) assert win._current_image.shape == expected, \ f"angle {a} shows its own geometry {expected}, got {win._current_image.shape}" @@ -106,6 +113,44 @@ def test_angle_switching_from_cache(ctx): "no compute job needed for cached DC angles" +def test_angle_steps_and_typing(ctx): + """The arrows step and redraw one angle at a time; typing commits once, + without a stop at every intermediate value.""" + win, s = ctx.win, ctx.s + assert s.n_angles > 2, "fixture needs several angles" + win.spin_angle.setValue(0) + pump(60) + + seen = [] + win.spin_angle.valueChanged.connect(seen.append) + try: + win.spin_angle.stepUp() + pump(60) + assert win._current_angle == 1, f"arrow stepped to 1, showing {win._current_angle}" + win.spin_angle.stepDown() + pump(60) + assert win._current_angle == 0, f"arrow stepped to 0, showing {win._current_angle}" + + # Typing must not redraw per keystroke — that is what editingFinished + # bought and what keyboard tracking has to keep buying. + seen.clear() + last = s.n_angles - 1 + win.spin_angle.lineEdit().selectAll() + QTest.keyClicks(win.spin_angle.lineEdit(), str(last)) + pump(30) + assert seen == [] and win._current_angle == 0, \ + f"half-typed text must not redraw ({seen}, showing {win._current_angle})" + QTest.keyClick(win.spin_angle, Qt.Key.Key_Return) + pump(60) + assert seen == [last], f"committed exactly once on Return: {seen}" + assert win._current_angle == last + finally: + win.spin_angle.valueChanged.disconnect(seen.append) + win.spin_angle.clearFocus() + win.spin_angle.setValue(0) + pump(60) + + def test_channel_switching(ctx): win = ctx.win win.spin_angle.setValue(0)