210 lines
7.4 KiB
Python
210 lines
7.4 KiB
Python
|
|
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
|