noaa-goes-visualization/tests/test_fillers.py

341 lines
14 KiB
Python

import numpy as np
import pytest
from conftest import solar_disc
from suvi import fillers
HEADER = {
"crpix1": 640.5,
"crpix2": 640.5,
"cdelt1": 2.5,
"diam_sun": 771.98,
"dsun_obs": 148781338180.972,
"solar_b0": -7.170855,
}
def context(**kwargs):
base = dict(dt_before=240.0, dt_after=240.0, header=HEADER)
base.update(kwargs)
return fillers.FillContext(**base)
# -------------------------------------------------------------------------- context
def test_alpha_locates_the_frame_within_its_gap():
assert context(dt_before=240, dt_after=240).alpha == pytest.approx(0.5)
assert context(dt_before=240, dt_after=720).alpha == pytest.approx(0.25)
assert context(dt_before=0, dt_after=0).alpha == 0.0
def test_gap_frames_counts_slots():
assert context(dt_before=240, dt_after=240).gap_frames == 2
assert context(dt_before=240, dt_after=2160).gap_frames == 10
# --------------------------------------------------------------------- baselines
def test_hold_last_repeats_the_preceding_frame():
before, after = np.ones((8, 8), np.float32), np.zeros((8, 8), np.float32)
np.testing.assert_array_equal(fillers.hold_last(context(before=before, after=after)), before)
def test_hold_last_falls_back_to_the_following_frame():
after = np.full((8, 8), 3.0, np.float32)
np.testing.assert_array_equal(fillers.hold_last(context(after=after)), after)
def test_hold_last_gives_up_with_nothing_to_hold():
assert fillers.hold_last(context()) is None
def test_linear_blend_is_exact_on_a_linear_ramp():
"""A quantity changing linearly in time must be recovered exactly."""
before = np.full((8, 8), 10.0, np.float32)
after = np.full((8, 8), 20.0, np.float32)
filled = fillers.linear_blend(context(before=before, after=after))
np.testing.assert_allclose(filled, 15.0, rtol=1e-6)
skewed = fillers.linear_blend(
context(before=before, after=after, dt_before=240, dt_after=720)
)
np.testing.assert_allclose(skewed, 12.5, rtol=1e-6)
def test_linear_blend_degrades_to_hold_last_at_a_boundary():
before = np.full((8, 8), 7.0, np.float32)
np.testing.assert_array_equal(fillers.linear_blend(context(before=before)), before)
def test_fillers_tolerate_non_finite_input():
before = np.full((8, 8), np.nan, np.float32)
after = np.ones((8, 8), np.float32)
filled = fillers.linear_blend(context(before=before, after=after))
assert np.isfinite(filled).all()
# ------------------------------------------------------------------ optical flow
def test_optical_flow_tracks_a_translation():
"""A feature moving at constant speed should land mid-way, not appear twice."""
base = solar_disc(size=256, radius=70, peak=3.0)
before = np.roll(base, -8, axis=1)
after = np.roll(base, 8, axis=1)
filled = fillers.optical_flow(context(before=before, after=after))
blended = fillers.linear_blend(context(before=before, after=after))
# The truth is the untranslated frame; flow should beat a plain cross-fade.
assert np.abs(filled - base).mean() < np.abs(blended - base).mean()
def test_optical_flow_falls_back_without_two_brackets():
before = np.ones((32, 32), np.float32)
np.testing.assert_array_equal(fillers.optical_flow(context(before=before)), before)
def test_optical_flow_falls_back_on_mismatched_shapes():
before = np.ones((32, 32), np.float32)
after = np.ones((16, 16), np.float32)
result = fillers.optical_flow(context(before=before, after=after))
np.testing.assert_array_equal(result, before)
# ---------------------------------------------------------------------- crosssat
def test_crosssat_returns_nothing_without_a_counterpart():
assert fillers.crosssat(context(before=np.ones((8, 8), np.float32))) is None
def test_crosssat_corrects_the_calibration_difference():
"""The other satellite's radiance scale must be matched, not copied blindly."""
truth = solar_disc(size=128, radius=38, peak=2.0)
counterpart = truth * 0.5 + 0.3 # a different flight model's response
filled = fillers.crosssat(context(before=truth, counterpart=counterpart), align=False)
np.testing.assert_allclose(filled, truth, atol=1e-3)
def test_crosssat_aligns_a_parallax_shift():
truth = solar_disc(size=128, radius=38, peak=2.0)
counterpart = np.roll(truth, 4, axis=1)
filled = fillers.crosssat(context(before=truth, counterpart=counterpart), align=True)
unaligned = fillers.crosssat(context(before=truth, counterpart=counterpart), align=False)
assert np.abs(filled - truth).mean() < np.abs(unaligned - truth).mean()
def test_crosssat_calibrates_from_the_simultaneous_pair_not_the_bracket():
"""The defect this fixes: a stale bracket poisons the instrument gain.
The counterpart is a perfect observation of the target instant on the other
instrument's scale. The bracketing frame is twenty hours old, so the Sun in it
is genuinely brighter -- fitting the gain against it folds that evolution into
what should be an instrument constant. A simultaneous pair, however distant in
time, does not have that problem.
"""
truth = solar_disc(size=128, radius=38, peak=2.0)
gain, offset = 0.5, 0.3 # the other instrument's response
counterpart = truth * gain + offset
stale = truth * 1.8 # the Sun, twenty hours earlier
pair = (stale * gain + offset, stale) # both satellites at that same instant
calibrated = fillers.crosssat(
context(before=stale, dt_before=72000.0, counterpart=counterpart,
calibration=pair), align=False
)
bracketed = fillers.crosssat(
context(before=stale, dt_before=72000.0, counterpart=counterpart), align=False
)
np.testing.assert_allclose(calibrated, truth, rtol=1e-3, atol=1e-3)
assert np.abs(bracketed - truth).mean() > 10 * np.abs(calibrated - truth).mean()
def test_crosssat_calibration_is_independent_of_how_distant_the_pair_is():
"""A simultaneous pair is equally valid at any separation from the target."""
truth = solar_disc(size=128, radius=38, peak=2.0)
counterpart = truth * 0.5 + 0.3
errors = []
for brightness in (1.0, 1.8, 4.0): # ever more distant, ever more evolved
evolved = truth * brightness
pair = (evolved * 0.5 + 0.3, evolved)
filled = fillers.crosssat(
context(before=evolved, counterpart=counterpart, calibration=pair),
align=False,
)
errors.append(float(np.abs(filled - truth).mean()))
assert max(errors) < 1e-3
def test_crosssat_falls_back_to_the_bracket_without_a_pair():
"""No slot anywhere had both satellites good; the bracket is all there is."""
truth = solar_disc(size=128, radius=38, peak=2.0)
counterpart = truth * 0.5 + 0.3
filled = fillers.crosssat(context(before=truth, counterpart=counterpart), align=False)
np.testing.assert_allclose(filled, truth, atol=1e-3)
def test_crosssat_ignores_a_malformed_calibration_pair():
truth = solar_disc(size=128, radius=38, peak=2.0)
counterpart = truth * 0.5 + 0.3
for pair in ((None, truth), (truth, None), (np.ones((8, 8), np.float32), truth)):
filled = fillers.crosssat(
context(before=truth, counterpart=counterpart, calibration=pair), align=False
)
np.testing.assert_allclose(filled, truth, atol=1e-3)
def test_gain_fit_returns_identity_for_a_constant_source():
assert fillers.gain_fit(np.ones((8, 8), np.float32), np.zeros((8, 8), np.float32)) == (
1.0,
0.0,
)
def test_gain_match_recovers_an_affine_transform():
source = solar_disc(size=64, radius=20, peak=1.0)
reference = source * 3.0 - 0.5
np.testing.assert_allclose(fillers.gain_match(source, reference), reference, atol=1e-4)
def test_gain_match_survives_a_constant_source():
flat = np.ones((8, 8), np.float32)
result = fillers.gain_match(flat, np.arange(64, dtype=np.float32).reshape(8, 8))
assert np.isfinite(result).all()
# --------------------------------------------------------------- solar rotation
def test_rotation_rate_is_fastest_at_the_equator():
equator = fillers.rotation_rate(0.0)
mid = fillers.rotation_rate(np.radians(45))
pole = fillers.rotation_rate(np.radians(80))
assert equator > mid > pole
def test_synodic_rate_is_slower_than_sidereal():
"""An Earth-orbiting observer sees the Sun turn more slowly than the stars do."""
assert fillers.rotation_rate(0.0, synodic=True) < fillers.rotation_rate(0.0, synodic=False)
difference = fillers.rotation_rate(0.0, synodic=False) - fillers.rotation_rate(0.0, True)
assert difference == pytest.approx(fillers.EARTH_ORBIT_DEG_PER_DAY)
def test_rotation_map_is_the_identity_at_zero_lag():
shape = (128, 128)
header = dict(HEADER, crpix1=64.5, crpix2=64.5, diam_sun=76.0)
map_x, map_y, visible = fillers._rotation_map(shape, header, 0.0)
grid_x, grid_y = np.meshgrid(np.arange(shape[1]), np.arange(shape[0]))
np.testing.assert_allclose(map_x[visible], grid_x[visible], atol=0.01)
np.testing.assert_allclose(map_y[visible], grid_y[visible], atol=0.01)
def test_rotation_map_marks_the_off_disc_region_invisible():
header = dict(HEADER, crpix1=64.5, crpix2=64.5, diam_sun=76.0)
_, _, visible = fillers._rotation_map((128, 128), header, 0.0)
assert not visible[0, 0] # a corner is outside the disc
assert visible[64, 64] # disc centre
def test_rotation_moves_content_west_toward_positive_x():
"""Solar rotation carries features east to west. SUVI L2 frames are HPLN/HPLT-TAN
with CDELT1 = +2.5 and CROTA = 0, so west is +x: a forward warp must pull each
pixel's value from a smaller column and push a blob's centroid to a larger one.
Every other rotation test is a round-trip, identity, or torch-vs-numpy
cross-check, all of which a globally mirrored convention would pass."""
header = dict(HEADER, crpix1=64.5, crpix2=64.5, diam_sun=76.0, solar_b0=0.0)
map_x, _, visible = fillers._rotation_map((128, 128), header, 24 * 3600.0)
assert visible[64, 64]
# 13.7 deg/day of synodic rotation at disc centre pulls from ~9 px east of R=38.
assert 5.0 < 64 - map_x[64, 64] < 15.0
disc = solar_disc(size=128, radius=38, peak=2.0, active_region=False)
blob = disc.copy()
blob[56:64, 40:48] += 3.0 # east of centre, near the equator
warped_blob, _ = fillers._warp(blob, header, 24 * 3600.0, True)
warped_disc, _ = fillers._warp(disc, header, 24 * 3600.0, True)
moved = np.clip(warped_blob - warped_disc, 0.0, None)
columns = np.arange(moved.shape[1], dtype=np.float64)
centroid = float((moved.sum(axis=0) * columns).sum() / moved.sum())
assert 43.5 + 4.0 < centroid < 43.5 + 12.0 # started at column 43.5, moved west
def test_rotation_map_b0_shifts_the_fast_equator_south_in_september():
"""With B0 = +7.25 deg (September) the north pole tips toward the observer and
the equator's fast band appears *below* disc centre, so the pull a fixed
distance below centre must exceed the pull the same distance above -- and the
inequality must flip with the sign of B0. Pins the B0 sign convention, which
no round-trip or cross-implementation test can see."""
def pulls(b0_deg):
header = dict(HEADER, crpix1=64.5, crpix2=64.5, diam_sun=76.0, solar_b0=b0_deg)
map_x, _, _ = fillers._rotation_map((128, 128), header, 24 * 3600.0)
south, north = 64 - map_x[48, 64], 64 - map_x[79, 64] # rows at -/+0.41 R
return south, north
south, north = pulls(+7.25)
assert south > north * 1.05
south, north = pulls(-7.25)
assert north > south * 1.05
def test_rotation_warp_round_trips():
"""Warping forward by dt then back by dt must return the original on-disc."""
header = dict(HEADER, crpix1=64.5, crpix2=64.5, diam_sun=76.0)
image = solar_disc(size=128, radius=38, peak=2.0)
forward, visible = fillers._warp(image, header, 3600.0, True)
back, visible_back = fillers._warp(forward, header, -3600.0, True)
core = np.zeros_like(visible)
core[50:78, 50:78] = True # well inside the disc, away from limb foreshortening
np.testing.assert_allclose(back[core], image[core], atol=0.05)
def test_solar_rotation_reduces_to_a_blend_off_disc():
header = dict(HEADER, crpix1=64.5, crpix2=64.5, diam_sun=76.0)
before = np.full((128, 128), 1.0, np.float32)
after = np.full((128, 128), 3.0, np.float32)
filled = fillers.solar_rotation(context(before=before, after=after, header=header))
assert filled[0, 0] == pytest.approx(2.0, abs=1e-4) # corner: pure cross-fade
def test_solar_rotation_handles_a_single_bracket():
header = dict(HEADER, crpix1=64.5, crpix2=64.5, diam_sun=76.0)
image = solar_disc(size=128, radius=38, peak=2.0)
filled = fillers.solar_rotation(context(before=image, header=header))
assert filled is not None and filled.shape == image.shape
def test_solar_rotation_gives_up_with_no_brackets():
assert fillers.solar_rotation(context()) is None
# ---------------------------------------------------------------------- registry
#: Fillers that reconstruct one band from the two-frame context. `learned` is not one
#: of them: it fuses a whole stack of six-band frames and reports "not applicable"
#: without one, which is why it is excluded here rather than exempted inside the loop.
CONTEXT_FILLERS = ("hold_last", "linear_blend", "optical_flow", "crosssat",
"solar_rotation")
def test_every_registered_filler_is_covered_here():
"""A new filler must be classified deliberately, not silently skipped."""
assert set(fillers.FILLERS) == set(CONTEXT_FILLERS) | {"learned"}
def test_every_context_filler_is_callable_and_shape_preserving():
before = solar_disc(size=64, radius=20, peak=2.0)
after = solar_disc(size=64, radius=20, peak=2.2)
header = dict(HEADER, crpix1=32.5, crpix2=32.5, diam_sun=40.0)
ctx = context(before=before, after=after, counterpart=after, header=header)
for name in CONTEXT_FILLERS:
result = fillers.FILLERS[name](ctx)
assert result is not None, name
assert result.shape == before.shape, name
assert np.isfinite(result).all(), name
def test_learned_declines_a_two_frame_context():
"""Reporting 'not applicable' beats returning something plausible but untrained."""
before = solar_disc(size=64, radius=20, peak=2.0)
assert fillers.learned(context(before=before, counterpart=before)) is None