noaa-goes-visualization/tests/test_metrics.py

351 lines
13 KiB
Python
Raw Normal View History

import numpy as np
import pytest
from conftest import solar_disc
from suvi import detectors, metrics
BAND = 171
def verdict(kind, reason=None):
return detectors.Verdict(kind, reason, {})
# ------------------------------------------------------------------------ display
def test_display_mapping_is_bounded():
image = solar_disc(size=64, radius=20, peak=50.0)
shown = metrics.to_display(image, BAND)
assert shown.min() >= 0.0 and shown.max() <= 1.0
def test_display_mapping_is_monotonic():
ramp = np.linspace(0, 20, 100).reshape(10, 10)
shown = metrics.to_display(ramp, BAND)
assert np.all(np.diff(shown.ravel()) >= -1e-12)
def test_display_mapping_differs_per_band():
ramp = np.full((4, 4), 5.0)
assert not np.allclose(metrics.to_display(ramp, 94), metrics.to_display(ramp, 304))
def test_display_mapping_handles_nan():
assert np.isfinite(metrics.to_display(np.full((4, 4), np.nan), BAND)).all()
# ---------------------------------------------------------------------- detection
def test_scores_a_perfect_detector():
injected = {(16, BAND, 0): "eclipse_dim", (16, BAND, 240): "truncate"}
verdicts = {
(16, BAND, 0): verdict("bad"),
(16, BAND, 240): verdict("bad"),
(16, BAND, 480): verdict("good"),
(16, BAND, 720): verdict("good"),
}
score = metrics.score_detection(verdicts, injected)
assert (score.true_positives, score.false_negatives) == (2, 0)
assert (score.false_positives, score.true_negatives) == (0, 2)
assert score.precision == 1.0 and score.recall == 1.0 and score.f1 == 1.0
assert score.false_positive_rate == 0.0
def test_scores_a_detector_that_flags_everything():
injected = {(16, BAND, 0): "eclipse_dim"}
verdicts = {(16, BAND, t): verdict("bad") for t in (0, 240, 480, 720)}
score = metrics.score_detection(verdicts, injected)
assert score.recall == 1.0
assert score.precision == pytest.approx(0.25)
assert score.false_positive_rate == 1.0
def test_unknown_verdicts_are_excluded_not_counted_as_good():
"""Abstaining is neither a hit nor a miss, and must not flatter the score."""
injected = {(16, BAND, 0): "eclipse_dim"}
verdicts = {(16, BAND, 0): verdict("unknown"), (16, BAND, 240): verdict("unknown")}
score = metrics.score_detection(verdicts, injected)
assert score.unknown == 2
assert score.true_positives == score.false_positives == 0
assert score.false_negatives == score.true_negatives == 0
assert np.isnan(score.precision) and np.isnan(score.recall)
def test_legacy_disagreements_are_reported_separately():
"""Flagging a frame the old filter passed may be a find, not an error."""
injected = {}
verdicts = {(16, BAND, 0): verdict("bad"), (16, BAND, 240): verdict("bad")}
score = metrics.score_detection(verdicts, injected, legacy_good={(16, BAND, 0)})
assert score.false_positives == 2
assert score.legacy_disagreements == 1
def test_recall_is_broken_down_by_mode():
injected = {
(16, BAND, 0): "eclipse_dim",
(16, BAND, 240): "eclipse_dim",
(16, BAND, 480): "gain_shift",
(16, BAND, 720): "gain_shift",
}
verdicts = {
(16, BAND, 0): verdict("bad"),
(16, BAND, 240): verdict("bad"),
(16, BAND, 480): verdict("bad"),
(16, BAND, 720): verdict("good"),
}
score = metrics.score_detection(verdicts, injected)
assert score.recall_by_mode == {"eclipse_dim": 1.0, "gain_shift": 0.5}
def test_unknown_counts_against_a_modes_recall():
injected = {(16, BAND, 0): "rotate", (16, BAND, 240): "rotate"}
verdicts = {(16, BAND, 0): verdict("bad"), (16, BAND, 240): verdict("unknown")}
score = metrics.score_detection(verdicts, injected)
assert score.recall_by_mode["rotate"] == 0.5
def test_scoring_an_empty_case():
score = metrics.score_detection({}, {})
assert np.isnan(score.precision) and np.isnan(score.recall) and np.isnan(score.f1)
assert np.isnan(score.microseconds_per_frame)
def test_throughput_is_per_frame():
verdicts = {(16, BAND, t): verdict("good") for t in range(0, 1000, 240)}
score = metrics.score_detection(verdicts, {}, elapsed_us=1000)
assert score.microseconds_per_frame == pytest.approx(1000 / len(verdicts))
def test_as_dict_is_serialisable():
score = metrics.score_detection({(16, BAND, 0): verdict("bad")}, {(16, BAND, 0): "x"})
import json
assert json.loads(json.dumps(score.as_dict()))["recall"] == 1.0
# ---------------------------------------------------------------------- pr curves
def test_precision_recall_curve_on_a_separable_score():
scores = [0.9, 0.8, 0.2, 0.1]
labels = [True, True, False, False]
thresholds, precision, recall = metrics.precision_recall_curve(scores, labels)
assert list(thresholds) == [0.9, 0.8, 0.2, 0.1]
assert precision[1] == 1.0 and recall[1] == 1.0
assert metrics.average_precision(scores, labels) == pytest.approx(1.0)
def test_average_precision_of_an_uninformative_score():
scores = [0.5, 0.5, 0.5, 0.5]
labels = [True, False, True, False]
assert 0.0 < metrics.average_precision(scores, labels) < 1.0
def test_curves_handle_degenerate_input():
assert metrics.average_precision([], []) != metrics.average_precision([], []) or True
assert np.isnan(metrics.average_precision([1.0], [False]))
thresholds, _, _ = metrics.precision_recall_curve([np.nan, np.inf], [True, False])
assert len(thresholds) <= 1
# --------------------------------------------------------------------------- fill
def test_identical_frames_score_perfectly():
image = solar_disc(size=64, radius=20, peak=3.0)
score = metrics.score_fill(image, image, BAND)
assert score.rmse == 0.0 and score.mae == 0.0 and score.log_rmse == 0.0
assert score.psnr == float("inf")
assert score.ssim == pytest.approx(1.0)
def test_worse_reconstructions_score_worse():
truth = solar_disc(size=64, radius=20, peak=3.0)
close = truth + 0.01
far = truth + 0.5
assert metrics.score_fill(close, truth, BAND).rmse < metrics.score_fill(far, truth, BAND).rmse
assert metrics.score_fill(close, truth, BAND).psnr > metrics.score_fill(far, truth, BAND).psnr
assert metrics.score_fill(close, truth, BAND).ssim > metrics.score_fill(far, truth, BAND).ssim
def test_log_error_keeps_the_faint_corona_visible():
"""The log term makes an equal *relative* error count comparably everywhere.
Radiance spans orders of magnitude, so a plain rmse is dominated by the bright
disc: tripling the faint corona and tripling the disc are the same mistake, but
rmse rates one ~1000x worse than the other. log_rmse compresses that gap so a
filler cannot look good by getting only the bright pixels right.
"""
faint = np.full((32, 32), 0.01)
bright = np.full((32, 32), 10.0)
faint_score = metrics.score_fill(faint * 3.0, faint, BAND)
bright_score = metrics.score_fill(bright * 3.0, bright, BAND)
assert bright_score.rmse / faint_score.rmse == pytest.approx(1000, rel=0.01)
assert bright_score.log_rmse / faint_score.log_rmse < 100
def test_fill_scoring_rejects_mismatched_shapes():
with pytest.raises(ValueError, match="shape mismatch"):
metrics.score_fill(np.zeros((4, 4)), np.zeros((8, 8)), BAND)
def test_fill_scoring_tolerates_nan():
truth = solar_disc(size=32, radius=10, peak=2.0)
filled = truth.copy()
filled[0:4, 0:4] = np.nan
score = metrics.score_fill(filled, truth, BAND)
assert np.isfinite(score.rmse) and np.isfinite(score.ssim)
# ------------------------------------------------------------------------ flicker
def test_flicker_is_zero_for_a_perfect_reconstruction():
frames = [solar_disc(size=32, radius=10, peak=1.0 + i * 0.1) for i in range(5)]
assert metrics.temporal_flicker(frames, frames, BAND) == pytest.approx(0.0)
def test_flicker_penalises_a_freeze_that_psnr_forgives():
"""The artifact per-frame metrics miss: hold-then-jump instead of smooth motion."""
truth = [solar_disc(size=32, radius=10, peak=1.0 + i * 0.4) for i in range(4)]
frozen = [truth[0], truth[0], truth[0], truth[3]] # hold, hold, then snap
smooth = truth
assert metrics.temporal_flicker(frozen, truth, BAND) > metrics.temporal_flicker(
smooth, truth, BAND
)
def test_flicker_requires_matching_lengths():
frames = [np.zeros((8, 8))] * 3
with pytest.raises(ValueError, match="same length"):
metrics.temporal_flicker(frames, frames[:2], BAND)
def test_flicker_of_a_single_frame_is_undefined():
assert np.isnan(metrics.temporal_flicker([np.zeros((8, 8))], [np.zeros((8, 8))], BAND))
# ------------------------------------------------------------------------ summary
def test_summary_groups_by_gap_length():
scores = [
metrics.FillScore(1.0, 1.0, 0.1, 30.0, 0.9, gap_frames=1, wavelength=BAND),
metrics.FillScore(3.0, 3.0, 0.3, 20.0, 0.7, gap_frames=1, wavelength=BAND),
metrics.FillScore(5.0, 5.0, 0.5, 10.0, 0.5, gap_frames=10, wavelength=BAND),
]
summary = metrics.summarise_fills(scores)
assert summary["n"] == 3
assert summary["rmse"] == pytest.approx(3.0)
assert summary["by_gap"][1]["n"] == 2
assert summary["by_gap"][1]["psnr"] == pytest.approx(25.0)
assert summary["by_gap"][10]["ssim"] == pytest.approx(0.5)
def test_summary_ignores_infinite_psnr_from_perfect_frames():
scores = [
metrics.FillScore(0.0, 0.0, 0.0, float("inf"), 1.0, 1, BAND),
metrics.FillScore(1.0, 1.0, 0.1, 20.0, 0.8, 1, BAND),
]
assert metrics.summarise_fills(scores)["psnr"] == pytest.approx(20.0)
def test_summary_of_nothing():
assert metrics.summarise_fills([]) == {}
# -------------------------------------------------------------------- combinations
def vmap(**pairs):
"""Build a {slot: Verdict} from slot-suffix -> verdict-string pairs."""
return {(16, BAND, int(k[1:])): verdict(v) for k, v in pairs.items()}
def test_any_policy_flags_if_one_detector_does():
combined = metrics.combine_verdicts(
[vmap(t0="bad", t1="good"), vmap(t0="good", t1="good")], "any"
)
assert combined[(16, BAND, 0)].verdict == "bad"
assert combined[(16, BAND, 1)].verdict == "good"
def test_all_policy_requires_unanimity():
combined = metrics.combine_verdicts(
[vmap(t0="bad", t1="bad"), vmap(t0="good", t1="bad")], "all"
)
assert combined[(16, BAND, 0)].verdict == "good"
assert combined[(16, BAND, 1)].verdict == "bad"
def test_majority_policy():
maps = [vmap(t0="bad"), vmap(t0="bad"), vmap(t0="good")]
assert metrics.combine_verdicts(maps, "majority")[(16, BAND, 0)].verdict == "bad"
maps = [vmap(t0="bad"), vmap(t0="good"), vmap(t0="good")]
assert metrics.combine_verdicts(maps, "majority")[(16, BAND, 0)].verdict == "good"
def test_unknown_abstains_rather_than_voting():
"""An abstention must not act as a 'good' vote.
Under 'all' that would let one abstaining detector veto a real detection; under
'any' it would quietly inflate recall.
"""
combined = metrics.combine_verdicts(
[vmap(t0="bad"), vmap(t0="unknown")], "all"
)
assert combined[(16, BAND, 0)].verdict == "bad" # the abstention is ignored
assert combined[(16, BAND, 0)].scores["votes_total"] == 1.0
def test_all_detectors_abstaining_yields_unknown():
combined = metrics.combine_verdicts([vmap(t0="unknown"), vmap(t0="unknown")], "any")
assert combined[(16, BAND, 0)].verdict == "unknown"
def test_slots_missing_from_one_map_are_still_judged():
combined = metrics.combine_verdicts([vmap(t0="bad"), vmap(t1="good")], "any")
assert set(combined) == {(16, BAND, 0), (16, BAND, 1)}
assert combined[(16, BAND, 0)].verdict == "bad"
def test_single_detector_reduces_to_itself():
single = vmap(t0="bad", t1="good", t2="unknown")
for policy in metrics.COMBINATION_POLICIES:
combined = metrics.combine_verdicts([single], policy)
for slot, original in single.items():
assert combined[slot].verdict == original.verdict, policy
def test_combining_nothing():
assert metrics.combine_verdicts([], "any") == {}
def test_unknown_policy_is_rejected():
with pytest.raises(ValueError, match="Unknown policy"):
metrics.combine_verdicts([vmap(t0="bad")], "consensus")
def test_combined_verdicts_carry_the_reasons():
maps = [vmap(t0="bad"), vmap(t0="bad")]
maps[0][(16, BAND, 0)] = detectors.Verdict("bad", "eclipse", {})
maps[1][(16, BAND, 0)] = detectors.Verdict("bad", "limb_contrast 0.0", {})
combined = metrics.combine_verdicts(maps, "any")
assert "eclipse" in combined[(16, BAND, 0)].reason
assert "limb_contrast" in combined[(16, BAND, 0)].reason
def test_a_combination_can_be_scored_like_any_detector():
"""Combinations feed straight into score_detection -- that is the point."""
injected = {(16, BAND, 0): "eclipse_dim"}
narrow = vmap(t0="bad", t1="good") # perfect
noisy = vmap(t0="bad", t1="bad") # one false positive
strict = metrics.combine_verdicts([narrow, noisy], "all")
loose = metrics.combine_verdicts([narrow, noisy], "any")
assert metrics.score_detection(strict, injected).false_positive_rate == 0.0
assert metrics.score_detection(loose, injected).false_positive_rate == 1.0
assert metrics.score_detection(strict, injected).recall == 1.0