noaa-goes-visualization/tests/test_detectors.py

1233 lines
50 KiB
Python
Raw Normal View History

import cv2 as cv
import numpy as np
import pytest
from conftest import solar_disc
from suvi import detectors
BAND = 171
def features(slot_band=BAND, time=1715299200, satellite=16, **header):
"""A FrameFeatures whose header describes a healthy frame, before overrides."""
base = {
"empty": 0,
"degraded": 0,
"eclipse": 0,
"num_imgs": 2,
"wavelnth": slot_band,
"img_mean": 0.35,
"img_sdev": 0.57,
"diam_sun": 771.98,
"crpix1": 640.5,
"crpix2": 640.5,
}
base.update(header)
return detectors.FrameFeatures(slot=(satellite, slot_band, time), header=base)
def bright_disc(**kwargs):
"""A disc bright enough to clear the 171A geometry threshold of 1.0."""
return solar_disc(peak=3.0, **kwargs)
# -------------------------------------------------------------------------- header
def test_header_accepts_a_healthy_frame():
assert detectors.header_v1(features()).verdict == detectors.GOOD
def test_header_records_scores_even_when_passing():
verdict = detectors.header_v1(features())
assert verdict.scores["img_mean"] == pytest.approx(0.35)
assert "crpix_offset" in verdict.scores
@pytest.mark.parametrize(
"override,fragment",
[
({"empty": 1}, "EMPTY"),
({"eclipse": 2}, "ECLIPSE"),
({"num_imgs": 0}, "no source images"),
2026-10-14 00:27:18 -04:00
({"n_long": 0}, "N_LONG=0"),
({"img_sdev": 0.0}, "zero variance"),
({"wavelnth": 304}, "wavelength"),
({"img_mean": 1e-5}, "below"),
({"img_mean": 500.0}, "above"),
({"diam_sun": 100.0}, "DIAM_SUN"),
({"crpix1": 700.0}, "sun centre offset"),
],
)
def test_header_rejects(override, fragment):
verdict = detectors.header_v1(features(**override))
assert verdict.verdict == detectors.BAD
assert fragment in verdict.reason
2026-10-14 00:27:18 -04:00
def test_header_accepts_a_frame_with_a_long_exposure_or_no_n_long_keyword():
assert detectors.header_v1(features(n_long=1)).verdict == detectors.GOOD
assert "n_long" not in features().header
assert detectors.header_v1(features()).verdict == detectors.GOOD
def test_header_does_not_reject_on_degraded_alone():
"""Regression: DEGRADED is set for whole months of the 195A band on good frames.
Rejecting on it discarded 40/40 good 195A frames on 2024-01-20 while flagging
0/40 on 2024-07-04. It is recorded as a score and nothing more.
"""
verdict = detectors.header_v1(features(degraded=1))
assert verdict.verdict == detectors.GOOD
assert verdict.scores["degraded"] == 1.0
def test_header_reports_read_errors_as_bad():
frame = detectors.FrameFeatures(slot=(16, BAND, 0), header={}, error="truncated")
verdict = detectors.header_v1(frame)
assert verdict.verdict == detectors.BAD and "truncated" in verdict.reason
def test_header_abstains_without_a_cached_header():
frame = detectors.FrameFeatures(slot=(16, BAND, 0), header={})
assert detectors.header_v1(frame).verdict == detectors.UNKNOWN
def test_header_bounds_are_per_band():
"""A radiance normal for 304A is a dropout for 94A."""
assert detectors.header_v1(features(slot_band=304, img_mean=3.0, wavelnth=304)).verdict == detectors.GOOD
assert detectors.header_v1(features(slot_band=94, img_mean=3.0, wavelnth=94)).verdict == detectors.BAD
# ------------------------------------------------------------------------ geometry
def test_geometry_accepts_a_synthetic_disc():
verdict = detectors.geometry_v1(bright_disc(), BAND)
assert verdict.verdict == detectors.GOOD, verdict.reason
def test_geometry_rejects_a_blank_frame():
verdict = detectors.geometry_v1(np.zeros((1280, 1280), np.float32), BAND)
assert verdict.verdict == detectors.BAD and "too low" in verdict.reason
def test_geometry_rejects_a_saturated_frame():
verdict = detectors.geometry_v1(np.full((1280, 1280), 99.0, np.float32), BAND)
assert verdict.verdict == detectors.BAD and "too high" in verdict.reason
def test_geometry_rejects_a_displaced_disc():
verdict = detectors.geometry_v1(bright_disc(centre=(500, 640)), BAND)
assert verdict.verdict == detectors.BAD
def test_geometry_reports_missing_and_misshapen_input():
assert detectors.geometry_v1(None, BAND).verdict == detectors.BAD
small = detectors.geometry_v1(np.zeros((64, 64), np.float32), BAND)
assert small.verdict == detectors.BAD and "dimensions" in small.reason
def test_geometry_abstains_on_an_unknown_band():
verdict = detectors.geometry_v1(bright_disc(), 999)
assert verdict.verdict == detectors.UNKNOWN
def test_geometry_centre_check_is_one_sided():
"""Documents a real defect in the baseline, so a fix cannot land unnoticed.
The test is `HALF_DIMS//2 - centre > skew`, so brightness pulled toward low
indices rejects the frame while the same pull toward high indices passes. This
asymmetry is why the filter rejects entire days during high solar activity.
"""
low = detectors.geometry_v1(bright_disc(centre=(560, 640)), BAND)
high = detectors.geometry_v1(bright_disc(centre=(720, 640)), BAND)
assert low.scores["centre_x"] < detectors.HALF_DIMS // 2
assert high.scores["centre_x"] > detectors.HALF_DIMS // 2
assert low.verdict == detectors.BAD
assert high.verdict == detectors.GOOD # symmetric displacement, opposite verdict
def test_geometry_catches_an_all_nan_frame_only_by_luck():
"""An all-NaN frame is caught by the ratio test, not by the shape tests.
`NaN > x` is False, so the centroid, radius and goodness-of-fit comparisons all
evaluate False and report "good" on NaN input. Only the emptiness of the
threshold mask saves this case -- a frame that was partly NaN could still slip
through the shape checks.
"""
verdict = detectors.geometry_v1(np.full((1280, 1280), np.nan, np.float32), BAND)
assert verdict.verdict == detectors.BAD
assert "too low" in verdict.reason
# Demonstrate the underlying blindness: NaN defeats each shape test directly.
assert not (detectors.HALF_DIMS // 2 - np.nan > detectors.MAX_CENTER_SKEW)
assert not (np.nan > detectors.MAX_GOF)
# ------------------------------------------------------------------------ temporal
def series(count=25, means=None, thumbs=None):
out = []
for index in range(count):
mean = 0.35 if means is None else means[index]
frame = detectors.FrameFeatures(
slot=(16, BAND, 1715299200 + index * 240),
header={"img_mean": mean},
)
if thumbs is not None:
frame.thumbnail = thumbs[index]
out.append(frame)
return out
def drifting_thumbs(count, rng=None):
"""Thumbnails that change a little each step, like the real Sun."""
rng = rng or np.random.default_rng(0)
base = solar_disc(size=detectors.THUMBNAIL_SIZE, radius=38, peak=1.0)
return [(base + rng.normal(0, 0.01, base.shape)).astype(np.float32) for _ in range(count)]
def test_temporal_accepts_a_steady_series():
verdicts = detectors.temporal_v1(series(thumbs=drifting_thumbs(25)))
assert all(v.verdict == detectors.GOOD for v in verdicts)
def test_temporal_catches_a_frozen_frame():
thumbs = drifting_thumbs(25)
thumbs[12] = thumbs[11].copy() # the feed stalled
verdicts = detectors.temporal_v1(series(thumbs=thumbs))
assert verdicts[12].verdict == detectors.BAD
assert "identical to previous" in verdicts[12].reason
def test_temporal_catches_a_brightness_step():
means = [0.35] * 25
means[12] = 3.5
verdicts = detectors.temporal_v1(series(means=means, thumbs=drifting_thumbs(25)))
assert verdicts[12].verdict == detectors.BAD
def test_temporal_catches_a_structural_jump():
thumbs = drifting_thumbs(25)
thumbs[12] = np.roll(thumbs[12], 40, axis=0) # a frame from somewhere else
verdicts = detectors.temporal_v1(series(thumbs=thumbs))
assert verdicts[12].verdict == detectors.BAD
def test_temporal_reports_unreadable_frames():
frames = series(thumbs=drifting_thumbs(25))
frames[5].error = "truncated"
verdicts = detectors.temporal_v1(frames)
assert verdicts[5].verdict == detectors.BAD
def test_temporal_abstains_without_context():
verdicts = detectors.temporal_v1(series(count=2))
assert all(v.verdict == detectors.UNKNOWN for v in verdicts)
def test_temporal_handles_an_empty_series():
assert detectors.temporal_v1([]) == []
# ------------------------------------------------------------------------ crosssat
def cross_pair(scale_b=1.0, shift_b=0, noise=0.005):
rng = np.random.default_rng(1)
base = solar_disc(size=detectors.THUMBNAIL_SIZE, radius=38, peak=1.0)
a = (base + rng.normal(0, noise, base.shape)).astype(np.float32)
b = (base + rng.normal(0, noise, base.shape)).astype(np.float32) * scale_b
if shift_b:
b = np.roll(b, shift_b, axis=1)
first = detectors.FrameFeatures(slot=(16, BAND, 0), header={"img_mean": 0.35})
second = detectors.FrameFeatures(slot=(18, BAND, 0), header={"img_mean": 0.35})
first.thumbnail, second.thumbnail = a, b
return first, second
def test_crosssat_agrees_on_matching_views():
a, b = cross_pair()
first, second = detectors.crosssat_v1(a, b)
assert first.verdict == detectors.GOOD and second.verdict == detectors.GOOD
def test_crosssat_tolerates_calibration_differences():
"""A modest scale factor between flight models is normal, not a fault."""
a, b = cross_pair(scale_b=1.3)
first, _ = detectors.crosssat_v1(a, b)
assert first.verdict == detectors.GOOD
def test_crosssat_catches_a_blackout_despite_gain_matching():
"""Regression: least-squares matching rescales a 1e-4 frame into agreement.
Without an explicit bound on the fitted gain this detector called a total
blackout a match, because the residual after rescaling is tiny.
"""
a, b = cross_pair(scale_b=1e-4)
first, second = detectors.crosssat_v1(a, b)
assert detectors.BAD in (first.verdict, second.verdict) or first.verdict == detectors.UNKNOWN
assert "gain" in (first.reason or "")
def test_crosssat_blames_the_frame_its_own_history_disowns():
a, b = cross_pair(scale_b=1e-4)
suspect = detectors.Verdict(detectors.BAD, "brightness z=40")
healthy = detectors.Verdict(detectors.GOOD)
first, second = detectors.crosssat_v1(a, b, temporal_a=healthy, temporal_b=suspect)
assert first.verdict == detectors.GOOD
assert second.verdict == detectors.BAD
def test_crosssat_abstains_when_it_cannot_tell_which_is_wrong():
a, b = cross_pair(scale_b=1e-4)
first, second = detectors.crosssat_v1(a, b)
assert first.verdict == detectors.UNKNOWN and second.verdict == detectors.UNKNOWN
def test_crosssat_abstains_without_a_counterpart():
a, _ = cross_pair()
first, second = detectors.crosssat_v1(a, None)
assert first.verdict == detectors.UNKNOWN and second.verdict == detectors.UNKNOWN
def test_crosssat_abstains_without_thumbnails():
a = detectors.FrameFeatures(slot=(16, BAND, 0), header={})
b = detectors.FrameFeatures(slot=(18, BAND, 0), header={})
first, _ = detectors.crosssat_v1(a, b)
assert first.verdict == detectors.UNKNOWN
# --------------------------------------------------------------------------- utils
def test_thumbnail_shrinks_and_removes_nans():
image = solar_disc()
image[0:10, 0:10] = np.nan
thumb = detectors.thumbnail(image)
assert thumb.shape == (detectors.THUMBNAIL_SIZE, detectors.THUMBNAIL_SIZE)
assert np.isfinite(thumb).all()
def test_align_shift_measures_a_known_translation():
base = solar_disc(size=128, radius=38)
shifted = np.roll(base, 5, axis=1)
assert detectors.align_shift(base, shifted) == pytest.approx(5.0, abs=1.0)
assert detectors.align_shift(base, base) == pytest.approx(0.0, abs=0.5)
def test_align_shift_returns_none_on_mismatched_shapes():
assert detectors.align_shift(np.zeros((8, 8)), np.zeros((4, 4))) is None
assert detectors.align_shift(None, np.zeros((4, 4))) is None
def test_robust_z_handles_a_constant_neighbourhood():
values = [1.0] * 10
assert detectors._robust_z(values, 5, 4) == 0.0
values[5] = 9.0
assert detectors._robust_z(values, 5, 4) == np.inf
def test_robust_z_needs_enough_neighbours():
assert detectors._robust_z([1.0, 2.0], 0, 4) is None
# ---------------------------------------------------------------------- disc_v1
DISC_HEADER = {"diam_sun": 772.0}
def quiet_disc(peak=1.1):
"""A disc whose quiet regions sit near the 171A threshold, as real frames do."""
return solar_disc(peak=peak, active_region=False)
def add_active_region(image, offset, strength=6.0, width=0.30, radius=386):
"""Add a bright compact region at `offset` solar radii along +x from centre."""
yy, xx = np.mgrid[0 : image.shape[0], 0 : image.shape[1]].astype(np.float32)
centre = (image.shape[1] - 1) / 2.0
on_disc = np.hypot(xx - centre, yy - centre) < radius
spot = np.hypot(xx - (centre + radius * offset), yy - centre)
return image + strength * np.exp(-((spot / (radius * width)) ** 2)) * on_disc
def test_disc_accepts_a_synthetic_disc():
assert detectors.disc_v1(bright_disc(), BAND, DISC_HEADER).verdict == detectors.GOOD
def test_disc_measurements_are_unmoved_by_an_active_region():
"""The whole point of the detector, and the fix for the 36.9% rejection rate.
Every disc_v1 measurement is an average over angle, so where the bright regions
sit does not move it. geometry_v1 averages along image rows and columns
instead, so its centroid swings with the active region -- and because its centre
test is one-sided, an equal displacement is fatal on one limb and harmless on
the other.
"""
base = quiet_disc()
left = add_active_region(base, -0.45)
right = add_active_region(base, +0.45)
measurements = [detectors.disc_profile(img, 772.0 / 4.0) for img in (base, left, right)]
for name in ("radius_ratio", "limb_width"):
values = [m[name] for m in measurements]
assert max(values) - min(values) < 1e-6, f"{name} moved: {values}"
contrasts = [m["limb_contrast"] for m in measurements]
assert max(contrasts) - min(contrasts) < 0.01
# Meanwhile the baseline's centroid swings, in opposite directions.
centroids = [detectors.geometry_v1(img, BAND).scores["centre_x"] for img in (left, base, right)]
assert centroids[0] < centroids[1] < centroids[2]
assert centroids[2] - centroids[0] > 5.0
for img in (base, left, right):
assert detectors.disc_v1(img, BAND, DISC_HEADER).verdict == detectors.GOOD
@pytest.mark.parametrize(
"image,label",
[
(np.zeros((1280, 1280), np.float32), "all zero"),
(np.full((1280, 1280), np.nan, np.float32), "all NaN"),
(np.full((1280, 1280), 1.0, np.float32), "uniform field"),
],
)
def test_disc_rejects_frames_with_no_disc(image, label):
"""A frame with no radial structure is a conclusion, not an abstention."""
verdict = detectors.disc_v1(image, BAND, DISC_HEADER)
assert verdict.verdict == detectors.BAD, label
assert verdict.scores["limb_contrast"] == 0.0
def test_disc_detects_a_wrong_sized_disc():
small = solar_disc(radius=300, peak=3.0, active_region=False)
measured = detectors.disc_profile(small, 772.0 / 4.0)
assert measured["radius_ratio"] < 0.85
def test_disc_detects_a_displaced_disc_through_limb_smearing():
"""A decentred disc smears the azimuthally averaged limb; that is the signal."""
base = solar_disc(peak=3.0, active_region=False)
sharp = detectors.disc_profile(base, 772.0 / 4.0)
for shift in (20, 40):
moved = detectors.disc_profile(np.roll(base, shift, axis=1), 772.0 / 4.0)
assert moved["limb_width"] > sharp["limb_width"] * 2, f"shift {shift}"
def test_disc_detects_a_blurred_limb():
base = solar_disc(peak=3.0, active_region=False)
blurred = cv.GaussianBlur(base, (81, 81), 25)
assert (
detectors.disc_profile(blurred, 772.0 / 4.0)["limb_width"]
> detectors.disc_profile(base, 772.0 / 4.0)["limb_width"] * 2
)
def test_disc_is_blind_to_rotation_by_construction():
"""Documents a deliberate limit: rotate/yaw_flip belong to the other detectors.
Any azimuthally averaged quantity is rotation invariant, and a single frame
carries no absolute rotation reference beyond the CROTA header.
"""
image = solar_disc(peak=3.0)
flipped = np.flip(np.flip(image, 0), 1).copy()
original = detectors.disc_profile(image, 772.0 / 4.0)
rotated = detectors.disc_profile(flipped, 772.0 / 4.0)
for name, value in original.items():
assert rotated[name] == pytest.approx(value, abs=1e-6), name
def test_disc_abstains_without_the_expected_radius():
verdict = detectors.disc_v1(bright_disc(), BAND, {})
assert verdict.verdict == detectors.UNKNOWN and "DIAM_SUN" in verdict.reason
def test_disc_abstains_on_an_uncalibrated_band():
verdict = detectors.disc_v1(bright_disc(), 999, DISC_HEADER)
assert verdict.verdict == detectors.UNKNOWN
def test_disc_reports_a_missing_image():
assert detectors.disc_v1(None, BAND, DISC_HEADER).verdict == detectors.BAD
def test_disc_profile_rejects_nonsense_input():
for image in (None, np.zeros((4, 4), np.float32), np.zeros((8, 8, 3), np.float32)):
assert detectors.disc_profile(image, 193.0)["limb_contrast"] is None
assert detectors.disc_profile(bright_disc(), 0)["limb_contrast"] is None
assert detectors.disc_profile(bright_disc(), np.nan)["limb_contrast"] is None
def test_disc_honours_supplied_bounds():
"""Bounds are per band and injectable, so calibration is testable in isolation."""
image = bright_disc()
permissive = {BAND: {"limb_contrast": (0.0, 1.0), "radius_ratio": None, "limb_width": None}}
strict = {BAND: {"limb_contrast": (0.999, 1.0), "radius_ratio": None, "limb_width": None}}
assert detectors.disc_v1(image, BAND, DISC_HEADER, permissive).verdict == detectors.GOOD
assert detectors.disc_v1(image, BAND, DISC_HEADER, strict).verdict == detectors.BAD
def test_disc_bounds_set_to_none_record_a_score_without_judging():
image = bright_disc()
bounds = {BAND: {"limb_contrast": None, "radius_ratio": None, "limb_width": None}}
verdict = detectors.disc_v1(image, BAND, DISC_HEADER, bounds)
assert verdict.verdict == detectors.GOOD
assert "radius_ratio" in verdict.scores # still measured and reported
def test_disc_is_registered_as_a_frame_detector():
assert "disc_v1" in detectors.FRAME_DETECTORS
assert "disc_v1" in detectors.ALL_DETECTORS
2026-10-14 00:27:18 -04:00
# ------------------------------------------------------------------- far field
RADIUS = 386.0
def with_bar(image, value=10.0, width=40, angle=22.5):
"""A saturated straight bar through the frame centre, like the archive's."""
yy, xx = np.mgrid[0:image.shape[0], 0:image.shape[1]].astype(np.float32)
centre = (image.shape[0] - 1) / 2.0
theta = np.radians(angle)
distance = np.abs((xx - centre) * np.sin(theta) - (yy - centre) * np.cos(theta))
out = image.copy()
out[distance < width / 2] = value
return out
def test_far_field_of_a_clean_disc_is_faint_corona():
image = solar_disc(peak=3.0)
assert detectors.far_field(image, RADIUS) < 0.01
def test_a_bar_across_the_frame_lifts_the_far_field():
image = solar_disc(peak=3.0)
assert detectors.far_field(with_bar(image), RADIUS) > 100 * detectors.far_field(image, RADIUS)
def test_a_thin_diffraction_spike_does_not_lift_the_far_field():
"""A flare's spikes are real and a few pixels wide: far below 1% of the far field."""
image = solar_disc(peak=3.0)
spiked = with_bar(image, value=50.0, width=2, angle=45.0)
assert detectors.far_field(spiked, RADIUS) < 2 * detectors.far_field(image, RADIUS)
def test_a_ghost_disc_in_the_corner_lifts_the_far_field():
image = solar_disc(peak=3.0)
ghost = image + solar_disc(peak=3.0, centre=(1180.0, 1180.0), active_region=False)
assert detectors.far_field(ghost, RADIUS) > 100 * detectors.far_field(image, RADIUS)
def test_far_field_ignores_nan_pixels():
image = solar_disc(peak=3.0)
holed = image.copy()
holed[:40, :] = np.nan
assert np.isfinite(detectors.far_field(holed, RADIUS))
@pytest.mark.parametrize("image,radius", [
(None, RADIUS),
(np.zeros((8, 8), np.float32), RADIUS),
(np.zeros((64, 64, 3), np.float32), RADIUS),
(np.zeros((1280, 1280), np.float32), 0.0),
(np.zeros((1280, 1280), np.float32), np.nan),
(np.zeros((1280, 1280), np.float32), 5000.0), # no far field inside the frame
(np.full((1280, 1280), np.nan, np.float32), RADIUS),
])
def test_far_field_measurements_abstain_on_unmeasurable_input(image, radius):
assert detectors.far_field(image, radius) is None
assert detectors.far_field_noise(image, radius) is None
def test_far_field_noise_measures_grain_not_the_corona():
rng = np.random.default_rng(0)
image = solar_disc(peak=3.0)
assert detectors.far_field_noise(image, RADIUS) < 1e-4
noisy = image + rng.normal(0, 0.1, image.shape).astype(np.float32)
# MAD of first differences of white noise: 0.6745 * sqrt(2) * sigma.
assert detectors.far_field_noise(noisy, RADIUS) == pytest.approx(0.6745 * 2 ** 0.5 * 0.1,
rel=0.05)
def test_far_field_noise_ignores_a_smooth_gradient():
ramp = np.tile(np.linspace(0, 5, 1280, dtype=np.float32), (1280, 1))
assert detectors.far_field_noise(ramp, RADIUS) < 1e-5
# --------------------------------------------------------- unified file judgment
def test_unreadable_is_the_single_bad_verdict_for_undecodable_frames():
verdict = detectors.unreadable("truncated: 42 bytes")
assert verdict.verdict == detectors.BAD
assert verdict.reason == "unreadable: truncated: 42 bytes"
assert detectors.unreadable(None).reason == "unreadable: no image data"
def test_judge_file_accepts_a_healthy_frame(tmp_path):
from conftest import write_fits
path = str(tmp_path / "d" / "good.fits")
write_fits(path, solar_disc())
assert detectors.judge_file((16, BAND, 0), path).verdict == detectors.GOOD
def test_judge_file_rejects_a_dark_frame_from_the_header_alone(tmp_path):
"""The 2024-05-09 failure class: pixels near zero, honestly reported by
IMG_MEAN, invisible to the EMPTY/ECLIPSE-only check the bench used to run."""
from conftest import write_fits
path = str(tmp_path / "d" / "dark.fits")
write_fits(path, solar_disc() * 2e-4)
verdict = detectors.judge_file((18, BAND, 0), path)
assert verdict.verdict == detectors.BAD
assert "IMG_MEAN" in verdict.reason and "below" in verdict.reason
def test_judge_file_rejects_eclipse_and_stops_before_decoding(tmp_path):
from conftest import write_fits
path = str(tmp_path / "d" / "eclipse.fits")
write_fits(path, solar_disc(), headers={"ECLIPSE": 2})
verdict = detectors.judge_file((16, BAND, 0), path, decode=False)
assert verdict.verdict == detectors.BAD and "ECLIPSE" in verdict.reason
def test_judge_file_reports_a_missing_file_as_unreadable():
verdict = detectors.judge_file((16, BAND, 0), "/nowhere/nothing.fits")
assert verdict.verdict == detectors.BAD
assert verdict.reason.startswith("unreadable:")
def test_judge_file_runs_disc_v1_when_the_header_passes(tmp_path):
"""A header that lies about healthy pixels must still fail on the pixels --
the extraction pipeline's second line of defence, now shared by the bench."""
from conftest import write_fits
path = str(tmp_path / "d" / "flat.fits")
flat = np.full((1280, 1280), 0.35, dtype=np.float32) # header stats look fine
write_fits(path, flat, headers={"IMG_SDEV": 0.57})
verdict = detectors.judge_file((16, BAND, 0), path)
assert verdict.verdict == detectors.BAD
assert "limb_contrast" in verdict.reason
def test_judge_file_rejects_the_wrong_shape(tmp_path):
from conftest import write_fits
path = str(tmp_path / "d" / "small.fits")
write_fits(path, solar_disc(size=640, radius=193),
headers={"CRPIX1": 640.5, "CRPIX2": 640.5})
verdict = detectors.judge_file((16, BAND, 0), path)
assert verdict.verdict == detectors.BAD and "shape" in verdict.reason
# --------------------------------------------------------------------- field_v1
REFCROTA = 22.4
def detector_line_bar(image, offset=0.0, width=40, value=None, noise=None, refcrota=REFCROTA,
rng=None):
"""A bar along a detector row: in the delivered image it lies at -REFCROTA."""
yy, xx = np.mgrid[0:image.shape[0], 0:image.shape[1]].astype(np.float32)
centre = (image.shape[0] - 1) / 2.0
theta = np.radians(-refcrota)
# Signed distance from the line through the centre at angle theta (y up).
distance = (xx - centre) * np.sin(theta) + (yy - centre) * np.cos(theta) - offset
inside = np.abs(distance) < width / 2
out = image.copy()
if noise is not None:
rng = rng or np.random.default_rng(0)
out[inside] = np.abs(rng.normal(0.0, noise, inside.sum())).astype(np.float32)
else:
out[inside] = value
return out
def clean_disc():
return solar_disc(peak=1.0)
def test_field_accepts_a_clean_disc():
verdict = detectors.field_v1(clean_disc(), BAND, DISC_HEADER, refcrota=REFCROTA)
assert verdict.verdict == detectors.GOOD, verdict.reason
assert set(verdict.scores) == {"far_field", "stripe_level", "stripe_grain"}
def test_field_rejects_a_saturated_bar_across_the_disc():
barred = detector_line_bar(clean_disc(), offset=100.0, value=20.0)
verdict = detectors.field_v1(barred, BAND, DISC_HEADER, refcrota=REFCROTA)
assert verdict.verdict == detectors.BAD and "stripe_level" in verdict.reason
def test_field_rejects_a_noise_filled_bar_that_barely_changes_the_level():
"""The #2140 case: noise, not light, lying mostly across the disc."""
image = clean_disc()
barred = detector_line_bar(image, offset=60.0, width=24, noise=0.6)
verdict = detectors.field_v1(barred, BAND, DISC_HEADER, refcrota=REFCROTA)
assert verdict.verdict == detectors.BAD
assert verdict.scores["stripe_grain"] > detectors.FIELD_LIMITS["stripe_grain"]
def test_field_rejects_a_ghost_disc_in_the_corner_by_its_far_field():
ghost = clean_disc() + solar_disc(peak=1.0, centre=(1180.0, 1180.0), active_region=False)
verdict = detectors.field_v1(ghost, BAND, DISC_HEADER, refcrota=None)
assert verdict.verdict == detectors.BAD and "far_field" in verdict.reason
def test_field_ignores_a_bright_active_region_and_a_fading_diffraction_spike():
"""Real flares: compact, and their spikes fade within a fraction of a row."""
image = clean_disc()
yy, xx = np.mgrid[0:1280, 0:1280].astype(np.float32)
kernel = np.hypot(xx - 800, yy - 600)
image = image + 200.0 * np.exp(-(kernel / 12.0) ** 2)
spike = (np.abs(yy - 600) < 1.5) * 50.0 * np.exp(-np.abs(xx - 800) / 60.0)
verdict = detectors.field_v1((image + spike).astype(np.float32), BAND, DISC_HEADER,
refcrota=0.0)
assert verdict.verdict == detectors.GOOD, verdict.reason
def test_the_stripe_test_needs_the_detector_orientation():
"""The same bar judged with the wrong rotation no longer fills whole lines."""
barred = detector_line_bar(clean_disc(), offset=100.0, value=20.0)
right = detectors.stripe_profile(barred, BAND, REFCROTA)
wrong = detectors.stripe_profile(barred, BAND, REFCROTA + 45.0)
assert right["stripe_level"] > 2 * wrong["stripe_level"]
def test_field_measures_what_it_can_and_abstains_on_nothing():
image = clean_disc()
far_only = detectors.field_v1(image, BAND, DISC_HEADER, refcrota=None)
assert far_only.verdict == detectors.GOOD and set(far_only.scores) == {"far_field"}
stripes_only = detectors.field_v1(image, BAND, {}, refcrota=REFCROTA)
assert set(stripes_only.scores) == {"stripe_level", "stripe_grain"}
nothing = detectors.field_v1(image, BAND, {}, refcrota=None)
assert nothing.verdict == detectors.UNKNOWN
assert detectors.field_v1(image, 999, DISC_HEADER, refcrota=0.0).verdict == detectors.UNKNOWN
assert detectors.field_v1(None, BAND, DISC_HEADER, refcrota=0.0).verdict == detectors.BAD
@pytest.mark.parametrize("refcrota", [None, float("nan")])
def test_stripe_profile_needs_a_finite_rotation(refcrota):
assert detectors.stripe_profile(clean_disc(), BAND, refcrota) is None
def test_stripe_profile_ignores_pixels_outside_the_field_of_view():
"""Zero corners (outside SUVI's delivered field) must not read as dark stripes."""
image = clean_disc()
image[:200, :200] = 0.0
image[-200:, -200:] = 0.0
profile = detectors.stripe_profile(image, BAND, REFCROTA)
assert profile["stripe_level"] < detectors.FIELD_LIMITS["stripe_level"]
def test_field_honours_supplied_limits():
image = clean_disc()
strict = {"far_field": 1e-9, "stripe_level": 1e9, "stripe_grain": 1e9}
assert detectors.field_v1(image, BAND, DISC_HEADER, refcrota=0.0,
limits=strict).verdict == detectors.BAD
def test_judge_file_runs_field_v1_with_the_header_rotation(tmp_path):
from conftest import write_fits
path = str(tmp_path / "d" / "bar.fits")
barred = detector_line_bar(solar_disc(peak=1.0) * 1.0, offset=100.0, value=20.0)
write_fits(path, barred.astype(np.float32),
headers={"REFCROTA": REFCROTA, "IMG_MEAN": 0.35, "IMG_SDEV": 0.57})
verdict = detectors.judge_file((16, BAND, 0), path)
assert verdict.verdict == detectors.BAD and "stripe_level" in verdict.reason
def test_judge_file_passes_a_clean_frame_through_all_three(tmp_path):
from conftest import write_fits
path = str(tmp_path / "d" / "clean.fits")
write_fits(path, solar_disc(peak=1.0), headers={"REFCROTA": REFCROTA})
verdict = detectors.judge_file((16, BAND, 0), path)
assert verdict.verdict == detectors.GOOD, verdict.reason
assert "stripe_level" in verdict.scores
# ------------------------------------------------------------------ judge_frame
def _cards(**overrides):
cards = {"WAVELNTH": BAND, "IMG_MEAN": 0.35, "IMG_SDEV": 0.57, "DIAM_SUN": 772.0,
"CRPIX1": 640.5, "CRPIX2": 640.5, "N_LONG": 1, "NUM_IMGS": 2,
"REFCROTA": REFCROTA}
cards.update(overrides)
return cards
def test_judge_frame_never_decodes_a_frame_the_header_rejects():
calls = []
source, verdict, image = detectors.judge_frame(
(16, BAND, 0), _cards(N_LONG=0), None, lambda: calls.append(1) or (None, "x"))
assert (source, verdict.verdict, image, calls) == ("header_v1", detectors.BAD, None, [])
@pytest.mark.parametrize("pixels,source", [
(lambda: (None, "boom"), "read"),
(lambda: (np.zeros((64, 64), np.float32), None), "read"),
(lambda: (np.zeros((1280, 1280), np.float32), None), "disc_v1"),
(lambda: (detector_line_bar(solar_disc(peak=1.0), offset=100.0, value=20.0), None),
"field_v1"),
(lambda: (solar_disc(peak=1.0), None), None),
])
def test_judge_frame_names_the_check_that_decided(pixels, source):
got, verdict, _ = detectors.judge_frame((16, BAND, 0), _cards(), None, pixels)
assert got == source
assert (verdict.verdict == detectors.BAD) == (source is not None)
def test_judge_frame_hands_back_the_decoded_pixels():
image = solar_disc(peak=1.0)
_, _, got = detectors.judge_frame((16, BAND, 0), _cards(), None, lambda: (image, None))
assert got is image
def test_judge_frame_keeps_every_checks_measurements():
"""The measurement run stores them all, not just the last check's."""
_, verdict, _ = detectors.judge_frame((16, BAND, 0), _cards(), None,
lambda: (solar_disc(peak=1.0), None))
assert verdict.verdict == detectors.GOOD
assert {"radius_ratio", "limb_contrast", "far_field"} <= set(verdict.scores)
def test_judge_frame_without_a_reader_is_header_only():
source, verdict, image = detectors.judge_frame((16, BAND, 0), _cards(), None, None)
assert (source, verdict.verdict, image) == (None, detectors.GOOD, None)
def test_judge_frame_tolerates_a_non_numeric_refcrota():
source, verdict, _ = detectors.judge_frame(
(16, BAND, 0), _cards(REFCROTA="junk"), None, lambda: (solar_disc(peak=1.0), None))
assert source is None and "stripe_level" not in verdict.scores
# ------------------------------------------------------------------ temporal_v2
def stream(means, thumbs=None, satellite=16, band=BAND, t0=0):
"""A FrameFeatures series with the given IMG_MEANs and thumbnails."""
base = solar_disc(size=128, radius=38, peak=1.0)
out = []
for i, mean in enumerate(means):
thumb = thumbs[i] if thumbs is not None else base * mean
out.append(detectors.FrameFeatures(slot=(satellite, band, t0 + i * 240),
header={"img_mean": mean}, thumbnail=thumb))
return out
def all_good(series):
return {f.slot for f in series}
def test_temporal2_passes_a_flare_rising_between_its_neighbours():
series = stream([0.4, 0.4, 0.7, 1.2, 1.6, 1.5, 1.3])
verdicts = detectors.temporal_v2(series, all_good(series))
assert [v.verdict for v in verdicts] == ["good"] * 7
def test_temporal2_rejects_a_one_frame_gain_jump():
"""The labelled #640: IMG_MEAN 0.388 -> 1.101 -> 0.387."""
series = stream([0.388, 0.388, 1.101, 0.387, 0.387])
good = all_good(series) - {series[2].slot}
verdicts = detectors.temporal_v2(series, good)
assert verdicts[2].verdict == detectors.BAD and "brightness" in verdicts[2].reason
assert all(v.verdict == detectors.GOOD for i, v in enumerate(verdicts) if i != 2)
def test_temporal2_rejects_a_ghost_limb_its_neighbours_lack():
base = solar_disc(size=128, radius=38, peak=1.0)
ghost = base + 0.6 * solar_disc(size=128, radius=38, centre=(85.0, 60.0), peak=1.0,
active_region=False)
series = stream([0.4] * 5, thumbs=[base, base, ghost, base, base])
verdicts = detectors.temporal_v2(series, all_good(series) - {series[2].slot})
assert verdicts[2].verdict == detectors.BAD and "structure" in verdicts[2].reason
def test_temporal2_only_trusts_good_neighbours():
"""A clean frame between two bad ones is judged against good frames further out
-- the flaw that cost temporal_v1 6.8% false positives."""
series = stream([0.4, 0.4, 3.0, 0.4, 3.0, 0.4, 0.4])
good = all_good(series) - {series[2].slot, series[4].slot}
verdicts = detectors.temporal_v2(series, good)
assert verdicts[3].verdict == detectors.GOOD
naive = detectors.temporal_v2(series, all_good(series))
assert naive[3].verdict == detectors.BAD # trusting every neighbour flags it
assert verdicts[2].verdict == verdicts[4].verdict == detectors.BAD
def test_temporal2_uses_one_side_when_only_one_has_good_frames():
series = stream([0.4, 0.4, 0.4, 2.0])
good = {series[0].slot, series[1].slot}
assert detectors.temporal_v2(series, good)[3].verdict == detectors.BAD
def test_temporal2_abstains_without_good_neighbours_in_reach():
series = stream([0.4, 0.4, 0.4])
verdicts = detectors.temporal_v2(series, set())
assert all(v.verdict == detectors.UNKNOWN for v in verdicts)
far = stream([0.4, 0.4], t0=0) + stream([0.4], t0=240 * 20)
assert detectors.temporal_v2(far, {far[0].slot})[2].verdict == detectors.UNKNOWN
def test_temporal2_reports_unreadable_frames_as_bad():
series = stream([0.4, 0.4, 0.4])
series[1].error = "truncated"
assert detectors.temporal_v2(series, all_good(series))[1].verdict == detectors.BAD
def test_temporal2_is_registered_as_a_series_detector():
assert "temporal_v2" in detectors.SERIES_DETECTORS
# ------------------------------------------------------------- temporal_verdict
def test_temporal_verdict_applies_the_limits_to_stored_scores():
judge = lambda **s: detectors.temporal_verdict(
{f"temporal_{k}": v for k, v in s.items()}).verdict
assert judge(brightness=1.05, structure=0.5) == detectors.BAD # #640, 2.85x
assert judge(brightness=0.51, structure=0.3) == detectors.GOOD # a flare peak, 1.66x
assert judge(structure=13.2) == detectors.BAD
assert detectors.temporal_verdict({}).verdict == detectors.UNKNOWN
# ------------------------------------------------------------------ relative_v2
def measured(medians, sectors=None, stripes=None, earth=None, t0=0, band=BAND):
"""A stream of FrameFeatures carrying stored measures."""
out = []
for i, median in enumerate(medians):
scores = {"disc_median": median}
for k in range(8):
scores[f"sector_{k}"] = sectors[i][k] if sectors else median
if stripes:
scores["stripe_level"], scores["stripe_grain"] = stripes[i]
if earth:
scores["earth_tangent_km"] = earth[i]
out.append(detectors.FrameFeatures(slot=(16, band, t0 + i * 240), scores=scores))
return out
def bad_slots(series, verdicts):
return [i for i, v in enumerate(verdicts) if v.verdict == detectors.BAD]
def relative(series, good=None):
return detectors.relative_v2(series, all_good(series) if good is None else good)
def test_relative_passes_a_flare_rising_and_decaying():
series = measured([0.4] * 4 + [0.6, 1.0, 1.6, 2.4, 2.0, 1.4, 0.9, 0.6, 0.45] + [0.4] * 4)
assert bad_slots(series, relative(series)) == []
def test_relative_rejects_a_half_exposure_frame():
"""2024-10-30 16:24: half its neighbours' brightness."""
series = measured([3.35] * 4 + [1.67] + [3.35] * 4)
verdicts = relative(series)
assert bad_slots(series, verdicts) == [4]
assert "brightness 0.50x" in verdicts[4].reason
def test_relative_catches_dim_frames_that_vouch_for_each_other():
"""2024-01-30 03:00/03:08: two half frames around an empty one."""
series = measured([1.0, 1.0, 1.0, 0.5, 0.0, 0.5, 1.0, 1.0, 1.0])
good = all_good(series) - {series[4].slot}
flagged = [i for i in bad_slots(series, relative(series, good)) if series[i].slot in good]
assert flagged == [3, 5]
def test_relative_works_a_run_of_dim_frames_from_the_ends_inwards():
series = measured([1.0] * 4 + [0.5, 0.5, 0.5] + [1.0] * 4)
assert bad_slots(series, relative(series)) == [4, 5, 6]
def test_relative_never_calls_a_frame_dim_from_one_side():
"""2024-10-02 05:28: the first frame after an eclipse, the next ones brightening
with a new flare -- seen from one side, a flare onset and a dim frame agree."""
series = measured([0.037, 0.039, 0.044, 0.045, 0.046])
assert bad_slots(series, relative(series)) == []
def test_relative_catches_a_half_frame_against_a_steady_side():
"""2023-11-01 17:52: half brightness right after a 2.3-day archive gap."""
series = measured([1.38, 2.77, 2.77, 2.78, 2.77])
assert bad_slots(series, relative(series)) == [0]
def test_relative_does_not_trust_a_rising_side_alone():
"""2023-08-07 20:04-20:36: the frames before an X-flare, with no good frame for
two hours before them, against a baseline of flare frames."""
series = measured([0.4, 0.4, 0.5, 0.9, 1.6, 2.4, 3.0, 3.2, 3.0])
assert bad_slots(series, relative(series)) == []
def test_relative_baseline_sees_through_a_cluster_of_half_frames():
"""Noon episodes: half frames among empty ones, each the other's near reference."""
values = [1.0] * 12 + [0.5, 0.0, 0.5, 0.0, 0.5, 0.5, 0.0, 0.5] + [1.0] * 12
series = measured(values)
good = {f.slot for f, v in zip(series, values) if v > 0}
flagged = [i for i in bad_slots(series, relative(series, good)) if values[i] > 0]
assert flagged == [i for i, v in enumerate(values) if v == 0.5]
def test_relative_finds_one_dimmed_sector():
"""A grazing eclipse dims one side of the disc."""
sectors = [[1.0] * 8 for _ in range(9)]
sectors[4] = [1.0, 1.0, 0.25, 0.6, 1.0, 1.0, 1.0, 1.0]
series = measured([1.0] * 4 + [0.92] + [1.0] * 4, sectors=sectors)
verdicts = relative(series)
assert bad_slots(series, verdicts) == [4] and "sector 2 at 0.25x" in verdicts[4].reason
def test_relative_keeps_a_mildly_dimmed_limb():
"""User-reviewed: limbs dimmed to 0.31-0.69x of their neighbours look fine."""
sectors = [[1.0] * 8 for _ in range(9)]
sectors[4] = [1.0, 1.0, 0.35, 0.6, 1.0, 1.0, 1.0, 1.0]
series = measured([1.0] * 4 + [0.92] + [1.0] * 4, sectors=sectors)
assert bad_slots(series, relative(series)) == []
def test_relative_never_takes_a_grazing_frame_as_a_reference():
"""2024-04-13 04:48-05:08: a grazing pass dims frame after frame, so each frame's
neighbours are nearly as dim as it is and none stands out among them."""
values = [1.0] * 4 + [0.92, 0.84, 0.78, 0.74, 0.78, 0.84, 0.92] + [1.0] * 4
earth = [900.0] * 4 + [380.0, 300.0, 220.0, 180.0, 220.0, 300.0, 380.0] + [900.0] * 4
with_geometry = measured(values, earth=earth)
assert bad_slots(with_geometry, relative(with_geometry)) == [5, 6, 7, 8, 9]
without = measured(values)
assert bad_slots(without, relative(without)) == []
def positioned(offsets, echoes=None, band=BAND):
"""A stream whose disc sits at (dx, 0) per frame, with optional echoes."""
series = measured([1.0] * len(offsets), band=band)
for i, frame in enumerate(series):
frame.scores.update(ring_dx=offsets[i], ring_dy=0.0,
echo=(echoes or [0.05] * len(offsets))[i])
return series
def test_relative_rejects_a_disc_that_moved_from_its_neighbours():
"""2025-01-27 17:32 (131A): the disc 0.65R off where its neighbours had it."""
series = positioned([0.0, 0.0, 0.0, 0.65, 0.0, 0.0, 0.0], band=131)
verdicts = relative(series)
assert bad_slots(series, verdicts) == [3] and "disc moved" in verdicts[3].reason
def test_relative_passes_a_disc_the_sun_holds_off_centre_all_day():
"""2023-07-24 (284A, both satellites): a lopsided corona pulls the ring fit for
hours; a steady offset is the Sun, not the pointing. 171A shown, as 284A's
position is not judged at all."""
series = positioned([0.25] * 7, band=171)
assert bad_slots(series, relative(series)) == []
def test_relative_never_judges_the_position_of_284():
series = positioned([0.0, 0.0, 0.0, 0.4, 0.0, 0.0, 0.0], band=284)
assert "relative_shift" not in relative(series)[3].scores
def test_relative_rejects_a_double_exposure_its_neighbours_lack():
series = positioned([0.0] * 7, echoes=[0.08] * 3 + [0.33] + [0.08] * 3)
verdicts = relative(series)
assert bad_slots(series, verdicts) == [3] and "double exposure" in verdicts[3].reason
def test_relative_passes_an_active_sun_that_echoes_all_day():
series = positioned([0.0] * 7, echoes=[0.3] * 7)
assert bad_slots(series, relative(series)) == []
def test_relative_rejects_a_one_frame_stripe_jump_in_both_measures():
"""2024-09-08 21:12, the square outline: both measures ~3.7x their neighbours."""
series = measured([3.3] * 7, stripes=[(0.11, 0.017)] * 3 + [(0.43, 0.063)]
+ [(0.11, 0.017)] * 3)
verdicts = relative(series)
assert bad_slots(series, verdicts) == [3] and "stripes" in verdicts[3].reason
def test_relative_needs_both_stripe_measures_raised():
series = measured([3.3] * 7, stripes=[(0.11, 0.017)] * 3 + [(0.43, 0.017)]
+ [(0.11, 0.017)] * 3)
assert bad_slots(series, relative(series)) == []
def test_relative_abstains_without_good_neighbours_in_reach():
series = measured([1.0, 0.5])
assert all(v.verdict == detectors.UNKNOWN for v in relative(series, set()))
far = measured([1.0]) + measured([0.5], t0=240 * (detectors.BASELINE_REACH + 1))
assert relative(far, {far[0].slot})[1].verdict == detectors.UNKNOWN
# ------------------------------------------------------------------ pointing_v1
def test_pointing_rejects_94_when_three_other_bands_failed_pixel_checks():
verdict = detectors.pointing_v1(94, {131: "disc_v1", 171: "temporal_v2",
195: "relative_v2", 284: None, 304: None})
assert verdict.verdict == detectors.BAD
assert "131 disc_v1" in verdict.reason and verdict.scores["pointing_others"] == 3.0
def test_pointing_ignores_header_rejections():
"""Noon episodes: most bands empty (header_v1) while the 94A frame is fine."""
verdict = detectors.pointing_v1(94, {131: "header_v1", 171: "header_v1",
195: "lunar_v1", 284: "read", 304: "disc_v1"})
assert verdict.verdict == detectors.GOOD and verdict.scores["pointing_others"] == 1.0
def test_pointing_needs_three_bands_and_ignores_its_own():
assert detectors.pointing_v1(94, {94: "disc_v1", 131: "disc_v1",
171: "temporal_v2"}).verdict == detectors.GOOD
def test_pointing_judges_only_94():
"""In the other bands a clean frame sits beside double ones as often as not."""
for band in (131, 171, 195, 284, 304):
others = {b: "disc_v1" for b in (94, 131, 171, 195, 284, 304) if b != band}
assert detectors.pointing_v1(band, others).verdict == detectors.UNKNOWN
def test_the_judgment_configuration_names_every_rule_and_its_measurements():
config = detectors.judgment_config()
assert config["sequence"][-3:] == ["temporal_v2", "relative_v2", "pointing_v1"]
assert config["measure"] == detectors.measure_config()
assert config["relative_limits"] == detectors.RELATIVE_LIMITS
assert config["pointing_bands"] == [94]
# --------------------------------------------------------------------- lunar_v1
def test_lunar_rejects_the_2023_annular_eclipse_seen_from_goes16(tmp_path):
from astropy.coordinates import EarthLocation
import astropy.units as u
# GOES-16 at 75.2W on the geostationary ring.
ring = EarthLocation.from_geodetic(-75.2 * u.deg, 0 * u.deg, 35786 * u.km)
cards = {"DATE-OBS": "2023-10-14T16:39:13.253", "OBSGEO-X": ring.x.to_value(u.m),
"OBSGEO-Y": ring.y.to_value(u.m), "OBSGEO-Z": ring.z.to_value(u.m)}
verdict = detectors.lunar_v1(cards)
assert verdict.verdict == detectors.BAD
assert verdict.scores["moon_separation"] < verdict.scores["moon_limit"]
def test_lunar_passes_the_same_moment_from_goes18():
from astropy.coordinates import EarthLocation
import astropy.units as u
ring = EarthLocation.from_geodetic(-137.2 * u.deg, 0 * u.deg, 35786 * u.km)
cards = {"DATE-OBS": "2023-10-14T16:39:37.982", "OBSGEO-X": ring.x.to_value(u.m),
"OBSGEO-Y": ring.y.to_value(u.m), "OBSGEO-Z": ring.z.to_value(u.m)}
assert detectors.lunar_v1(cards).verdict == detectors.GOOD
@pytest.mark.parametrize("cards", [
{}, {"DATE-OBS": "2023-10-14T16:39:13"},
{"OBSGEO-X": 1.0, "OBSGEO-Y": 1.0, "OBSGEO-Z": 1.0},
{"DATE-OBS": "not a date", "OBSGEO-X": 1.0e7, "OBSGEO-Y": 4.0e7, "OBSGEO-Z": 0.0},
{"DATE-OBS": "2023-10-14T16:39:13", "OBSGEO-X": "x", "OBSGEO-Y": 1.0, "OBSGEO-Z": 1.0},
])
def test_lunar_abstains_without_usable_position_or_time(cards):
assert detectors.lunar_v1(cards).verdict == detectors.UNKNOWN
def test_judge_frame_rejects_a_lunar_transit_without_decoding():
from astropy.coordinates import EarthLocation
import astropy.units as u
ring = EarthLocation.from_geodetic(-75.2 * u.deg, 0 * u.deg, 35786 * u.km)
cards = _cards(**{"DATE-OBS": "2023-10-14T16:39:13.253",
"OBSGEO-X": ring.x.to_value(u.m), "OBSGEO-Y": ring.y.to_value(u.m),
"OBSGEO-Z": ring.z.to_value(u.m)})
calls = []
source, verdict, _ = detectors.judge_frame((16, BAND, 0), cards, None,
lambda: calls.append(1) or (None, "x"))
assert source == "lunar_v1" and calls == []
@pytest.mark.parametrize("date_obs", ["2023-10-28T20:00:00.123", "2023-10-14T16:39:13.253"])
def test_judge_frame_frees_the_image_without_waiting_for_a_gc_pass(date_obs):
"""Regression: a format-guessing DATE-OBS parse left exception cycles that held
judge_frame's frame -- and its 6.5 MB image -- until a full GC pass; the
archive judge's workers grew to 3.5 GB each. Both the gated and the full
ephemeris path must let the image go as soon as the caller does."""
import gc
import weakref
from astropy.coordinates import EarthLocation
import astropy.units as u
ring = EarthLocation.from_geodetic(-137.2 * u.deg, 0 * u.deg, 35786 * u.km)
cards = _cards(**{"DATE-OBS": date_obs, "OBSGEO-X": ring.x.to_value(u.m),
"OBSGEO-Y": ring.y.to_value(u.m), "OBSGEO-Z": ring.z.to_value(u.m)})
detectors.judge_frame((18, BAND, 0), cards, None, lambda: (solar_disc(peak=1.0), None))
gc.collect()
gc.disable()
try:
image = solar_disc(peak=1.0)
alive = weakref.ref(image)
source = detectors.judge_frame((18, BAND, 0), cards, None, lambda: (image, None))[0]
del image
assert source is None and alive() is None
finally:
gc.enable()
# ------------------------------------------------------------- speed-ups, exact
@pytest.mark.parametrize("seed", range(20))
def test_row_medians_match_nanmedian_exactly(seed):
import warnings
rng = np.random.default_rng(seed)
values = rng.normal(size=(int(rng.integers(1, 25)), int(rng.integers(1, 30))))
ok = rng.random(values.shape) < rng.random()
with warnings.catch_warnings():
warnings.simplefilter("ignore")
want = np.nanmedian(np.where(ok, values, np.nan), axis=1)
got = detectors._row_medians(values, ok)
assert np.array_equal(np.isnan(want), np.isnan(got))
assert np.array_equal(want[~np.isnan(want)], got[~np.isnan(got)])
def test_row_medians_edge_cases():
values = np.array([[1.0, 5.0, 3.0, 9.0], [2.0, 4.0, 6.0, 8.0], [7.0, 7.0, 7.0, 7.0]])
ok = np.array([[True] * 4, [True, False, False, True], [False] * 4])
got = detectors._row_medians(values, ok)
assert got[0] == 4.0 and got[1] == 5.0 and np.isnan(got[2])
def test_lunar_gate_skips_the_ephemeris_far_from_new_moon():
from astropy.coordinates import EarthLocation
import astropy.units as u
ring = EarthLocation.from_geodetic(-75.2 * u.deg, 0 * u.deg, 35786 * u.km)
cards = {"DATE-OBS": "2023-10-28T20:00:00", "OBSGEO-X": ring.x.to_value(u.m),
"OBSGEO-Y": ring.y.to_value(u.m), "OBSGEO-Z": ring.z.to_value(u.m)}
verdict = detectors.lunar_v1(cards) # full moon: ~180 degrees
assert verdict.verdict == detectors.GOOD
assert verdict.scores["moon_elongation"] > 170 and "moon_separation" not in verdict.scores
def test_lunar_gate_is_conservative_for_any_geostationary_observer():
"""At the eclipse the geocentric elongation is inside the gate, so the exact
topocentric test runs; the gate must exceed the worst-case GEO parallax."""
hour = int(1697301553 // 3600) # 2023-10-14 16:39 UTC
assert detectors._geocentric_elongation(hour) < detectors.LUNAR_GATE_DEG
parallax = np.degrees(np.arcsin(42164.0 / 356000.0))
assert detectors.LUNAR_GATE_DEG > parallax + 0.55 + 0.6