noaa-goes-visualization/tests/test_corruptions.py

210 lines
7.4 KiB
Python
Raw Permalink Normal View History

import numpy as np
import pytest
from conftest import solar_disc, write_fits
from suvi import corruptions, fitsio
ARRAY_MODES = [n for n, c in corruptions.CATALOG.items() if c.kind == "array"]
FILE_MODES = [n for n, c in corruptions.CATALOG.items() if c.kind == "file"]
@pytest.fixture
def image():
return solar_disc(peak=3.0)
@pytest.fixture
def donor():
return solar_disc(peak=3.0, centre=(700, 640))
def apply(name, image, donor=None, seed=7, severity=1.0):
return corruptions.apply_array(name, image, seed, severity, donor)
# ------------------------------------------------------------------------ catalogue
def test_catalogue_covers_all_four_groups():
assert set(corruptions.GROUPS) == {"dropout", "structural", "radiometric", "geometric"}
for group in corruptions.GROUPS:
assert corruptions.by_group(group), f"{group} has no modes"
def test_every_entry_is_self_consistent():
for name, corruption in corruptions.CATALOG.items():
assert corruption.name == name
assert corruption.kind in ("array", "file")
assert callable(corruption.apply)
# -------------------------------------------------------------------- determinism
@pytest.mark.parametrize("name", ARRAY_MODES)
def test_array_corruptions_are_deterministic(name, image, donor):
first, header_a = apply(name, image, donor)
second, header_b = apply(name, image, donor)
np.testing.assert_array_equal(np.nan_to_num(first), np.nan_to_num(second))
assert header_a == header_b
@pytest.mark.parametrize("name", ARRAY_MODES)
def test_array_corruptions_change_the_data(name, image, donor):
damaged, _ = apply(name, image, donor)
assert damaged.shape == image.shape
assert not np.array_equal(np.nan_to_num(damaged), np.nan_to_num(image))
@pytest.mark.parametrize("name", ARRAY_MODES)
def test_different_seeds_stay_reproducible(name, image, donor):
first, _ = apply(name, image, donor, seed=1)
again, _ = apply(name, image, donor, seed=1)
np.testing.assert_array_equal(np.nan_to_num(first), np.nan_to_num(again))
@pytest.mark.parametrize("name", FILE_MODES)
def test_file_corruptions_are_deterministic(name, tmp_path, image):
path = write_fits(str(tmp_path / "src.fits"), image)
raw = open(path, "rb").read()
first = corruptions.apply_file(name, raw, 7, 1.0)
second = corruptions.apply_file(name, raw, 7, 1.0)
assert first == second
assert first != raw
# ------------------------------------------------------------------ specific modes
def test_eclipse_dim_reproduces_the_real_failure(image):
"""The archive's commonest fault: radiance collapses, flags are set."""
damaged, header = apply("eclipse_dim", image)
assert header["DEGRADED"] is True and header["ECLIPSE"] == 2
assert abs(float(damaged.mean())) < float(image.mean()) / 1000
def test_all_zero_sets_the_empty_flag(image):
damaged, header = apply("all_zero", image)
assert not damaged.any() and header["EMPTY"] is True
def test_nan_fill_at_full_severity_covers_everything(image):
damaged, _ = apply("nan_fill", image, severity=1.0)
assert np.isnan(damaged).all()
def test_nan_fill_at_partial_severity_is_partial(image):
damaged, _ = apply("nan_fill", image, severity=0.3)
fraction = np.isnan(damaged).mean()
assert 0.1 < fraction < 0.5
def test_zblank_fill_uses_the_fits_sentinel(image):
damaged, _ = apply("zblank_fill", image, severity=1.0)
assert (damaged == fitsio.ZBLANK).all()
def test_frozen_returns_the_donor_exactly(image, donor):
damaged, _ = apply("frozen", image, donor)
np.testing.assert_array_equal(damaged, donor)
def test_torn_frame_mixes_two_observations(image, donor):
damaged, _ = apply("torn_frame", image, donor, severity=1.0)
assert np.array_equal(damaged[-1], donor[-1])
assert np.array_equal(damaged[0], image[0])
def test_dropped_rows_blanks_whole_scan_lines(image):
damaged, _ = apply("dropped_rows", image, severity=1.0)
blank = [row for row in range(damaged.shape[0]) if not damaged[row].any()]
assert len(blank) >= 1
def test_yaw_flip_is_a_180_degree_rotation(image):
damaged, header = apply("yaw_flip", image)
assert header["YAW_FLIP"] == 1
np.testing.assert_array_equal(damaged, np.flip(np.flip(image, 0), 1))
def test_translate_moves_the_disc_and_updates_the_wcs(image):
damaged, header = apply("translate", image, severity=1.0)
assert "CRPIX1" in header and "CRPIX2" in header
assert not np.array_equal(damaged, image)
def test_saturate_raises_the_ceiling(image):
damaged, _ = apply("saturate", image, severity=1.0)
assert float(damaged.max()) > float(image.max())
def test_modes_requiring_a_donor_say_so(image):
for name, corruption in corruptions.CATALOG.items():
if corruption.needs_donor:
with pytest.raises(ValueError, match="donor"):
corruptions.apply_array(name, image, 1, 1.0, None)
def test_truncate_keeps_at_least_one_block(tmp_path, image):
path = write_fits(str(tmp_path / "src.fits"), image)
raw = open(path, "rb").read()
damaged = corruptions.apply_file("truncate", raw, 1, 1.0)
assert fitsio.BLOCK <= len(damaged) < len(raw)
def test_drop_image_hdu_leaves_only_the_primary_header(tmp_path, image):
path = write_fits(str(tmp_path / "src.fits"), image)
raw = open(path, "rb").read()
damaged = corruptions.apply_file("drop_image_hdu", raw, 1, 1.0)
assert len(damaged) == fitsio.BLOCK
out = str(tmp_path / "damaged.fits")
open(out, "wb").write(damaged)
data, error = fitsio.read_image(out)
assert data is None and error
def test_block_corruption_leaves_the_header_readable(tmp_path, image):
"""Simulates bit rot: the header still parses, the data no longer matches it."""
path = write_fits(str(tmp_path / "src.fits"), image)
raw = open(path, "rb").read()
damaged = corruptions.apply_file("block_corruption", raw, 3, 1.0)
out = str(tmp_path / "damaged.fits")
open(out, "wb").write(damaged)
cards, error = fitsio.read_header(out)
assert cards.get("WAVELNTH") == 171
assert "datasum mismatch" in fitsio.verify_datasums(out)
# ---------------------------------------------------------------------- interface
def test_wrong_kind_is_rejected(image, tmp_path):
with pytest.raises(ValueError, match="not an array one"):
corruptions.apply_array("truncate", image, 1)
with pytest.raises(ValueError, match="not a file one"):
corruptions.apply_file("eclipse_dim", b"x" * 3000, 1)
def test_unknown_mode_raises(image):
with pytest.raises(KeyError):
corruptions.apply_array("does_not_exist", image, 1)
def test_recomputed_stats_match_the_damaged_pixels(image):
stats = corruptions.recomputed_stats(image)
assert stats["IMG_MEAN"] == pytest.approx(float(image.mean()), rel=1e-5)
assert stats["IMG_MAX"] == pytest.approx(float(image.max()), rel=1e-5)
def test_recomputed_stats_survive_an_all_nan_frame():
stats = corruptions.recomputed_stats(np.full((4, 4), np.nan, np.float32))
assert stats == {"IMG_MIN": 0.0, "IMG_MAX": 0.0, "IMG_MEAN": 0.0, "IMG_SDEV": 0.0}
def test_stats_are_recomputed_only_where_the_header_would_follow():
"""A real eclipse has a matching header; bit rot does not. See the module docs."""
assert corruptions.CATALOG["eclipse_dim"].recompute_stats is True
assert corruptions.CATALOG["gain_shift"].recompute_stats is True
assert corruptions.CATALOG["block_corruption"].recompute_stats is False
assert corruptions.CATALOG["salt_pepper"].recompute_stats is False