2937c017a7
The two clones had diverged from origin/main (191d1b8) and both had
grown uncommitted work. This merges the other clone's three commits and
reconciles four conflicting files.
Both clones independently grew a feature called "batch export", but they
are different sweeps and both are kept:
- BatchExportWorker (this clone) — every angle x channel of the one open
file, fixed colorbar per channel, under a new Export menu.
- BatchExportImagesWorker (other clone) — the current view across many
selected files, process-pooled, under Convert.
Conflict resolutions of note:
- sras_average.py: the other clone's memory-bounded rewrite had silently
reverted this clone's float32 -> int16 accumulator fix, which exists to
keep averaged output from landing 1 ADC count off the original tool.
Kept the rewrite, re-applied the fix, and corrected the two docstrings
that still claimed float32.
- tests/test_compute.py: kept the other clone's meta["waveforms"] key
(meta["data"] no longer exists for this fixture and would KeyError)
and its laser_freq_hz assertions, plus this clone's int16 expectation
and its dropped subprocess returncode assertions.
- ComputeWorker: row_avg_n and min_freq_mhz were added independently on
either side; both are now threaded through.
- compute_rf_image's `exact` parameter is gone, removed by the other
clone's PyFFTW peak-search rewrite. It had no remaining callers.
Verified: 149 passed with a complete dependency set. The failures seen
with this clone's own .venv are a missing scikit-image, which predates
this merge and reproduces identically on the pre-merge commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
952 lines
43 KiB
Python
952 lines
43 KiB
Python
"""Does a batch-computed FFT cache actually spare the viewer the FFT?
|
|
|
|
Storing a peak-frequency image per angle in the file is only worth doing if
|
|
displaying it is then free. The regression this module pins down is the
|
|
viewer's *dispatch* decision: it used to find the stored image only inside
|
|
ComputeWorker, so after a batch every angle change still queued a background
|
|
job behind a "Computing FFT…" popup for an image already on disk.
|
|
|
|
Both layers are covered — cached_rf_image's accept/reject rules, and the
|
|
window never reaching _start_compute for a batch-cached angle.
|
|
"""
|
|
|
|
import struct
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
import numpy as np
|
|
import pytest
|
|
from PyQt6.QtCore import QEventLoop, QTimer
|
|
from PyQt6.QtWidgets import QApplication, QDialog
|
|
|
|
import sras_compute as compute
|
|
import sras_format as fmt
|
|
from sras_compute import cache_file, cached_rf_image, compute_rf_image, dc_image_mv
|
|
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, SrasFile
|
|
from sras_viewer import SrasViewerWindow, VELOCITY_MODE_IDX
|
|
import tools.make_test_sras as gen
|
|
|
|
_THRESHOLD_MV = 50.0 # the viewer's own default
|
|
|
|
|
|
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()
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def rig(tmp_path_factory):
|
|
"""A v6 file, the FFT images a from-scratch compute gives for it, and the
|
|
same file after Batch Compute FFT has written them into its v7 cache."""
|
|
path = tmp_path_factory.mktemp("stored_cache") / "cached.sras"
|
|
gen.write(path, n_angles=4, seed=7, samples_per_frame=256)
|
|
|
|
src = SrasFile(str(path))
|
|
fresh = {a: compute_rf_image(src, a, dc_threshold_mv=_THRESHOLD_MV,
|
|
apply_bg_sub=True)
|
|
for a in range(src.n_angles)}
|
|
assert src.background is not None, "the fixture file must have a background"
|
|
|
|
err = cache_file(str(path), "fft", True)
|
|
assert err == "", err
|
|
cached = SrasFile(str(path))
|
|
assert all(x is not None for x in cached.precomputed_freq_mhz)
|
|
assert all(x is None for x in cached.precomputed_dc4_mv), \
|
|
"FFT-only batch: the mask has to come from the viewer, not the file"
|
|
|
|
return SimpleNamespace(path=path, fresh=fresh, sras=cached,
|
|
n_angles=cached.n_angles)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def dc_rig(tmp_path_factory):
|
|
"""A file that has been through Batch Compute DC and Store."""
|
|
path = tmp_path_factory.mktemp("stored_dc") / "dc_cached.sras"
|
|
gen.write(path, n_angles=4, seed=9, samples_per_frame=128)
|
|
err = cache_file(str(path), "dc", True)
|
|
assert err == "", err
|
|
|
|
sras = SrasFile(str(path))
|
|
assert all(x is not None for x in sras.precomputed_dc3_mv)
|
|
assert all(x is not None for x in sras.precomputed_dc4_mv)
|
|
assert len(np.unique(sras.precomputed_dc4_mv[0])) > 1, \
|
|
"a degenerate DC image would make the comparisons below vacuous"
|
|
return SimpleNamespace(path=path, sras=sras, n_angles=sras.n_angles)
|
|
|
|
|
|
@pytest.fixture
|
|
def no_fft(monkeypatch):
|
|
"""Make any real FFT work loud: returns a list that stays empty unless a
|
|
peak search actually runs."""
|
|
calls = []
|
|
for name in ("_peak_bins",):
|
|
original = getattr(compute, name)
|
|
|
|
def spy(*args, _f=original, **kwargs):
|
|
calls.append(_f.__name__)
|
|
return _f(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(compute, name, spy)
|
|
return calls
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# cached_rf_image: when may the stored image stand in for a compute?
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_stored_image_matches_a_fresh_compute(rig, no_fft):
|
|
for a in range(rig.n_angles):
|
|
dc4 = dc_image_mv(SrasFile(str(rig.path)), a, CH4_IDX)
|
|
img = cached_rf_image(rig.sras, a, dc_threshold_mv=_THRESHOLD_MV,
|
|
apply_bg_sub=True, dc4_mv=dc4)
|
|
assert img is not None, f"angle {a} is cached in the file"
|
|
assert np.allclose(img, rig.fresh[a], atol=1e-3), \
|
|
f"angle {a} differs from a from-scratch compute"
|
|
assert not no_fft, f"the stored image was used, no FFT ran: {no_fft}"
|
|
|
|
|
|
def test_unmasked_when_no_threshold(rig):
|
|
img = cached_rf_image(rig.sras, 0, dc_threshold_mv=None, apply_bg_sub=True)
|
|
assert img is not None and np.array_equal(img, rig.sras.precomputed_freq_mhz[0])
|
|
img[:] = -1.0
|
|
assert not np.any(rig.sras.precomputed_freq_mhz[0] == -1.0), \
|
|
"callers get a copy, never the file's own array"
|
|
|
|
|
|
def test_settings_the_stored_image_cannot_serve(rig):
|
|
"""A stored image carries one bg-sub state and one padding, so anything
|
|
else must fall through to a real compute rather than lie."""
|
|
spf = rig.sras.samples_per_frame
|
|
assert cached_rf_image(rig.sras, 0, _THRESHOLD_MV, apply_bg_sub=True,
|
|
n_fft=spf * 4) is None, \
|
|
"cache was written at pad 1, a pad-4 view resolves different peaks"
|
|
assert cached_rf_image(rig.sras, 0, _THRESHOLD_MV, apply_bg_sub=False) is None, \
|
|
"cache was written with bg-sub on"
|
|
|
|
uncached = SrasFile(str(rig.path))
|
|
uncached.precomputed_freq_mhz[1] = None
|
|
assert cached_rf_image(uncached, 1, _THRESHOLD_MV, apply_bg_sub=True) is None
|
|
|
|
|
|
@pytest.mark.parametrize("pad", [2, 10])
|
|
def test_cache_is_stored_at_the_configured_pad(tmp_path, pad):
|
|
"""Batching at a pad factor must produce a cache that view can read back —
|
|
a pad-1-only cache is one the padded viewer can never use."""
|
|
path = tmp_path / f"pad{pad}.sras"
|
|
gen.write(path, n_angles=2, seed=13, samples_per_frame=256)
|
|
spf = SrasFile(str(path)).samples_per_frame
|
|
|
|
assert cache_file(str(path), "fft", True, 0, pad) == ""
|
|
sras = SrasFile(str(path))
|
|
assert sras.precomputed_pad_factor == pad, "pad factor survives the round trip"
|
|
|
|
assert cached_rf_image(sras, 0, None, apply_bg_sub=True,
|
|
n_fft=spf * pad) is not None, f"usable at pad {pad}"
|
|
assert cached_rf_image(sras, 0, None, apply_bg_sub=True) is None, \
|
|
"not usable unpadded"
|
|
assert cached_rf_image(sras, 0, None, apply_bg_sub=True,
|
|
n_fft=spf * (pad + 1)) is None, "not usable at another pad"
|
|
|
|
# The stored numbers must be the padded ones, not pad-1 relabelled.
|
|
fresh = SrasFile(str(path))
|
|
fresh.precomputed_freq_mhz = [None] * fresh.n_angles
|
|
for a in range(sras.n_angles):
|
|
assert np.allclose(
|
|
compute_rf_image(sras, a, dc_threshold_mv=None, apply_bg_sub=True,
|
|
n_fft=spf * pad),
|
|
compute_rf_image(fresh, a, dc_threshold_mv=None, apply_bg_sub=True,
|
|
n_fft=spf * pad), atol=1e-3), \
|
|
f"angle {a}: stored image is the pad-{pad} answer"
|
|
|
|
|
|
def test_cach_v1_reads_as_natural_resolution(tmp_path):
|
|
"""Files cached before the pad factor existed must keep working: a v1 tail
|
|
has no pad field and is pad 1 by construction."""
|
|
path = tmp_path / "v1.sras"
|
|
gen.write(path, n_angles=2, seed=14, samples_per_frame=256)
|
|
assert cache_file(str(path), "fft", True) == ""
|
|
|
|
# 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()
|
|
# 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))
|
|
assert v1.precomputed_pad_factor == 1
|
|
assert v1.precomputed_bg_sub is True
|
|
assert all(np.array_equal(v1.precomputed_freq_mhz[a], freq[a]) for a in entries), \
|
|
"v1 images read back unchanged"
|
|
assert cached_rf_image(v1, 0, None, apply_bg_sub=True) is not None
|
|
assert cached_rf_image(v1, 0, None, apply_bg_sub=True,
|
|
n_fft=v1.samples_per_frame * 10) is None
|
|
|
|
|
|
def test_mask_read_can_be_refused(rig, no_fft):
|
|
"""With no DC4 in hand, building the mask means reading a whole channel —
|
|
the GUI thread asks for None instead."""
|
|
assert cached_rf_image(rig.sras, 0, _THRESHOLD_MV, apply_bg_sub=True,
|
|
allow_dc_recompute=False) is None
|
|
dc4 = dc_image_mv(SrasFile(str(rig.path)), 0, CH4_IDX)
|
|
img = cached_rf_image(rig.sras, 0, _THRESHOLD_MV, apply_bg_sub=True,
|
|
dc4_mv=dc4, allow_dc_recompute=False)
|
|
assert img is not None and np.allclose(img, rig.fresh[0], atol=1e-3)
|
|
assert not no_fft, f"no FFT on either branch: {no_fft}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The viewer: no compute job at all for a batch-cached angle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_viewer_shows_stored_angles_without_computing(rig, no_fft, monkeypatch):
|
|
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"
|
|
# The file carries no DC block, so the mask comes from the window's own
|
|
# background precompute — the state a user is in by the time they click.
|
|
assert wait_until(lambda: all((a, CH4_IDX) in win._dc_cache
|
|
for a in range(rig.n_angles))), \
|
|
"DC precompute finished"
|
|
|
|
dispatched.clear()
|
|
win.combo_channel.setCurrentIndex(CH1_IDX)
|
|
assert wait_until(lambda: win._current_ch == CH1_IDX), "CH1 displayed"
|
|
|
|
for a in list(range(rig.n_angles)) + [1, 0]:
|
|
win.spin_angle.setValue(a)
|
|
win._on_view_changed()
|
|
pump(60)
|
|
assert win._current_angle == a, f"angle {a} displayed"
|
|
assert np.allclose(win._current_image, rig.fresh[a], atol=1e-3), \
|
|
f"angle {a} shows the stored image"
|
|
|
|
assert dispatched == [], \
|
|
f"stored angles need no compute job, dispatched for {dispatched}"
|
|
assert not no_fft, f"no FFT ran for any stored angle: {no_fft}"
|
|
|
|
# Velocity is still a post-multiply of the same stored image.
|
|
win.combo_channel.setCurrentIndex(VELOCITY_MODE_IDX)
|
|
pump(120)
|
|
assert np.allclose(win._current_image,
|
|
rig.fresh[0] * win.spin_grating_um.value(), atol=1e-3)
|
|
assert dispatched == [] and not no_fft
|
|
|
|
# ...and a live control that no longer matches the stored image's own
|
|
# provenance must NOT force a recompute either — the stored image is
|
|
# shown as-is; only an explicit batch recompute changes what's shown.
|
|
win.combo_channel.setCurrentIndex(CH1_IDX)
|
|
pump(60)
|
|
win.chk_bg_sub.setChecked(False)
|
|
pump(200)
|
|
assert not win._job_running("compute") and not no_fft, \
|
|
"bg-sub off still shows the stored image, no real FFT"
|
|
assert np.allclose(win._current_image, rig.fresh[0], atol=1e-3)
|
|
finally:
|
|
win.close()
|
|
pump(300)
|
|
|
|
|
|
def test_batch_caches_at_the_viewers_pad_factor(tmp_path, no_fft, monkeypatch):
|
|
"""The bug a pad-10 user hits: Batch Compute FFT used to store pad-1
|
|
images regardless, so the padded view recomputed every angle forever.
|
|
"""
|
|
path = tmp_path / "padded_gui.sras"
|
|
gen.write(path, n_angles=3, seed=15, 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._fft_pad_factor = 10
|
|
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"
|
|
|
|
# Convert -> Batch Compute FFT, on the open file, through the real slot.
|
|
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_pad_factor == 10, \
|
|
f"cached at the viewer's pad, got {win._sras.precomputed_pad_factor}"
|
|
|
|
expected = {a: compute.cached_rf_image(
|
|
win._sras, a, dc_threshold_mv=win.spin_threshold_mv.value(),
|
|
apply_bg_sub=win.chk_bg_sub.isChecked(),
|
|
n_fft=win._current_n_fft(),
|
|
dc4_mv=win._dc_cache.get((a, CH4_IDX)))
|
|
for a in range(win._sras.n_angles)}
|
|
assert all(v is not None for v in expected.values()), "cache is readable at pad 10"
|
|
|
|
no_fft.clear()
|
|
dispatched.clear()
|
|
win.combo_channel.setCurrentIndex(CH1_IDX)
|
|
assert wait_until(lambda: win._current_ch == CH1_IDX), "CH1 displayed"
|
|
for a in range(win._sras.n_angles):
|
|
win.spin_angle.setValue(a)
|
|
pump(60)
|
|
assert np.allclose(win._current_image, expected[a], atol=1e-3), \
|
|
f"angle {a} served from the pad-10 cache"
|
|
assert dispatched == [] and not no_fft, \
|
|
f"no recompute at pad 10 (jobs={dispatched}, fft={no_fft})"
|
|
assert "unusable" not in win.lbl_frame_warn.text()
|
|
|
|
# Change the live pad control so it no longer matches the stored
|
|
# image's own provenance — the info panel has to say so rather than
|
|
# leave it a mystery, but the stored pad-10 image keeps displaying;
|
|
# only an explicit batch recompute would ever produce a pad-4 one.
|
|
win._fft_pad_factor = 4
|
|
win._update_scan_info_labels()
|
|
assert "differ from the stored cache" in win.lbl_frame_warn.text(), \
|
|
win.lbl_frame_warn.text()
|
|
assert "pad 10x" in win.lbl_frame_warn.text()
|
|
last_angle = win._current_angle
|
|
win._refresh_display()
|
|
assert wait_until(lambda: not win._job_running("compute")), "settled"
|
|
assert not no_fft, "no real FFT ran — the pad-10 cache still served the view"
|
|
assert np.allclose(win._current_image, expected[last_angle], atol=1e-3), \
|
|
"pad-10 cache still shown after the live pad control diverged"
|
|
finally:
|
|
win.close()
|
|
pump(300)
|
|
|
|
|
|
def test_viewer_shows_stored_dc_without_computing(dc_rig, monkeypatch):
|
|
"""Same for the DC half, with the background precompute silenced so the
|
|
file's stored block is the only thing that can be carrying the display."""
|
|
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])
|
|
monkeypatch.setattr(type(win), "_start_dc_precompute", lambda self: None)
|
|
try:
|
|
win._load_file(str(dc_rig.path))
|
|
assert wait_until(lambda: win._sras is not None), "file loaded"
|
|
pump(120)
|
|
|
|
for ch in (CH3_IDX, CH4_IDX):
|
|
win.combo_channel.setCurrentIndex(ch)
|
|
for a in range(dc_rig.n_angles):
|
|
win.spin_angle.setValue(a)
|
|
pump(60)
|
|
assert (win._current_angle, win._current_ch) == (a, ch), \
|
|
f"angle {a} on channel {ch} displayed"
|
|
assert np.array_equal(win._current_image,
|
|
dc_rig.sras.cached_dc_mv(a, ch)), \
|
|
f"angle {a} channel {ch} shows the file's stored DC image"
|
|
|
|
assert dispatched == [], \
|
|
f"stored DC angles need no compute job, dispatched for {dispatched}"
|
|
finally:
|
|
win.close()
|
|
pump(300)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Row-averaged FFT: cache_file("fft_rowavg", ...) and its on-disk provenance
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_row_average_flag_and_n_round_trip(tmp_path):
|
|
path = tmp_path / "rowavg_roundtrip.sras"
|
|
gen.write(path, n_angles=2, seed=20, samples_per_frame=128)
|
|
err = cache_file(str(path), "fft_rowavg", True, dc_threshold_mv=-1e9, row_avg_n=5)
|
|
assert err == "", err
|
|
|
|
sras = SrasFile(str(path))
|
|
assert sras.version == 7
|
|
assert sras.precomputed_row_avg_n == 5
|
|
assert all(x is not None for x in sras.precomputed_freq_mhz)
|
|
|
|
|
|
def test_fft_rowavg_mode_requires_positive_n_and_threshold(tmp_path):
|
|
path = tmp_path / "rowavg_bad_args.sras"
|
|
gen.write(path, n_angles=1, seed=27, samples_per_frame=64)
|
|
assert cache_file(str(path), "fft_rowavg", True, dc_threshold_mv=0.0, row_avg_n=0)
|
|
assert cache_file(str(path), "fft_rowavg", True, dc_threshold_mv=None, row_avg_n=5)
|
|
|
|
|
|
def test_raw_and_row_averaged_caches_never_cross_served(tmp_path):
|
|
"""The central regression this feature must never allow: a raw request
|
|
served a row-averaged image (or vice versa), or a request at one window
|
|
size served a cache stored at a different one."""
|
|
path = tmp_path / "cross_serve.sras"
|
|
gen.write(path, n_angles=1, seed=21, samples_per_frame=128)
|
|
err = cache_file(str(path), "fft_rowavg", True, dc_threshold_mv=-1e9, row_avg_n=5)
|
|
assert err == "", err
|
|
|
|
sras = SrasFile(str(path))
|
|
assert cached_rf_image(sras, 0, None, apply_bg_sub=True, row_avg_n=0) is None, \
|
|
"a raw request must not be served a row-averaged cache"
|
|
assert cached_rf_image(sras, 0, None, apply_bg_sub=True, row_avg_n=3) is None, \
|
|
"a request at the wrong window size must not be served either"
|
|
served = cached_rf_image(sras, 0, None, apply_bg_sub=True, row_avg_n=5)
|
|
assert served is not None
|
|
assert np.array_equal(served, sras.precomputed_freq_mhz[0])
|
|
|
|
|
|
def test_write_v7_cache_row_avg_n_carries_forward(tmp_path):
|
|
"""A later DC-only write must leave a previously-written row-averaged
|
|
FFT block -- including its row_avg_n -- byte-for-byte unchanged."""
|
|
path = tmp_path / "carry_forward.sras"
|
|
gen.write(path, n_angles=2, seed=22, samples_per_frame=64)
|
|
err = cache_file(str(path), "fft_rowavg", True, dc_threshold_mv=-1e9, row_avg_n=7)
|
|
assert err == "", err
|
|
|
|
before = SrasFile(str(path))
|
|
assert before.precomputed_row_avg_n == 7
|
|
freq_before = [x.copy() for x in before.precomputed_freq_mhz]
|
|
|
|
err = cache_file(str(path), "dc", True)
|
|
assert err == "", err
|
|
|
|
after = SrasFile(str(path))
|
|
assert after.precomputed_row_avg_n == 7, "row_avg_n survives a DC-only write"
|
|
assert all(np.array_equal(after.precomputed_freq_mhz[a], freq_before[a])
|
|
for a in range(after.n_angles)), \
|
|
"the row-averaged FFT block itself is untouched by a DC-only write"
|
|
|
|
|
|
def test_row_average_never_touches_dc_images(tmp_path):
|
|
path = tmp_path / "dc_untouched.sras"
|
|
gen.write(path, n_angles=2, seed=23, samples_per_frame=64)
|
|
|
|
src = SrasFile(str(path))
|
|
expect_dc3 = [dc_image_mv(src, a, CH3_IDX) for a in range(src.n_angles)]
|
|
expect_dc4 = [dc_image_mv(src, a, CH4_IDX) for a in range(src.n_angles)]
|
|
|
|
assert cache_file(str(path), "dc", True) == ""
|
|
assert cache_file(str(path), "fft_rowavg", True,
|
|
dc_threshold_mv=-1e9, row_avg_n=6) == ""
|
|
|
|
after = SrasFile(str(path))
|
|
assert all(np.allclose(after.precomputed_dc3_mv[a], expect_dc3[a], atol=1e-4)
|
|
for a in range(after.n_angles))
|
|
assert all(np.allclose(after.precomputed_dc4_mv[a], expect_dc4[a], atol=1e-4)
|
|
for a in range(after.n_angles))
|
|
|
|
|
|
def test_row_average_never_modifies_raw_waveform_data(tmp_path):
|
|
path = tmp_path / "waveform_untouched.sras"
|
|
gen.write(path, n_angles=2, seed=24, samples_per_frame=64)
|
|
orig = tmp_path / "waveform_untouched_orig.sras"
|
|
gen.write(orig, n_angles=2, seed=24, samples_per_frame=64)
|
|
|
|
assert cache_file(str(path), "fft_rowavg", True,
|
|
dc_threshold_mv=-1e9, row_avg_n=5) == ""
|
|
|
|
o, n = SrasFile(str(orig)), SrasFile(str(path))
|
|
assert all(np.array_equal(np.asarray(o.data[a]), np.asarray(n.data[a]))
|
|
for a in range(o.n_angles)), \
|
|
"waveform data untouched by a row-averaged cache write"
|
|
|
|
|
|
def test_cach_v1_backward_compat_defaults_row_avg_n_zero(tmp_path):
|
|
"""A v1 CACH tail predates row-averaged FFT caching entirely (no
|
|
row_avg_n byte at all) -- readers must still parse it in full, treating
|
|
it as row_avg_n=0. This is what protects an existing real-world v7
|
|
file's already-stored FFT cache from silently becoming unusable after
|
|
this change ships."""
|
|
path = tmp_path / "v1_rowavg.sras"
|
|
gen.write(path, n_angles=2, seed=25, samples_per_frame=128)
|
|
assert cache_file(str(path), "fft", True) == ""
|
|
|
|
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()
|
|
# 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))
|
|
assert v1.precomputed_row_avg_n == 0
|
|
assert all(np.array_equal(v1.precomputed_freq_mhz[a], freq[a]) for a in entries), \
|
|
"v1 images read back unchanged"
|
|
assert cached_rf_image(v1, 0, None, apply_bg_sub=True, row_avg_n=0) is not None
|
|
assert cached_rf_image(v1, 0, None, apply_bg_sub=True, row_avg_n=5) is None, \
|
|
"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
|
|
worker, the worker reaches cache_file, and the written file is
|
|
self-describing afterward. Also the exact scenario the row-averaged-FFT
|
|
recompute bug reported: before the fix, the display always asked for
|
|
row_avg_n=0 regardless of what the file actually had stored, so viewing
|
|
an angle after this batch action saw a phantom mismatch and launched a
|
|
full raw recompute on every view switch. This asserts that no longer
|
|
happens -- the stored row-averaged image is shown directly, with no
|
|
dispatched compute job and no real FFT."""
|
|
path = tmp_path / "rowavg_gui.sras"
|
|
gen.write(path, n_angles=2, seed=26, samples_per_frame=128)
|
|
|
|
class _StubDialog:
|
|
def __init__(self, *a, **k):
|
|
pass
|
|
|
|
def exec(self):
|
|
return QDialog.DialogCode.Accepted
|
|
|
|
def get_half_width(self):
|
|
return 6
|
|
|
|
def get_threshold_mv(self):
|
|
return -1e9 # mask nothing, keep the comparison simple
|
|
|
|
monkeypatch.setattr("sras_viewer.main_window.RowAverageFftOptionsDialog", _StubDialog)
|
|
|
|
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"
|
|
|
|
with patch("sras_viewer.main_window.QFileDialog.getOpenFileNames",
|
|
return_value=([str(path)], "")):
|
|
win._on_batch_compute_row_avg()
|
|
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_row_avg_n == 6
|
|
assert "row-averaged n=6" in win.lbl_frame_warn.text(), win.lbl_frame_warn.text()
|
|
|
|
expected = compute.cached_rf_image(win._sras, 0, dc_threshold_mv=None,
|
|
apply_bg_sub=win.chk_bg_sub.isChecked(),
|
|
row_avg_n=6)
|
|
assert expected is not None, "the batch write left a readable row-averaged cache"
|
|
|
|
# The regression this batch action used to leave unfixed: viewing an
|
|
# angle afterward must show the stored row-averaged image directly,
|
|
# never fall through to a real (raw) recompute.
|
|
no_fft.clear()
|
|
dispatched.clear()
|
|
win.combo_channel.setCurrentIndex(CH1_IDX)
|
|
assert wait_until(lambda: win._current_ch == CH1_IDX), "CH1 displayed"
|
|
for a in range(win._sras.n_angles):
|
|
win.spin_angle.setValue(a)
|
|
pump(60)
|
|
assert np.allclose(win._current_image,
|
|
compute.cached_rf_image(
|
|
win._sras, a,
|
|
dc_threshold_mv=win.spin_threshold_mv.value(),
|
|
apply_bg_sub=win.chk_bg_sub.isChecked(),
|
|
row_avg_n=6), atol=1e-3), \
|
|
f"angle {a} shows the stored row-averaged image"
|
|
assert dispatched == [] and not no_fft, \
|
|
f"no recompute for row-averaged angles (jobs={dispatched}, fft={no_fft})"
|
|
finally:
|
|
win.close()
|
|
pump(300)
|
|
|
|
|
|
def test_compute_worker_fallback_honors_row_avg_n(tmp_path, monkeypatch, no_fft):
|
|
"""The other half of the row-averaged recompute bug: _stored_fft_image
|
|
isn't the only path that can serve a view. When the DC4 mask isn't known
|
|
yet without I/O (e.g. background DC precompute hasn't reached this angle),
|
|
_stored_fft_image's allow_dc_recompute=False guard bails and
|
|
_refresh_display falls through to _start_compute's ComputeWorker instead.
|
|
That worker must still ask compute_rf_image for the file's own
|
|
row_avg_n -- not silently default to 0 -- so its own internal cache
|
|
fast path also serves the stored row-averaged image rather than running
|
|
a real, non-averaged FFT."""
|
|
path = tmp_path / "rowavg_fallback.sras"
|
|
gen.write(path, n_angles=2, seed=33, samples_per_frame=128)
|
|
# Only fft_rowavg -- deliberately no DC block, exactly what "Batch
|
|
# Compute Row-Averaged FFT and Store" leaves on disk by itself.
|
|
err = cache_file(str(path), "fft_rowavg", True, dc_threshold_mv=-1e9, row_avg_n=5)
|
|
assert err == "", err
|
|
|
|
app = QApplication.instance() or QApplication([]) # noqa: F841
|
|
win = SrasViewerWindow()
|
|
win.show()
|
|
|
|
# Keep _dc_cache empty so _stored_fft_image can't resolve a mask without
|
|
# I/O and _refresh_display must fall through to _start_compute.
|
|
monkeypatch.setattr(type(win), "_start_dc_precompute", lambda self: None)
|
|
|
|
dispatched = []
|
|
original_start = type(win)._start_compute
|
|
monkeypatch.setattr(type(win), "_start_compute",
|
|
lambda self: (dispatched.append(self.spin_angle.value()),
|
|
original_start(self))[1])
|
|
try:
|
|
win._load_file(str(path))
|
|
assert wait_until(lambda: win._sras is not None), "file loaded"
|
|
assert win._sras.precomputed_row_avg_n == 5
|
|
# The initial default view (CH4, DC) has nothing cached either, so
|
|
# it dispatches its own one-off DC compute for angle 0 on load --
|
|
# unrelated to this bug. Let that settle, then clear it so no DC4 is
|
|
# available for any angle, simulating "background DC precompute
|
|
# hasn't reached this angle yet".
|
|
assert wait_until(lambda: not win._job_running("compute")), "initial DC view settled"
|
|
dispatched.clear()
|
|
win._dc_cache.clear()
|
|
|
|
no_fft.clear()
|
|
win.combo_channel.setCurrentIndex(CH1_IDX)
|
|
assert wait_until(lambda: win._current_ch == CH1_IDX
|
|
and not win._job_running("compute")), "CH1 displayed"
|
|
|
|
assert dispatched == [0], \
|
|
"with no DC cache available yet, the fallback compute must run"
|
|
assert not no_fft, \
|
|
f"the fallback's own compute_rf_image call must still hit the " \
|
|
f"stored row-averaged cache internally: {no_fft}"
|
|
expected = compute.cached_rf_image(
|
|
win._sras, 0, dc_threshold_mv=win.spin_threshold_mv.value(),
|
|
apply_bg_sub=win.chk_bg_sub.isChecked(), row_avg_n=5)
|
|
assert expected is not None
|
|
assert np.allclose(win._current_image, expected, atol=1e-3), \
|
|
"the displayed image is the stored row-averaged one, not a raw recompute"
|
|
finally:
|
|
win.close()
|
|
pump(300)
|