377 lines
14 KiB
Python
377 lines
14 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, and that a frame the
|
||
|
|
case corrupted 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
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- 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 = bench._build_stack(overlay, 16, times[280], (16, 18), WAVELENGTHS, set(),
|
||
|
|
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 = bench._build_stack(overlay, 16, times[20], (16, 18), WAVELENGTHS, set(), read)
|
||
|
|
for entry in stack:
|
||
|
|
assert entry["image"].shape[0] == len(WAVELENGTHS)
|
||
|
|
|
||
|
|
|
||
|
|
def test_stack_excludes_the_target_slot():
|
||
|
|
overlay, times, _ = overlay_for()
|
||
|
|
read, _ = reader()
|
||
|
|
stack = bench._build_stack(overlay, 16, times[20], (16, 18), WAVELENGTHS, set(), read)
|
||
|
|
for entry in stack:
|
||
|
|
assert not (entry["same_satellite"] and entry["dt"] == 0.0)
|
||
|
|
|
||
|
|
|
||
|
|
def test_stack_includes_the_counterpart_at_the_target_instant():
|
||
|
|
overlay, times, _ = overlay_for()
|
||
|
|
read, _ = reader()
|
||
|
|
stack = bench._build_stack(overlay, 16, times[20], (16, 18), WAVELENGTHS, set(), read)
|
||
|
|
assert any(entry["dt"] == 0.0 and not entry["same_satellite"] for entry in stack)
|
||
|
|
|
||
|
|
|
||
|
|
def test_damaged_neighbours_arrive_suspect_rather_than_dropped():
|
||
|
|
"""The property the whole design rests on: a flagged frame is data, not a hole."""
|
||
|
|
overlay, times, _ = overlay_for()
|
||
|
|
read, _ = reader()
|
||
|
|
target = times[20]
|
||
|
|
neighbour_time = target - paths.CADENCE
|
||
|
|
bad = {(16, w, neighbour_time) for w in WAVELENGTHS}
|
||
|
|
stack = bench._build_stack(overlay, 16, target, (16, 18), WAVELENGTHS, bad, read)
|
||
|
|
|
||
|
|
entry = next(e for e in stack if e["slot"] == (16, neighbour_time))
|
||
|
|
assert entry["state"] == "suspect"
|
||
|
|
assert entry["image"] is not None
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_slot_damaged_in_one_band_marks_the_whole_frame_suspect():
|
||
|
|
"""One instrument makes all six bands; a fault in one is a reason to distrust all."""
|
||
|
|
overlay, times, _ = overlay_for()
|
||
|
|
read, _ = reader()
|
||
|
|
target = times[20]
|
||
|
|
neighbour_time = target - paths.CADENCE
|
||
|
|
stack = bench._build_stack(overlay, 16, target, (16, 18), WAVELENGTHS,
|
||
|
|
{(16, 195, neighbour_time)}, read)
|
||
|
|
entry = next(e for e in stack if e["slot"] == (16, neighbour_time))
|
||
|
|
assert entry["state"] == "suspect"
|
||
|
|
|
||
|
|
|
||
|
|
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 = bench._build_stack(overlay, 16, target, (16, 18), WAVELENGTHS, set(), 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 = bench._build_stack(overlay, 16, times[20], (16, 18), WAVELENGTHS, set(), 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():
|
||
|
|
"""A long outage must still reach a real frame."""
|
||
|
|
overlay, times, _ = overlay_for(count=80)
|
||
|
|
read, _ = reader()
|
||
|
|
target = times[40]
|
||
|
|
bad = {(16, w, target + n * paths.CADENCE)
|
||
|
|
for w in WAVELENGTHS for n in range(-30, 31)}
|
||
|
|
stack = bench._build_stack(overlay, 16, target, (16, 18), WAVELENGTHS, bad, read)
|
||
|
|
|
||
|
|
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 = bench._build_stack(overlay, 16, times[0], (16, 18), WAVELENGTHS, set(), 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_for_damaged_slots(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(overrides))
|
||
|
|
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)
|
||
|
|
|
||
|
|
read = bench._band_reader(overlay, WAVELENGTHS, set(), limit=3)
|
||
|
|
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 limit of 3
|
||
|
|
assert len(reads) > before_evicted, "cache grew past its limit"
|
||
|
|
finally:
|
||
|
|
bench.fitsio.read_image = original
|
||
|
|
|
||
|
|
|
||
|
|
def test_band_reader_returns_none_when_a_band_is_missing(tmp_path):
|
||
|
|
"""Five bands is not a frame the joint model can consume."""
|
||
|
|
from conftest import write_fits
|
||
|
|
|
||
|
|
frame = solar_disc(size=32, radius=10, peak=1.0)
|
||
|
|
when = 1715400000
|
||
|
|
archive_map = {}
|
||
|
|
for wavelength in WAVELENGTHS[:-1]:
|
||
|
|
path = str(tmp_path / f"{wavelength}.fits")
|
||
|
|
write_fits(path, frame)
|
||
|
|
archive_map[(16, wavelength, when)] = path
|
||
|
|
overlay = cases.Overlay(archive=archive_map)
|
||
|
|
assert bench._band_reader(overlay, WAVELENGTHS, set())(16, when) is None
|
||
|
|
|
||
|
|
|
||
|
|
# -------------------------------------------------------------------------- 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": "suspect", "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},
|
||
|
|
"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": "suspect", "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 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}}, 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}}, 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_ticks_the_reliever_per_read(tmp_path):
|
||
|
|
"""One target pulls ~90 frames through the reader. Ticking once per target would
|
||
|
|
undercount ninety-fold, and the mount would exhaust its file handles between two
|
||
|
|
ticks -- which has taken this machine down more than once."""
|
||
|
|
from conftest import write_fits
|
||
|
|
|
||
|
|
class Counter:
|
||
|
|
def __init__(self):
|
||
|
|
self.n = 0
|
||
|
|
|
||
|
|
def tick(self, count=1):
|
||
|
|
self.n += count
|
||
|
|
|
||
|
|
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
|
||
|
|
|
||
|
|
counter = Counter()
|
||
|
|
overlay = cases.Overlay(archive=archive_map)
|
||
|
|
read = bench._band_reader(overlay, WAVELENGTHS, set(), reliever=counter)
|
||
|
|
read(16, when)
|
||
|
|
assert counter.n == len(WAVELENGTHS)
|