Add min peak frequency floor (CACH v4) and Batch Export View as Images

Two strands of in-progress work, committed together because they overlap
in sras_workers.py and main_window.py.

Min peak frequency floor:
- CACH tail bumped to version 4, adding u32 min_freq_khz provenance in
  fixed-point kHz (a float32 20.1 reads back as 20.10000038 and would
  report a spurious mismatch forever). v1-v3 tails read as no floor.
- Stored FFT caches are accepted when the reader's floor is at or above
  the stored one, since a higher floor is re-applicable by masking.
- Floor plumbed through compute_rf_image, BatchCacheWorker and the viewer.

Batch Export View as Images:
- New sras_render.py holds draw_view_image, shared by the Qt canvas and
  the headless exporter so a PNG cannot drift from what the GUI shows.
  Deliberately Qt-free so it is importable in a pool subprocess.
- BatchExportImagesWorker renders the current view settings across many
  files, process-pooled with an inline fallback, reporting per-file
  output names so the caller can flag same-stem collisions.
- _axes_extent extracted into sras_format for both render paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales [M S E]
2026-08-12 10:17:43 -05:00
parent c54cce453c
commit a8b962e317
13 changed files with 1442 additions and 153 deletions
+81 -16
View File
@@ -386,7 +386,8 @@ def pad_factor_for(sras: SrasFile, n_fft: int | None) -> int:
def cache_mismatch_reasons(sras: SrasFile, *, n_fft: int | None,
apply_bg_sub: bool, row_avg_n: int) -> list[str]:
apply_bg_sub: bool, row_avg_n: int,
min_freq_mhz: float = 0.0) -> list[str]:
"""Why this file's stored FFT images can't answer a request, as human-
readable phrases; empty means they can.
@@ -396,8 +397,23 @@ def cache_mismatch_reasons(sras: SrasFile, *, n_fft: int | None,
serves a stored image iff this returns nothing, and callers that want to
*explain* the miss (rather than silently recompute) format these same
strings, so the two can't drift apart.
The min peak frequency floor is the asymmetric case: a stored floor
*above* the request is a genuine mismatch (bins below the stored floor
were never searched, so the stored numbers can't answer a request that
wants them considered), but a stored floor at or *below* the request is
servable — the difference is re-applied as a display-time mask by
cached_rf_image (pixels whose stored peak falls below the requested
floor are marked invalid rather than re-resolved). Compared in whole
kHz, the header field's own fixed-point grid, so a value that
round-trips through the file can never miscompare against itself.
"""
reasons = []
if round(sras.precomputed_min_freq_mhz * 1000) > round(min_freq_mhz * 1000):
reasons.append(
f"stored with a {sras.precomputed_min_freq_mhz:g} MHz "
f"min-peak-freq floor, requested {min_freq_mhz:g} MHz "
f"(bins below the stored floor were never searched)")
pad = pad_factor_for(sras, n_fft)
if pad != sras.precomputed_pad_factor:
want = f"pad {pad}x" if pad else "a ragged n_fft"
@@ -421,7 +437,8 @@ def cached_rf_image(sras: SrasFile, angle_idx: int,
n_fft: int | None = None,
dc4_mv: np.ndarray | None = None,
row_avg_n: int = 0,
allow_dc_recompute: bool = True) -> np.ndarray | None:
allow_dc_recompute: bool = True,
min_freq_mhz: float = 0.0) -> np.ndarray | None:
"""The precomputed-cache fast path for compute_rf_image: a ready-to-
display peak-frequency image if this file already has one matching
every setting the caller cares about, else None (caller must run a
@@ -449,10 +466,20 @@ def cached_rf_image(sras: SrasFile, angle_idx: int,
channel on the caller's behalf; this returns None instead so a caller
that wants to stay off the I/O path (e.g. a GUI thread) can choose to
fall through to a real compute rather than block.
*min_freq_mhz* above the stored floor is re-imposed here as a mask:
pixels whose stored peak is below it are set to 0.0 — the same sentinel
the DC mask uses, and unambiguous, since a genuine peak can never be
0.0 (bin 0 is always excluded from the search). Masked-below-floor
pixels are marked invalid, not re-resolved; only a real recompute can
recover the strongest peak *above* the floor for them. A request whose
floor is *below* the stored one can't be answered at all (see
cache_mismatch_reasons) and returns None like any other mismatch.
"""
cached_freq = sras.precomputed_freq_mhz[angle_idx]
if cached_freq is None or cache_mismatch_reasons(
sras, n_fft=n_fft, apply_bg_sub=apply_bg_sub, row_avg_n=row_avg_n):
sras, n_fft=n_fft, apply_bg_sub=apply_bg_sub, row_avg_n=row_avg_n,
min_freq_mhz=min_freq_mhz):
return None
dc4_img = None
if dc_threshold_mv is not None:
@@ -472,6 +499,10 @@ def cached_rf_image(sras: SrasFile, angle_idx: int,
freq_img = cached_freq.copy()
if dc4_img is not None:
freq_img[dc4_img < dc_threshold_mv] = 0.0
if min_freq_mhz > 0.0:
# Strict <, matching the compute path: the first bin at or above
# the floor survives searchsorted there, so it must survive here.
freq_img[freq_img < min_freq_mhz] = 0.0
return freq_img
@@ -484,7 +515,8 @@ def compute_rf_image(sras: SrasFile, angle_idx: int,
budget: int | None = None,
should_stop=None,
row_avg_n: int = 0,
min_freq_mhz: float = 0.0) -> np.ndarray:
min_freq_mhz: float = 0.0,
use_stored: bool = True) -> np.ndarray:
"""FFT of each CH1 waveform; pixel = peak frequency in MHz.
Pixels where CH4_dc < dc_threshold_mv are set to 0 — and the FFT is
@@ -525,17 +557,27 @@ 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) matching every one of the caller's settings
— including row_avg_n exactly — the stored image is used directly, no
FFT is run. See cached_rf_image. *min_freq_mhz* plays no part in that
match — a stored image was baked without any floor, so it is returned
as-is; the floor only ever affects a real (re)compute.
FFT is run. See cached_rf_image. A *min_freq_mhz* at or above the
stored floor is part of that service: pixels whose stored peak falls
below it come back masked to 0.0 rather than re-resolved. A request
*below* the stored floor is a genuine mismatch and falls through to
the real FFT here.
*use_stored=False* skips the fast path entirely and always runs the
real FFT. Batch recompute (cache_file) needs this: served-then-masked
pixels written back into the store would permanently replace peaks a
real recompute re-resolves above the floor.
"""
n_rows, n_frames = sras.image_shape(angle_idx)
data = sras.data[angle_idx]
fast = cached_rf_image(sras, angle_idx, dc_threshold_mv, apply_bg_sub=apply_bg_sub,
n_fft=n_fft, dc4_mv=dc4_mv, row_avg_n=row_avg_n)
if fast is not None:
return fast
if use_stored:
fast = cached_rf_image(sras, angle_idx, dc_threshold_mv,
apply_bg_sub=apply_bg_sub, n_fft=n_fft,
dc4_mv=dc4_mv, row_avg_n=row_avg_n,
min_freq_mhz=min_freq_mhz)
if fast is not None:
return fast
# ---- Chunked FFT path --------------------------------------------------
spf = sras.samples_per_frame
@@ -653,7 +695,8 @@ def cache_file(path: str, mode: str, apply_bg_sub: bool,
max_workers: int = 0,
pad_factor: int = 1,
dc_threshold_mv: float | None = None,
row_avg_n: int = 0) -> str:
row_avg_n: int = 0,
min_freq_mhz: float = 0.0) -> 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.
@@ -675,6 +718,16 @@ def cache_file(path: str, mode: str, apply_bg_sub: bool,
using, since a pad-1 cache is dead weight to a padded view and vice
versa (cached_rf_image refuses the mismatch rather than showing peaks
resolved at the wrong resolution).
*min_freq_mhz* is the peak-search floor (see compute_rf_image), also
recorded in the SFFT block; it is quantized up front to the header
field's 0.001 MHz grid so the stored images and the recorded floor
can never disagree. Both FFT modes force use_stored=False on
compute_rf_image: a batch recompute must always run the real FFT, never
serve this file's own existing cache back to itself — with a floor (or
a changed DC threshold in fft_rowavg mode) that would silently bake a
masked copy of the old image into the store in place of a genuine
recompute.
"""
global _MAX_WORKERS
try:
@@ -685,6 +738,12 @@ def cache_file(path: str, mode: str, apply_bg_sub: bool,
# between a bad argument costing nothing and costing the whole run.
if not (1 <= pad_factor <= MAX_PAD_FACTOR):
return f"pad_factor must be 1-{MAX_PAD_FACTOR}, got {pad_factor}"
if not (np.isfinite(min_freq_mhz) and min_freq_mhz >= 0):
return f"min_freq_mhz must be a finite value >= 0, got {min_freq_mhz}"
# Quantize to the SFFT header's fixed-point grid (whole kHz) before
# computing, so the floor the FFT actually ran with is exactly the
# floor the header records.
min_freq_mhz = round(min_freq_mhz * 1000) / 1000.0
if max_workers:
_MAX_WORKERS = max_workers
@@ -716,13 +775,16 @@ def cache_file(path: str, mode: str, apply_bg_sub: bool,
# 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, n_fft=n_fft)
apply_bg_sub=effective_bg, n_fft=n_fft,
min_freq_mhz=min_freq_mhz,
use_stored=False)
for a in range(n)]
# new_row_avg_n=0 explicitly: these images are raw per-pixel FFTs,
# and carrying forward a row_avg_n left by an earlier fft_rowavg
# write would label them as something they are not.
sras.write_v7_cache(new_freq_mhz=freq, new_bg_sub=effective_bg,
new_row_avg_n=0, new_pad_factor=pad_factor)
new_row_avg_n=0, new_pad_factor=pad_factor,
new_min_freq_mhz=min_freq_mhz)
else: # "fft_rowavg"
if row_avg_n <= 0:
return "row_avg_n must be a positive neighbor half-width for fft_rowavg mode"
@@ -732,11 +794,14 @@ def cache_file(path: str, mode: str, apply_bg_sub: bool,
effective_bg = apply_bg_sub and sras.background is not None
freq = [compute_rf_image(sras, a, dc_threshold_mv=dc_threshold_mv,
apply_bg_sub=effective_bg, n_fft=n_fft,
row_avg_n=row_avg_n)
row_avg_n=row_avg_n,
min_freq_mhz=min_freq_mhz,
use_stored=False)
for a in range(n)]
sras.write_v7_cache(new_freq_mhz=freq, new_bg_sub=effective_bg,
new_row_avg_n=row_avg_n,
new_pad_factor=pad_factor)
new_pad_factor=pad_factor,
new_min_freq_mhz=min_freq_mhz)
return ""
except Exception as exc:
return str(exc)