656 lines
26 KiB
Python
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"
|