noaa-goes-visualization/tests/test_learned_filler.py

656 lines
26 KiB
Python

"""The learned filler's wiring: the stack it is handed, and how it is loaded.
The model itself is covered in test_model.py. What matters here is that `bench.py`
hands it the same stack shape `suvi.samples` builds during training, that a band judged
bad is withheld from it, and that damage the judgment missed arrives as *corrupted
pixels* rather than as the pristine original.
"""
import numpy as np
import pytest
import bench
from conftest import solar_disc
from suvi import cases, fillers, paths, samples
WAVELENGTHS = paths.WAVELENGTHS
def overlay_for(satellites=(16, 18), count=40, deleted=()):
"""An Overlay whose slots resolve through in-memory lookups rather than files."""
base = 1715400000 // paths.CADENCE * paths.CADENCE
times = [base + i * paths.CADENCE for i in range(count)]
archive = {(s, w, t): f"truth/{s}/{w}/{t}"
for s in satellites for w in WAVELENGTHS for t in times}
return cases.Overlay(archive=archive, deleted=frozenset(deleted)), times, base
def reader(size=16, missing=()):
"""A six-band reader. `missing` names (satellite, time) pairs with no frame."""
frame = np.stack([solar_disc(size=size, radius=size // 3, peak=1.0 + i)
for i in range(6)]).astype(np.float32)
def read(satellite, when):
if (satellite, when) in missing:
return None
return frame.copy()
return read, frame
def judged_reader(monkeypatch, overlay, bad, size=16):
"""The real `bench._band_reader` over `overlay_for`'s in-memory archive: every
path decodes to the matching band of one six-band frame."""
_, frame = reader(size)
index = {wavelength: i for i, wavelength in enumerate(WAVELENGTHS)}
def read_image(path):
wavelength = int(path.split("/")[2])
return frame[index[wavelength]].copy(), None
monkeypatch.setattr(bench.fitsio, "read_image", read_image)
return bench._band_reader(overlay, WAVELENGTHS, set(bad)), frame
def build(overlay, target, read, bad=frozenset(), satellites=(16, 18)):
"""`bench._build_stack` for GOES-16, with anchors from the judgment `read` uses."""
band_times = bench._band_clean_times(overlay, satellites, WAVELENGTHS, bad)
return bench._build_stack(overlay, 16, target, satellites, WAVELENGTHS, read,
band_times)
# --------------------------------------------------------------------------- stack
def test_stack_matches_the_training_layout():
"""Trained on one stack shape, evaluated on another, would be a silent mismatch.
The window spans 560 slots so even the +/-256 rungs of the exponential ladder
exist; a real window edge simply omits the rungs it cannot reach.
"""
overlay, times, _ = overlay_for(count=560)
read, _ = reader()
stack = build(overlay, times[280], read)
offsets = {(entry["same_satellite"], entry["dt"] / paths.CADENCE) for entry in stack}
for source, offset in samples.stack_layout((16, 18), 16):
assert (source == 16, float(offset)) in offsets, f"missing {(source, offset)}"
def test_stack_carries_all_six_bands():
"""The model is joint across bands; a per-band stack would not fit it."""
overlay, times, _ = overlay_for()
read, _ = reader()
stack = build(overlay, times[20], read)
for entry in stack:
assert entry["image"].shape[0] == len(WAVELENGTHS)
def test_stack_holds_the_targets_undamaged_bands_at_dt_zero(monkeypatch):
"""As in training: the renderer fills only the bands a slot lacks, so the bands
it kept are the model's best evidence -- and the lost ones are withheld."""
overlay, times, _ = overlay_for()
target = times[20]
bad = {(16, 171, target), (16, 304, target)}
read, frame = judged_reader(monkeypatch, overlay, bad)
stack = build(overlay, target, read, bad)
own = [e for e in stack if e["same_satellite"] and e["dt"] == 0.0]
assert len(own) == 1 and own[0]["slot"] == (16, target)
assert own[0]["state"] == "available"
assert own[0]["bands"] == (True, True, False, True, True, False)
assert np.isnan(own[0]["image"][[2, 5]]).all()
np.testing.assert_allclose(own[0]["image"][[0, 1, 3, 4]], frame[[0, 1, 3, 4]])
def test_a_target_with_no_usable_band_is_a_missing_entry(monkeypatch):
overlay, times, _ = overlay_for()
target = times[20]
bad = {(16, w, target) for w in WAVELENGTHS}
read, _ = judged_reader(monkeypatch, overlay, bad)
stack = build(overlay, target, read, bad)
own = next(e for e in stack if e["same_satellite"] and e["dt"] == 0.0)
assert own["state"] == "missing" and own["image"] is None
def test_a_target_absent_from_the_archive_is_no_entry():
overlay, times, _ = overlay_for()
for wavelength in WAVELENGTHS:
del overlay.archive[(16, wavelength, times[20])]
read, _ = reader()
stack = build(overlay, times[20], read)
assert not any(e["same_satellite"] and e["dt"] == 0.0 for e in stack)
def test_stack_includes_the_counterpart_at_the_target_instant():
overlay, times, _ = overlay_for()
read, _ = reader()
stack = build(overlay, times[20], read)
assert any(entry["dt"] == 0.0 and not entry["same_satellite"] for entry in stack)
def test_judged_bad_neighbours_are_withheld(monkeypatch):
"""2024-04-17: shown to v8, N_LONG=0 static still got a small blend weight and
speckled every fill's far field. A judged-bad frame is a hole."""
overlay, times, _ = overlay_for()
target = times[20]
neighbour_time = target - paths.CADENCE
bad = {(16, w, neighbour_time) for w in WAVELENGTHS}
read, _ = judged_reader(monkeypatch, overlay, bad)
stack = build(overlay, target, read, bad)
entry = next(e for e in stack if e["slot"] == (16, neighbour_time))
assert entry["state"] == "missing"
assert entry["image"] is None
assert {e["state"] for e in stack} <= {"available", "missing"}
def test_a_neighbour_bad_in_one_band_keeps_its_other_bands_available(monkeypatch):
"""Per band: a fault in 195 withholds 195, not the frame's five clean bands."""
overlay, times, _ = overlay_for()
target = times[20]
neighbour_time = target - paths.CADENCE
bad = {(16, 195, neighbour_time)}
read, frame = judged_reader(monkeypatch, overlay, bad)
stack = build(overlay, target, read, bad)
entry = next(e for e in stack if e["slot"] == (16, neighbour_time))
assert entry["state"] == "available"
assert entry["bands"] == (True, True, True, False, True, True)
assert np.isnan(entry["image"][3]).all()
np.testing.assert_allclose(entry["image"][[0, 1, 2, 4, 5]], frame[[0, 1, 2, 4, 5]])
def test_unreadable_neighbours_arrive_missing_with_no_pixels():
overlay, times, _ = overlay_for()
target = times[20]
gone = (16, target - paths.CADENCE)
read, _ = reader(missing={gone})
stack = build(overlay, target, read)
entry = next(e for e in stack if e["slot"] == gone)
assert entry["state"] == "missing"
assert entry["image"] is None
def test_undamaged_neighbours_arrive_available():
overlay, times, _ = overlay_for()
read, frame = reader()
stack = build(overlay, times[20], read)
assert {e["state"] for e in stack} == {"available"}
for entry in stack:
np.testing.assert_allclose(entry["image"], frame)
def test_stack_adds_anchors_beyond_the_fixed_offsets(monkeypatch):
"""A long outage must still reach a real frame."""
overlay, times, _ = overlay_for(count=80)
target = times[40]
bad = {(16, w, target + n * paths.CADENCE)
for w in WAVELENGTHS for n in range(-30, 31)}
read, _ = judged_reader(monkeypatch, overlay, bad)
stack = build(overlay, target, read, bad)
reach = [abs(e["dt"]) for e in stack
if e["same_satellite"] and e["state"] == "available"]
assert reach, "no usable same-satellite frame found at all"
assert max(reach) >= 31 * paths.CADENCE
def test_stack_skips_slots_the_window_does_not_contain():
"""Near a window edge the offsets run off the end; those must not appear as data."""
overlay, times, _ = overlay_for(count=40)
read, _ = reader()
stack = build(overlay, times[0], read)
for entry in stack:
satellite, when = entry["slot"]
assert any((satellite, w, when) in overlay.archive for w in WAVELENGTHS)
# ------------------------------------------------------------------- band reader
def test_band_reader_returns_damaged_pixels_the_judgment_missed(tmp_path):
"""A filler handed the pristine frame for a corrupted slot reads the answer key."""
from conftest import write_fits
truth_frame = solar_disc(size=64, radius=20, peak=1.0)
damaged_frame = solar_disc(size=64, radius=20, peak=9.0)
when = 1715400000
archive_map, overrides = {}, {}
for wavelength in WAVELENGTHS:
good = str(tmp_path / f"t{wavelength}.fits")
harmed = str(tmp_path / f"d{wavelength}.fits")
write_fits(good, truth_frame)
write_fits(harmed, damaged_frame)
archive_map[(16, wavelength, when)] = good
overrides[(16, wavelength, when)] = harmed
overlay = cases.Overlay(archive=archive_map, overrides=overrides)
read = bench._band_reader(overlay, WAVELENGTHS, set())
got = read(16, when)
assert got.shape == (6, 64, 64)
# Compared loosely: the FITS tile compression is lossy at the 1e-3 level, and what
# is under test is which *file* was opened, not the codec.
assert got[0].max() == pytest.approx(damaged_frame.max(), rel=1e-2)
assert got[0].max() > truth_frame.max() * 5
def test_band_reader_caches_but_stays_bounded(tmp_path):
"""Six bands of 1280x1280 float32 is 39 MB; an unbounded cache exhausts the VM."""
from conftest import write_fits
frame = solar_disc(size=32, radius=10, peak=1.0)
archive_map = {}
times = [1715400000 + i * paths.CADENCE for i in range(10)]
for when in times:
for wavelength in WAVELENGTHS:
path = str(tmp_path / f"{when}_{wavelength}.fits")
write_fits(path, frame)
archive_map[(16, wavelength, when)] = path
overlay = cases.Overlay(archive=archive_map)
reads = []
real = bench.fitsio.read_image
def counting(path):
reads.append(path)
return real(path)
slot_bytes = 6 * 32 * 32 * 4
read = bench._band_reader(overlay, WAVELENGTHS, set(), budget=3 * slot_bytes)
original = bench.fitsio.read_image
bench.fitsio.read_image = counting
try:
read(16, times[0])
first = len(reads)
read(16, times[0])
assert len(reads) == first, "a repeat read was not served from cache"
for when in times[1:5]:
read(16, when)
before_evicted = len(reads)
read(16, times[0]) # evicted by the 3-slot budget
assert len(reads) > before_evicted, "cache grew past its budget"
# Least recently used goes first: touching a slot keeps it.
for when in times[5:8]:
read(16, when)
read(16, times[5]) # refresh the oldest
read(16, times[8]) # evicts times[6], not times[5]
before = len(reads)
read(16, times[5])
assert len(reads) == before, "a recently used slot was evicted"
read(16, times[6])
assert len(reads) > before, "the least recently used slot was kept"
finally:
bench.fitsio.read_image = original
def test_band_reader_builds_one_float32_array_per_slot(monkeypatch):
overlay, times, _ = overlay_for()
read, frame = judged_reader(monkeypatch, overlay, {(16, 171, times[3])})
image = read(16, times[3])
assert image.dtype == np.float32 and image.shape == (6, 16, 16)
assert np.isnan(image[2]).all()
np.testing.assert_array_equal(image[[0, 1, 3, 4, 5]], frame[[0, 1, 3, 4, 5]])
def _write_bands(tmp_path, wavelengths, when=1715400000, satellite=16):
from conftest import write_fits
frame = solar_disc(size=32, radius=10, peak=1.0)
archive_map = {}
for wavelength in wavelengths:
path = str(tmp_path / f"{satellite}_{wavelength}.fits")
write_fits(path, frame)
archive_map[(satellite, wavelength, when)] = path
return cases.Overlay(archive=archive_map), when
def test_band_reader_keeps_the_bands_a_slot_has(tmp_path):
"""Per band, not all or nothing: a slot without 304 still carries five real
bands, and the missing one is NaN so nothing mistakes it for dark sky."""
overlay, when = _write_bands(tmp_path, WAVELENGTHS[:-1])
image = bench._band_reader(overlay, WAVELENGTHS, set())(16, when)
assert image.shape == (6, 32, 32)
assert np.isnan(image[5]).all()
assert np.isfinite(image[:5]).all()
assert bench.band_presence(image) == (True,) * 5 + (False,)
assert not bench._complete(image)
def test_band_reader_withholds_bands_judged_bad(tmp_path):
"""A judged-bad band reads as absent, like a missing one -- its pixels never
reach the model -- and a slot bad in every band reads as nothing at all."""
overlay, when = _write_bands(tmp_path, WAVELENGTHS)
image = bench._band_reader(overlay, WAVELENGTHS, {(16, 131, when)})(16, when)
assert np.isnan(image[1]).all()
assert bench.band_presence(image) == (True, False, True, True, True, True)
other = bench._band_reader(overlay, WAVELENGTHS, {(18, 131, when)})(16, when)
assert bench._complete(other) # the other satellite's verdict
every = {(16, wavelength, when) for wavelength in WAVELENGTHS}
assert bench._band_reader(overlay, WAVELENGTHS, every)(16, when) is None
def test_band_reader_returns_none_only_when_no_band_reads(tmp_path):
overlay, when = _write_bands(tmp_path, ())
assert bench._band_reader(overlay, WAVELENGTHS, set())(16, when) is None
overlay, when = _write_bands(tmp_path, WAVELENGTHS)
assert bench._complete(bench._band_reader(overlay, WAVELENGTHS, set())(16, when))
assert not bench._complete(None)
# ------------------------------------------------------------ per-band stacks
def partial_reader(size=16, absent=()):
"""A reader whose slots lack the bands named in `absent`:
{(satellite, time): (band index, ...)}."""
read, frame = reader(size)
absent = dict(absent)
def read_partial(satellite, when):
image = read(satellite, when)
for band in absent.get((satellite, when), ()):
image[band] = np.nan
return image
return read_partial, frame
def test_entries_say_which_bands_they_hold():
overlay, times, _ = overlay_for()
read, _ = reader()
stack = build(overlay, times[20], read)
assert all(entry["bands"] == (True,) * 6 for entry in stack)
def test_a_counterpart_missing_a_band_still_joins_the_stack():
"""2024-03-03: GOES-18 had no 304 all day. Its 94 and 131 were exactly what
GOES-16 lacked, and dropping the whole frame left the model nothing."""
overlay, times, _ = overlay_for()
target = times[20]
read, _ = partial_reader(absent={(18, target): (5,)})
stack = build(overlay, target, read)
counterpart = next(e for e in stack if e["slot"] == (18, target))
assert counterpart["state"] == "available"
assert counterpart["bands"] == (True,) * 5 + (False,)
assert np.isfinite(counterpart["image"][:5]).all()
def test_band_clean_times_are_per_satellite_and_band():
overlay, times, _ = overlay_for(count=5)
bad = {(16, 94, times[1]), (18, 304, times[3])}
band_times = bench._band_clean_times(overlay, (16, 18), WAVELENGTHS, bad)
assert band_times[16][94] == [times[i] for i in (0, 2, 3, 4)]
assert band_times[16][304] == times
assert band_times[18][304] == [times[i] for i in (0, 1, 2, 4)]
def test_anchors_reach_each_band_separately(monkeypatch):
"""A band bad for a long stretch gets its own anchor at its nearest clean
slot, though every other band is clean right next to the target."""
overlay, times, _ = overlay_for(count=140)
target = times[70]
step = paths.CADENCE
bad = {(16, 94, target + n * step) for n in range(-45, 46)}
read, _ = judged_reader(monkeypatch, overlay, bad)
stack = build(overlay, target, read, bad)
anchors = {e["slot"][1] - target: e for e in stack
if e["same_satellite"] and e["slot"][1] in (target - 46 * step,
target + 46 * step)}
assert sorted(anchors) == [-46 * step, 46 * step]
assert all(e["bands"][0] and e["state"] == "available" for e in anchors.values())
def test_an_anchor_leaves_out_its_bands_judged_bad(monkeypatch):
overlay, times, _ = overlay_for(count=140)
target = times[70]
step = paths.CADENCE
bad = ({(16, 94, target + n * step) for n in range(-45, 46)}
| {(16, 304, target - 46 * step)})
read, _ = judged_reader(monkeypatch, overlay, bad)
stack = build(overlay, target, read, bad)
anchor = next(e for e in stack if e["slot"] == (16, target - 46 * step))
assert anchor["bands"] == (True,) * 5 + (False,)
assert anchor["state"] == "available"
# -------------------------------------------------------------------------- filler
def test_learned_returns_nothing_without_a_stack():
assert fillers.learned(fillers.FillContext()) is None
def test_learned_returns_nothing_without_a_checkpoint(monkeypatch):
monkeypatch.delenv(fillers.LEARNED_CHECKPOINT_ENV, raising=False)
fillers._LEARNED.clear()
context = fillers.FillContext(stack=[
{"image": np.zeros((6, 16, 16), np.float32), "state": "available",
"dt": -240.0, "same_satellite": True}
])
assert fillers.learned(context) is None
def test_learned_is_registered_alongside_the_others():
assert "learned" in fillers.FILLERS
assert fillers.FILLERS["learned"] is fillers.learned
def test_existing_fillers_ignore_the_stack():
"""Adding `stack` must not perturb any measured baseline."""
before = solar_disc(size=64, radius=20, peak=1.0)
after = solar_disc(size=64, radius=20, peak=1.2)
extra = np.stack([after * 5] * 6)
plain = fillers.FillContext(before=before, after=after, dt_before=240, dt_after=240)
with_stack = fillers.FillContext(
before=before, after=after, dt_before=240, dt_after=240,
stack=[{"image": extra, "state": "available", "dt": -240.0,
"same_satellite": True}],
)
for name in ("hold_last", "linear_blend", "optical_flow", "solar_rotation"):
np.testing.assert_allclose(fillers.FILLERS[name](plain),
fillers.FILLERS[name](with_stack))
def test_learned_runs_end_to_end_against_a_saved_checkpoint(tmp_path, monkeypatch):
"""Checkpoint -> load -> fill, at the archive's native frame size."""
torch = pytest.importorskip("torch")
from suvi import model
net = model.build(base=8, depth=2)
path = tmp_path / "model.pt"
torch.save({"model": net.state_dict(), "args": {"base": 8, "depth": 2, "cond_layout": list(model.COND_LAYOUT)},
"epoch": 0}, path)
monkeypatch.setenv(fillers.LEARNED_CHECKPOINT_ENV, str(path))
fillers._LEARNED.clear()
def six(peak):
return np.stack([solar_disc(size=128, radius=40, peak=peak + b * 0.2)
for b in range(6)]).astype(np.float32)
when = 1715400000
context = fillers.FillContext(
stack=[
{"image": six(1.0), "state": "available", "dt": -240.0,
"same_satellite": True, "slot": (16, when - 240)},
{"image": six(1.1), "state": "available", "dt": 240.0,
"same_satellite": True, "slot": (16, when + 240)},
{"image": six(0.9), "state": "available", "dt": 0.0,
"same_satellite": False, "slot": (18, when)},
{"image": None, "state": "missing", "dt": -960.0,
"same_satellite": True, "slot": (16, when - 960)},
],
calibration=(six(0.9), six(1.05)),
)
filled = fillers.learned(context)
assert filled.shape == (6, 128, 128)
assert np.isfinite(filled).all()
fillers._LEARNED.clear()
def _save_tiny_model(tmp_path, monkeypatch):
torch = pytest.importorskip("torch")
from suvi import model
net = model.build(base=8, depth=2)
path = tmp_path / "model.pt"
torch.save({"model": net.state_dict(),
"args": {"base": 8, "depth": 2, "cond_layout": list(model.COND_LAYOUT)}}, path)
monkeypatch.setenv(fillers.LEARNED_CHECKPOINT_ENV, str(path))
fillers._LEARNED.clear()
def _six(peak, size=64, absent=()):
image = np.stack([solar_disc(size=size, radius=size // 3, peak=peak + b * 0.2)
for b in range(6)]).astype(np.float32)
image[list(absent)] = np.nan
return image
def _entry(image, dt, same, slot):
return {"image": image, "bands": bench.band_presence(image), "state": "available",
"dt": dt, "same_satellite": same, "slot": slot}
def test_assemble_stack_reports_which_bands_each_entry_holds(tmp_path, monkeypatch):
torch = pytest.importorskip("torch")
when = 1715400000
context = fillers.FillContext(stack=[
_entry(_six(1.0, absent=(0, 1)), -240.0, True, (16, when - 240)),
_entry(_six(1.0, absent=(5,)), 0.0, False, (18, when)),
])
aligned, condition, present = fillers.assemble_stack(context, torch,
torch.device("cpu"))
assert present.tolist() == [[[0, 0, 1, 1, 1, 1], [1, 1, 1, 1, 1, 0]]]
assert torch.isfinite(aligned).all()
def test_learned_fills_from_bands_split_across_satellites(tmp_path, monkeypatch):
"""The 2024-03-03 case: the target satellite lacks 94/131, the counterpart
lacks 304. Every band is observed somewhere, so every band is filled."""
_save_tiny_model(tmp_path, monkeypatch)
when = 1715400000
context = fillers.FillContext(stack=[
_entry(_six(1.0, absent=(0, 1)), -240.0, True, (16, when - 240)),
_entry(_six(1.1, absent=(0, 1)), 240.0, True, (16, when + 240)),
_entry(_six(0.9, absent=(5,)), 0.0, False, (18, when)),
])
filled = fillers.learned(context)
assert filled.shape == (6, 64, 64)
assert np.isfinite(filled).all()
fillers._LEARNED.clear()
def test_learned_releases_the_raw_stack_before_the_forward_pass(tmp_path, monkeypatch):
"""The raw six-band images are a second copy of everything the model blends;
held through the forward pass they were ~0.8 GB of a 1280px fill's peak."""
import weakref
_save_tiny_model(tmp_path, monkeypatch)
when = 1715400000
images = [_six(1.0), _six(1.1), _six(0.9)]
refs = [weakref.ref(image) for image in images]
context = fillers.FillContext(
stack=[_entry(images[0], -240.0, True, (16, when - 240)),
_entry(images[1], 240.0, True, (16, when + 240)),
_entry(images[2], 0.0, False, (18, when))],
calibration=(_six(0.9), _six(1.0)),
)
del images
net = fillers.load_learned()[0]
alive_at_forward = []
real_forward = type(net).forward
def forward(self, *args, **kwargs):
alive_at_forward.append(sum(ref() is not None for ref in refs))
return real_forward(self, *args, **kwargs)
monkeypatch.setattr(type(net), "forward", forward)
filled = fillers.learned(context)
assert filled.shape == (6, 64, 64)
assert alive_at_forward == [0]
assert context.stack == [] and context.calibration is None
fillers._LEARNED.clear()
def test_a_band_no_frame_holds_comes_back_nan(tmp_path, monkeypatch):
"""Not a black band: the caller must be able to tell 'unfillable' from 'dark'."""
_save_tiny_model(tmp_path, monkeypatch)
when = 1715400000
context = fillers.FillContext(stack=[
_entry(_six(1.0, absent=(2,)), -240.0, True, (16, when - 240)),
_entry(_six(0.9, absent=(2,)), 0.0, False, (18, when)),
])
filled = fillers.learned(context)
assert np.isnan(filled[2]).all()
assert np.isfinite(np.delete(filled, 2, axis=0)).all()
fillers._LEARNED.clear()
def test_learned_declines_a_stack_with_no_pixels_anywhere(tmp_path, monkeypatch):
"""Silently emitting a black frame here is how a fabricated fill would enter the
archive; the contract is None."""
torch = pytest.importorskip("torch")
from suvi import model
net = model.build(base=8, depth=2)
path = tmp_path / "model.pt"
torch.save({"model": net.state_dict(), "args": {"base": 8, "depth": 2, "cond_layout": list(model.COND_LAYOUT)}}, path)
monkeypatch.setenv(fillers.LEARNED_CHECKPOINT_ENV, str(path))
fillers._LEARNED.clear()
context = fillers.FillContext(stack=[
{"image": None, "state": "missing", "dt": -240.0, "same_satellite": True,
"slot": (16, 1715400000 - 240)},
])
assert fillers.learned(context) is None
fillers._LEARNED.clear()
def test_learned_checkpoint_is_loaded_once(tmp_path, monkeypatch):
"""Thousands of slots per bench run; reloading 14M parameters each time would
dominate the wall clock."""
torch = pytest.importorskip("torch")
from suvi import model
net = model.build(base=8, depth=2)
path = tmp_path / "model.pt"
torch.save({"model": net.state_dict(), "args": {"base": 8, "depth": 2, "cond_layout": list(model.COND_LAYOUT)}}, path)
monkeypatch.setenv(fillers.LEARNED_CHECKPOINT_ENV, str(path))
fillers._LEARNED.clear()
loads = []
real_load = torch.load
monkeypatch.setattr(torch, "load", lambda *a, **k: (loads.append(1), real_load(*a, **k))[1])
for _ in range(3):
fillers.load_learned()
assert len(loads) == 1
fillers._LEARNED.clear()
def test_band_reader_caches_a_repeated_target(tmp_path, monkeypatch):
"""One target pulls one frame per band through the reader, and consecutive targets
share most of their stack. Without the cache each shared frame is re-read, and on
this archive a read is an SMB round trip through two filesystems."""
from conftest import write_fits
frame = solar_disc(size=32, radius=10, peak=1.0)
when = 1715400000
archive_map = {}
for wavelength in WAVELENGTHS:
path = str(tmp_path / f"{wavelength}.fits")
write_fits(path, frame)
archive_map[(16, wavelength, when)] = path
reads = []
real = bench.fitsio.read_image
monkeypatch.setattr(bench.fitsio, "read_image",
lambda p, *a, **k: reads.append(p) or real(p, *a, **k))
overlay = cases.Overlay(archive=archive_map)
read = bench._band_reader(overlay, WAVELENGTHS, set())
read(16, when)
assert len(reads) == len(WAVELENGTHS), "one read per band"
read(16, when)
assert len(reads) == len(WAVELENGTHS), "re-read a slot already in the cache"