Merge the Documents/sras-viewer working copy into this repo
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>
This commit is contained in:
@@ -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"
|
||||
+171
-54
@@ -12,6 +12,7 @@ from pathlib import Path
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import sras_average
|
||||
import sras_compute as compute
|
||||
from sras_compute import (
|
||||
cache_file, compute_dc_image, compute_rf_image, dc_image_mv,
|
||||
@@ -122,7 +123,7 @@ def test_parallel_identity(tmp_path, monkeypatch):
|
||||
# the block size so every chunk splits into many FFT tasks — the worst
|
||||
# case for boundary bugs.
|
||||
monkeypatch.setattr(compute, "_TOTAL_BYTES_BUDGET", 8 * n_frames * spf * 4)
|
||||
monkeypatch.setattr(compute, "_FFT_BLOCK", 4)
|
||||
monkeypatch.setattr(compute, "_FFT_BLOCK_MAX", 4)
|
||||
fft_rows = compute._plan_fft_rows(n_frames, spf, compute._TOTAL_BYTES_BUDGET)
|
||||
assert fft_rows < n_rows, \
|
||||
f"FFT work actually splits into multiple chunks ({fft_rows} of {n_rows})"
|
||||
@@ -155,43 +156,10 @@ def test_parallel_identity(tmp_path, monkeypatch):
|
||||
"rf image identical (masked, padded/zoom)"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spf,bps", [(64, 2), (37, 1)])
|
||||
def test_zoom_identity(tmp_path, monkeypatch, spf, bps):
|
||||
"""The zoom peak search must reproduce the full padded-rfft argmax
|
||||
bit-for-bit, across pad factors, masking, bg-sub, dtype, and backend."""
|
||||
path = tmp_path / f"zoom_{spf}.sras"
|
||||
gen.write(path, n_angles=2, seed=6, samples_per_frame=spf, bps=bps)
|
||||
sras = SrasFile(str(path))
|
||||
dc4 = dc_image_mv(sras, 0, CH4_IDX)
|
||||
thr = float(np.median(dc4))
|
||||
|
||||
backends = ["scipy"] + (["pyfftw"] if compute.PYFFTW_AVAILABLE else [])
|
||||
for backend in backends:
|
||||
monkeypatch.setattr(compute, "_fft_backend", backend)
|
||||
for pad in (4, 8, 40):
|
||||
n_fft = spf * pad
|
||||
for thr_v in (None, thr):
|
||||
for bg in (False, True):
|
||||
ref = compute_rf_image(sras, 0, dc_threshold_mv=thr_v,
|
||||
apply_bg_sub=bg, n_fft=n_fft,
|
||||
exact=True)
|
||||
zoom = compute_rf_image(sras, 0, dc_threshold_mv=thr_v,
|
||||
apply_bg_sub=bg, n_fft=n_fft)
|
||||
diff = int((ref != zoom).sum())
|
||||
assert diff == 0, \
|
||||
(f"{diff} px differ: backend={backend} pad={pad} "
|
||||
f"thr={thr_v} bg={bg} spf={spf}")
|
||||
|
||||
# A threshold above every pixel masks everything: both paths must agree
|
||||
# on an all-zero image.
|
||||
all_masked = compute_rf_image(sras, 0, dc_threshold_mv=1e9, n_fft=spf * 8)
|
||||
assert not all_masked.any()
|
||||
|
||||
|
||||
def test_zoom_identity_fuzz():
|
||||
"""Hammer _peak_bins_zoom directly with adversarial spectra: noise,
|
||||
def test_peak_bins_fuzz():
|
||||
"""Hammer _peak_bins directly with adversarial spectra: noise,
|
||||
un-subtracted DC offsets, on-bin and off-bin tones, near-tie tone pairs,
|
||||
and all-zero rows."""
|
||||
and all-zero rows — against an independent scipy.fft reference."""
|
||||
import scipy.fft as scipy_fft
|
||||
|
||||
rng = np.random.default_rng(42)
|
||||
@@ -222,14 +190,102 @@ def test_zoom_identity_fuzz():
|
||||
P[:, 0] = 0.0
|
||||
ref = np.argmax(P, axis=1)
|
||||
|
||||
zp = compute._zoom_plan(spf, n_fft)
|
||||
got = compute._peak_bins_zoom(w, zp)
|
||||
got = compute._peak_bins(w, n_fft)
|
||||
bad = np.nonzero(ref != got)[0]
|
||||
assert not len(bad), \
|
||||
(f"spf={spf} pad={pad}: rows {bad.tolist()} picked "
|
||||
f"{got[bad].tolist()} instead of {ref[bad].tolist()}")
|
||||
|
||||
|
||||
def test_peak_bins_fuzz_min_freq():
|
||||
"""Same adversarial-spectra fuzz as test_peak_bins_fuzz, but with a swept
|
||||
min_bin floor: bins below the floor must be excluded from the argmax
|
||||
exactly as the independent scipy.fft reference is, when zeroed the same
|
||||
way before argmax."""
|
||||
import scipy.fft as scipy_fft
|
||||
|
||||
rng = np.random.default_rng(43)
|
||||
for _ in range(25):
|
||||
spf = int(rng.integers(16, 220))
|
||||
pad = int(rng.choice([4, 5, 8, 16, 40]))
|
||||
n_fft = spf * pad
|
||||
n_wf = 24
|
||||
w = rng.normal(scale=20.0, size=(n_wf, spf))
|
||||
t = np.arange(spf)
|
||||
for r in range(6):
|
||||
f = rng.uniform(1.0, spf / 2 - 1)
|
||||
w[r] = 60 * np.sin(2 * np.pi * f * t / spf) + w[r] * (r % 2)
|
||||
f1, f2 = rng.uniform(2.0, spf / 2 - 2, size=2)
|
||||
w[6] = 50 * np.sin(2 * np.pi * f1 * t / spf) \
|
||||
+ 49.9 * np.sin(2 * np.pi * f2 * t / spf)
|
||||
w[7] = 50 * np.sin(2 * np.pi * f1 * t / spf) \
|
||||
+ 50 * np.cos(2 * np.pi * f2 * t / spf)
|
||||
w[8] = 90 + rng.normal(scale=5.0, size=spf)
|
||||
w[9] = 0.0
|
||||
w = w.astype(np.float32)
|
||||
|
||||
S = scipy_fft.rfft(w, n=n_fft, axis=-1, workers=1)
|
||||
P = S.real ** 2
|
||||
P += S.imag ** 2
|
||||
n_bins_fine = n_fft // 2 + 1
|
||||
min_bin = int(rng.integers(1, max(2, n_bins_fine // 3)))
|
||||
P[:, :min_bin] = 0.0
|
||||
ref = np.argmax(P, axis=1)
|
||||
|
||||
got = compute._peak_bins(w, n_fft, min_bin)
|
||||
bad = np.nonzero(ref != got)[0]
|
||||
assert not len(bad), \
|
||||
(f"spf={spf} pad={pad} min_bin={min_bin}: rows {bad.tolist()} picked "
|
||||
f"{got[bad].tolist()} instead of {ref[bad].tolist()}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spf", [64, 500, 2500])
|
||||
def test_fft_block_for(spf):
|
||||
"""Block size == _FFT_BLOCK_MAX at natural resolution, shrinks and stays
|
||||
>= _FFT_BLOCK_MIN as n_len grows, and the implied per-thread byte
|
||||
estimate respects _FFT_PLAN_BYTES_BUDGET except when the floor is
|
||||
engaged."""
|
||||
block_natural = compute._fft_block_for(spf, spf)
|
||||
assert block_natural == compute._FFT_BLOCK_MAX
|
||||
|
||||
prev = compute._FFT_BLOCK_MAX
|
||||
for pad in (2, 4, 8, 40, 500):
|
||||
n_len = spf * pad
|
||||
block = compute._fft_block_for(spf, n_len)
|
||||
assert compute._FFT_BLOCK_MIN <= block <= prev
|
||||
bytes_per_wf = 4 * spf + 8 * (n_len // 2 + 1)
|
||||
if block > compute._FFT_BLOCK_MIN:
|
||||
assert block * bytes_per_wf <= compute._FFT_PLAN_BYTES_BUDGET
|
||||
prev = block
|
||||
|
||||
|
||||
def test_compute_rf_image_min_freq_mhz(tmp_path):
|
||||
"""min_freq_mhz threads through compute_rf_image end-to-end, for both
|
||||
the natural-resolution and padded paths: 0.0 (default) must reproduce
|
||||
the pre-existing image exactly, and a floor above every real peak must
|
||||
collapse the image to bin 0 (0 MHz) — the same fallback the low-level
|
||||
search uses when nothing survives the floor."""
|
||||
path = tmp_path / "floor_e2e.sras"
|
||||
gen.write(path, n_angles=1, seed=12, samples_per_frame=64)
|
||||
sras = SrasFile(str(path))
|
||||
huge_floor = float(sras.freq_axis_mhz(None)[-1]) + 1.0 # above Nyquist
|
||||
|
||||
for n_fft in (None, 64 * 8):
|
||||
unfiltered = compute_rf_image(sras, 0, dc_threshold_mv=None,
|
||||
apply_bg_sub=False, n_fft=n_fft)
|
||||
same = compute_rf_image(sras, 0, dc_threshold_mv=None, apply_bg_sub=False,
|
||||
n_fft=n_fft, min_freq_mhz=0.0)
|
||||
assert np.array_equal(unfiltered, same), \
|
||||
f"n_fft={n_fft}: min_freq_mhz=0.0 changed the output"
|
||||
assert unfiltered.any(), \
|
||||
f"n_fft={n_fft}: fixture should have real signal"
|
||||
|
||||
collapsed = compute_rf_image(sras, 0, dc_threshold_mv=None, apply_bg_sub=False,
|
||||
n_fft=n_fft, min_freq_mhz=huge_floor)
|
||||
assert not collapsed.any(), \
|
||||
f"n_fft={n_fft}: floor above Nyquist should collapse to 0 MHz"
|
||||
|
||||
|
||||
def test_nomask_equals_low_threshold(tmp_path):
|
||||
"""dc_threshold_mv=None must equal a threshold below every pixel, while
|
||||
skipping the CH4 read."""
|
||||
@@ -307,42 +363,50 @@ def test_legacy_parse(tmp_path):
|
||||
|
||||
|
||||
def test_sras_average(tmp_path):
|
||||
"""The sras_average.py CLI: frame averaging with remainder handling."""
|
||||
src = tmp_path / "legacy_v4.sras"
|
||||
meta = gen.write_legacy(src, version=4, n_angles=2, n_rows=4, n_frames=12,
|
||||
samples_per_frame=32, seed=4)
|
||||
dst = tmp_path / "legacy_v4_avg.sras"
|
||||
"""The sras_average.py CLI: v6 frame averaging with remainder handling,
|
||||
per-angle ragged geometry, and the laser_freq_hz X-axis correction."""
|
||||
src = tmp_path / "v6.sras"
|
||||
meta = gen.write(src, n_angles=2, seed=4, samples_per_frame=32,
|
||||
geometry=[(4, 12)])
|
||||
src_sras = SrasFile(str(src))
|
||||
|
||||
dst = tmp_path / "v6_avg.sras"
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(REPO / "sras_average.py"), str(src), str(dst), "--n", "4"],
|
||||
capture_output=True, text=True, cwd=REPO)
|
||||
assert proc.returncode == 0, (proc.stderr or proc.stdout).strip()[-200:]
|
||||
|
||||
avg = SrasFile(str(dst))
|
||||
assert avg.version == 4
|
||||
assert avg.version == 6
|
||||
assert list(avg.n_frames) == [3, 3], f"{list(avg.n_frames)}"
|
||||
assert (avg.n_angles == 2 and list(avg.n_rows) == [4, 4]
|
||||
and avg.n_channels == meta["n_channels"])
|
||||
assert np.allclose(avg.ch_ymult_mv, SrasFile(str(src)).ch_ymult_mv), \
|
||||
and avg.n_channels == src_sras.n_channels == 3)
|
||||
assert np.allclose(avg.ch_ymult_mv, src_sras.ch_ymult_mv), \
|
||||
"calibration preserved"
|
||||
assert np.array_equal(avg.background, SrasFile(str(src)).background), \
|
||||
assert np.array_equal(avg.background, src_sras.background), \
|
||||
"background preserved"
|
||||
src_data = meta["data"]
|
||||
# int16 (not float32) before .mean(): matches average_rows' own
|
||||
assert avg.laser_freq_hz == pytest.approx(src_sras.laser_freq_hz / 4), \
|
||||
"laser_freq_hz divided by N keeps pixel_x_mm correct after binning"
|
||||
assert np.array_equal(avg.x_start_mm, src_sras.x_start_mm), \
|
||||
"per-angle x_start unchanged"
|
||||
|
||||
src_waves = meta["waveforms"]
|
||||
# int16 (not float32) before .mean(): matches _average_block's own
|
||||
# float64-accumulator behavior for integer input, so this doesn't
|
||||
# drift from what average_rows actually guarantees.
|
||||
expect0 = src_data[0][:, :, 0:4, :].astype(np.int16).mean(axis=2).astype(np.int16)
|
||||
# drift from what _average_block actually guarantees.
|
||||
expect0 = src_waves[0][:, :, 0:4, :].astype(np.int16).mean(axis=2).astype(np.int16)
|
||||
assert np.array_equal(np.asarray(avg.data[0])[:, :, 0, :], expect0), \
|
||||
"first averaged group equals the mean of its 4 source frames"
|
||||
|
||||
# Remainder handling: 12 frames / 5 -> 2 full groups + 1 partial.
|
||||
dst2 = tmp_path / "legacy_v4_avg5.sras"
|
||||
dst2 = tmp_path / "v6_avg5.sras"
|
||||
proc2 = subprocess.run([sys.executable, str(REPO / "sras_average.py"),
|
||||
str(src), str(dst2), "--n", "5"],
|
||||
capture_output=True, text=True, cwd=REPO)
|
||||
assert proc2.returncode == 0, (proc2.stderr or proc2.stdout).strip()[-200:]
|
||||
assert list(SrasFile(str(dst2)).n_frames) == [3, 3], \
|
||||
"partial trailing group kept by default"
|
||||
dst3 = tmp_path / "legacy_v4_avg5d.sras"
|
||||
dst3 = tmp_path / "v6_avg5d.sras"
|
||||
proc3 = subprocess.run([sys.executable, str(REPO / "sras_average.py"),
|
||||
str(src), str(dst3), "--n", "5", "--discard-remainder"],
|
||||
capture_output=True, text=True, cwd=REPO)
|
||||
@@ -351,6 +415,59 @@ def test_sras_average(tmp_path):
|
||||
"--discard-remainder drops the partial group"
|
||||
|
||||
|
||||
def test_sras_average_v7_cache_dropped(tmp_path):
|
||||
"""A v7 input's cache tail is indexed by frame count, so it's invalid
|
||||
after averaging changes that count -- the output must always be plain
|
||||
v6, never a v7 carrying a stale cache."""
|
||||
src = tmp_path / "v7.sras"
|
||||
gen.write(src, n_angles=2, seed=1, samples_per_frame=32, geometry=[(3, 8)])
|
||||
src_sras = SrasFile(str(src))
|
||||
src_sras.write_v7_cache(
|
||||
new_dc3_mv=[compute_dc_image(src_sras, a, CH3_IDX) for a in range(src_sras.n_angles)],
|
||||
new_dc4_mv=[compute_dc_image(src_sras, a, CH4_IDX) for a in range(src_sras.n_angles)])
|
||||
assert SrasFile(str(src)).version == 7
|
||||
|
||||
dst = tmp_path / "v7_avg.sras"
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(REPO / "sras_average.py"), str(src), str(dst), "--n", "2"],
|
||||
capture_output=True, text=True, cwd=REPO)
|
||||
assert proc.returncode == 0, (proc.stderr or proc.stdout).strip()[-200:]
|
||||
assert SrasFile(str(dst)).version == 6, "cache-bearing input still writes plain v6"
|
||||
|
||||
|
||||
def test_sras_average_rejects_legacy(tmp_path):
|
||||
"""This tool only speaks v6/v7 now; a legacy file must fail clearly
|
||||
rather than being silently misparsed."""
|
||||
src = tmp_path / "legacy_v4.sras"
|
||||
gen.write_legacy(src, version=4, n_angles=1, n_rows=2, n_frames=8,
|
||||
samples_per_frame=16, seed=0)
|
||||
dst = tmp_path / "legacy_v4_avg.sras"
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(REPO / "sras_average.py"), str(src), str(dst), "--n", "2"],
|
||||
capture_output=True, text=True, cwd=REPO)
|
||||
assert proc.returncode != 0
|
||||
assert "v6" in proc.stderr and "v7" in proc.stderr
|
||||
|
||||
|
||||
def test_sras_average_chunking_matches_unchunked(tmp_path):
|
||||
"""A tiny memory budget (forcing one row per chunk) must produce
|
||||
byte-identical output to a huge budget (everything in one chunk) -- the
|
||||
load-bearing correctness claim of the memory-bounded rewrite: chunk
|
||||
boundaries must never affect the averaged result."""
|
||||
src = tmp_path / "v6.sras"
|
||||
gen.write(src, n_angles=2, seed=7, samples_per_frame=48,
|
||||
geometry=[(6, 10), (5, 13)])
|
||||
sras = SrasFile(str(src))
|
||||
|
||||
dst_tiny = tmp_path / "avg_tiny.sras"
|
||||
dst_big = tmp_path / "avg_big.sras"
|
||||
sras_average.write_v6_averaged(sras, dst_tiny, 3, False, budget=1)
|
||||
sras_average.write_v6_averaged(sras, dst_big, 3, False, budget=1 << 30)
|
||||
|
||||
assert dst_tiny.read_bytes() == dst_big.read_bytes(), \
|
||||
"chunk size must not affect the averaged output"
|
||||
|
||||
|
||||
def test_unsupported_version_reported(tmp_path):
|
||||
"""cache_file must report, not raise, for a file it can't handle."""
|
||||
bogus = tmp_path / "bogus.sras"
|
||||
|
||||
@@ -223,6 +223,54 @@ def test_threshold_change_recomputes(ctx):
|
||||
f"masking zeroed some pixels ({n_zero} of {win._current_image.size})"
|
||||
|
||||
|
||||
def test_highlight_masked_pixels(ctx):
|
||||
"""Masked (below-threshold) pixels are drawn as NaN - filled with a
|
||||
highlight color, separate from the normal colormap - so they can't be
|
||||
mistaken for a real, possibly-low-frequency pixel; unchecking restores
|
||||
the old behavior where both blend into the same plain 0."""
|
||||
win = ctx.win
|
||||
assert win.chk_highlight_masked.isChecked(), "on by default"
|
||||
dc4 = win._dc_cache[(0, CH4_IDX)]
|
||||
expect_masked = dc4 < win.spin_threshold_mv.value()
|
||||
assert expect_masked.any() and not expect_masked.all(), \
|
||||
"fixture threshold should mask some but not all pixels"
|
||||
|
||||
calls = []
|
||||
orig = win.image_canvas.show_image
|
||||
|
||||
def spy(img, *a, **kw):
|
||||
calls.append((np.array(img, copy=True), kw.get("bad_color")))
|
||||
return orig(img, *a, **kw)
|
||||
|
||||
with patch.object(win.image_canvas, "show_image", side_effect=spy):
|
||||
win._redraw_image(win._current_image)
|
||||
shown, bad_color = calls[-1]
|
||||
assert bad_color is not None, "highlight color set while checkbox is on"
|
||||
# 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()
|
||||
with patch.object(win.image_canvas, "show_image", side_effect=spy):
|
||||
win._redraw_image(win._current_image)
|
||||
shown2, bad_color2 = calls[-1]
|
||||
assert bad_color2 is None, "no highlight color once unchecked"
|
||||
assert not np.isnan(shown2).any(), "unchecked: no pixel pulled out to NaN"
|
||||
assert np.array_equal(shown2, win._current_image), \
|
||||
"unchecked: displayed array is the raw, unmodified image"
|
||||
|
||||
win.chk_highlight_masked.setChecked(True)
|
||||
pump(60)
|
||||
|
||||
|
||||
def test_bg_sub_toggle(ctx):
|
||||
"""bg-sub no longer gates the display: it only affects a future live
|
||||
compute for an angle with nothing cached yet, or an explicit batch
|
||||
|
||||
+34
-15
@@ -14,7 +14,7 @@ import pytest
|
||||
|
||||
import sras_compute as compute
|
||||
from sras_compute import compute_rf_image, dc_image_mv
|
||||
from sras_format import CH4_IDX, SrasFile
|
||||
from sras_format import CH1_IDX, CH4_IDX, SrasFile
|
||||
import tools.make_test_sras as gen
|
||||
|
||||
|
||||
@@ -193,26 +193,45 @@ def test_row_average_respects_own_center_mask(tmp_path):
|
||||
|
||||
def test_row_average_composes_with_padding(tmp_path):
|
||||
"""row_avg_n and n_fft (zero-padding) are independent knobs: using them
|
||||
together must not raise, and must still agree with the exact (non-zoom)
|
||||
reference path at that pad factor -- i.e. row-averaging composes with
|
||||
the zoom peak search correctly, not just with the direct one."""
|
||||
together must not raise, and must agree bit-for-bit with an independent
|
||||
reference that row-averages the raw waveforms and background-subtracts
|
||||
them, then runs a plain scipy rfft + argmax at the same pad factor --
|
||||
i.e. row-averaging composes correctly with the padded peak search."""
|
||||
import scipy.fft as scipy_fft
|
||||
|
||||
path = tmp_path / "padded_rowavg.sras"
|
||||
gen.write(path, n_angles=1, seed=12, samples_per_frame=64)
|
||||
sras = SrasFile(str(path))
|
||||
spf = sras.samples_per_frame
|
||||
n_rows, n_frames = sras.image_shape(0)
|
||||
n_fft = spf * 40
|
||||
|
||||
raw_natural = compute_rf_image(sras, 0, dc_threshold_mv=None, apply_bg_sub=True)
|
||||
avg_natural = compute_rf_image(sras, 0, dc_threshold_mv=None, apply_bg_sub=True,
|
||||
row_avg_n=3)
|
||||
avg_padded_zoom = compute_rf_image(sras, 0, dc_threshold_mv=None, apply_bg_sub=True,
|
||||
row_avg_n=3, n_fft=spf * 40)
|
||||
avg_padded_exact = compute_rf_image(sras, 0, dc_threshold_mv=None, apply_bg_sub=True,
|
||||
row_avg_n=3, n_fft=spf * 40, exact=True)
|
||||
avg_padded = compute_rf_image(sras, 0, dc_threshold_mv=None, apply_bg_sub=True,
|
||||
row_avg_n=3, n_fft=n_fft)
|
||||
|
||||
assert avg_natural.shape == raw_natural.shape == avg_padded_zoom.shape
|
||||
assert np.all(np.isfinite(avg_padded_zoom))
|
||||
assert np.array_equal(avg_padded_zoom, avg_padded_exact), \
|
||||
"row-averaged waveforms feed the zoom and exact FFT paths identically"
|
||||
assert avg_natural.shape == raw_natural.shape == avg_padded.shape
|
||||
assert np.all(np.isfinite(avg_padded))
|
||||
|
||||
weights = compute._row_average_weights(3)
|
||||
freq32 = sras.freq_axis_mhz(n_fft).astype(np.float32)
|
||||
data = sras.data[0]
|
||||
expected = np.zeros((n_rows, n_frames), dtype=np.float32)
|
||||
for r in range(n_rows):
|
||||
v = np.ones(n_frames, dtype=bool)
|
||||
avg = compute._row_average_waveforms(
|
||||
data[r, CH1_IDX].astype(np.float32), v, weights)
|
||||
avg = avg - sras.background
|
||||
S = scipy_fft.rfft(avg, n=n_fft, axis=-1, workers=1)
|
||||
power = S.real ** 2 + S.imag ** 2
|
||||
power[:, 0] = 0.0
|
||||
expected[r] = freq32[np.argmax(power, axis=1)]
|
||||
|
||||
assert np.array_equal(avg_padded, expected), \
|
||||
"row-averaged waveforms feed the padded peak search identically " \
|
||||
"to an independent reference"
|
||||
|
||||
|
||||
def test_row_average_improves_snr_recovery():
|
||||
@@ -233,8 +252,8 @@ def test_row_average_improves_snr_recovery():
|
||||
weights = compute._row_average_weights(8) # wide window: lots of averaging
|
||||
averaged = compute._row_average_waveforms(raw, valid, weights)
|
||||
|
||||
raw_bins = compute._peak_bins_direct(raw, spf)
|
||||
avg_bins = compute._peak_bins_direct(averaged, spf)
|
||||
raw_bins = compute._peak_bins(raw, spf)
|
||||
avg_bins = compute._peak_bins(averaged, spf)
|
||||
|
||||
raw_hits = int(np.sum(raw_bins == true_bin))
|
||||
avg_hits = int(np.sum(avg_bins == true_bin))
|
||||
@@ -257,7 +276,7 @@ def test_row_average_parallel_identity(tmp_path, monkeypatch):
|
||||
sras = SrasFile(str(path))
|
||||
|
||||
monkeypatch.setattr(compute, "_TOTAL_BYTES_BUDGET", 8 * n_frames * spf * 4)
|
||||
monkeypatch.setattr(compute, "_FFT_BLOCK", 4)
|
||||
monkeypatch.setattr(compute, "_FFT_BLOCK_MAX", 4)
|
||||
# row_avg_n > 0 halves the effective budget before chunk planning.
|
||||
fft_rows = compute._plan_fft_rows(n_frames, spf, compute._TOTAL_BYTES_BUDGET // 2)
|
||||
assert fft_rows < n_rows, \
|
||||
|
||||
+296
-4
@@ -90,7 +90,7 @@ 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_direct", "_peak_bins_zoom"):
|
||||
for name in ("_peak_bins",):
|
||||
original = getattr(compute, name)
|
||||
|
||||
def spy(*args, _f=original, **kwargs):
|
||||
@@ -147,7 +147,7 @@ def test_cache_is_stored_at_the_configured_pad(tmp_path, pad):
|
||||
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, "scipy", 0, pad) == ""
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user