noaa-goes-visualization/tests/test_defect_audit.py

266 lines
11 KiB
Python
Raw Permalink Normal View History

2026-10-14 00:27:18 -04:00
"""The real-defect audit: candidate sources, proposals, corrections and labels.
The audit's output is the ground truth every detector is later judged against, so
the properties pinned here are the ones that would silently corrupt it: a typo in a
correction must fail rather than leave a frame at its guess, a generic far-field
guess can never become a label, and a one-frame defect must not be taken for a
flare when picking the clean-but-hard controls.
"""
import json
import os
import numpy as np
import pytest
import defect_audit as audit
from conftest import solar_disc, write_fits
from suvi import db, detectors, paths
T0 = 1704067200 # 2024-01-01 00:00 UTC
# ------------------------------------------------------------------ header spikes
def test_a_one_frame_spread_jump_is_a_spike():
series = [(T0 + i * 240, 1.0, 1) for i in range(5)]
series[2] = (T0 + 480, 3.0, 1)
assert audit.header_spikes(series) == [T0 + 480]
def test_a_sustained_rise_is_not_a_spike():
"""A flare brightens and stays bright; only its first frame could look odd."""
series = [(T0 + i * 240, s, 1) for i, s in enumerate([1, 1, 3, 3, 3, 3])]
assert audit.header_spikes(series) == []
def test_spikes_skip_no_long_exposure_frames_and_distant_neighbours():
series = [(T0, 1.0, 1), (T0 + 240, 3.0, 0), (T0 + 480, 1.0, 1)]
assert audit.header_spikes(series) == []
gapped = [(T0, 1.0, 1), (T0 + 5 * 240, 3.0, 1), (T0 + 6 * 240, 1.0, 1)]
assert audit.header_spikes(gapped) == []
@pytest.mark.parametrize("bad", [None, 0.0, -1.0])
def test_spikes_need_positive_spreads(bad):
series = [(T0, bad, 1), (T0 + 240, 3.0, 1), (T0 + 480, 1.0, 1)]
assert audit.header_spikes(series) == []
def test_spikes_on_series_too_short_to_have_neighbours():
assert audit.header_spikes([]) == []
assert audit.header_spikes([(T0, 1.0, 1), (T0 + 240, 5.0, 1)]) == []
# -------------------------------------------------------------------- flare peaks
def test_flare_peaks_prefer_a_sustained_rise_over_a_brighter_single_frame():
times = [T0 + i * 240 for i in range(200)]
means = [1.0] * 200
means[50] = 100.0 # one-frame defect
for i in range(120, 130):
means[i] = 10.0 # a flare
peaks = audit.flare_peaks(times, means, count=1, separation=3600)
assert len(peaks) == 1 and T0 + 120 * 240 <= peaks[0] < T0 + 130 * 240
def test_flare_peaks_are_separated_and_counted():
times = [T0 + i * 240 for i in range(1000)]
means = np.ones(1000)
means[100:110] = 10
means[112:120] = 9 # the same flare's tail
means[700:710] = 5
peaks = audit.flare_peaks(times, list(means), count=5, separation=86400 // 2)
assert len(peaks) == 2
assert peaks[1] - peaks[0] >= 86400 // 2
assert audit.flare_peaks(times[:3], list(means[:3]), count=2) == []
# ---------------------------------------------------------------------- sampling
def test_stratified_sample_caps_each_stratum_without_repeats():
rng = np.random.default_rng(0)
rows = [(s, i) for s in "abc" for i in range(10 if s != "c" else 2)]
chosen = audit.stratified_sample(rows, lambda r: r[0], 4, rng)
assert len(chosen) == len(set(chosen)) == 10
assert sum(r[0] == "c" for r in chosen) == 2
again = audit.stratified_sample(rows, lambda r: r[0], 4, np.random.default_rng(0))
assert again == chosen
# --------------------------------------------------------------------- proposals
BASE = {171: {"far_field": 0.03, "noise": 0.0004}}
def candidate(**kw):
out = {"wavelength": 171, "far_field": 0.03, "noise": 0.0004, "n_long": 1,
"verdict": "good", "reason": None, "sources": ["random"]}
out.update(kw)
return out
@pytest.mark.parametrize("fields,expected", [
({}, "normal"),
({"sources": ["flare"]}, "flare_normal"),
({"n_long": 0}, "static"),
({"noise": 0.01}, "static"),
({"far_field": 0.5}, audit.PENDING),
({"verdict": "bad", "reason": "ECLIPSE flag set (2)"}, "dark"),
({"verdict": "bad", "reason": "radius_ratio 1.3 outside"}, "other_defect"),
({"far_field": None, "noise": None}, "normal"),
])
def test_proposals(fields, expected):
assert audit.propose(candidate(**fields), BASE) == expected
def test_a_band_without_a_baseline_is_judged_on_header_and_verdict_alone():
assert audit.propose(candidate(wavelength=94, far_field=50.0), BASE) == "normal"
assert audit.propose(candidate(wavelength=94, n_long=0), BASE) == "static"
def test_baselines_come_only_from_random_frames_with_a_long_exposure():
pool = [candidate(far_field=v, noise=v / 100) for v in (1.0, 2.0, 3.0)]
pool += [candidate(far_field=99.0, sources=["spike"]),
candidate(far_field=99.0, n_long=0)]
base = audit.band_baselines(pool)
assert base[171] == {"far_field": 2.0, "noise": 0.02}
assert 94 not in base
# ------------------------------------------------------------------- corrections
def test_corrections_parse_with_comments_and_blank_lines():
text = "# sheet bar_01\n3 block\n\n7 normal # was a flare loop\n"
assert audit.parse_corrections(text, {3, 7}) == {3: "block", 7: "normal"}
@pytest.mark.parametrize("text,message", [
("3 bar\n9 bar", "no candidate 9"),
("3 barr", "unknown category"),
("3", "expected 'ID CATEGORY'"),
("x bar", "expected 'ID CATEGORY'"),
(f"3 {audit.PENDING}", "unknown category"),
])
def test_bad_corrections_fail_loudly(text, message):
with pytest.raises(ValueError, match=message):
audit.parse_corrections(text, {3})
def test_current_labels_apply_corrections_over_proposals():
cands = [{"id": 1, "proposed": "normal"}, {"id": 2, "proposed": audit.PENDING}]
assert audit.current_labels(cands, {2: "bar"}) == {1: "normal", 2: "bar"}
def write_audit(path, candidates):
with open(path, "w") as handle:
json.dump({"baselines": {"171": BASE[171]}, "candidates": candidates}, handle)
def test_apply_refuses_while_anything_is_only_proposed(tmp_path):
source = tmp_path / "c.json"
write_audit(source, [{"id": 1, "proposed": audit.PENDING, "satellite": 16,
"wavelength": 171, "t_start": T0, "sources": ["spike"]}])
with pytest.raises(SystemExit, match="classify them first"):
audit.main(["apply", "--candidates", str(source), "--out", str(tmp_path / "l.json")])
def test_apply_writes_sorted_confirmed_labels(tmp_path):
source, fixes, out = tmp_path / "c.json", tmp_path / "fix.txt", tmp_path / "labels" / "l.json"
write_audit(source, [
{"id": 1, "proposed": audit.PENDING, "satellite": 18, "wavelength": 171,
"t_start": T0, "sources": ["spike"]},
{"id": 2, "proposed": "normal", "satellite": 16, "wavelength": 171,
"t_start": T0 + 240, "sources": ["random"]},
])
fixes.write_text("1 bar\n")
assert audit.main(["apply", "--candidates", str(source), "--corrections", str(fixes),
"--out", str(out)]) == 0
labels = json.loads(out.read_text())["labels"]
assert [(r["satellite"], r["category"]) for r in labels] == [(16, "normal"), (18, "bar")]
# --------------------------------------------------------- measurement and sheets
def write_frame(root, satellite, wavelength, when, image, **headers):
name = paths.FrameName(satellite=satellite, wavelength=wavelength, t_start=when,
t_end=when + 240, version="1-0-2")
write_fits(os.path.join(str(root), name.relpath()), image,
headers={"WAVELNTH": wavelength, **headers})
return name
def test_measure_reports_far_field_grain_and_verdict(archive):
image = solar_disc(peak=1.0)
clean = write_frame(archive, 16, 171, T0, image)
static = write_frame(archive, 16, 171, T0 + 240,
image + np.random.default_rng(1).normal(0, 0.2, image.shape)
.astype(np.float32), N_LONG=0)
a = audit.measure((16, 171, T0, paths.abspath(clean.relpath(), str(archive))))
b = audit.measure((16, 171, T0 + 240, paths.abspath(static.relpath(), str(archive))))
assert a["verdict"] == "good" and a["n_long"] == 1 and a["far_field"] < 0.01
assert b["verdict"] == "bad" and "N_LONG" in b["reason"] and b["noise"] > 100 * a["noise"]
def test_sheets_draw_every_candidate_grouped_by_category(archive, tmp_path):
image = solar_disc(peak=1.0)
cands = []
for i, proposed in enumerate(["normal", audit.PENDING, "normal"]):
name = write_frame(archive, 16, 171, T0 + i * 240, image)
cands.append({"id": i + 1, "proposed": proposed, "satellite": 16, "wavelength": 171,
"t_start": T0 + i * 240, "path": name.relpath(), "far_field": 0.03,
"noise": 0.0004, "verdict": "good", "sources": ["random"]})
source, fixes = tmp_path / "c.json", tmp_path / "fix.txt"
write_audit(source, cands)
fixes.write_text("2 ghost\n")
out = tmp_path / "sheets"
assert audit.main(["sheets", "--candidates", str(source), "--corrections", str(fixes),
"--out-dir", str(out), "--workers", "1"]) == 0
assert sorted(os.listdir(out)) == ["ghost_01.jpg", "index.txt", "normal_01.jpg"]
assert "ids 1..3 (2 frames)" in (out / "index.txt").read_text()
# ------------------------------------------------------------------- the sources
def test_gather_finds_every_source_in_a_scratch_index(tmp_path):
"""Spikes, N_LONG=0, legacy rejections, flare windows and random controls."""
conn = db.connect(str(tmp_path / "index.sqlite"))
ids = {}
for satellite in (16, 18):
for wavelength in paths.WAVELENGTHS:
for i in range(400):
when = T0 + i * 240
name = paths.FrameName(satellite=satellite, wavelength=wavelength,
t_start=when, t_end=when + 240, version="1-0-2")
frame_id = db.upsert_frame(conn, name, name.relpath(), size_bytes=1_500_000)
ids[(satellite, wavelength, when)] = frame_id
flare = wavelength == 131 and 300 <= i < 310
db.record_headers(conn, [(frame_id, {
"img_sdev": 5.0 if (satellite, wavelength, i) == (18, 171, 100) else 1.0,
"img_mean": 9.0 if flare else 1.0,
"n_long": 0 if (satellite, wavelength, i) == (16, 94, 200) else 1,
}, None)])
run = db.create_detector_run(conn, "legacy_filter", {"source": "test"})
db.record_detections(conn, run, [(ids[(16, 304, T0 + 50 * 240)], "bad", None, None, 0)])
conn.commit()
found, peaks = audit.gather(conn, T0, T0 + 400 * 240, np.random.default_rng(0),
random_per_stratum=3, flare_count=1, flare_frames=20)
by_source = {}
for key, (_, sources) in found.items():
for source in sources:
by_source.setdefault(source, set()).add(key)
assert by_source["spike"] == {(18, 171, T0 + 100 * 240)}
assert by_source["n_long0"] == {(16, 94, T0 + 200 * 240)}
assert by_source["legacy"] == {(16, 304, T0 + 50 * 240)}
assert T0 + 300 * 240 <= peaks[0] < T0 + 310 * 240
assert all(abs(t - peaks[0]) <= audit.FLARE_WINDOW for _, _, t in by_source["flare"])
assert (16, 94, T0 + 200 * 240) not in by_source.get("random", set())