2026-09-01 22:36:13 -04:00
|
|
|
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"),
|
2026-09-01 22:36:13 -04:00
|
|
|
({"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
|
|
|
|
|
|
|
|
|
|
|
2026-09-01 22:36:13 -04:00
|
|
|
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
|