noaa-goes-visualization/tests/test_samples.py

415 lines
18 KiB
Python

import numpy as np
import pytest
from conftest import solar_disc
from suvi import corruptions, dataset, paths, samples
SIZE = dataset.SHARD_SIZE
class FakeShard:
"""A shard-shaped object holding frames in memory, so tests need no files."""
def __init__(self, satellite, times, size=32):
self.satellite = satellite
self.size = size
self._frames = {
t: np.stack([
solar_disc(size=size, radius=size // 3, peak=1.0 + band * 0.2 + t / 1e6)
for band in range(6)
]).astype(np.float32)
for t in times
}
def times(self):
return sorted(self._frames)
def frames(self, time):
got = self._frames.get(time)
return None if got is None else got.copy()
def make_sampler(count=120, satellites=(16, 18), size=256, **kwargs):
base = 1715400000 // paths.CADENCE * paths.CADENCE
times = [base + i * paths.CADENCE for i in range(count)]
shards = {("d", s): FakeShard(s, times, size) for s in satellites}
return samples.Sampler(shards, satellites=satellites, **kwargs), times
# --------------------------------------------------------------------------- layout
def test_layout_excludes_the_frame_being_reconstructed():
layout = samples.stack_layout((16, 18), 16)
assert (16, 0) not in layout
def test_layout_includes_the_counterpart_at_the_target_instant():
"""The single most valuable entry: a real observation of the right Sun, right time."""
assert (18, 0) in samples.stack_layout((16, 18), 16)
assert (16, 0) in samples.stack_layout((16, 18), 18)
def test_layout_is_multi_scale_in_both_directions():
layout = samples.stack_layout((16, 18), 16)
for offset in samples.OFFSETS:
assert (16, offset) in layout and (16, -offset) in layout
assert (18, offset) in layout and (18, -offset) in layout
def test_layout_is_stable():
"""A frame's position in the stack must always mean the same thing."""
assert samples.stack_layout((16, 18), 16) == samples.stack_layout((16, 18), 16)
assert len(set(samples.stack_layout((16, 18), 16))) == len(
samples.stack_layout((16, 18), 16)
)
def test_classes_cover_every_catalogued_mode_plus_clean():
assert samples.CLASSES[0] == "clean"
assert set(samples.CLASSES[1:]) == set(corruptions.CATALOG)
assert len(samples.CLASS_INDEX) == len(corruptions.CATALOG) + 1
# -------------------------------------------------------------------------- samples
def test_build_returns_a_full_stack():
sampler, times = make_sampler()
sample = sampler.build(16, times[60])
expected = len(samples.stack_layout((16, 18), 16))
assert sample["frames"].shape[0] >= expected
assert sample["frames"].shape[1:] == (6, 256, 256)
assert sample["target"].shape == (6, 256, 256)
assert len(sample["states"]) == sample["frames"].shape[0]
assert len(sample["dts"]) == sample["frames"].shape[0]
assert len(sample["classes"]) == sample["frames"].shape[0]
def test_build_returns_nothing_without_a_target():
sampler, times = make_sampler()
assert sampler.build(16, times[-1] + 99 * paths.CADENCE) is None
def test_target_is_never_in_its_own_stack():
"""Leakage of the most direct kind: the answer among the inputs."""
sampler, times = make_sampler(damage_probability=0.0, drop_probability=0.0)
sample = sampler.build(16, times[60])
for index, (dt, same) in enumerate(zip(sample["dts"], sample["same_satellite"])):
assert not (dt == 0.0 and same), f"stack entry {index} is the target itself"
def test_undamaged_sampler_marks_everything_available():
# 600 slots, so even the +/-256 rungs of the exponential ladder land on frames.
sampler, times = make_sampler(count=600, size=32,
damage_probability=0.0, drop_probability=0.0)
sample = sampler.build(16, times[300])
assert set(sample["states"]) == {"available"}
assert set(sample["classes"].tolist()) == {samples.CLASS_INDEX["clean"]}
def test_missing_frames_are_zeroed_and_labelled():
"""Off the end of the shard there is genuinely nothing."""
sampler, times = make_sampler(damage_probability=0.0, drop_probability=0.0)
sample = sampler.build(16, times[1]) # offsets -2, -4, -16 fall off the start
missing = [i for i, s in enumerate(sample["states"]) if s == "missing"]
assert missing, "expected some offsets to fall outside the shard"
for index in missing:
assert np.all(sample["frames"][index] == 0.0)
def test_anchors_reach_past_the_sampled_offsets():
"""A long gap must still find something *real* to work from -- reach is measured
over frames that carry pixels, not over empty ladder rungs."""
base = 1715400000 // paths.CADENCE * paths.CADENCE
times = [base] + [base + i * paths.CADENCE for i in range(100, 130)]
shards = {("d", 16): FakeShard(16, times, 256)}
sampler = samples.Sampler(shards, satellites=(16,), damage_probability=0.0,
drop_probability=0.0)
sample = sampler.build(16, base)
reach = max(abs(dt) for dt, state in zip(sample["dts"], sample["states"])
if state == "available")
assert reach >= 100 * paths.CADENCE, "no anchor was added beyond the fixed offsets"
def test_damage_produces_suspect_frames_that_keep_their_pixels():
"""The design claim under test: a flagged frame is data, not a hole."""
sampler, times = make_sampler(damage_probability=1.0, drop_probability=0.0, seed=3)
seen = set()
for time in times[20:80]:
sample = sampler.build(16, time)
seen.update(sample["states"])
for index, state in enumerate(sample["states"]):
if state == "suspect":
assert np.any(sample["frames"][index] != 0.0)
assert sample["classes"][index] != samples.CLASS_INDEX["clean"]
assert "suspect" in seen
def test_no_signal_modes_are_presented_as_missing():
"""all_zero and friends leave nothing; calling them 'suspect' would be a lie."""
sampler, times = make_sampler(damage_probability=1.0, drop_probability=0.0, seed=5)
for time in times[20:80]:
sample = sampler.build(16, time)
for index, state in enumerate(sample["states"]):
label = samples.CLASSES[sample["classes"][index]]
if label in samples.NO_SIGNAL:
assert state == "missing"
assert np.all(sample["frames"][index] == 0.0)
def test_every_catalogued_mode_can_be_applied():
"""Including the three that need a donor frame, which raise if given none."""
sampler, times = make_sampler(damage_probability=1.0, drop_probability=0.0, seed=11)
applied = set()
for time in times[10:110]:
sample = sampler.build(16, time)
applied.update(samples.CLASSES[c] for c in sample["classes"].tolist())
for mode, corruption in corruptions.CATALOG.items():
if corruption.needs_donor:
assert mode in applied, f"{mode} never applied; donors may be unavailable"
def test_damage_is_coherent_across_bands():
"""One instrument makes all six bands, so a fault hits them together."""
radiance = np.stack([solar_disc(size=32, radius=10, peak=1.0 + i) for i in range(6)])
flipped = samples.decode_from_model(
samples._damage(samples.encode_for_model(radiance), "yaw_flip", seed=1,
severity=1.0)
)
for band in range(6):
expected, _ = corruptions.apply_array("yaw_flip", radiance[band], 1, 1.0)
np.testing.assert_allclose(flipped[band], expected, rtol=2e-3, atol=1e-6)
def test_damage_operates_on_radiance_not_model_space():
"""A gain_shift multiplies a physical quantity; applying it to asinh values would
model an entirely different fault."""
radiance = np.stack([np.full((16, 16), 2.0, np.float32) for _ in range(6)])
damaged = samples.decode_from_model(
samples._damage(samples.encode_for_model(radiance), "gain_shift", seed=4,
severity=1.0)
)
expected, _ = corruptions.apply_array("gain_shift", radiance[0], 4, 1.0)
ratio = float(damaged[0].mean() / radiance[0].mean())
np.testing.assert_allclose(damaged[0], expected, rtol=2e-3)
assert abs(ratio - float(expected.mean() / 2.0)) < 1e-3
def test_sampling_is_reproducible_from_its_seed():
first, times = make_sampler(seed=7)
second, _ = make_sampler(seed=7)
a = first.build(16, times[60])
b = second.build(16, times[60])
assert a["states"] == b["states"]
np.testing.assert_array_equal(a["classes"], b["classes"])
np.testing.assert_allclose(a["frames"], b["frames"])
def test_targets_lists_both_satellites():
sampler, times = make_sampler()
found = sampler.targets()
assert {s for s, _ in found} == {16, 18}
assert len(found) == 2 * len(times)
# ------------------------------------------------------------------------ encoding
def test_encode_decode_round_trips():
values = np.array([[-3.7, -0.01, 0.0, 1e-3, 0.19, 50.0, 1231.0]], dtype=np.float32)
restored = samples.decode_from_model(samples.encode_for_model(values))
np.testing.assert_allclose(restored, values, rtol=2e-3, atol=1e-7)
def test_encoding_matches_the_shard_transform():
"""Shards and live frames must land in the same space, or inference sees a shift."""
frames = np.stack([solar_disc(size=1280, radius=386, peak=1.0 + i) for i in range(6)])
from_shard = dataset.decode_frames(dataset.encode_frames(frames))
direct = samples.decode_from_model(samples.encode_for_model(from_shard))
np.testing.assert_allclose(direct, from_shard, rtol=2e-3, atol=1e-6)
def test_encoding_stays_in_range_for_absurd_input():
coded = samples.encode_for_model(np.array([1e12, -1e12, np.nan, np.inf]))
assert np.isfinite(coded).all()
assert coded.max() <= 1.0 and coded.min() >= -1.0
# -------------------------------------------------------------------------- tensors
def test_to_tensors_produces_what_the_model_expects():
torch = pytest.importorskip("torch")
from suvi import align, model
sampler, times = make_sampler(size=64)
sample = sampler.build(16, times[60])
packed = samples.to_tensors(sample, torch)
stack = sample["frames"].shape[0]
assert packed["stack"].shape == (stack, 6, 64, 64)
assert packed["condition"].shape == (stack, model.COND_DIM)
assert packed["gains"].shape == (stack, 6)
assert packed["offsets"].shape == (stack, 6)
assert packed["target"].shape == (6, 64, 64)
condition = packed["condition"][None]
valid = (condition[..., 0] + condition[..., 2]).clamp(0, 1)
aligned = align.align_stack(
packed["stack"][None], packed["dts"][None], valid, packed["gains"][None],
packed["offsets"][None], packed["b0"][None], packed["radius"][None],
)
net = model.build(base=8, depth=2)
out = net(aligned, condition)
assert out.shape == (1, 6, 64, 64)
assert torch.isfinite(out).all()
# ---------------------------------------------------------------------- transfers
def test_same_satellite_frames_carry_the_identity_transfer():
sampler, times = make_sampler(damage_probability=0.0, drop_probability=0.0)
sample = sampler.build(16, times[60])
for index, same in enumerate(sample["same_satellite"]):
if same:
np.testing.assert_array_equal(sample["gains"][index], np.ones(6))
np.testing.assert_array_equal(sample["offsets"][index], np.zeros(6))
def test_cross_satellite_frames_carry_a_fitted_transfer():
"""The counterpart must arrive with the day's calibration attached, so alignment
can put it on the target instrument's scale before the model sees it."""
class Scaled(FakeShard):
def frames(self, time):
got = super().frames(time)
return None if got is None else got * 0.8 + 0.01
base = 1715400000 // paths.CADENCE * paths.CADENCE
times = [base + i * paths.CADENCE for i in range(40)]
shards = {("d", 16): FakeShard(16, times, 32), ("d", 18): Scaled(18, times, 32)}
sampler = samples.Sampler(shards, damage_probability=0.0, drop_probability=0.0)
sample = sampler.build(16, times[20])
cross = [i for i, same in enumerate(sample["same_satellite"])
if not same and sample["states"][i] == "available"]
assert cross, "no cross-satellite frame in the stack"
for index in cross:
np.testing.assert_allclose(sample["gains"][index], np.full(6, 1 / 0.8),
rtol=1e-3)
np.testing.assert_allclose(sample["offsets"][index], np.full(6, -0.01 / 0.8),
atol=1e-4)
def test_calibration_never_fits_against_the_target_itself():
"""A pair at the target instant would fit the transfer against the answer --
the oracle gain the whole exercise exists to estimate honestly."""
sampler, times = make_sampler()
for target in (times[0], times[60], times[-1]):
sampler._transfers.clear()
assert sampler._calibration("d", 16, 18, exclude=target) is not None
for (_, _, _, pair_time) in sampler._transfers:
assert pair_time != target
def test_outages_are_contiguous_runs():
sampler, times = make_sampler()
rng = np.random.default_rng(2)
seen = 0
for _ in range(50):
blocked = sampler._outages(rng, times[60])
for satellite, interval in blocked.items():
if not interval:
continue
seen += 1
ordered = sorted(interval)
gaps = {b - a for a, b in zip(ordered, ordered[1:])}
assert gaps <= {paths.CADENCE}, "outage is not a contiguous run"
assert len(ordered) <= samples.MAX_OUTAGE_SLOTS
assert seen, "no episodic outage was ever drawn"
def test_dual_outages_take_down_both_satellites_sometimes():
sampler, times = make_sampler()
rng = np.random.default_rng(3)
dual = single = 0
for _ in range(200):
blocked = sampler._outages(rng, times[60])
affected = [s for s, interval in blocked.items() if interval]
if len(affected) == 2:
assert blocked[16] == blocked[18], "dual outage must share one interval"
dual += 1
elif len(affected) == 1:
single += 1
assert dual > 10, f"dual outages too rare to train on ({dual}/200)"
assert single > 10, f"single-satellite outages too rare ({single}/200)"
def test_the_long_dual_outage_case_actually_occurs_in_training():
"""The regime the archive says is 11% of reality: both satellites dark around
the target, nearest real frame far away. With independent per-frame drops this
configuration had probability ~p^14 and was never trained."""
sampler, times = make_sampler(count=600, size=32, seed=9)
starved = 0
for time in times[280:380]:
sample = sampler.build(16, time)
if sample is None:
continue
near_same = [i for i, (dt, same) in enumerate(zip(sample["dts"],
sample["same_satellite"]))
if same and abs(dt) <= 16 * paths.CADENCE]
cross = [i for i, (dt, same) in enumerate(zip(sample["dts"],
sample["same_satellite"]))
if not same and abs(dt) <= 16 * paths.CADENCE]
if all(sample["states"][i] == "missing" for i in near_same) and \
all(sample["states"][i] == "missing" for i in cross):
starved += 1
assert starved >= 2, f"long dual-outage stacks essentially absent ({starved}/100)"
def test_anchors_land_outside_a_simulated_outage():
sampler, times = make_sampler(count=600, size=32,
damage_probability=0.0, drop_probability=0.0)
exclude = frozenset(times[300 + k] for k in range(-150, 150))
anchor = sampler._anchor(16, times[300], direction=1, exclude=exclude)
assert anchor is not None and anchor not in exclude
assert anchor >= times[300] + 150 * paths.CADENCE
def test_build_declines_when_nothing_in_the_stack_has_pixels():
"""Both satellites out for the whole window: the contract is None, not a
fabricated frame."""
base = 1715400000 // paths.CADENCE * paths.CADENCE
shards = {("d", 16): FakeShard(16, [base], 32)}
sampler = samples.Sampler(shards, satellites=(16, 18), damage_probability=0.0,
drop_probability=0.0)
assert sampler.build(16, base) is None
def test_build_reports_solar_geometry():
sampler, times = make_sampler()
sample = sampler.build(16, times[60])
assert -0.13 < sample["b0"] < 0.13 # +/-7.25 deg in radians
assert 0.28 < sample["radius"] < 0.32 # disc fraction of the frame
def test_severity_is_drawn_per_sample_not_fixed_per_frame():
"""Fixed rates left barely half of every stack clean, so the model never saw the
easy case and learned to hedge. Real outages are episodic: some stacks should come
through almost untouched and some should be wrecked."""
# A wide window, so every ladder rung exists and the clean fraction measures
# injected damage rather than the shard's edges.
sampler, times = make_sampler(count=600, size=32, damage_probability=0.30,
drop_probability=0.40, seed=17)
clean_fractions = []
for time in times[280:380]:
sample = sampler.build(16, time)
states = sample["states"]
clean_fractions.append(sum(s == "available" for s in states) / len(states))
assert max(clean_fractions) > 0.95, "no sample came through nearly clean"
assert min(clean_fractions) < 0.75, "no sample was substantially degraded"
spread = max(clean_fractions) - min(clean_fractions)
assert spread > 0.3, f"severity barely varied between samples (spread {spread:.2f})"