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"), ({"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 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