diff --git a/docs/design.md b/docs/design.md index 1543828..c5a6f99 100644 --- a/docs/design.md +++ b/docs/design.md @@ -157,6 +157,18 @@ by `cached_rf_image` before it hands the image back. Getting this wrong is not a slow display, it is a *wrong* display, which is why the check is a single predicate in one place rather than spread across callers. +The min peak frequency floor is the fourth recorded setting, and it sits +between the DC threshold (fully re-applicable at display time) and the +baked-in three: it is *tighten-only* re-applicable. A floor at or above the +stored one is served by masking stored pixels below it to the 0.0 "no valid +peak" sentinel — invalidated, not re-resolved; only a real recompute can +find their true above-floor peak. A floor *below* the stored one is a +genuine mismatch: the stored search never looked at those bins. Because a +served-then-masked image is a lossy stand-in for a real floored compute, +`cache_file` passes `use_stored=False` so a batch recompute always runs the +real FFT — otherwise re-batching a cached file would silently bake the +masked copy of its own old image back into the store. + Padding is the subtlest of the three, because a padded FFT looks like it should be a refinement of the unpadded one. It isn't: zero-padding interpolates between the natural bins, so it resolves a different peak @@ -183,8 +195,12 @@ recompute. The *display* path (`_stored_fft_image`) never asks bg-sub/pad/row-averaging controls — it asks whether the image matches its *own* recorded settings (`sras.precomputed_bg_sub`/`precomputed_pad_factor`/ `precomputed_row_avg_n`), which is always true whenever a stored image -exists. So presence alone decides whether it's shown; the live controls -never gate it. They still matter for two things: a genuinely never-computed +exists. Two settings are taken live instead: the DC threshold and the min +peak frequency floor, both cheaply re-appliable as masks on serve. So for +bg-sub/pad/row-avg, presence alone decides whether a stored image is shown; +the floor is the one case where a live control can gate it — a live floor +*below* the stored one can't be answered by masking, so `cached_rf_image` +declines and the caller falls through to a real compute. They still matter for two things: a genuinely never-computed angle's first live compute, and an explicit batch recompute — both of which read the live controls and produce new stored data, at which point it's the new data's *own* settings that get self-matched from then on. This is what diff --git a/pyproject.toml b/pyproject.toml index 8617fab..7913506 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ sras-viewer = "sras_viewer.main_window:main" py-modules = [ "sras_format", "sras_compute", + "sras_render", "sras_workers", "sras_align_export", "sras_average", diff --git a/scan_format.md b/scan_format.md index 6181c02..57e4df4 100644 --- a/scan_format.md +++ b/scan_format.md @@ -255,7 +255,7 @@ actions. | Offset | Size | Type | Field | Description | |--------|------|------|-------|-------------| | 0 | 4 | `char[4]` | `cach_magic` | `CACH` (ASCII). Missing/wrong magic → treat file as having no cache. | -| 4 | 1 | `u8` | `cach_version` | Cache format version. Currently `3`; readers also accept `1` and `2` (each older tail simply lacks the fields added since — see the `SFFT` block below and [CACH tail version history](#cach-tail-version-history)). Any other value → treat the file as uncached (unlike v5's `PREC` section, which read but never validated its version byte). | +| 4 | 1 | `u8` | `cach_version` | Cache format version. Currently `4`; readers also accept `1`–`3` (each older tail simply lacks the fields added since — see the `SFFT` block below and [CACH tail version history](#cach-tail-version-history)). Any other value → treat the file as uncached (unlike v5's `PREC` section, which read but never validated its version byte). | | 5 | 1 | `u8` | `block_flags` | Bit 0 = DC block (`SDCB`) follows. Bit 1 = FFT block (`SFFT`) follows, immediately after the DC block if both are present. Bits 2–7 reserved, must be zero on write. | ### DC block `SDCB` (present iff `block_flags & 0x01`) @@ -292,13 +292,16 @@ never shifts an existing offset: - **`cach_version` 1**: 7 bytes, format `">4sBH"` — magic, flags, n_stored. - **`cach_version` 2**: 8 bytes, format `">4sBHB"` — + `row_avg_n`. - **`cach_version` 3**: 10 bytes, format `">4sBHBH"` — + `pad_factor`. +- **`cach_version` 4**: 14 bytes, format `">4sBHBHI"` — + `min_freq_khz`. Always written by current code. An older tail is read with its absent fields taken as the only value such a tail can describe: `row_avg_n = 0` for a `cach_version` 1 tail, which -predates row-averaged FFT caching, and `pad_factor = 1` for `cach_version` -1 or 2, which predate padded caching and are therefore natural-resolution. -Files cached before either change keep working with no recompute. +predates row-averaged FFT caching, `pad_factor = 1` for `cach_version` +1 or 2, which predate padded caching and are therefore natural-resolution, +and `min_freq_khz = 0` (no floor) for `cach_version` 1–3, which predate the +min peak frequency floor and therefore searched every bin above DC. +Files cached before any of these changes keep working with no recompute. | Offset (rel) | Size | Type | Field | Description | |--------------|------|------|-------|-------------| @@ -306,7 +309,8 @@ Files cached before either change keep working with no recompute. | 4 | 1 | `u8` | `flags` | Bit 0 = `bg_sub_applied` — background waveform was subtracted from CH1 before the FFT when these images were computed. Bit 1 = `row_averaged` — `peak_freq_mhz` came from same-row, distance-weighted averaged CH1 waveforms rather than raw per-pixel ones; `row_avg_n` (below) is the neighbor half-width used. Bits 2–7 reserved. | | 5 | 2 | `u16` | `n_stored` | Number of angle entries that follow | | 7 | 1 | `u8` | `row_avg_n` | *`cach_version` ≥ 2 only.* Same-row neighbor half-width, in pixels, that `peak_freq_mhz` was averaged over before its FFT; `0` = raw (unaveraged). Meaningful only when `flags` bit 1 is set — a `cach_version` 1 tail has no such byte and is always `row_avg_n = 0`. | -| 8 | 2 | `u16` | `pad_factor` | *`cach_version` 3 only.* Zero-padding factor the stored `peak_freq_mhz` was resolved at: `n_fft = pad_factor × samples_per_frame`, so `1` = natural resolution. Never `0`; a `cach_version` 1 or 2 tail has no such field and is always `pad_factor = 1`. | +| 8 | 2 | `u16` | `pad_factor` | *`cach_version` ≥ 3 only.* Zero-padding factor the stored `peak_freq_mhz` was resolved at: `n_fft = pad_factor × samples_per_frame`, so `1` = natural resolution. Never `0`; a `cach_version` 1 or 2 tail has no such field and is always `pad_factor = 1`. | +| 10 | 4 | `u32` | `min_freq_khz` | *`cach_version` ≥ 4 only.* Min peak frequency floor the stored peak search excluded bins below, fixed-point in units of 0.001 MHz (kHz); `0` = no floor. Fixed-point rather than `f32` so a value that round-trips through the file compares exactly against the same value re-requested by a reader (the viewer's floor control has 0.001 MHz granularity). A `cach_version` 1–3 tail has no such field and is always `min_freq_khz = 0`. | followed by `n_stored` entries, each: @@ -341,14 +345,27 @@ Readers must fall back to real-time FFT computation (ignoring stored the reader is asking for: time-domain gating is active, the reader's requested `n_fft` doesn't equal `pad_factor × samples_per_frame`, the reader's background-subtraction setting doesn't match -`flags.bg_sub_applied`, or the reader's requested `row_avg_n` doesn't match -the stored value exactly. A raw request must never be served a row-averaged -store, or vice versa; a request at one row-averaging window size must never -be served a store at another; and a request at one padding must never be -served a store at another, since a padded FFT interpolates between the -natural bins and so resolves genuinely different peak frequencies. An -`n_fft` that is not a whole multiple of `samples_per_frame` can never match -any store, because only an integer `pad_factor` is representable. +`flags.bg_sub_applied`, the reader's requested `row_avg_n` doesn't match +the stored value exactly, or the reader's requested min peak frequency +floor is *below* the stored `min_freq_khz`. A raw request must never be +served a row-averaged store, or vice versa; a request at one row-averaging +window size must never be served a store at another; and a request at one +padding must never be served a store at another, since a padded FFT +interpolates between the natural bins and so resolves genuinely different +peak frequencies. An `n_fft` that is not a whole multiple of +`samples_per_frame` can never match any store, because only an integer +`pad_factor` is representable. + +The min peak frequency floor is the one asymmetric provenance field. A +request at a floor *below* the stored one cannot be served: the stored +search never looked at bins below its floor, so the stored numbers cannot +say what a lower-floored search would have found. A request at a floor at +or *above* the stored one **is** servable — the difference is re-applied at +display time by masking every pixel whose stored `peak_freq_mhz` is below +the requested floor to `0` (the same sentinel as the DC threshold mask; +a genuine peak can never be `0`, since bin 0 is always excluded from the +search). Such masked pixels are *invalid*, not re-resolved — only a real +recompute can recover the strongest peak above the floor for them. ### In-place write ordering @@ -374,6 +391,7 @@ which has stayed `7` since the Cache Tail was introduced — this is the inner | 1 | Initial Cache Tail: `SDCB` (DC) and `SFFT` (FFT, 7-byte header) blocks. | | 2 | `SFFT` header grows one byte, `row_avg_n` — the same-row neighbor half-width the stored `peak_freq_mhz` was averaged over before its FFT, `0` = raw. Readers still accept a `cach_version` 1 tail, treated as `row_avg_n = 0` for every angle it stores, so files cached before this change keep working without a recompute. | | 3 | `SFFT` header grows a `u16` `pad_factor` — the zero-padding factor the stored `peak_freq_mhz` was resolved at, `1` = natural resolution. Before this, a padded view could never use a stored cache at all (the store was pad 1 by definition and readers rejected any `n_fft ≠ samples_per_frame`), so a user working at a pad factor got no benefit from batch-computing a file. Recording the factor lets such a view be served, while still refusing a store resolved at a *different* pad. Readers accept `cach_version` 1 and 2 tails as `pad_factor = 1`. | +| 4 | `SFFT` header grows a `u32` `min_freq_khz` — the min peak frequency floor the stored peak search excluded bins below, in 0.001 MHz units, `0` = no floor. The floor exists because a pixel that passes the DC-bias threshold but carries only weak real signal can otherwise resolve to the un-subtracted background's DC-leakage skirt — an implausibly-near-zero frequency (and so an implausibly slow velocity) for a pixel that has a genuine peak higher up. Recording the floor is what makes it enforceable against a store: without it, a stored image silently bypassed the floor entirely. Unlike the other provenance fields it is asymmetric — a *higher* requested floor is servable by masking stored pixels below it, only a *lower* one forces a recompute (see above). Readers accept `cach_version` 1–3 tails as `min_freq_khz = 0`. | A reader that does not know a `cach_version` must treat the file as uncached — not attempt a partial parse — and the file still reads as an diff --git a/sras_compute.py b/sras_compute.py index 28af21e..a715688 100644 --- a/sras_compute.py +++ b/sras_compute.py @@ -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) diff --git a/sras_format.py b/sras_format.py index 469c607..ee219f1 100644 --- a/sras_format.py +++ b/sras_format.py @@ -60,15 +60,18 @@ 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 = 3 # written on every fresh write -CACH_VERSIONS_READABLE = (1, 2, 3) # accepted on read — see +CACH_VERSION = 4 # written on every fresh write +CACH_VERSIONS_READABLE = (1, 2, 3, 4) # accepted on read — see # _read_sfft_block. Each bump only # appended a field, and every older # tail has a well-defined reading: # v1 predates row-averaged FFT - # caching (row_avg_n=0) and v1/v2 + # caching (row_avg_n=0), v1/v2 # predate padded caching, so both - # are natural-resolution (pad 1). + # are natural-resolution (pad 1), + # and v1-v3 predate the min peak + # frequency floor (min_freq 0 = + # no floor). CACH_FLAG_DC = 0x01 CACH_FLAG_FFT = 0x02 @@ -79,11 +82,20 @@ SDCB_HDR_SIZE = struct.calcsize(SDCB_HDR_FMT) SFFT_MAGIC = b"SFFT" SFFT_HDR_FMT_V1 = ">4sBH" # magic, flags, n_stored (cach_version 1) SFFT_HDR_FMT_V2 = ">4sBHB" # + row_avg_n (cach_version 2) -SFFT_HDR_FMT = ">4sBHBH" # + pad_factor (cach_version 3) +SFFT_HDR_FMT_V3 = ">4sBHBH" # + pad_factor (cach_version 3) +SFFT_HDR_FMT = ">4sBHBHI" # + min_freq_khz (cach_version 4) SFFT_HDR_SIZE_V1 = struct.calcsize(SFFT_HDR_FMT_V1) SFFT_HDR_SIZE_V2 = struct.calcsize(SFFT_HDR_FMT_V2) +SFFT_HDR_SIZE_V3 = struct.calcsize(SFFT_HDR_FMT_V3) SFFT_HDR_SIZE = struct.calcsize(SFFT_HDR_FMT) MAX_PAD_FACTOR = 0xFFFF # the H field above +# min_freq_khz is fixed-point (u32, units of 0.001 MHz), not a float32: +# the viewer's floor spinbox has 0.001 MHz granularity, and +# round(mhz * 1000) / 1000.0 reproduces the exact float64 the user typed, +# so the accept rule in sras_compute.cache_mismatch_reasons can compare +# with plain integer ordering. A ">f" float32 of e.g. 20.1 would read back +# as 20.10000038… > 20.1 and report a spurious mismatch forever. +MAX_MIN_FREQ_KHZ = 0xFFFFFFFF # the I field above; 0 = no floor SFFT_FLAG_BG_SUB = 0x01 SFFT_FLAG_ROW_AVG = 0x02 # peak_freq_mhz came from same-row, # distance-weighted averaged CH1 @@ -134,6 +146,13 @@ def adc_to_mv(adc, ymult_mv: float = _FALLBACK_YMULT_MV, return (adc - yoff_adc) * ymult_mv + yzero_mv +def _axes_extent(x_axis, y_axis, dx: float, dy: float) -> list[float]: + """Matplotlib imshow extent with half-pixel margins, Y flipped so row 0 + renders at the top.""" + return [x_axis[0] - dx / 2, x_axis[-1] + dx / 2, + y_axis[-1] + dy / 2, y_axis[0] - dy / 2] + + # --------------------------------------------------------------------------- # Binary read helpers # --------------------------------------------------------------------------- @@ -188,9 +207,10 @@ class SrasFile: 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. The scalars ``precomputed_bg_sub`` / - ``precomputed_row_avg_n`` / ``precomputed_pad_factor`` record the - settings the stored FFT images were computed under, so a reader can tell - whether they answer the question it is actually asking. + ``precomputed_row_avg_n`` / ``precomputed_pad_factor`` / + ``precomputed_min_freq_mhz`` record the settings the stored FFT images + were computed under, so a reader can tell whether they answer the + question it is actually asking. """ def __init__(self, path: str): @@ -253,6 +273,13 @@ class SrasFile: # FFT resolves peaks a padded view would, and only such a view can # be served from it — see sras_compute.cached_rf_image. self.precomputed_pad_factor: int = 1 + # Min peak frequency floor (MHz) the stored peak search excluded + # bins below; 0.0 = no floor. Unlike bg-sub/pad/row-avg it is + # tighten-only re-applicable: a *higher* floor can be re-imposed on + # a stored image by masking pixels below it, but bins below the + # stored floor were never searched, so a lower floor needs a real + # recompute — see sras_compute.cache_mismatch_reasons. + self.precomputed_min_freq_mhz: float = 0.0 def encoded_preambles(self) -> bytes: """This file's Preamble Blocks section, as bytes a writer can emit. @@ -595,30 +622,35 @@ class SrasFile: store[angle_idx] = _read_f32_image(f, shape) return flags - def _read_sfft_block(self, f, cach_version: int) -> tuple[int, int, int] | None: + def _read_sfft_block(self, f, cach_version: int) -> tuple[int, int, int, float] | None: """Read the SFFT block header — its layout depends on cach_version, since each bump appended a trailing field (v2 row_avg_n, v3 - pad_factor) — then n_stored per-angle peak_freq_mhz entries - (unchanged across versions). + pad_factor, v4 min_freq_khz) — then n_stored per-angle + peak_freq_mhz entries (unchanged across versions). - Returns (flags, row_avg_n, pad_factor), or None if the block is - malformed. The absent fields of an older tail take the value that - describes what such a tail can only have been: row_avg_n=0 for v1, - which predates row-averaged FFT caching, and pad_factor=1 for v1/v2, - which predate padded caching and so are natural-resolution. + Returns (flags, row_avg_n, pad_factor, min_freq_mhz), or None if + the block is malformed. The absent fields of an older tail take the + value that describes what such a tail can only have been: + row_avg_n=0 for v1, which predates row-averaged FFT caching; + pad_factor=1 for v1/v2, which predate padded caching and so are + natural-resolution; and min_freq_mhz=0.0 for v1-v3, which predate + the min peak frequency floor and so searched every bin above DC. """ - hdr_fmt = {1: SFFT_HDR_FMT_V1, 2: SFFT_HDR_FMT_V2}.get( - cach_version, SFFT_HDR_FMT) + hdr_fmt = {1: SFFT_HDR_FMT_V1, 2: SFFT_HDR_FMT_V2, + 3: SFFT_HDR_FMT_V3}.get(cach_version, SFFT_HDR_FMT) raw = f.read(struct.calcsize(hdr_fmt)) if len(raw) < struct.calcsize(hdr_fmt): return None - row_avg_n, pad_factor = 0, 1 + row_avg_n, pad_factor, min_freq_khz = 0, 1, 0 if cach_version == 1: magic, flags, n_stored = struct.unpack(hdr_fmt, raw) elif cach_version == 2: magic, flags, n_stored, row_avg_n = struct.unpack(hdr_fmt, raw) - else: + elif cach_version == 3: magic, flags, n_stored, row_avg_n, pad_factor = struct.unpack(hdr_fmt, raw) + else: + (magic, flags, n_stored, row_avg_n, pad_factor, + min_freq_khz) = struct.unpack(hdr_fmt, raw) if magic != SFFT_MAGIC: return None for _ in range(n_stored): @@ -627,7 +659,7 @@ class SrasFile: break self.precomputed_freq_mhz[angle_idx] = _read_f32_image( f, self.image_shape(angle_idx)) - return flags, row_avg_n, pad_factor + return flags, row_avg_n, pad_factor, min_freq_khz / 1000.0 def _parse_cach_section(self, offset: int): """Parse the v7 CACH tail that holds precomputed DC/FFT images.""" @@ -650,10 +682,13 @@ class SrasFile: result = self._read_sfft_block(f, cach_version) if result is None: return - flags, row_avg_n, pad_factor = result + flags, row_avg_n, pad_factor, min_freq_mhz = result self.precomputed_bg_sub = bool(flags & SFFT_FLAG_BG_SUB) self.precomputed_row_avg_n = row_avg_n if (flags & SFFT_FLAG_ROW_AVG) else 0 self.precomputed_pad_factor = max(1, pad_factor) + # No flag bit gates the floor: 0 (= no floor) is already the + # value every pre-v4 tail reads as. + self.precomputed_min_freq_mhz = min_freq_mhz def write_v7_cache(self, *, new_dc3_mv: list[np.ndarray | None] | None = None, @@ -661,7 +696,8 @@ class SrasFile: new_freq_mhz: list[np.ndarray | None] | None = None, new_bg_sub: bool | None = None, new_row_avg_n: int | None = None, - new_pad_factor: int | None = None): + new_pad_factor: int | None = None, + new_min_freq_mhz: float | 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 @@ -681,6 +717,13 @@ class SrasFile: different pad resolves different peaks, so recording it is what lets a reader refuse the cache instead of showing the wrong numbers. + *new_min_freq_mhz* is the min peak frequency floor the passed + *new_freq_mhz*'s peak search excluded bins below (0.0 = no floor), + carried forward the same way. It is stored fixed-point (whole kHz), + so the value is quantized to 0.001 MHz on write and + ``precomputed_min_freq_mhz`` is updated to the quantized value — + what a reload would see, never a float the header can't represent. + The waveform data itself is never touched: the cache tail always starts at ``_cache_tail_offset()``, a fixed offset derived from the header and geometry table alone. @@ -697,11 +740,21 @@ class SrasFile: else self.precomputed_row_avg_n) final_pad_factor = (new_pad_factor if new_pad_factor is not None else self.precomputed_pad_factor) + final_min_freq_mhz = (new_min_freq_mhz if new_min_freq_mhz is not None + else self.precomputed_min_freq_mhz) if not (0 <= final_row_avg_n <= 255): raise ValueError(f"row_avg_n must fit in a byte (0-255), got {final_row_avg_n}") if not (1 <= final_pad_factor <= MAX_PAD_FACTOR): raise ValueError( f"pad_factor must be 1-{MAX_PAD_FACTOR}, got {final_pad_factor}") + if not (np.isfinite(final_min_freq_mhz) and final_min_freq_mhz >= 0): + raise ValueError( + f"min_freq_mhz must be a finite value >= 0, got {final_min_freq_mhz}") + final_min_freq_khz = int(round(final_min_freq_mhz * 1000)) + if final_min_freq_khz > MAX_MIN_FREQ_KHZ: + raise ValueError( + f"min_freq_mhz too large for the u32 kHz header field: " + f"{final_min_freq_mhz}") 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] @@ -724,7 +777,7 @@ class SrasFile: fft_flags |= SFFT_FLAG_ROW_AVG if final_row_avg_n else 0 payload += struct.pack(SFFT_HDR_FMT, SFFT_MAGIC, fft_flags, len(fft_entries), final_row_avg_n, - final_pad_factor) + final_pad_factor, final_min_freq_khz) for a in fft_entries: payload += struct.pack(">H", a) payload += final_freq[a].astype(">f4").tobytes() @@ -754,6 +807,7 @@ class SrasFile: self.precomputed_bg_sub = final_bg_sub self.precomputed_row_avg_n = final_row_avg_n self.precomputed_pad_factor = final_pad_factor + self.precomputed_min_freq_mhz = final_min_freq_khz / 1000.0 # ------------------------------------------------------------------ # Axes helpers diff --git a/sras_render.py b/sras_render.py new file mode 100644 index 0000000..65dbcb0 --- /dev/null +++ b/sras_render.py @@ -0,0 +1,153 @@ +"""Pure-matplotlib rendering of a displayed SRAS image: imshow + colorbar + +axis/title labeling, shared by the interactive Qt canvas +(sras_viewer.canvases.ImageCanvas, which supplies its own already-Qt-backed +Figure/Axes) and the headless batch image-export worker (which builds a +throwaway Agg Figure per file and never touches Qt) -- so an exported PNG +can never quietly start looking different from what the GUI actually shows. + +Deliberately no PyQt6 import anywhere in this module: BatchExportImagesWorker +(sras_workers.py) may run export_view_image inside a spawned +ProcessPoolExecutor subprocess, exactly like sras_compute.cache_file, and +importing anything under the sras_viewer package would run its __init__.py +and pull in the whole Qt widget tree for no reason. +""" + +from pathlib import Path + +import matplotlib as mpl +import numpy as np +from matplotlib.backends.backend_agg import FigureCanvasAgg +from matplotlib.figure import Figure + +from sras_compute import compute_rf_image, dc_image_mv +from sras_format import CH4_IDX, CH_NAMES, SrasFile, _axes_extent + + +def draw_view_image(ax, fig, img: np.ndarray, extent: list[float], cmap, + vmin: float, vmax: float, xlabel: str, ylabel: str, + title: str, colorbar_label: str = "", cb_ticks=None, + norm=None, bad_color=None): + """imshow + colorbar + labels onto an already-created (ax, fig) pair. + + *cmap* may be a name or a Colormap instance. *norm* (which overrides + vmin/vmax) and *cb_ticks* let a caller draw a discrete integer image + with whole-number colorbar bands instead of a continuous shade. + *bad_color*, if given, is the fill for NaN pixels -- a copy of *cmap* is + made so a shared, registered instance is never mutated. + + Shared by ImageCanvas.show_image (Qt-backed ax/fig) and + export_view_image (headless Agg ax/fig) so the two can never drift into + showing different things for the same settings. + """ + if bad_color is not None: + cmap = (cmap if hasattr(cmap, "with_extremes") + else mpl.colormaps[cmap]).with_extremes(bad=bad_color) + + kw = ({"norm": norm} if norm is not None + else {"vmin": vmin, "vmax": vmax}) + im = ax.imshow( + img, aspect="auto", origin="upper", + extent=extent, cmap=cmap, interpolation="nearest", **kw, + ) + cb = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04, ticks=cb_ticks) + if colorbar_label: + cb.set_label(colorbar_label) + + ax.set_xlabel(xlabel) + ax.set_ylabel(ylabel) + ax.set_title(title) + return im + + +def export_view_image(path: str, *, out_dir: str, angle_idx: int, ch_idx: int, + is_fft_mode: bool, is_velocity: bool, + dc_threshold_mv: float, apply_bg_sub: bool, + pad_factor: int, min_freq_mhz: float, grating_um: float, + cmap: str, auto_scale: bool, vmin: float, vmax: float, + highlight_masked: bool, mode_str: str, + colorbar_label: str, mask_color: str = "magenta", + max_workers: int | None = None, + dpi: int = 150) -> tuple[str, str]: + """One file's contribution to Batch Export View as Images: renders + (angle_idx, ch_idx) at the given display settings to a PNG under + *out_dir*, via draw_view_image -- so a batch export is a folder of what + ImageCanvas.show_image would have put on screen for these settings, not + a raw data dump. + + Module-level and picklable, like sras_compute.cache_file, so it can run + in a ProcessPoolExecutor -- see BatchExportImagesWorker. Unlike + cache_file this never writes to *path*: export is a read of the file's + own data, not a cache conversion, so any version SrasFile can open + works, with no v6/v7 precondition. + + *pad_factor* (not n_fft) travels across files deliberately: n_fft + depends on samples_per_frame, which can differ between files in the + same batch, so n_fft is derived per file, here, from *this* file's own + value -- the same reason sras_compute.cache_file does the same thing. + + Returns (error, out_name). error is "" on success. out_name is the + filename this call targeted -- set as soon as it's known, even on most + failures -- so the caller can flag same-stem collisions across the + batch without any cross-process bookkeeping. + """ + out_name = "" + try: + sras = SrasFile(path) + if angle_idx >= sras.n_angles: + return (f"angle {angle_idx} out of range " + f"(file has {sras.n_angles} angle(s))", out_name) + + out_name = f"{Path(path).stem}_angle{angle_idx}_{CH_NAMES[ch_idx]}.png" + + if is_fft_mode: + n_fft = (sras.samples_per_frame * pad_factor + if pad_factor > 1 else None) + freq = compute_rf_image( + sras, angle_idx, dc_threshold_mv=dc_threshold_mv, + apply_bg_sub=apply_bg_sub, n_fft=n_fft, + min_freq_mhz=min_freq_mhz, max_workers=max_workers) + img = freq * grating_um if is_velocity else freq + else: + img = dc_image_mv(sras, angle_idx, ch_idx, max_workers=max_workers) + + display_img, bad_color = img, None + if highlight_masked and is_fft_mode: + # Mirrors the viewer's _redraw_image rule: DC-masked pixels and + # value-0 pixels (the "no valid peak" sentinel — DC-masked, + # below the min-freq floor, or empty spectrum; the grating + # multiply above preserves zeros, so this holds for Velocity + # too) both render in the highlight color. + dc4 = dc_image_mv(sras, angle_idx, CH4_IDX, max_workers=max_workers) + valid = dc4 >= dc_threshold_mv + if valid.shape == display_img.shape: + valid &= display_img != 0.0 + display_img = display_img.astype(np.float32, copy=True) + display_img[~valid] = np.nan + bad_color = mask_color + + if auto_scale: + v0, v1 = float(np.nanmin(display_img)), float(np.nanmax(display_img)) + if not np.isfinite(v0): + v0, v1 = 0.0, 0.0 # every pixel masked out + else: + v0, v1 = vmin, vmax + + x_axis = sras.x_axis_mm(angle_idx) + y_axis = sras.y_positions_mm(angle_idx) + dx = x_axis[1] - x_axis[0] if len(x_axis) > 1 else sras.pixel_x_mm + dy = float(y_axis[1] - y_axis[0]) if len(y_axis) > 1 else 1.0 + extent = _axes_extent(x_axis, y_axis, dx, dy) + + title = (f"{CH_NAMES[ch_idx]} | {mode_str} | " + f"{sras.angles_deg[angle_idx]:.1f}°") + + fig = Figure(figsize=(7, 5), tight_layout=True) + FigureCanvasAgg(fig) # Agg-only: never registered with pyplot + ax = fig.add_subplot(111) + draw_view_image(ax, fig, display_img, extent, cmap, v0, v1, + "X (mm)", "Y (mm)", title, colorbar_label, + bad_color=bad_color) + fig.savefig(str(Path(out_dir) / out_name), dpi=dpi) + return ("", out_name) + except Exception as exc: + return (str(exc), out_name) diff --git a/sras_viewer/canvases.py b/sras_viewer/canvases.py index a4c8373..835427a 100644 --- a/sras_viewer/canvases.py +++ b/sras_viewer/canvases.py @@ -12,6 +12,7 @@ from PyQt6.QtGui import QKeyEvent from PyQt6.QtWidgets import QSizePolicy from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, CH_NAMES, SrasFile, adc_to_mv +from sras_render import draw_view_image def count_colormap(n_angles: int): """(cmap, norm, ticks) for an integer "how many angles cover this pixel" @@ -182,24 +183,12 @@ class ImageCanvas(FigureCanvasQTAgg): self._extent = extent self._img_shape = img.shape - if bad_color is not None: - cmap = (cmap if hasattr(cmap, "with_extremes") - else mpl.colormaps[cmap]).with_extremes(bad=bad_color) - - kw = ({"norm": norm} if norm is not None - else {"vmin": vmin, "vmax": vmax}) - im = self.ax.imshow( - img, aspect="auto", origin="upper", - extent=extent, cmap=cmap, interpolation="nearest", **kw, - ) - cb = self.figure.colorbar(im, ax=self.ax, fraction=0.046, pad=0.04, - ticks=cb_ticks) - if colorbar_label: - cb.set_label(colorbar_label) - - self.ax.set_xlabel(xlabel) - self.ax.set_ylabel(ylabel) - self.ax.set_title(title) + # Shared with the headless batch image-export worker (sras_render.py) + # so an exported PNG can never quietly drift from what this canvas + # shows on screen for the same settings. + draw_view_image(self.ax, self.figure, img, extent, cmap, vmin, vmax, + xlabel, ylabel, title, colorbar_label, cb_ticks, norm, + bad_color) # Re-draw the ROI (if any) on top of the fresh image so it persists # unchanged across mode / angle / channel switches. @@ -440,13 +429,22 @@ class WaveformCanvas(FigureCanvasQTAgg): def show_rf_waveform(self, sras: SrasFile, angle_idx: int, row_idx: int, frame_idx: int, - apply_bg_sub: bool = True): + apply_bg_sub: bool = True, + min_freq_mhz: float = 0.0): """CH1 RF: time-domain + FFT spectrum. If apply_bg_sub is True and sras.background is not None, the background waveform is overlaid on the time-domain plot and the FFT is computed on the subtracted signal. The unsubtracted FFT is also shown faintly for comparison. + + *min_freq_mhz* > 0 restricts the labeled peak to bins at or above + it — the same floor the image's peak search uses, so the label + explains the map pixel instead of contradicting it — and shades the + excluded band on the spectrum. The spectrum curves themselves stay + complete (they are the evidence for choosing the floor). The peak + can still legitimately differ from a padded or row-averaged map: + this panel is always a single waveform at natural resolution. """ data = sras.data[angle_idx] waveform = data[row_idx, CH1_IDX, frame_idx, :].astype(np.float32) @@ -487,7 +485,20 @@ class WaveformCanvas(FigureCanvasQTAgg): # FFT of the (possibly subtracted) waveform power_sub = np.abs(np.fft.rfft(waveform_plot)) ** 2 power_sub[0] = 0.0 - peak_mhz = f_mhz[int(np.argmax(power_sub))] + # First bin at or above the floor, exactly as the image peak search + # picks it (bin 0 always excluded). If the floor excludes every bin, + # fall back to the unrestricted peak rather than indexing past the + # end — the label is informational, not a mask. + lo = max(1, int(np.searchsorted(f_mhz, min_freq_mhz))) + if lo < len(power_sub): + peak_mhz = f_mhz[lo + int(np.argmax(power_sub[lo:]))] + else: + peak_mhz = f_mhz[int(np.argmax(power_sub))] + + if min_freq_mhz > 0.0: + self.ax_right.axvspan(0, min_freq_mhz, color="#888888", + alpha=0.15, zorder=0, + label=f"< {min_freq_mhz:g} MHz excluded") if bg is not None: # Also show the unsubtracted FFT for reference diff --git a/sras_viewer/common.py b/sras_viewer/common.py index 7c9b050..9c0fc50 100644 --- a/sras_viewer/common.py +++ b/sras_viewer/common.py @@ -6,7 +6,7 @@ from PyQt6.QtWidgets import ( QScrollArea, QSizePolicy, QVBoxLayout, QWidget, ) -from sras_format import CH1_IDX, CH3_IDX, CH4_IDX +from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, _axes_extent # noqa: F401 (re-exported) # --------------------------------------------------------------------------- # Display constants @@ -112,13 +112,6 @@ def _combo(items=(), *, min_chars: int = 10) -> QComboBox: return combo -def _axes_extent(x_axis, y_axis, dx: float, dy: float) -> list[float]: - """Matplotlib imshow extent with half-pixel margins, Y flipped so row 0 - renders at the top.""" - return [x_axis[0] - dx / 2, x_axis[-1] + dx / 2, - y_axis[-1] + dy / 2, y_axis[0] - dy / 2] - - def _wrap_label(text: str = "", css: str | None = None) -> QLabel: """A word-wrapped QLabel that reports its *wrapped* height to the layout. diff --git a/sras_viewer/main_window.py b/sras_viewer/main_window.py index 481ea63..709e916 100644 --- a/sras_viewer/main_window.py +++ b/sras_viewer/main_window.py @@ -1,6 +1,7 @@ """The SrasViewerWindow main window and application entry point.""" import sys +from collections import Counter from pathlib import Path import numpy as np @@ -23,7 +24,8 @@ from sras_format import ( _FALLBACK_YMULT_MV, _FALLBACK_YOFF_ADC, ) from sras_workers import ( - BatchCacheWorker, ComputeWorker, DcPrecomputeWorker, LoadWorker, + BatchCacheWorker, BatchExportImagesWorker, ComputeWorker, + DcPrecomputeWorker, LoadWorker, ) from .canvases import ImageCanvas, WaveformCanvas @@ -92,13 +94,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, 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. + # cached per (angle, threshold, min peak freq) 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, float], np.ndarray] = {} + self._fft_cache: dict[tuple[int, float, float], np.ndarray] = {} self._dc_generation: int = 0 # Angle alignment ("Fusion" menu) @@ -287,9 +289,12 @@ class SrasViewerWindow(QMainWindow): "is real, valid data. Raising this floor above that skirt forces\n" "the search to report the strongest peak that is plausibly real\n" "signal instead.\n" - "Only affects a fresh/live compute — it cannot change an image\n" - "already shown, or one already stored in this file's own cache\n" - "(use Batch Compute to regenerate those)." + "Raising the floor also re-masks images already shown or stored\n" + "in this file's cache: pixels whose stored peak falls below it\n" + "display as invalid. Lowering it below a stored cache's own\n" + "floor needs a recompute (bins below that floor were never\n" + "searched). Batch Compute FFT records the floor in the file and\n" + "re-resolves masked pixels to their true above-floor peak." ) self.spin_min_freq_mhz.editingFinished.connect(self._on_min_freq_changed) min_freq_form.addRow("Min peak freq:", self.spin_min_freq_mhz) @@ -518,6 +523,20 @@ class SrasViewerWindow(QMainWindow): self._batch_fft_rowavg_act.triggered.connect(self._on_batch_compute_row_avg) convert_menu.addAction(self._batch_fft_rowavg_act) + convert_menu.addSeparator() + self._batch_export_images_act = QAction( + "Batch Export View as &Images…", self) + self._batch_export_images_act.setStatusTip( + "Select .sras files and export the currently selected view " + "(channel, angle, threshold, colormap, etc.) as one PNG per " + "file, rendered the same way it is shown on screen. Read-only " + "— never modifies the source files. Aligned View is ignored " + "even if enabled, since an alignment result belongs to one " + "specific file's geometry.") + self._batch_export_images_act.triggered.connect( + self._on_batch_export_images) + convert_menu.addAction(self._batch_export_images_act) + # ------------------------------------------------------------------ # Drag-and-drop # ------------------------------------------------------------------ @@ -661,10 +680,13 @@ class SrasViewerWindow(QMainWindow): if s.precomputed_row_avg_n else "") pad_note = (f", pad {s.precomputed_pad_factor}x" if s.precomputed_pad_factor > 1 else "") + floor_note = (f", floor ≥ {s.precomputed_min_freq_mhz:g} MHz" + if s.precomputed_min_freq_mhz > 0 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"{avg_note if n_fft else ''}{pad_note if n_fft else ''} " + f"{avg_note if n_fft else ''}{pad_note if n_fft else ''}" + f"{floor_note if n_fft else ''} " "— display is instant for cached angles") notes += self._cache_mismatch_notes() elif s.version == 7: @@ -673,17 +695,24 @@ class SrasViewerWindow(QMainWindow): def _cache_mismatch_notes(self) -> list[str]: """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. + computed under different bg-sub/pad/min-freq settings than these + controls currently say. The display always shows the stored image + (re-masked for a raised floor) 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. + The min peak frequency floor gets two directions: a live floor + *below* the stored one is a genuine mismatch reason (the stored + search never looked below its floor), while a live floor *above* + the stored one is servable — pixels under it are shown as masked — + so that direction gets its own softer note. + Asks compute for the reasons rather than restating the accept rule, so a new provenance field can only be added in one place. """ @@ -691,15 +720,26 @@ class SrasViewerWindow(QMainWindow): if s is None or all(x is None for x in s.precomputed_freq_mhz): return [] + live_floor = self.spin_min_freq_mhz.value() + notes = [] reasons = compute.cache_mismatch_reasons( s, n_fft=self._current_n_fft(), apply_bg_sub=self.chk_bg_sub.isChecked(), - row_avg_n=s.precomputed_row_avg_n) - if not reasons: - return [] - 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."] + row_avg_n=s.precomputed_row_avg_n, + min_freq_mhz=live_floor) + if reasons: + notes.append( + "Note: current bg-sub/pad/min-freq controls differ from the " + "stored cache — " + "; ".join(reasons) + ". Shown as stored; " + "use Batch Compute to recompute with these settings.") + if round(live_floor * 1000) > round(s.precomputed_min_freq_mhz * 1000): + notes.append( + f"Note: Min peak freq ({live_floor:g} MHz) is above the " + f"stored cache's floor ({s.precomputed_min_freq_mhz:g} MHz) — " + f"stored pixels whose peak falls below {live_floor:g} MHz are " + "shown as masked; Batch Compute FFT re-resolves them above " + "the floor instead.") + return notes # ------------------------------------------------------------------ # Controls @@ -774,11 +814,13 @@ class SrasViewerWindow(QMainWindow): self._refresh_display() def _on_min_freq_changed(self): - # Like the DC threshold, this changes what the FFT itself produces — - # a genuine cache-key change — but unlike the threshold it can't be - # re-applied to an already-computed image; it only takes effect on a - # fresh compute (see _fft_cache_key / compute_rf_image's docstring). + # Like the DC threshold, this is a genuine cache-key change that can + # be cheaply re-applied to a stored image — tighten-only: raising the + # floor masks stored pixels below it, while lowering it below the + # stored cache's own floor can only be answered by a fresh compute + # (see cached_rf_image / cache_mismatch_reasons). if self._is_fft_mode(): + self._update_scan_info_labels() self._refresh_display() def _on_autoscale_toggled(self, checked: bool): @@ -1072,11 +1114,10 @@ class SrasViewerWindow(QMainWindow): 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 and min-freq stay in the key so revisiting a combination - already computed this session is instant — even though only - threshold can be cheaply re-applied to a stored image; a min-freq - change against a stored image still falls through to - _stored_fft_image, which ignores it (see _on_min_freq_changed).""" + Threshold and min-freq are both in the key because both are cheaply + re-applied when a stored image is served (_stored_fft_image takes + them live), so the cached value genuinely reflects every component + of its key and revisiting a combination is instant.""" return (angle_idx, self.spin_threshold_mv.value(), self.spin_min_freq_mhz.value()) @@ -1155,10 +1196,13 @@ class SrasViewerWindow(QMainWindow): 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 — or the min - peak frequency floor — which are baked irreversibly into the stored - numbers. + The DC threshold and the min peak frequency floor are taken live: + re-masking a stored image against either is free, unlike + bg-sub/pad/row-averaging, which are baked irreversibly into the + stored numbers. The floor is tighten-only, though — a live floor + *below* the stored cache's own floor is the one case where a stored + image genuinely can't answer (cached_rf_image refuses it), and the + caller falls through to a real compute. 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 @@ -1175,7 +1219,8 @@ class SrasViewerWindow(QMainWindow): 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) + allow_dc_recompute=False, + min_freq_mhz=self.spin_min_freq_mhz.value()) def _cached_value_image(self, angle_idx: int, ch_idx: int) -> np.ndarray | None: """The already-available (no compute) image for (angle, channel): @@ -1289,22 +1334,27 @@ class SrasViewerWindow(QMainWindow): dy = float(y_axis[1] - y_axis[0]) if len(y_axis) > 1 else 1.0 extent = _axes_extent(x_axis, y_axis, dx, dy) - # Masked-out (below-threshold) pixels are stored as a plain 0, the - # same value a real but low-frequency pixel can legitimately have - - # the two are indistinguishable once both land near the bottom of a - # linear colormap. Pull masked pixels out to NaN (drawn in a - # distinct highlight color, excluded from the auto-scale range) so a - # real low-frequency pixel keeps its own true shade instead of - # disappearing into the same black as "no data". + # Masked-out pixels are stored as a plain 0, the same value near + # the bottom of a linear colormap as a real low-frequency pixel - + # the two are indistinguishable there. Pull masked pixels out to + # NaN (drawn in a distinct highlight color, excluded from the + # auto-scale range) so a real pixel keeps its own true shade + # instead of disappearing into the same black as "no data". In an + # FFT-derived mode a value of exactly 0 is itself the "no valid + # peak" sentinel (DC-masked, below the min-freq floor, or an empty + # spectrum — bin 0 is always excluded, so no genuine peak is ever + # 0), so those pixels are masked by value too; that covers + # floor-masked pixels even when no DC4 image is cached yet. highlight_masked = (ch_idx in CH1_DERIVED_MODES and self.chk_highlight_masked.isChecked()) - mask_valid = (self._dc_validity_mask(angle_idx, aligned) - if highlight_masked else None) - if mask_valid is not None and mask_valid.shape == display_img.shape: + if highlight_masked: + mask_valid = self._dc_validity_mask(angle_idx, aligned) + if mask_valid is not None and mask_valid.shape != display_img.shape: + mask_valid = None display_img = display_img.astype(np.float32, copy=True) - display_img[~mask_valid] = np.nan - else: - mask_valid = None + if mask_valid is not None: + display_img[~mask_valid] = np.nan + display_img[display_img == 0.0] = np.nan if self.chk_auto.isChecked(): vmin, vmax = float(np.nanmin(display_img)), float(np.nanmax(display_img)) @@ -1333,7 +1383,7 @@ class SrasViewerWindow(QMainWindow): vmin=vmin, vmax=vmax, xlabel="X (mm)", ylabel="Y (mm)", title=title, colorbar_label=colorbar_label, - bad_color=_MASKED_HIGHLIGHT_COLOR if mask_valid is not None else None, + bad_color=_MASKED_HIGHLIGHT_COLOR if highlight_masked else None, ) self.statusBar().showMessage( f"{s.path.name} | {ch_label} @ {angle_deg:.1f}° " @@ -1493,7 +1543,8 @@ class SrasViewerWindow(QMainWindow): if self._current_ch in CH1_DERIVED_MODES: self.wave_canvas.show_rf_waveform( self._sras, angle_idx, row_idx, frame_idx, - apply_bg_sub=self.chk_bg_sub.isChecked()) + apply_bg_sub=self.chk_bg_sub.isChecked(), + min_freq_mhz=self.spin_min_freq_mhz.value()) else: self.wave_canvas.show_dc_waveform( self._sras, angle_idx, self._current_ch, row_idx, frame_idx) @@ -1545,7 +1596,8 @@ class SrasViewerWindow(QMainWindow): # Cache the FFT at the pad the viewer is actually displaying at, # otherwise the batch stores images this window can never use. worker = BatchCacheWorker(paths, mode, self.chk_bg_sub.isChecked(), - pad_factor=self._fft_pad_factor) + pad_factor=self._fft_pad_factor, + min_freq_mhz=self.spin_min_freq_mhz.value()) started = self._run_worker( Jobs.BATCH, worker, connect=( @@ -1558,9 +1610,7 @@ class SrasViewerWindow(QMainWindow): if not started: return # a second trigger snuck in while the file dialog was open - self._batch_dc_act.setEnabled(False) - self._batch_fft_act.setEnabled(False) - self._batch_fft_rowavg_act.setEnabled(False) + self._set_batch_actions_enabled(False) self._show_progress( Jobs.BATCH, f"Batch computing {label} for {len(paths)} file(s)…", maximum=100) @@ -1587,7 +1637,8 @@ class SrasViewerWindow(QMainWindow): self._batch_errors = [] worker = BatchCacheWorker(paths, "fft_rowavg", self.chk_bg_sub.isChecked(), dc_threshold_mv=threshold_mv, row_avg_n=n, - pad_factor=self._fft_pad_factor) + pad_factor=self._fft_pad_factor, + min_freq_mhz=self.spin_min_freq_mhz.value()) started = self._run_worker( Jobs.BATCH, worker, connect=( @@ -1600,9 +1651,7 @@ class SrasViewerWindow(QMainWindow): if not started: return # a second trigger snuck in while a dialog was open - self._batch_dc_act.setEnabled(False) - self._batch_fft_act.setEnabled(False) - self._batch_fft_rowavg_act.setEnabled(False) + self._set_batch_actions_enabled(False) self._show_progress( Jobs.BATCH, f"Batch computing row-averaged FFT (n={n}, threshold={threshold_mv:.1f} mV) " @@ -1633,9 +1682,100 @@ class SrasViewerWindow(QMainWindow): self._load_file(str(self._sras.path)) def _after_batch(self): - self._batch_dc_act.setEnabled(True) - self._batch_fft_act.setEnabled(True) - self._batch_fft_rowavg_act.setEnabled(True) + self._set_batch_actions_enabled(True) + + def _set_batch_actions_enabled(self, enabled: bool): + """All four Convert-menu batch actions share one Jobs.BATCH slot; + grey out every sibling while one runs, rather than leaving one + clickable but silently no-op'd by the busy guard.""" + for act in (self._batch_dc_act, self._batch_fft_act, + self._batch_fft_rowavg_act, self._batch_export_images_act): + act.setEnabled(enabled) + + # ------------------------------------------------------------------ + # Batch export view as images + # ------------------------------------------------------------------ + + def _on_batch_export_images(self): + if self._job_running(Jobs.BATCH): + return + paths, _ = QFileDialog.getOpenFileNames( + self, "Select .sras files to export the current view from", "", + "SRAS files (*.sras);;All files (*)") + if not paths: + return + out_dir = QFileDialog.getExistingDirectory( + self, "Select output folder for exported images") + if not out_dir: + return + + angle_idx = self.spin_angle.value() + ch_idx = self.combo_channel.currentIndex() + is_fft_mode = ch_idx in CH1_DERIVED_MODES + mode_str, _unit, colorbar_label = _CHANNEL_DISPLAY[ch_idx] + + self._batch_errors = [] + self._batch_export_names = [] + worker = BatchExportImagesWorker( + paths, + out_dir=out_dir, angle_idx=angle_idx, ch_idx=ch_idx, + is_fft_mode=is_fft_mode, is_velocity=(ch_idx == VELOCITY_MODE_IDX), + dc_threshold_mv=self.spin_threshold_mv.value(), + apply_bg_sub=self.chk_bg_sub.isChecked(), + pad_factor=self._fft_pad_factor, + min_freq_mhz=self.spin_min_freq_mhz.value(), + grating_um=self.spin_grating_um.value(), + cmap=self.combo_cmap.currentText(), + auto_scale=self.chk_auto.isChecked(), + vmin=self.spin_vmin.value(), vmax=self.spin_vmax.value(), + highlight_masked=self.chk_highlight_masked.isChecked(), + mode_str=mode_str, colorbar_label=colorbar_label, + mask_color=_MASKED_HIGHLIGHT_COLOR, + ) + started = self._run_worker( + Jobs.BATCH, worker, + connect=( + ("progress", lambda pct: self._set_progress(Jobs.BATCH, pct)), + ("file_done", self._on_batch_export_file_done), + ("finished", + lambda p=paths, d=out_dir: self._on_batch_export_finished(p, d)), + ), + on_done=self._after_batch, + ) + if not started: + return # a second trigger snuck in while a dialog was open + + self._set_batch_actions_enabled(False) + self._show_progress( + Jobs.BATCH, f"Exporting images for {len(paths)} file(s)…", + maximum=100) + + def _on_batch_export_file_done(self, path: str, err: str, out_name: str): + if err: + self._batch_errors.append(f"{Path(path).name} — {err}") + else: + self._batch_export_names.append(out_name) + self._show_progress(Jobs.BATCH, f"Exported {Path(path).name}…") + + def _on_batch_export_finished(self, paths: list[str], out_dir: str): + self._close_progress(Jobs.BATCH) + + n_total = len(paths) + n_failed = len(self._batch_errors) + n_ok = n_total - n_failed + summary = (f"Batch export: {n_ok}/{n_total} image(s) written to " + f"{Path(out_dir).name}") + if n_failed: + summary += f", {n_failed} failed: {'; '.join(self._batch_errors)}" + dupes = sum(1 for _name, count in Counter(self._batch_export_names).items() + if count > 1) + if dupes: + summary += (f" | {dupes} filename collision(s) — later file(s) " + "overwrote earlier ones with the same output name") + self.statusBar().showMessage(summary) + self._batch_errors = [] + self._batch_export_names = [] + # No reload: unlike Batch Compute, export never touches the source files. # ------------------------------------------------------------------ # Fusion: angle alignment diff --git a/sras_workers.py b/sras_workers.py index 0d8f05d..e26f17a 100644 --- a/sras_workers.py +++ b/sras_workers.py @@ -18,6 +18,7 @@ import sras_compute as compute from sras_align_export import write_aligned_sras from sras_compute import cache_file, compute_rf_image, dc_image_mv from sras_format import CH3_IDX, CH4_IDX, SrasFile +from sras_render import export_view_image # Concurrency caps. Batch conversion runs one process per file, and each of # those processes threads internally, so the two must be divided rather than @@ -197,7 +198,9 @@ class BatchCacheWorker(QObject): Both FFT modes cache at *pad_factor*, which the caller sets from the viewer's own padding — a cache stored at a pad the user is not viewing - at is one the display can never use. + at is one the display can never use. *min_freq_mhz* travels the same + way: the viewer's live min-peak-freq floor, recorded in the store as + provenance so a reader knows which bins the peak search considered. 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 @@ -211,7 +214,7 @@ class BatchCacheWorker(QObject): def __init__(self, paths: list[str], mode: str, apply_bg_sub: bool, dc_threshold_mv: float | None = None, row_avg_n: int = 0, - pad_factor: int = 1): + pad_factor: int = 1, min_freq_mhz: float = 0.0): super().__init__() self._paths = paths self._mode = mode @@ -219,6 +222,7 @@ class BatchCacheWorker(QObject): self._dc_threshold = dc_threshold_mv self._row_avg_n = row_avg_n self._pad_factor = pad_factor + self._min_freq_mhz = min_freq_mhz def _report(self, path: str, err: str, done: int, total: int): self.file_done.emit(path, err) @@ -246,7 +250,8 @@ class BatchCacheWorker(QObject): per_proc_workers, pad_factor=self._pad_factor, dc_threshold_mv=self._dc_threshold, - row_avg_n=self._row_avg_n): p + row_avg_n=self._row_avg_n, + min_freq_mhz=self._min_freq_mhz): p for p in paths } for fut in as_completed(futures): @@ -272,7 +277,8 @@ class BatchCacheWorker(QObject): compute.default_max_workers(), pad_factor=self._pad_factor, dc_threshold_mv=self._dc_threshold, - row_avg_n=self._row_avg_n) + row_avg_n=self._row_avg_n, + min_freq_mhz=self._min_freq_mhz) except Exception as exc: err = str(exc) done += 1 @@ -310,6 +316,112 @@ class BatchCacheWorker(QObject): self.finished.emit() +class BatchExportImagesWorker(QObject): + """Renders the view settings captured by the caller at trigger time + (angle/channel/threshold/etc.) to one PNG per file in *paths*, via + sras_render.export_view_image. + + Same process-pool-with-inline-fallback strategy as BatchCacheWorker + above (same _BATCH_MAX_PROCS / _BATCH_POOL_MIN_BYTES thresholds): an + FFT-derived (CH1/Velocity) view is exactly the same expensive per-file + compute Batch Compute FFT already parallelizes this way. Never mutates + *paths* — each file is opened read-only — so unlike BatchCacheWorker + there is no version gate. + + Emits progress(int) (0-100 by files completed), file_done(str, str, str) + (path, error message or "", output filename this file targeted — set + even on most failures so the caller can flag same-stem collisions across + the batch without any cross-process bookkeeping), and finished(). + """ + progress = pyqtSignal(int) + file_done = pyqtSignal(str, str, str) + finished = pyqtSignal() + + def __init__(self, paths: list[str], **render_kwargs): + """*render_kwargs* is exactly export_view_image's keyword-only + settings (out_dir, angle_idx, ch_idx, is_fft_mode, is_velocity, + dc_threshold_mv, apply_bg_sub, pad_factor, min_freq_mhz, grating_um, + cmap, auto_scale, vmin, vmax, highlight_masked, mode_str, + colorbar_label, mask_color) — bundled rather than repeated as + positional params across __init__/_run_pooled/_run_inline.""" + super().__init__() + self._paths = paths + self._kw = render_kwargs + + def _report(self, path: str, err: str, out_name: str, done: int, total: int): + self.file_done.emit(path, err, out_name) + self.progress.emit(int(done / max(1, total) * 100)) + + def _run_pooled(self, paths: list[str], n_procs: int) -> list[str]: + """Same contract as BatchCacheWorker._run_pooled: returns the paths + that never got a real answer because the pool itself died, so the + caller can retry them in-process.""" + per_proc_workers = max(1, (os.cpu_count() or 4) // n_procs) + unresolved: list[str] = [] + done = 0 + + with ProcessPoolExecutor(max_workers=n_procs) as executor: + futures = { + executor.submit(export_view_image, p, + max_workers=per_proc_workers, **self._kw): p + for p in paths + } + for fut in as_completed(futures): + path = futures[fut] + try: + err, out_name = fut.result() + except BrokenProcessPool: + unresolved.append(path) + continue + except Exception as exc: + err, out_name = str(exc), "" + done += 1 + self._report(path, err, out_name, done, len(paths)) + + return unresolved + + def _run_inline(self, paths: list[str], done: int, total: int): + for path in paths: + try: + err, out_name = export_view_image( + path, max_workers=compute.default_max_workers(), **self._kw) + except Exception as exc: + err, out_name = str(exc), "" + done += 1 + self._report(path, err, out_name, done, total) + + def _worth_pooling(self, paths: list[str]) -> bool: + if len(paths) < 2: + return False + total = 0 + for p in paths: + try: + total += os.path.getsize(p) + except OSError: + pass # unreadable files are reported by export_view_image + return total >= _BATCH_POOL_MIN_BYTES + + def run(self): + paths = self._paths + n_procs = max(1, min(_BATCH_MAX_PROCS, len(paths))) + + if not self._worth_pooling(paths): + self._run_inline(paths, 0, len(paths)) + self.finished.emit() + return + + try: + unresolved = self._run_pooled(paths, n_procs) + except Exception: + # The pool could not be created or collapsed wholesale. + unresolved = list(paths) + + if unresolved: + self._run_inline(unresolved, len(paths) - len(unresolved), len(paths)) + + self.finished.emit() + + class Ch4MaskWorker(_PooledWorker): """Fetches each requested angle's CH4 (Bias B) DC image in mV, for the alignment wizard's initial threshold-mask stack. diff --git a/tests/test_batch_export.py b/tests/test_batch_export.py new file mode 100644 index 0000000..22f8d7d --- /dev/null +++ b/tests/test_batch_export.py @@ -0,0 +1,426 @@ +"""Batch Export View as Images: does the exported PNG actually match what +the live view would show, and does the batch dispatch (menu action -> +worker -> per-file render) behave like Batch Compute's proven pattern? + +sras_render.export_view_image is tested directly (no Qt) for the plumbing +that decides *what* gets rendered -- pad_factor derived per file, masked- +pixel NaN fill, per-file auto-scale, velocity scaling -- via a +draw_view_image spy rather than pixel-diffing PNGs, the same "spy on the +seam, don't inspect the rendered artifact" approach test_highlight_masked_ +pixels (tests/test_gui.py) uses for the live canvas. + +The GUI-dispatch half drives SrasViewerWindow._on_batch_export_images() +end-to-end with patched file dialogs, the same shape as +test_stored_cache.py's test_viewer_batch_row_average_dispatch. +""" + +from pathlib import Path +from unittest.mock import patch + +import numpy as np +import pytest +from PyQt6.QtCore import QEventLoop, QTimer +from PyQt6.QtWidgets import QApplication + +import sras_render +from sras_compute import compute_rf_image, dc_image_mv +from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, CH_NAMES, SrasFile +from sras_render import export_view_image +from sras_viewer import SrasViewerWindow, VELOCITY_MODE_IDX +from sras_workers import BatchExportImagesWorker +import tools.make_test_sras as gen + +_THRESHOLD_MV = 50.0 + + +def pump(ms: int = 200): + loop = QEventLoop() + QTimer.singleShot(ms, loop.quit) + loop.exec() + + +def wait_until(pred, timeout_ms: int = 20000, step: int = 100) -> bool: + waited = 0 + while waited < timeout_ms: + if pred(): + return True + pump(step) + waited += step + return pred() + + +# --------------------------------------------------------------------------- +# sras_render.export_view_image -- pure function, no Qt +# --------------------------------------------------------------------------- + +_DEFAULT_KW = dict( + is_fft_mode=False, is_velocity=False, dc_threshold_mv=_THRESHOLD_MV, + apply_bg_sub=True, pad_factor=1, min_freq_mhz=0.0, grating_um=1.0, + cmap="viridis", auto_scale=True, vmin=0.0, vmax=1.0, + highlight_masked=False, mode_str="DC", colorbar_label="mV", +) + + +def _kw(**overrides): + kw = dict(_DEFAULT_KW) + kw.update(overrides) + return kw + + +def _spy_draw(monkeypatch): + """Patches sras_render.draw_view_image to record the image array and + vmin/vmax/bad_color it was called with, then delegates to the real + implementation so the PNG is still written -- lets a test check *what* + export_view_image computed without depending on rendered PNG pixels.""" + orig = sras_render.draw_view_image + captured = {} + + def spy(ax, fig, img, extent, cmap, vmin, vmax, xlabel, ylabel, title, + colorbar_label="", cb_ticks=None, norm=None, bad_color=None): + captured["img"] = np.array(img, copy=True) + captured["vmin"] = vmin + captured["vmax"] = vmax + captured["bad_color"] = bad_color + return orig(ax, fig, img, extent, cmap, vmin, vmax, xlabel, ylabel, + title, colorbar_label, cb_ticks, norm, bad_color) + + monkeypatch.setattr(sras_render, "draw_view_image", spy) + return captured + + +def test_export_dc_channel_writes_png(tmp_path): + path = tmp_path / "dc.sras" + gen.write(path, n_angles=2, seed=1, samples_per_frame=64) + out_dir = tmp_path / "out" + out_dir.mkdir() + + err, out_name = export_view_image( + str(path), out_dir=str(out_dir), angle_idx=0, ch_idx=CH4_IDX, **_kw()) + assert err == "" + assert out_name == f"dc_angle0_{CH_NAMES[CH4_IDX]}.png" + out_path = out_dir / out_name + assert out_path.exists() and out_path.stat().st_size > 0 + assert out_path.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n", "not a valid PNG" + + +def test_export_out_of_range_angle(tmp_path): + path = tmp_path / "short.sras" + gen.write(path, n_angles=2, seed=2, samples_per_frame=64) + out_dir = tmp_path / "out" + out_dir.mkdir() + + err, out_name = export_view_image( + str(path), out_dir=str(out_dir), angle_idx=5, ch_idx=CH4_IDX, **_kw()) + assert err != "" and "2" in err, "error should mention the file's actual angle count" + assert out_name == "" + assert list(out_dir.iterdir()) == [], "no file written for a failed export" + + +def test_export_fft_mode_matches_compute_rf_image(tmp_path, monkeypatch): + path = tmp_path / "fft.sras" + gen.write(path, n_angles=1, seed=3, samples_per_frame=128) + out_dir = tmp_path / "out" + out_dir.mkdir() + + captured = _spy_draw(monkeypatch) + err, _ = export_view_image( + str(path), out_dir=str(out_dir), angle_idx=0, ch_idx=CH1_IDX, + **_kw(is_fft_mode=True)) + assert err == "" + + sras = SrasFile(str(path)) + expected = compute_rf_image(sras, 0, dc_threshold_mv=_THRESHOLD_MV, + apply_bg_sub=True, n_fft=None, min_freq_mhz=0.0) + assert np.array_equal(captured["img"], expected) + + +def test_export_velocity_scales_frequency(tmp_path, monkeypatch): + path = tmp_path / "vel.sras" + gen.write(path, n_angles=1, seed=4, samples_per_frame=128) + out_dir = tmp_path / "out" + out_dir.mkdir() + + captured = _spy_draw(monkeypatch) + grating_um = 3.5 + err, _ = export_view_image( + str(path), out_dir=str(out_dir), angle_idx=0, ch_idx=VELOCITY_MODE_IDX, + **_kw(is_fft_mode=True, is_velocity=True, grating_um=grating_um)) + assert err == "" + + sras = SrasFile(str(path)) + freq = compute_rf_image(sras, 0, dc_threshold_mv=_THRESHOLD_MV, + apply_bg_sub=True, n_fft=None, min_freq_mhz=0.0) + assert np.array_equal(captured["img"], freq * grating_um) + + +def test_pad_factor_uses_each_files_own_samples_per_frame(tmp_path, monkeypatch): + """n_fft must be derived per file from that file's own samples_per_frame, + never a value carried over from whichever file the caller had open -- + otherwise every file but one in a batch gets silently mis-padded.""" + orig_draw = sras_render.draw_view_image + out_dir = tmp_path / "out" + out_dir.mkdir() + + for i, spf in enumerate((64, 256)): + path = tmp_path / f"pad_{spf}.sras" + gen.write(path, n_angles=1, seed=5 + i, samples_per_frame=spf) + + captured = {} + + def spy(ax, fig, img, *a, __c=captured, **kw): + __c["img"] = np.array(img, copy=True) + return orig_draw(ax, fig, img, *a, **kw) + + monkeypatch.setattr(sras_render, "draw_view_image", spy) + + err, _ = export_view_image( + str(path), out_dir=str(out_dir), angle_idx=0, ch_idx=CH1_IDX, + **_kw(is_fft_mode=True, pad_factor=4)) + assert err == "" + + sras = SrasFile(str(path)) + expected = compute_rf_image(sras, 0, dc_threshold_mv=_THRESHOLD_MV, + apply_bg_sub=True, n_fft=spf * 4, + min_freq_mhz=0.0) + assert np.array_equal(captured["img"], expected), \ + f"samples_per_frame={spf}: n_fft must use this file's own value" + + +def test_highlight_masked_sets_nan_and_bad_color(tmp_path, monkeypatch): + path = tmp_path / "mask.sras" + gen.write(path, n_angles=1, seed=7, samples_per_frame=128) + out_dir = tmp_path / "out" + out_dir.mkdir() + + sras = SrasFile(str(path)) + dc4 = dc_image_mv(sras, 0, CH4_IDX) + threshold = float(np.median(dc4)) + expect_masked = dc4 < threshold + assert expect_masked.any() and not expect_masked.all(), \ + "fixture threshold should mask some but not all pixels" + + captured = _spy_draw(monkeypatch) + err, _ = export_view_image( + str(path), out_dir=str(out_dir), angle_idx=0, ch_idx=CH1_IDX, + **_kw(is_fft_mode=True, dc_threshold_mv=threshold, + highlight_masked=True, mask_color="magenta")) + assert err == "" + assert captured["bad_color"] == "magenta" + # The export masks by value too (0 == the "no valid peak" sentinel, same + # rule as the viewer's _redraw_image); on this fixture every + # above-threshold pixel has a nonzero peak, so the value mask coincides + # with the DC mask and the NaN set is exactly expect_masked. + assert np.array_equal(np.isnan(captured["img"]), expect_masked) + valid_vals = captured["img"][~expect_masked] + assert not np.isnan(valid_vals).any() and (valid_vals != 0).all(), \ + "fixture precondition: every valid pixel has a nonzero peak" + + captured2 = _spy_draw(monkeypatch) + err, _ = export_view_image( + str(path), out_dir=str(out_dir), angle_idx=0, ch_idx=CH1_IDX, + **_kw(is_fft_mode=True, dc_threshold_mv=threshold, highlight_masked=False)) + assert err == "" + assert captured2["bad_color"] is None + assert not np.isnan(captured2["img"]).any() + + +def test_auto_scale_uses_per_file_min_max(tmp_path, monkeypatch): + path = tmp_path / "scale.sras" + gen.write(path, n_angles=1, seed=8, samples_per_frame=64) + out_dir = tmp_path / "out" + out_dir.mkdir() + + captured = _spy_draw(monkeypatch) + err, _ = export_view_image( + str(path), out_dir=str(out_dir), angle_idx=0, ch_idx=CH4_IDX, + **_kw(auto_scale=True)) + assert err == "" + img = captured["img"] + assert captured["vmin"] == pytest.approx(float(np.nanmin(img))) + assert captured["vmax"] == pytest.approx(float(np.nanmax(img))) + + captured2 = _spy_draw(monkeypatch) + err, _ = export_view_image( + str(path), out_dir=str(out_dir), angle_idx=0, ch_idx=CH4_IDX, + **_kw(auto_scale=False, vmin=-5.0, vmax=5.0)) + assert err == "" + assert captured2["vmin"] == -5.0 + assert captured2["vmax"] == 5.0 + + +# --------------------------------------------------------------------------- +# GUI dispatch: SrasViewerWindow._on_batch_export_images end-to-end +# --------------------------------------------------------------------------- + +def _make_window(path) -> SrasViewerWindow: + app = QApplication.instance() or QApplication([]) # noqa: F841 + win = SrasViewerWindow() + win.show() + win._load_file(str(path)) + assert wait_until(lambda: win._sras is not None), "file loaded" + assert wait_until(lambda: all((a, CH4_IDX) in win._dc_cache + for a in range(win._sras.n_angles))), \ + "DC precompute finished" + return win + + +def test_batch_export_images_writes_one_png_per_file(tmp_path): + path = tmp_path / "src.sras" + gen.write(path, n_angles=2, seed=10, samples_per_frame=64) + paths = [str(path)] + for i in range(2): + p2 = tmp_path / f"other{i}.sras" + gen.write(p2, n_angles=2, seed=20 + i, samples_per_frame=64) + paths.append(str(p2)) + out_dir = tmp_path / "images" + out_dir.mkdir() + + win = _make_window(path) + try: + with patch("sras_viewer.main_window.QFileDialog.getOpenFileNames", + return_value=(paths, "")), \ + patch("sras_viewer.main_window.QFileDialog.getExistingDirectory", + return_value=str(out_dir)): + win._on_batch_export_images() + assert wait_until(lambda: not win._job_running("batch"), 60000), "batch ran" + + angle = win.spin_angle.value() + ch_name = CH_NAMES[win.combo_channel.currentIndex()] + expected_names = {f"{Path(p).stem}_angle{angle}_{ch_name}.png" for p in paths} + actual_names = {p.name for p in out_dir.iterdir()} + assert actual_names == expected_names + assert "Batch export: 3/3 image(s)" in win.statusBar().currentMessage() + finally: + win.close() + pump(200) + + +def test_batch_export_out_of_range_angle_reports_error_continues(tmp_path): + good_path = tmp_path / "good.sras" + short_path = tmp_path / "short.sras" + gen.write(good_path, n_angles=3, seed=30, samples_per_frame=64) + gen.write(short_path, n_angles=1, seed=31, samples_per_frame=64) + out_dir = tmp_path / "images" + out_dir.mkdir() + + win = _make_window(good_path) + try: + win.spin_angle.setValue(2) # valid for good_path, out of range for short_path + with patch("sras_viewer.main_window.QFileDialog.getOpenFileNames", + return_value=([str(good_path), str(short_path)], "")), \ + patch("sras_viewer.main_window.QFileDialog.getExistingDirectory", + return_value=str(out_dir)): + win._on_batch_export_images() + assert wait_until(lambda: not win._job_running("batch"), 60000), "batch ran" + + msg = win.statusBar().currentMessage() + assert "Batch export: 1/2 image(s)" in msg, msg + assert "1 failed" in msg, msg + assert len(list(out_dir.iterdir())) == 1, \ + "the batch must not abort — the good file still exports" + finally: + win.close() + pump(200) + + +def test_batch_export_busy_guard_skips_dialogs(tmp_path, monkeypatch): + path = tmp_path / "busy.sras" + gen.write(path, n_angles=1, seed=40, samples_per_frame=64) + win = _make_window(path) + try: + monkeypatch.setattr(win, "_job_running", lambda key: True) + with patch("sras_viewer.main_window.QFileDialog.getOpenFileNames") as mock_dlg: + win._on_batch_export_images() + assert mock_dlg.call_count == 0, \ + "the Jobs.BATCH busy guard must return before opening any dialog" + finally: + win.close() + pump(200) + + +def test_batch_export_ignores_aligned_view_toggle(tmp_path): + """Aligned View is geometry specific to whichever single file the + Alignment Wizard last ran against and cannot be meaningfully applied + across a batch of different files -- _on_batch_export_images must not + read chk_aligned_view / self._alignment_result at all, regardless of + what's checked in the live view.""" + path = tmp_path / "aligned.sras" + gen.write(path, n_angles=1, seed=41, samples_per_frame=64) + out_dir = tmp_path / "images" + out_dir.mkdir() + + win = _make_window(path) + try: + captured_kwargs = [] + orig_init = BatchExportImagesWorker.__init__ + + def spy_init(self, paths, **kw): + captured_kwargs.append(kw) + return orig_init(self, paths, **kw) + + win.chk_aligned_view.setChecked(True) + with patch.object(BatchExportImagesWorker, "__init__", spy_init), \ + patch("sras_viewer.main_window.QFileDialog.getOpenFileNames", + return_value=([str(path)], "")), \ + patch("sras_viewer.main_window.QFileDialog.getExistingDirectory", + return_value=str(out_dir)): + win._on_batch_export_images() + assert wait_until(lambda: not win._job_running("batch"), 60000), "batch ran" + + assert len(captured_kwargs) == 1 + assert not any("align" in k.lower() for k in captured_kwargs[0]), \ + captured_kwargs[0].keys() + finally: + win.close() + pump(200) + + +def test_batch_export_filename_collision_note(tmp_path): + dir_a, dir_b = tmp_path / "dir_a", tmp_path / "dir_b" + dir_a.mkdir() + dir_b.mkdir() + path_a, path_b = dir_a / "dup.sras", dir_b / "dup.sras" + gen.write(path_a, n_angles=1, seed=50, samples_per_frame=64) + gen.write(path_b, n_angles=1, seed=51, samples_per_frame=64) + out_dir = tmp_path / "images" + out_dir.mkdir() + + win = _make_window(path_a) + try: + with patch("sras_viewer.main_window.QFileDialog.getOpenFileNames", + return_value=([str(path_a), str(path_b)], "")), \ + patch("sras_viewer.main_window.QFileDialog.getExistingDirectory", + return_value=str(out_dir)): + win._on_batch_export_images() + assert wait_until(lambda: not win._job_running("batch"), 60000), "batch ran" + + msg = win.statusBar().currentMessage() + assert "Batch export: 2/2 image(s)" in msg, msg + assert "collision" in msg, msg + assert len(list(out_dir.iterdir())) == 1, \ + "same-stem inputs silently overwrite to one output file" + finally: + win.close() + pump(200) + + +def test_batch_export_does_not_modify_source_files(tmp_path): + path = tmp_path / "untouched.sras" + gen.write(path, n_angles=1, seed=60, samples_per_frame=64) + before = path.read_bytes() + out_dir = tmp_path / "images" + out_dir.mkdir() + + win = _make_window(path) + try: + with patch("sras_viewer.main_window.QFileDialog.getOpenFileNames", + return_value=([str(path)], "")), \ + patch("sras_viewer.main_window.QFileDialog.getExistingDirectory", + return_value=str(out_dir)): + win._on_batch_export_images() + assert wait_until(lambda: not win._job_running("batch"), 60000), "batch ran" + finally: + win.close() + pump(200) + + assert path.read_bytes() == before, "export must never write to the source file" diff --git a/tests/test_gui.py b/tests/test_gui.py index b27a6ca..1ae89fb 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -246,8 +246,16 @@ def test_highlight_masked_pixels(ctx): win._redraw_image(win._current_image) shown, bad_color = calls[-1] assert bad_color is not None, "highlight color set while checkbox is on" - assert np.array_equal(np.isnan(shown), expect_masked), \ - "NaN exactly where DC4 is below threshold, nowhere else" + # The highlight masks by value too: in an FFT mode, exactly 0 is the + # "no valid peak" sentinel (DC-masked, below the min-freq floor, or an + # empty spectrum), so the NaN set is the union of the DC mask and the + # zero-valued pixels. On this fixture every above-threshold pixel has a + # nonzero peak, so the union equals the DC mask alone. + expect_nan = expect_masked | (win._current_image == 0) + assert np.array_equal(np.isnan(shown), expect_nan), \ + "NaN where DC4 is below threshold or the value-0 sentinel, nowhere else" + assert np.array_equal(expect_nan, expect_masked), \ + "fixture precondition: every valid pixel has a nonzero peak" win.chk_highlight_masked.setChecked(False) calls.clear() diff --git a/tests/test_stored_cache.py b/tests/test_stored_cache.py index b73adc9..d9b71c6 100644 --- a/tests/test_stored_cache.py +++ b/tests/test_stored_cache.py @@ -180,12 +180,18 @@ def test_cach_v1_reads_as_natural_resolution(tmp_path): # Rewrite the tail as a genuine CACH v1 block (old header, no pad field). v2 = SrasFile(str(path)) freq, entries = v2.precomputed_freq_mhz, list(range(v2.n_angles)) + tail_offset = v2._cache_tail_offset() payload = struct.pack(fmt.CACH_HDR_FMT, fmt.CACH_MAGIC, 1, fmt.CACH_FLAG_FFT) payload += struct.pack(fmt.SFFT_HDR_FMT_V1, fmt.SFFT_MAGIC, fmt.SFFT_FLAG_BG_SUB, len(entries)) for a in entries: payload += struct.pack(">H", a) + freq[a].astype(">f4").tobytes() - head = path.read_bytes()[:v2._cache_tail_offset()] + # Windows refuses to truncate a file with a live mapping (write_bytes + # opens 'wb'), and every SrasFile holds its waveform memmaps for life — + # drop the instance first. The parsed freq arrays are plain copies and + # stay usable. + del v2 + head = path.read_bytes()[:tail_offset] path.write_bytes(head + payload) v1 = SrasFile(str(path)) @@ -493,12 +499,16 @@ def test_cach_v1_backward_compat_defaults_row_avg_n_zero(tmp_path): v2 = SrasFile(str(path)) freq, entries = v2.precomputed_freq_mhz, list(range(v2.n_angles)) + tail_offset = v2._cache_tail_offset() payload = struct.pack(fmt.CACH_HDR_FMT, fmt.CACH_MAGIC, 1, fmt.CACH_FLAG_FFT) payload += struct.pack(fmt.SFFT_HDR_FMT_V1, fmt.SFFT_MAGIC, fmt.SFFT_FLAG_BG_SUB, len(entries)) for a in entries: payload += struct.pack(">H", a) + freq[a].astype(">f4").tobytes() - head = path.read_bytes()[:v2._cache_tail_offset()] + # See test_cach_v1_reads_as_natural_resolution: release the memmaps + # before write_bytes truncates, or Windows raises EINVAL. + del v2 + head = path.read_bytes()[:tail_offset] path.write_bytes(head + payload) v1 = SrasFile(str(path)) @@ -510,6 +520,288 @@ def test_cach_v1_backward_compat_defaults_row_avg_n_zero(tmp_path): "a v1 tail (predating this feature) can never satisfy a row-averaged request" +# --------------------------------------------------------------------------- +# Min peak frequency floor: serve-time masking, on-disk provenance, batch +# --------------------------------------------------------------------------- + +def _biting_floor(stored: np.ndarray) -> float: + """A floor that zeroes some-but-not-all of *stored*'s positive peaks: + the median distinct positive value, so pixels below it get masked and + pixels at/above it survive (the mask is a strict <).""" + positive = np.unique(stored[stored > 0]) + assert len(positive) >= 2, "fixture must have varied peak frequencies" + return float(positive[len(positive) // 2]) + + +def test_stored_image_served_with_raised_floor_masked(rig, no_fft): + """The core of the 5 m/s bug fix: a floor above the stored one (here 0) + is re-applied when the stored image is served — pixels whose stored + peak falls below it come back as the 0.0 invalid sentinel, everything + else passes through, and no FFT runs. Both directly through + cached_rf_image and through compute_rf_image's fast path.""" + assert rig.sras.precomputed_min_freq_mhz == 0.0, "batched without a floor" + stored = rig.sras.precomputed_freq_mhz[0] + floor = _biting_floor(stored) + expect = np.where(stored < floor, np.float32(0.0), stored) + + img = cached_rf_image(rig.sras, 0, None, apply_bg_sub=True, + min_freq_mhz=floor) + assert img is not None, "an equal-or-higher floor is servable" + assert np.array_equal(img, expect) + assert (img == 0).any() and (img > 0).any(), \ + "the floor bites some pixels but not all" + + via_compute = compute_rf_image(rig.sras, 0, dc_threshold_mv=None, + apply_bg_sub=True, min_freq_mhz=floor) + assert np.array_equal(via_compute, expect), \ + "compute_rf_image's fast path applies the same serve-time mask" + assert not no_fft, f"serving + masking must not run an FFT: {no_fft}" + + +def test_min_freq_floor_round_trips_and_gates_serving(tmp_path): + """cache_file records the floor in the SFFT header and the accept rule + is asymmetric: an equal-or-higher request is servable, a lower one is + refused (the stored search never looked below its floor). 20.1 pins the + fixed-point kHz encoding — a float32 header field would read back as + 20.10000038…, above the requested 20.1, and mismatch forever.""" + path = tmp_path / "floor_roundtrip.sras" + gen.write(path, n_angles=2, seed=31, samples_per_frame=128) + floor = 20.1 + assert cache_file(str(path), "fft", True, min_freq_mhz=floor) == "" + + sras = SrasFile(str(path)) + assert sras.precomputed_min_freq_mhz == floor, "exact fixed-point round-trip" + assert all(((img == 0) | (img >= floor)).all() + for img in sras.precomputed_freq_mhz), \ + "no stored peak below the floor" + + def reasons(f): + return compute.cache_mismatch_reasons( + sras, n_fft=None, apply_bg_sub=True, row_avg_n=0, min_freq_mhz=f) + + assert reasons(floor) == [] + assert reasons(floor + 5.0) == [], "a higher request is servable (masked)" + low = reasons(0.0) + assert low and "min-peak-freq floor" in low[0], \ + "a lower request cannot be answered by the stored search" + assert cached_rf_image(sras, 0, None, apply_bg_sub=True) is None, \ + "default floor-0 request refused against a floored store" + assert cached_rf_image(sras, 0, None, apply_bg_sub=True, + min_freq_mhz=floor) is not None + + +def test_batch_recompute_resolves_not_masks(tmp_path, no_fft): + """Re-batching an already-cached file at a raised floor must run the + real FFT and store re-resolved peaks — never let compute_rf_image's + fast path serve the file's own stale cache back to it and bake the + masked copy in as if it were a recompute (silent, permanent data + loss: a masked pixel's true above-floor peak is unrecoverable).""" + path = tmp_path / "rebatch.sras" + gen.write(path, n_angles=2, seed=32, samples_per_frame=256) + assert cache_file(str(path), "fft", True) == "" + + first = SrasFile(str(path)) + n_angles = first.n_angles + # On the header's kHz grid up front (as the spinbox value would be), so + # the recorded floor reads back equal — cache_file quantizes whatever it + # is given, and this test wants that to be the identity. + floor = round(_biting_floor(first.precomputed_freq_mhz[0]) * 1000) / 1000.0 + bites = [img < floor for img in first.precomputed_freq_mhz] + assert bites[0].any(), "the floor must actually bite this fixture" + # What a real floored compute gives, from a view blinded to the cache. + first.precomputed_freq_mhz = [None] * n_angles + expected = [compute_rf_image(first, a, dc_threshold_mv=None, + apply_bg_sub=True, min_freq_mhz=floor) + for a in range(n_angles)] + del first # release memmaps before cache_file rewrites the tail + + no_fft.clear() + assert cache_file(str(path), "fft", True, min_freq_mhz=floor) == "" + assert no_fft, "the re-batch ran a real FFT" + + after = SrasFile(str(path)) + assert after.precomputed_min_freq_mhz == floor + for a in range(n_angles): + assert np.array_equal(after.precomputed_freq_mhz[a], expected[a]), \ + f"angle {a}: stored image is a real floored recompute" + assert (after.precomputed_freq_mhz[a][bites[a]] >= floor).all(), \ + f"angle {a}: bitten pixels re-resolved above the floor, not zeroed" + + +def test_min_freq_carries_forward_through_dc_write(tmp_path): + """A later DC-only write must leave the FFT block's recorded floor + untouched, like row_avg_n and pad_factor.""" + path = tmp_path / "floor_carry.sras" + gen.write(path, n_angles=2, seed=33, samples_per_frame=128) + assert cache_file(str(path), "fft", True, min_freq_mhz=75.0) == "" + assert cache_file(str(path), "dc", True) == "" + after = SrasFile(str(path)) + assert after.precomputed_min_freq_mhz == 75.0, \ + "floor survives a DC-only write" + + +def test_cach_v3_backward_compat_defaults_floor_zero(tmp_path): + """A v3 CACH tail predates the min peak frequency floor entirely (no + min_freq_khz field) — readers must still parse it in full, treating it + as floor 0: servable as-is at floor 0, and serve-maskable at any higher + one. This is what protects existing real-world v7 caches from silently + becoming unusable after the v4 bump ships.""" + path = tmp_path / "v3_floor.sras" + gen.write(path, n_angles=2, seed=34, samples_per_frame=128) + assert cache_file(str(path), "fft", True) == "" + + # Rewrite the tail as a genuine CACH v3 block (no min_freq field). + v4 = SrasFile(str(path)) + freq, entries = v4.precomputed_freq_mhz, list(range(v4.n_angles)) + tail_offset = v4._cache_tail_offset() + payload = struct.pack(fmt.CACH_HDR_FMT, fmt.CACH_MAGIC, 3, fmt.CACH_FLAG_FFT) + payload += struct.pack(fmt.SFFT_HDR_FMT_V3, fmt.SFFT_MAGIC, + fmt.SFFT_FLAG_BG_SUB, len(entries), 0, 1) + for a in entries: + payload += struct.pack(">H", a) + freq[a].astype(">f4").tobytes() + # See test_cach_v1_reads_as_natural_resolution: release the memmaps + # before write_bytes truncates, or Windows raises EINVAL. + del v4 + head = path.read_bytes()[:tail_offset] + path.write_bytes(head + payload) + + v3 = SrasFile(str(path)) + assert v3.precomputed_min_freq_mhz == 0.0 + assert v3.precomputed_pad_factor == 1 and v3.precomputed_bg_sub is True + assert all(np.array_equal(v3.precomputed_freq_mhz[a], freq[a]) + for a in entries), "v3 images read back unchanged" + assert cached_rf_image(v3, 0, None, apply_bg_sub=True) is not None + floor = _biting_floor(freq[0]) + served = cached_rf_image(v3, 0, None, apply_bg_sub=True, + min_freq_mhz=floor) + assert served is not None + assert np.array_equal(served, + np.where(freq[0] < floor, np.float32(0.0), freq[0])) + + +def test_min_freq_validation(tmp_path): + path = tmp_path / "floor_bad.sras" + gen.write(path, n_angles=1, seed=35, samples_per_frame=64) + assert cache_file(str(path), "fft", True, min_freq_mhz=-1.0), \ + "negative floor must be an error, not a write" + assert cache_file(str(path), "fft", True, min_freq_mhz=float("nan")), \ + "NaN floor must be an error, not a write" + sras = SrasFile(str(path)) + with pytest.raises(ValueError): + sras.write_v7_cache(new_min_freq_mhz=-0.5) + + +def test_viewer_reapplies_floor_to_stored_images_without_computing( + rig, no_fft, monkeypatch): + """The session-cache poisoning bug behind the '95 MHz peak but 5 m/s' + report: changing 'Min peak freq' against a stored cache used to re-file + the identical un-floored image under a key claiming the new floor — the + UI looked updated, the pixels weren't. Now the floor really is + re-applied on serve (masked, still no compute), and clearing it + restores the unmasked image, still without computing.""" + app = QApplication.instance() or QApplication([]) # noqa: F841 + win = SrasViewerWindow() + win.show() + + 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(rig.path)) + assert wait_until(lambda: win._sras is not None), "file loaded" + assert wait_until(lambda: all((a, CH4_IDX) in win._dc_cache + for a in range(rig.n_angles))), \ + "DC precompute finished" + + win.combo_channel.setCurrentIndex(CH1_IDX) + assert wait_until(lambda: win._current_ch == CH1_IDX), "CH1 displayed" + assert np.allclose(win._current_image, rig.fresh[0], atol=1e-3) + + # Round to the spinbox's 3-decimal granularity; the chosen bin value + # still survives its own (strict-<) floor after rounding down. + floor = round(_biting_floor(rig.fresh[0]), 3) + expect = np.where(rig.fresh[0] < floor, np.float32(0.0), rig.fresh[0]) + + dispatched.clear() + no_fft.clear() + win.spin_min_freq_mhz.setValue(floor) + win._on_min_freq_changed() + pump(120) + assert np.allclose(win._current_image, expect, atol=1e-3), \ + "raised floor re-masks the stored image on serve" + assert (win._current_image == 0).any() and (win._current_image > 0).any() + assert dispatched == [] and not no_fft, \ + f"re-masked serve needs no compute (jobs={dispatched}, fft={no_fft})" + key = (0, win.spin_threshold_mv.value(), win.spin_min_freq_mhz.value()) + assert key in win._fft_cache + assert np.allclose(win._fft_cache[key], expect, atol=1e-3), \ + "the session cache holds the value its key claims" + + win.spin_min_freq_mhz.setValue(0.0) + win._on_min_freq_changed() + pump(120) + assert np.allclose(win._current_image, rig.fresh[0], atol=1e-3), \ + "clearing the floor restores the unmasked stored image" + assert dispatched == [] and not no_fft + finally: + win.close() + pump(300) + + +def test_viewer_batch_fft_records_the_floor(tmp_path, no_fft, monkeypatch): + """Convert → Batch Compute FFT with a floor set: the live spinbox value + reaches cache_file, lands in the reloaded file's provenance and the + info panel, and the viewer then serves the floored cache without + recomputing — the tooltip's promised remedy, end to end.""" + path = tmp_path / "floor_gui.sras" + gen.write(path, n_angles=2, seed=36, samples_per_frame=256) + + app = QApplication.instance() or QApplication([]) # noqa: F841 + win = SrasViewerWindow() + win.show() + + 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 wait_until(lambda: all((a, CH4_IDX) in win._dc_cache + for a in range(win._sras.n_angles))), \ + "DC precompute finished" + + floor = 100.0 + win.spin_min_freq_mhz.setValue(floor) + with patch("sras_viewer.main_window.QFileDialog.getOpenFileNames", + return_value=([str(path)], "")): + win._on_batch_compute("fft") + assert wait_until(lambda: not win._job_running("batch"), 60000), "batch ran" + assert wait_until(lambda: win._sras is not None + and win._sras.version == 7), "file reloaded as v7" + pump(200) + assert win._sras.precomputed_min_freq_mhz == floor, \ + "the viewer's floor reached the stored provenance" + assert "floor ≥ 100 MHz" in win.lbl_frame_warn.text() + + no_fft.clear() + dispatched.clear() + win.combo_channel.setCurrentIndex(CH1_IDX) + assert wait_until(lambda: win._current_ch == CH1_IDX), "CH1 displayed" + pump(120) + assert dispatched == [] and not no_fft, \ + f"floored cache serves the view directly (jobs={dispatched}, fft={no_fft})" + img = win._current_image + assert ((img == 0) | (img >= floor)).all(), \ + "no displayed peak below the floor" + finally: + win.close() + pump(300) + + def test_viewer_batch_row_average_dispatch(tmp_path, monkeypatch, no_fft): """Driving the new 'Batch Compute Row-Averaged FFT and Store' action end-to-end through the real menu handler: dialog values reach the