noaa-goes-visualization/suvi/detectors.py

1521 lines
68 KiB
Python
Raw Normal View History

"""Bad-frame detectors.
Every detector is a pure function: data in, a :class:`Verdict` out. Nothing here
renames a file, writes a plot, or touches the database -- that is the driver's job.
This is what lets the same code run in production (``filter_FITS.py``) and under the
test bench, and lets several detectors judge the same frame independently.
Two shapes of detector:
* **Frame-level** (:func:`header_v1`, :func:`geometry_v1`) judge one frame alone.
* **Series-level** (:func:`temporal_v1`, :func:`crosssat_v1`) judge a frame in the
context of its neighbours in time, or of the other satellite at the same instant.
They work from :class:`FrameFeatures` -- cached header values plus a small
thumbnail -- rather than full 1280x1280 arrays, which keeps a whole day of context
in memory at once.
Every detector reports continuous ``scores`` alongside its verdict. The driver
stores those, so re-tuning a threshold later is a database query rather than another
pass over the archive.
"""
2026-10-14 00:27:18 -04:00
import bisect
import functools
from dataclasses import dataclass, field
import cv2 as cv
import numpy as np
2026-10-14 00:27:18 -04:00
from . import measures
# --------------------------------------------------------------------------- types
@dataclass(frozen=True)
class Verdict:
"""One detector's judgement of one frame."""
verdict: str # 'good' | 'bad' | 'unknown'
reason: str | None = None
scores: dict = field(default_factory=dict)
GOOD = "good"
BAD = "bad"
UNKNOWN = "unknown"
@dataclass
class FrameFeatures:
"""The cheap summary of a frame that series-level detectors work from."""
slot: tuple # (satellite, wavelength, t_start)
header: dict = field(default_factory=dict)
#: Read error from fitsio, if the frame could not be parsed at all.
error: str | None = None
#: Small float32 image (see THUMBNAIL_SIZE), or None if not loaded.
thumbnail: np.ndarray | None = None
2026-10-14 00:27:18 -04:00
#: The frame's own single-frame scores (field_v1's stripe measures among them),
#: for series checks that compare a measure with the neighbours' values.
scores: dict = field(default_factory=dict)
@property
def t_start(self):
return self.slot[2]
@property
def wavelength(self):
return self.slot[1]
#: Series detectors compare thumbnails, not full frames. 128px preserves the disc,
#: active regions and gross structure while making a day of context cheap to hold.
THUMBNAIL_SIZE = 128
def thumbnail(image, size=THUMBNAIL_SIZE):
"""Downsample a frame for structural comparison. NaNs become zero."""
clean = np.nan_to_num(np.asarray(image, dtype=np.float32), nan=0.0, posinf=0.0, neginf=0.0)
return cv.resize(clean, (size, size), interpolation=cv.INTER_AREA)
# ------------------------------------------------------------------ header_v1
#: Plausible radiance range of a healthy frame, per passband, in W m-2 sr-1.
#:
#: Deliberately wide -- roughly a factor of five outside the observed annual range of
#: good frames. Sampled across 24 days of 2024 on both satellites, the *rejected*
#: frames' IMG_MEAN distribution sits almost entirely inside the good one (171A: good
#: 0.288-0.452, rejected median 0.352), so a tight bound here would cost enormous
#: numbers of false positives while catching little. What this rule is actually for
#: is gross dropout and blowout -- eclipse frames run 1e-4, three orders of magnitude
#: below the floor -- and it is priced accordingly.
HEADER_MEAN_BOUNDS = {
94: (0.004, 0.41),
131: (0.005, 0.39),
171: (0.057, 2.3),
195: (0.15, 5.4),
284: (0.13, 7.0),
304: (0.29, 20.0),
}
#: Solar diameter in pixels; varies ~3% over the year with Earth-Sun distance.
DIAM_SUN_RANGE = (740.0, 800.0)
#: How far the recorded sun centre may sit from the image centre, in pixels.
MAX_CRPIX_OFFSET = 8.0
2026-10-14 00:27:18 -04:00
def unreadable(error):
"""The one way an undecodable frame becomes a verdict.
Several drivers used to build this Verdict by hand (bench, filter_FITS, and
header_v1 itself), and hand-rolled copies of a quality rule are exactly how
the bench came to disagree with the pipeline. A frame that cannot be read
carries no information regardless of what any other rule might say.
"""
return Verdict(BAD, f"unreadable: {error or 'no image data'}", {"read_ok": 0.0})
def judge_frame(slot, cards, header_error=None, read_image=None):
"""THE production judgment for one frame, from its parsed header onward.
header_v1 and lunar_v1 on the header, then -- only if those pass and
`read_image` is given -- disc_v1 and field_v1 on the pixels. This is the one sequence the corpus
builder, the bench and the real-data renderer all use, so none of them can
hold a different opinion of the same file. The first BAD decides; UNKNOWN
abstains in favour of the others' answers.
`read_image` is called lazily -- a header rejection costs no decompression --
and must return (image, error) like ``fitsio.read_image``.
Returns (source, verdict, image): `source` names the check that decided a BAD
verdict ("header_v1", "lunar_v1", "read", "disc_v1", "field_v1") or is None, and `image` is
whatever was decoded, so a caller that needs the pixels need not read twice.
"""
from . import fitsio
values = fitsio.header_metadata(cards or {})
verdict = header_v1(FrameFeatures(slot=slot, header=values, error=header_error))
if verdict.verdict == BAD:
return "header_v1", verdict, None
# Every check's measurements travel with the verdict, whichever check decided:
# the stored measurement run keeps them all.
scores = dict(verdict.scores)
lunar = lunar_v1(cards)
scores.update(lunar.scores)
if lunar.verdict == BAD:
return "lunar_v1", Verdict(BAD, lunar.reason, scores), None
if read_image is None:
return None, Verdict(verdict.verdict, verdict.reason, scores), None
image, image_error = read_image()
if image is None:
return "read", unreadable(image_error), None
if image.shape != (1280, 1280):
return "read", Verdict(BAD, f"wrong shape {image.shape}", scores), image
refcrota = (cards or {}).get("REFCROTA")
refcrota = float(refcrota) if isinstance(refcrota, (int, float)) else None
for name, judged in (("disc_v1", disc_v1(image, slot[1], header=values)),
("field_v1", field_v1(image, slot[1], header=values,
refcrota=refcrota))):
scores.update(judged.scores)
if judged.verdict == BAD:
return name, Verdict(BAD, judged.reason, dict(scores)), image
if judged.verdict == GOOD:
verdict = judged
return None, Verdict(verdict.verdict, verdict.reason, scores), image
#: Detector-run name under which every frame's measurements are stored
#: (``filter_FITS.py measure``): the single-frame verdict and everything the
#: judgment compares, read from the archive once.
MEASURE_RUN = "measure"
def measure_config():
"""Everything a stored measurement depends on. Matched exactly, like the
judgment's, so a changed single-frame limit or measure starts a fresh run."""
return {
"driver": "filter_FITS measure",
"sequence": ["header_v1", "lunar_v1", "disc_v1", "field_v1"],
"header_mean_bounds": {str(k): v for k, v in HEADER_MEAN_BOUNDS.items()},
"disc_bounds": {str(k): v for k, v in DISC_BOUNDS.items()},
"field_scale": {str(k): v for k, v in FIELD_SCALE.items()},
"field_limits": FIELD_LIMITS,
"lunar_margin_arcmin": LUNAR_MARGIN_ARCMIN,
"n_long_rule": True,
"measures": {"size": measures.MEASURE_SIZE, "sectors": measures.SECTORS,
"sector_band": list(measures.SECTOR_BAND),
"edge_limit": measures.EDGE_LIMIT,
"centre_limit": measures.CENTRE_LIMIT,
"echo_lags": list(measures.ECHO_LAGS)},
"temporal2_reach": TEMPORAL2_REACH,
"temporal2_floor": TEMPORAL2_FLOOR,
}
def measure_run(conn):
"""The stored measurement run made under the current rules, or None."""
from . import db
return db.latest_run_id(conn, MEASURE_RUN, measure_config())
#: Detector-run name under which the production judgment is stored in the index
#: (``filter_FITS.py judge``); every consumer reads verdicts from it.
JUDGMENT_RUN = "judge"
def judgment_config():
"""Everything a stored judgment depends on. Runs are matched on it exactly, so
changing any rule or limit -- or the measurements it is made from -- makes
consumers look for, and the judge command build, a fresh run rather than
mixing verdicts made under different rules."""
return {
"driver": "filter_FITS judge",
"measure": measure_config(),
"sequence": ["header_v1", "lunar_v1", "disc_v1", "field_v1", "temporal_v2",
"relative_v2", "pointing_v1"],
"position_bands": list(POSITION_BANDS),
"temporal2_limits": TEMPORAL2_LIMITS,
"relative_limits": RELATIVE_LIMITS,
"relative_reach": RELATIVE_REACH,
"relative_references": RELATIVE_REFERENCES,
"baseline_reach": BASELINE_REACH,
"baseline_references": BASELINE_REFERENCES,
"steady": [STEADY, STEADY_MIN],
"earth_reference_km": EARTH_REFERENCE_KM,
"pointing_bands": list(POINTING_BANDS),
"pointing_min_others": POINTING_MIN_OTHERS,
"pointing_evidence": list(POINTING_EVIDENCE),
}
def judgment_run(conn):
"""The stored run made under the current rules, or None if there is none yet."""
from . import db
return db.latest_run_id(conn, JUDGMENT_RUN, judgment_config())
def judge_file(slot, path, decode=True):
"""``judge_frame`` for a file on disk; returns only the Verdict."""
from . import fitsio
cards, header_error = fitsio.read_header(path)
reader = (lambda: fitsio.read_image(path)) if decode else None
return judge_frame(slot, cards, header_error, reader)[1]
def header_v1(features, mean_bounds=None, diam_range=DIAM_SUN_RANGE,
max_crpix_offset=MAX_CRPIX_OFFSET):
"""Judge a frame from its header alone -- no pixel decompression.
The instrument already reports most of what matters: ``ECLIPSE`` marks frames
taken through Earth's shadow, ``EMPTY`` marks frames built from no source
imagery, and ``IMG_MEAN``/``IMG_SDEV`` summarise the radiance. On sampled
archive days every frame the pixel filter rejected also carried ``ECLIPSE=2``,
at roughly a fifth of the cost of decompressing the image.
``DEGRADED`` is recorded as a score but is deliberately *not* a rejection rule.
It is set for reasons unrelated to a frame's usability: across sampled days the
195A band carried ``DEGRADED=T`` on 40/40 good frames on 2024-01-20 and 0/40 on
2024-07-04, on both satellites. Rejecting on it would discard entire months of
that band. The bench can re-weight it from the stored score if that turns out
to be worth doing.
"""
mean_bounds = mean_bounds or HEADER_MEAN_BOUNDS
header = features.header
scores = {}
if features.error:
2026-10-14 00:27:18 -04:00
return unreadable(features.error)
if not header:
return Verdict(UNKNOWN, "no header cached", scores)
for field_name in ("img_mean", "img_sdev", "diam_sun", "eclipse", "degraded"):
if field_name in header:
scores[field_name] = float(header[field_name])
if header.get("empty"):
return Verdict(BAD, "EMPTY flag set", scores)
eclipse = header.get("eclipse")
if eclipse:
return Verdict(BAD, f"ECLIPSE flag set ({int(eclipse)})", scores)
# A frame built from no source images carries no information regardless of what
# the radiance statistics happen to say.
if header.get("num_imgs") is not None and header["num_imgs"] < 1:
scores["num_imgs"] = float(header["num_imgs"])
return Verdict(BAD, "no source images", scores)
2026-10-14 00:27:18 -04:00
# A composite with no long exposure is built from short exposures scaled up to
# the long-exposure level, and their noise is scaled with them: the frame reads
# as static. Measured on 360 such frames from 2024, every one had far-corner
# pixel noise of 0.03-3.3 against at most 0.0076 on ~560 frames with a long
# exposure, in every band, and ~3/4 of them passed every other check here.
if header.get("n_long") is not None and header["n_long"] < 1:
scores["n_long"] = float(header["n_long"])
return Verdict(BAD, "no long exposure (N_LONG=0): noise-dominated", scores)
# The band the file claims must match the band its name puts it in, or the
# archive has a misfiled frame and every downstream threshold is wrong.
declared = header.get("wavelnth")
if declared is not None and int(declared) != features.wavelength:
scores["wavelnth"] = float(declared)
return Verdict(BAD, f"wavelength {int(declared)} != {features.wavelength}", scores)
mean = header.get("img_mean")
if mean is not None:
low, high = mean_bounds.get(features.wavelength, (0.0, np.inf))
if mean < low:
return Verdict(BAD, f"IMG_MEAN {mean:.3g} below {low:g}", scores)
if mean > high:
return Verdict(BAD, f"IMG_MEAN {mean:.3g} above {high:g}", scores)
sdev = header.get("img_sdev")
if sdev is not None and sdev <= 0.0:
return Verdict(BAD, "zero variance", scores)
diameter = header.get("diam_sun")
if diameter is not None and not (diam_range[0] <= diameter <= diam_range[1]):
return Verdict(BAD, f"DIAM_SUN {diameter:.1f} outside {diam_range}", scores)
crpix1, crpix2 = header.get("crpix1"), header.get("crpix2")
if crpix1 is not None and crpix2 is not None:
offset = max(abs(crpix1 - 640.5), abs(crpix2 - 640.5))
scores["crpix_offset"] = float(offset)
if offset > max_crpix_offset:
return Verdict(BAD, f"sun centre offset {offset:.1f}px", scores)
return Verdict(GOOD, None, scores)
# ---------------------------------------------------------------- geometry_v1
#: Per-band radiance ceiling used to normalise the image before shape analysis.
#: Index order matches paths.WAVELENGTHS.
GEOMETRY_THRESHOLDS = {94: 0.050, 131: 0.10, 171: 1.00, 195: 1.40, 284: 1.0, 304: 2.50}
EXPECTED_DIMS = 1280
HALF_DIMS = EXPECTED_DIMS // 2
VALID_RADII = (383, 394)
AVG_RADIUS = (VALID_RADII[0] + VALID_RADII[1]) // 2
MAX_RADIUS_ERROR = 80
MAX_CENTER_SKEW = 7
RATIO_ABOVE_THRESH_MAX = 0.4
RATIO_ABOVE_THRESH_MIN = 0.07
EDGE_THRESH = 0.98
MAX_GOF = 0.2
_ideal_axis_cache = {}
def ideal_disc_axis():
"""Column-mean profile of a perfect solar disc, used as the shape reference."""
if "axis" not in _ideal_axis_cache:
disc = np.zeros((HALF_DIMS, HALF_DIMS))
cv.circle(disc, (HALF_DIMS // 2, HALF_DIMS // 2), AVG_RADIUS // 2, 1, -1)
_ideal_axis_cache["axis"] = np.average(disc, 0)
return _ideal_axis_cache["axis"]
def geometry_v1(image, wavelength):
"""The pre-existing pixel-geometry filter, preserved as the baseline.
Normalises against a per-band radiance ceiling, then checks the fraction of
saturated pixels, the intensity-weighted centroid, the radius implied by the
98th-percentile cumulative edges, and the fit against an ideal disc profile.
Deliberately kept behaviourally identical to the original so bench numbers
describe what the pipeline actually did, with two known weaknesses left intact
for the bench to quantify:
* the centre check is one-sided -- ``HALF_DIMS//2 - centre > skew`` catches a
disc displaced up/left but not one displaced down/right;
* non-finite pixels propagate as NaN through every comparison, and ``NaN > x``
is False, so an all-NaN frame passes every check.
The one deliberate change: the original raised on an unexpected image size and
left the file unlabelled. Here that is a ``bad`` verdict, because "no verdict"
is not a usable baseline to score against.
"""
scores = {}
if image is None:
return Verdict(BAD, "no image data", scores)
if image.shape[0] != EXPECTED_DIMS or image.shape[1] != EXPECTED_DIMS:
return Verdict(BAD, f"unexpected dimensions {image.shape}", scores)
threshold = GEOMETRY_THRESHOLDS.get(wavelength)
if threshold is None:
return Verdict(UNKNOWN, f"no threshold for band {wavelength}", scores)
data = cv.resize(
np.asarray(image, dtype=np.float32),
dsize=(HALF_DIMS, HALF_DIMS),
interpolation=cv.INTER_LINEAR,
)
above = data > threshold
normalised = np.copy(data)
normalised[above] = threshold
normalised /= threshold
ratio = np.count_nonzero(above) / data.shape[0] / data.shape[1]
scores["ratio_above_thresh"] = float(ratio)
if ratio > RATIO_ABOVE_THRESH_MAX:
return Verdict(BAD, f"ratio_above_thresh {ratio:.2f} too high", scores)
if ratio < RATIO_ABOVE_THRESH_MIN:
return Verdict(BAD, f"ratio_above_thresh {ratio:.2f} too low", scores)
xavg = np.average(normalised, 0)
yavg = np.average(normalised, 1)
axis = list(range(HALF_DIMS))
centre_x = np.average(axis, 0, xavg)
centre_y = np.average(axis, 0, yavg)
scores["centre_x"] = float(centre_x)
scores["centre_y"] = float(centre_y)
good_centre = not (
(HALF_DIMS // 2 - centre_x > MAX_CENTER_SKEW)
or (HALF_DIMS // 2 - centre_y > MAX_CENTER_SKEW)
)
high_x = np.argmax(np.cumsum(xavg) > (np.sum(xavg) * EDGE_THRESH))
low_x = len(xavg) - np.argmax(np.cumsum(np.flip(xavg)) > (np.sum(xavg) * EDGE_THRESH))
high_y = np.argmax(np.cumsum(yavg) > (np.sum(yavg) * EDGE_THRESH))
low_y = len(yavg) - np.argmax(np.cumsum(np.flip(yavg)) > (np.sum(yavg) * EDGE_THRESH))
# Not halved: the image is already downsampled by two in each dimension.
radius = ((high_x - low_x) + (high_y - low_y)) / 2.0
scores["radius"] = float(radius)
good_radius = abs(radius - AVG_RADIUS) <= MAX_RADIUS_ERROR
reference = ideal_disc_axis()
gof_x = np.sum(np.abs(xavg - reference)) / HALF_DIMS
gof_y = np.sum(np.abs(yavg - reference)) / HALF_DIMS
scores["gof_x"] = float(gof_x)
scores["gof_y"] = float(gof_y)
good_fit = not (gof_x > MAX_GOF or gof_y > MAX_GOF)
if good_centre and good_radius and good_fit:
return Verdict(GOOD, None, scores)
failed = [
name
for name, ok in (("centre", good_centre), ("radius", good_radius), ("fit", good_fit))
if not ok
]
return Verdict(BAD, "failed " + "+".join(failed), scores)
# -------------------------------------------------------------------- disc_v1
#: Angular samples in the polar resampling. The measurements are all averages over
#: this axis, so more angles cost little and reduce noise.
DISC_ANGLES = 720
#: Working resolution. Halving 1280 keeps the limb several pixels wide while making
#: the polar transform cheap.
DISC_SIZE = 640
#: Radial bands, in units of the expected solar radius, used to characterise the
#: profile. The on-disc band avoids both the centre (where limb darkening and
#: filaments live) and the limb itself; the off-limb band sits outside the corona's
#: steepest falloff but inside the frame -- 1.45R of a ~193px radius is 280px, within
#: the 320px half-width.
DISC_ON_BAND = (0.30, 0.70)
DISC_OFF_BAND = (1.25, 1.45)
#: Where the limb is allowed to be found, again in units of expected radius.
DISC_SEARCH_BAND = (0.75, 1.25)
def disc_profile(image, expected_radius, size=DISC_SIZE, angles=DISC_ANGLES):
"""Measure the solar disc from its azimuthally averaged radial profile.
Returns a dict of continuous measurements, or with None values where the frame
is too degenerate to measure. Separated from the verdict logic so that
``filter_FITS.py calibrate`` can gather the same numbers over a sample.
Averaging over angle is the entire point. An active region is localised in
angle, so it barely shifts the averaged profile; the existing ``geometry_v1``
averages along image rows and columns instead, which a bright region shifts
directly -- the cause of its 36.9% rejection rate.
`expected_radius` is in pixels *at the working resolution*, i.e. DIAM_SUN / 4.
"""
empty = {"limb_contrast": None, "radius_ratio": None, "limb_width": None}
if image is None or image.ndim != 2 or min(image.shape) < 16:
return empty
if not expected_radius or not np.isfinite(expected_radius) or expected_radius <= 0:
return empty
working = cv.resize(
np.nan_to_num(np.asarray(image, dtype=np.float32), nan=0.0, posinf=0.0, neginf=0.0),
(size, size),
interpolation=cv.INTER_AREA,
)
# Radiance spans orders of magnitude; log compresses a flare so it cannot
# dominate the profile the way it dominates a linear mean.
working = np.log1p(np.clip(working, 0.0, None))
max_radius = size / 2.0
polar = cv.warpPolar(
working,
(int(max_radius), angles),
(size / 2.0, size / 2.0),
max_radius,
cv.WARP_POLAR_LINEAR,
)
profile = polar.mean(axis=0)
radii = np.arange(profile.size, dtype=np.float64)
scale = expected_radius
def band(low, high):
mask = (radii > low * scale) & (radii < high * scale)
return profile[mask] if mask.any() else np.array([])
on_values, off_values = band(*DISC_ON_BAND), band(*DISC_OFF_BAND)
if on_values.size < 3 or off_values.size < 3:
return empty
on_disc, off_limb = float(np.median(on_values)), float(np.median(off_values))
if not np.isfinite(on_disc) or not np.isfinite(off_limb):
return empty
if on_disc <= 0:
# No signal at all where the disc should be. This is a conclusion, not a
# failure to measure: report zero contrast so the caller rejects the frame
# rather than abstaining on it.
return {"limb_contrast": 0.0, "radius_ratio": None, "limb_width": None}
contrast = (on_disc - off_limb) / on_disc
measured = {"limb_contrast": float(contrast), "radius_ratio": None, "limb_width": None}
if on_disc <= off_limb:
# No radial falloff at all: there is no disc here to measure the size of.
return measured
# The limb is the steepest descent of the smoothed profile. Taken on the
# *averaged* profile rather than per angle: fitting each angle separately failed
# outright on frames where too few angles yielded a usable edge.
smoothed = cv.GaussianBlur(profile.astype(np.float32).reshape(1, -1), (9, 1), 0).ravel()
derivative = np.gradient(smoothed.astype(np.float64))
low, high = DISC_SEARCH_BAND
window = (radii >= low * scale) & (radii <= high * scale)
if window.sum() < 5:
return measured
indices = np.flatnonzero(window)
local = derivative[indices]
trough = int(indices[np.argmin(local)])
depth = derivative[trough]
if not np.isfinite(depth) or depth >= 0:
return measured # no descending edge: not a disc
measured["radius_ratio"] = float(trough / scale)
# Width of the descent at half its depth. A disc displaced by d smears the
# averaged limb across roughly 2d, so this is what makes the measurement
# sensitive to decentring without ever having to locate a centre.
half = depth / 2.0
left = trough
while left > 0 and derivative[left - 1] <= half:
left -= 1
right = trough
last = derivative.size - 1
while right < last and derivative[right + 1] <= half:
right += 1
measured["limb_width"] = float((right - left + 1) / scale)
return measured
#: Per-band acceptance ranges for the disc_v1 measurements.
#:
#: Generated by ``filter_FITS.py calibrate``; regenerate and paste the output here
#: rather than hand-editing. Derived from 400 frames per band sampled evenly across
#: the archive's whole time range, with 0/400 sample failures in every band.
#:
#: The bounds sit well outside the observed spread of good frames, because the
#: false-positive budget for this detector is <0.1%: the method it replaces was
#: discarding 36.9% of perfectly good frames, which is the entire reason it exists.
#: Only the diagnostic side of each measurement is bounded -- see CALIBRATION_SIDES
#: in filter_FITS.py.
#:
#: Note what the wide contrast floors mean in practice. Good frames in most bands
#: reach as low as 0.11-0.22 contrast, so a floor tight enough to catch a partially
#: degraded frame would reject real data. At this budget limb_contrast therefore
#: catches total signal loss (all-zero, NaN, uniform, which measure exactly 0.0) and
#: little else; size and shape faults are caught by the other two measurements.
#: 171A is the exception, where good frames never drop below 0.68.
#:
#: A measurement whose calibrated bounds cannot achieve the budget is set to None
#: here, which records it as a score without letting it fail a frame.
DISC_BOUNDS = {
94: {
"limb_contrast": (0.02, 1.0),
"radius_ratio": (0.9631, 1.1176),
"limb_width": (0.0, 0.1217),
},
131: {
"limb_contrast": (0.02, 1.0),
"radius_ratio": (0.9578, 1.0854),
"limb_width": (0.0, 0.1011),
},
171: {
"limb_contrast": (0.3183, 1.0),
"radius_ratio": (0.9616, 1.1062),
"limb_width": (0.0, 0.1375),
},
195: {
"limb_contrast": (0.02, 1.0),
"radius_ratio": (0.9676, 1.1167),
"limb_width": (0.0, 0.1704),
},
284: {
"limb_contrast": (0.02, 1.0),
"radius_ratio": (0.9548, 1.1489),
"limb_width": (0.0, 0.3177),
},
304: {
"limb_contrast": (0.02, 1.0),
"radius_ratio": (0.9548, 1.0813),
"limb_width": (0.0, 0.1016),
},
}
def disc_v1(image, wavelength, header=None, bounds=None):
"""Verify that the frame holds a correctly sized, centred, sharp solar disc.
Replaces ``geometry_v1``'s intensity-weighted centroid, which a large active
region drags off centre -- rejecting entire days of good data during exactly the
solar activity most worth watching.
Everything measured here is an average over angle, so where the bright regions
sit on the disc does not move it. What it deliberately cannot see is rotation:
``rotate`` and ``yaw_flip`` leave every azimuthally averaged quantity unchanged,
and one frame carries no absolute rotation reference. Those belong to
``temporal_v1`` and ``crosssat_v1``.
The expected radius comes from the frame's own ``DIAM_SUN``, which varies by
3.3% over the year with the Earth-Sun distance.
"""
bounds = bounds or DISC_BOUNDS
if image is None:
return Verdict(BAD, "no image data", {})
diameter = (header or {}).get("diam_sun")
if not diameter:
return Verdict(UNKNOWN, "no DIAM_SUN in header", {})
# DIAM_SUN is a full diameter at full resolution; halve for radius, halve again
# for the working downsample.
expected_radius = float(diameter) / 4.0
measured = disc_profile(image, expected_radius)
scores = {k: v for k, v in measured.items() if v is not None}
if measured["limb_contrast"] is None:
return Verdict(UNKNOWN, "radial profile not measurable", scores)
limits = bounds.get(wavelength)
if limits is None:
return Verdict(UNKNOWN, f"no disc bounds for band {wavelength}", scores)
failed = []
for name, limit in limits.items():
if limit is None:
continue # recorded as a score, not used to judge
value = measured.get(name)
if value is None:
failed.append(f"{name} unmeasurable")
elif not (limit[0] <= value <= limit[1]):
failed.append(f"{name} {value:.3f} outside [{limit[0]:.3f}, {limit[1]:.3f}]")
if failed:
return Verdict(BAD, "; ".join(failed), scores)
return Verdict(GOOD, None, scores)
2026-10-14 00:27:18 -04:00
# ------------------------------------------------------------------ field_v1
#: Where the far field starts, in solar radii. Beyond ~1.6R a healthy frame holds
#: only faint corona, so this is where bars, blocks, ghost or displaced discs and
#: short-exposure static stand out -- the defects an angle-averaged profile dilutes.
#: At a ~390px radius this leaves ~25% of a 1280px frame, mostly the corners.
FAR_FIELD_RADIUS = 1.6
#: Percentile summarising far-field brightness. The 99th ignores thin real features
#: (a flare's diffraction spikes cover well under 1% of the far field) while any
#: defect covering a few percent of it -- every bar seen in the archive -- lifts it.
FAR_FIELD_PERCENTILE = 99.0
def _far_field_mask(image, radius):
"""Boolean mask of pixels beyond FAR_FIELD_RADIUS * radius of the centre, or None."""
if image is None or np.ndim(image) != 2 or min(np.shape(image)) < 16:
return None
if not radius or not np.isfinite(radius) or radius <= 0:
return None
height, width = image.shape
yy, xx = np.ogrid[:height, :width]
far = np.hypot(xx - (width - 1) / 2.0, yy - (height - 1) / 2.0) > FAR_FIELD_RADIUS * radius
return far
def far_field(image, radius):
"""Brightness of the far field: the 99th percentile of radiance beyond 1.6R.
`radius` is the solar radius in pixels at the image's own resolution. Returns
None when there is no far field to measure (no finite pixels beyond 1.6R).
"""
mask = _far_field_mask(image, radius)
if mask is None:
return None
values = np.asarray(image, dtype=np.float64)[mask]
values = values[np.isfinite(values)]
if values.size == 0:
return None
return float(np.percentile(values, FAR_FIELD_PERCENTILE))
def far_field_noise(image, radius):
"""Pixel-to-pixel noise in the far field: MAD of horizontal first differences.
First differences cancel the smooth corona, and the median absolute deviation
ignores the odd star or cosmic ray, so what remains is the frame's grain. A
short-exposure-only frame reads 0.03-3.3 here against <= 0.0076 for a normal one.
"""
mask = _far_field_mask(image, radius)
if mask is None:
return None
data = np.asarray(image, dtype=np.float64)
diffs = np.diff(data, axis=1)[mask[:, 1:] & mask[:, :-1]]
diffs = diffs[np.isfinite(diffs)]
if diffs.size == 0:
return None
return float(np.median(np.abs(diffs - np.median(diffs))))
#: Typical far-field brightness per band (99th percentile beyond 1.6R, W m-2 sr-1):
#: the median over ~1,600 random frames with a long exposure, 2023-01..2025-03,
#: from ``defect_audit.py``. It scales each band's radiance to a common footing,
#: not a bound -- it held within 0.67-1.25x in every band across those years.
FIELD_SCALE = {94: 0.018, 131: 0.0045, 171: 0.026, 195: 0.059, 284: 0.10, 304: 0.074}
#: Limits for field_v1, set on the user-confirmed real defect set
#: (``labels/real_defects.json``, scored by ``detector_eval.py``):
#:
#: * ``far_field``: far-field brightness / FIELD_SCALE. Normal frames and frames
#: around the era's largest flares (X9.0 included) reach 3.2x, and one large
#: erupting prominence 4.4x; bars, blocks and ghost discs in the corners run
#: 4x-2,500x. At 5x no labelled defect is lost that the stripe test does not
#: also catch, and no clean frame is rejected.
#: * ``stripe_level``: the largest step of any whole detector row or column
#: against the lines either side (asinh units, see ``stripe_profile``). Clean
#: frames reach 0.93 (the hardest being quiet 94A frames); every labelled bar
#: exceeds 1.4.
#: * ``stripe_grain``: the largest excess pixel-to-pixel noise of a detector line
#: over the lines either side. Clean frames reach 0.12; short-exposure static and
#: noise-filled bars run 0.75-3.
FIELD_LIMITS = {"far_field": 5.0, "stripe_level": 1.1, "stripe_grain": 0.25}
#: Working size and line offsets for the stripe profiles. At 640px a bar is ~10-40
#: lines wide; comparing each line with those 6 and 16 away sees both edges of a
#: narrow bar and the body of a wide one.
STRIPE_SIZE = 640
STRIPE_OFFSETS = (6, 16)
#: A line needs this many valid pixels to be profiled.
STRIPE_MIN_PIXELS = 100
def _row_medians(values, ok):
"""Median of each row over its `ok` entries; NaN for a row with none.
Exactly ``np.nanmedian(np.where(ok, values, nan), axis=1)`` -- the two middle
values are averaged for an even count -- but by one sort rather than
nanmedian's per-row partitioning, which was most of field_v1's cost.
"""
ordered = np.sort(np.where(ok, values, np.nan), axis=1) # NaN sorts last
counts = ok.sum(axis=1)
rows = np.arange(values.shape[0])
low = ordered[rows, np.maximum(counts - 1, 0) // 2]
high = ordered[rows, np.maximum(counts, 1) // 2 - (counts == 0)]
out = (low + high) / 2.0
out[counts == 0] = np.nan
return out
def stripe_profile(image, wavelength, refcrota, size=STRIPE_SIZE, offsets=STRIPE_OFFSETS):
"""Largest whole-line anomalies along the detector's rows and columns.
Every bar in the archive lies exactly along a detector row or column: in the
delivered image, rotated to solar north, that is at -REFCROTA. Rotating back
by REFCROTA makes those lines the image rows and columns again, and a defect
that fills a whole line then stands out against the lines either side of it,
wherever it crosses the disc. Natural features never fill a line: the disc is
at most ~60% of a row, and a flare or loop far less, so the *median* over a
line ignores them.
Two profiles, both in asinh units of the band's FIELD_SCALE:
* ``stripe_level`` -- median over each line of (pixel - mean of the pixels
`k` lines either side): a bright or dark bar, a band of dropped rows.
* ``stripe_grain`` -- median |step| along each line, minus the same for the
lines `k` either side: a bar filled with noise rather than light, which
leaves the level almost unchanged.
Blind spot: a bar no brighter than the disc vanishes where it crosses it, and
if the disc fills most of that line the median follows the disc. Real bars are
saturated far above the disc, and one this dim is still caught in the corners
by the far-field test.
Returns {"stripe_level": float, "stripe_grain": float}, or None when the band
has no scale or no rotation is known.
"""
scale = FIELD_SCALE.get(wavelength)
if scale is None or refcrota is None or not np.isfinite(refcrota):
return None
if image is None or np.ndim(image) != 2 or min(np.shape(image)) < 4 * max(offsets):
return None
data = np.nan_to_num(np.asarray(image, dtype=np.float32), nan=0.0, posinf=0.0, neginf=0.0)
# Pixels outside the delivered field of view are exactly zero.
valid = (data != 0).astype(np.float32)
height, width = data.shape
rotation = cv.getRotationMatrix2D(((width - 1) / 2.0, (height - 1) / 2.0),
float(refcrota), 1.0)
data = cv.warpAffine(data, rotation, (width, height), flags=cv.INTER_LINEAR)
valid = cv.warpAffine(valid, rotation, (width, height), flags=cv.INTER_NEAREST)
data = cv.resize(data, (size, size), interpolation=cv.INTER_AREA)
valid = cv.resize(valid, (size, size), interpolation=cv.INTER_NEAREST) > 0.5
# Interpolation smears the field-of-view edge; keep clear of it.
valid = cv.erode(valid.astype(np.uint8), np.ones((5, 5), np.uint8)) > 0
values = np.arcsinh(np.clip(data, 0.0, None) / scale)
level = grain = 0.0
for lines, mask in ((values, valid), (values.T, valid.T)):
steps = np.abs(np.diff(lines, axis=1))
step_ok = mask[:, 1:] & mask[:, :-1]
enough = step_ok.sum(axis=1) >= STRIPE_MIN_PIXELS
line_grain = np.full(lines.shape[0], np.nan)
if enough.any():
line_grain[enough] = _row_medians(steps[enough], step_ok[enough])
for k in offsets:
ok = mask[k:-k] & mask[:-2 * k] & mask[2 * k:]
rows = ok.sum(axis=1) >= STRIPE_MIN_PIXELS
if rows.any():
excess = lines[k:-k] - 0.5 * (lines[:-2 * k] + lines[2 * k:])
medians = _row_medians(excess[rows], ok[rows])
level = max(level, float(np.nanmax(np.abs(medians))))
relative = line_grain[k:-k] - 0.5 * (line_grain[:-2 * k] + line_grain[2 * k:])
if np.isfinite(relative).any():
grain = max(grain, float(np.nanmax(relative)))
return {"stripe_level": level, "stripe_grain": grain}
def field_v1(image, wavelength, header=None, refcrota=None, limits=None):
"""Judge everything off the disc and across it that disc_v1 cannot see.
disc_v1 averages around the disc, so a bar crossing it dilutes to nothing, and
it never looks past 1.45R. This checks the far field's brightness (bars,
blocks, ghost discs in the corners) and every detector row and column for a
whole-line anomaly (bars anywhere, including noise-filled ones lying across
the disc). Measurements that cannot be made are skipped; if none can, the
verdict is UNKNOWN.
"""
limits = limits or FIELD_LIMITS
if image is None:
return Verdict(BAD, "no image data", {})
scale = FIELD_SCALE.get(wavelength)
if scale is None:
return Verdict(UNKNOWN, f"no field scale for band {wavelength}", {})
scores = {}
diameter = (header or {}).get("diam_sun")
if diameter:
brightness = far_field(image, float(diameter) / 2.0)
if brightness is not None:
scores["far_field"] = brightness / scale
stripes = stripe_profile(image, wavelength, refcrota)
if stripes is not None:
scores.update(stripes)
if not scores:
return Verdict(UNKNOWN, "nothing measurable off the disc", scores)
failed = [f"{name} {scores[name]:.2f} above {limit:g}"
for name, limit in limits.items()
if name in scores and scores[name] > limit]
if failed:
return Verdict(BAD, "; ".join(failed), scores)
return Verdict(GOOD, None, scores)
# ---------------------------------------------------------------- temporal_v1
#: Frames either side used to build the local reference. At a 4-minute cadence,
#: +/-6 spans about 50 minutes -- long enough to be robust, short enough that real
#: solar evolution does not dominate.
TEMPORAL_WINDOW = 6
#: Robust z-score above which a frame's brightness is judged anomalous.
TEMPORAL_BRIGHTNESS_Z = 8.0
#: Structural difference from neighbours, relative to the neighbours' own churn.
TEMPORAL_STRUCTURE_RATIO = 6.0
#: Below this relative difference two consecutive frames are considered identical.
FROZEN_TOLERANCE = 1e-6
def _robust_z(values, index, window):
"""Median-absolute-deviation z-score of values[index] against its neighbours."""
low = max(0, index - window)
high = min(len(values), index + window + 1)
neighbours = np.array(
[values[i] for i in range(low, high) if i != index and np.isfinite(values[i])]
)
if neighbours.size < 3:
return None
median = np.median(neighbours)
mad = np.median(np.abs(neighbours - median))
if mad <= 0:
# A perfectly steady neighbourhood: fall back to the spread, and treat an
# exactly-constant one as uninformative rather than infinitely sensitive.
spread = neighbours.std()
if spread <= 0:
return 0.0 if values[index] == median else np.inf
return abs(values[index] - median) / spread
return abs(values[index] - median) / (1.4826 * mad)
def temporal_v1(series, window=TEMPORAL_WINDOW, brightness_z=TEMPORAL_BRIGHTNESS_Z,
structure_ratio=TEMPORAL_STRUCTURE_RATIO,
frozen_tolerance=FROZEN_TOLERANCE):
"""Judge each frame against its neighbours in the same band and satellite.
Catches what single-frame checks cannot: a frame that is individually plausible
but inconsistent with the minutes either side of it -- a frozen duplicate, a
brightness step, a substituted frame from another time.
`series` must be ordered by time and come from one (satellite, wavelength)
stream; gaps are fine, they simply widen the neighbourhood in wall-clock terms.
Returns one Verdict per input frame.
"""
count = len(series)
means = [
f.header.get("img_mean", np.nan) if not f.error else np.nan for f in series
]
verdicts = []
# Structural churn between consecutive thumbnails, when they are available.
diffs = [np.nan] * count
for i in range(1, count):
a, b = series[i - 1].thumbnail, series[i].thumbnail
if a is not None and b is not None and a.shape == b.shape:
scale = float(np.abs(a).mean() + np.abs(b).mean()) / 2.0
diffs[i] = float(np.abs(a - b).mean()) / scale if scale > 0 else np.nan
for i, frame in enumerate(series):
scores = {}
if frame.error:
verdicts.append(Verdict(BAD, f"unreadable: {frame.error}", {"read_ok": 0.0}))
continue
z = _robust_z(means, i, window)
if z is not None:
scores["brightness_z"] = float(z)
# A frame identical to its predecessor means the feed stalled.
if i > 0 and np.isfinite(diffs[i]) and diffs[i] < frozen_tolerance:
scores["frame_diff"] = float(diffs[i])
verdicts.append(Verdict(BAD, "identical to previous frame", scores))
continue
neighbour_diffs = np.array(
[
diffs[j]
for j in range(max(1, i - window), min(count, i + window + 1))
if j != i and np.isfinite(diffs[j])
]
)
if np.isfinite(diffs[i]) and neighbour_diffs.size >= 3:
typical = float(np.median(neighbour_diffs))
scores["frame_diff"] = float(diffs[i])
scores["frame_diff_ratio"] = float(diffs[i] / typical) if typical > 0 else 0.0
if typical > 0 and diffs[i] / typical > structure_ratio:
verdicts.append(
Verdict(BAD, f"structural jump {diffs[i] / typical:.1f}x typical", scores)
)
continue
if z is not None and z > brightness_z:
verdicts.append(Verdict(BAD, f"brightness z={z:.1f}", scores))
continue
if z is None and not np.isfinite(diffs[i]):
verdicts.append(Verdict(UNKNOWN, "insufficient context", scores))
continue
verdicts.append(Verdict(GOOD, None, scores))
return verdicts
2026-10-14 00:27:18 -04:00
# ---------------------------------------------------------------- temporal_v2
#: Frames either side searched for a production-good reference, in slots.
TEMPORAL2_REACH = 6
#: Limits, set on the user-confirmed real defect set (``detector_eval.py``).
#:
#: * ``brightness``: |log| of how far IMG_MEAN falls outside the range spanned by
#: the nearest good frames before and after. A flare rising between them stays
#: inside that range; a one-frame gain jump (0.39 -> 1.10 -> 0.39) does not.
#: * ``structure``: how far the frame's thumbnail departs from the mean of those
#: two frames, relative to how far they differ from each other (plus a floor for
#: a static Sun). Real evolution lies between its neighbours; a ghost limb or a
#: hole does not.
#:
#: Structure 2.0 rather than 3.0 caught two more defects for one more false
#: positive. Brightness was 0.25 until the user's neighbour review: every clean
#: frame it rejected was an impulsive flare peak in 94A or 131A (1.29x-1.66x its
#: neighbours), while every defect it caught also failed structure or jumped
#: 2.4x or more. At 0.7 (2.0x) those flare peaks -- frames worth keeping -- pass.
TEMPORAL2_LIMITS = {"brightness": 0.7, "structure": 2.0}
#: Relative thumbnail difference below which two good frames count as identical,
#: so a quiet Sun does not make every small change look large.
TEMPORAL2_FLOOR = 0.02
def _asinh_thumbnail(thumbnail, wavelength):
scale = FIELD_SCALE.get(wavelength)
if thumbnail is None or scale is None:
return None
return np.arcsinh(np.clip(thumbnail, 0.0, None) / scale)
def temporal_v2(series, good, reach=TEMPORAL2_REACH, limits=None, floor=TEMPORAL2_FLOOR):
"""Judge each frame against its nearest *good* neighbours in the same stream.
temporal_v1 compares a frame with every neighbour, so a clean frame between two
eclipse frames, or beside a bar, looks anomalous (6.8% false positives on real
clean frames). This uses as references only frames the production judgment
passed (`good`, a set of slots), and asks whether the frame lies *between* the
nearest good frame before and after it -- in brightness and in structure --
which real solar change does and a one-frame defect does not. With good
frames on one side only, that side serves as both.
`series` is one (satellite, wavelength) stream of FrameFeatures ordered by
time, with thumbnails. Returns one Verdict per frame.
"""
limits = limits or TEMPORAL2_LIMITS
cadence = 240
times = [f.t_start for f in series]
verdicts = []
for i, frame in enumerate(series):
if frame.error:
verdicts.append(Verdict(BAD, f"unreadable: {frame.error}", {"read_ok": 0.0}))
continue
before = next((series[j] for j in range(i - 1, -1, -1)
if times[i] - times[j] <= reach * cadence and series[j].slot in good),
None)
after = next((series[j] for j in range(i + 1, len(series))
if times[j] - times[i] <= reach * cadence and series[j].slot in good),
None)
if before is None and after is None:
verdicts.append(Verdict(UNKNOWN, "no good neighbour within reach", {}))
continue
before, after = before or after, after or before
scores = {}
mean = frame.header.get("img_mean")
refs = [r.header.get("img_mean") for r in (before, after)]
if mean and mean > 0 and all(r and r > 0 for r in refs):
low, high = min(refs), max(refs)
scores["brightness"] = float(max(np.log(mean / high), np.log(low / mean), 0.0))
band = frame.wavelength
own, a, b = (_asinh_thumbnail(x.thumbnail, band) for x in (frame, before, after))
if own is not None and a is not None and b is not None \
and own.shape == a.shape == b.shape:
level = float(np.mean(np.abs(a)) + np.mean(np.abs(b))) / 2.0 or 1.0
departure = float(np.mean(np.abs(own - (a + b) / 2.0))) / level
spread = float(np.mean(np.abs(a - b))) / level
scores["structure"] = departure / max(spread, floor)
if not scores:
verdicts.append(Verdict(UNKNOWN, "nothing comparable", scores))
continue
failed = [f"{name} {scores[name]:.2f} above {limit:g}"
for name, limit in limits.items()
if name in scores and scores[name] > limit]
verdicts.append(Verdict(BAD, "; ".join(failed), scores) if failed
else Verdict(GOOD, None, scores))
return verdicts
# ------------------------------------------------------------ temporal_verdict
def temporal_verdict(scores, limits=None):
"""temporal_v2's verdict from its stored scores (``temporal_brightness``,
``temporal_structure``, as ``filter_FITS.py measure`` keeps them). UNKNOWN
when the measuring pass found nothing comparable."""
limits = limits or TEMPORAL2_LIMITS
measured = {name: scores.get(f"temporal_{name}") for name in limits}
measured = {name: value for name, value in measured.items() if value is not None}
if not measured:
return Verdict(UNKNOWN, "no series measure", {})
failed = [f"{name} {value:.2f} above {limits[name]:g}"
for name, value in measured.items() if value > limits[name]]
return Verdict(BAD, "; ".join(failed), {}) if failed else Verdict(GOOD, None, {})
# ---------------------------------------------------------------- relative_v2
#: Near references: up to RELATIVE_REFERENCES good frames per side within
#: RELATIVE_REACH slots (an hour). Their median, not the nearest frame alone, so
#: one bad frame among them cannot vouch for another.
RELATIVE_REACH = 15
RELATIVE_REFERENCES = 3
#: The baseline the half-brightness test compares with: up to BASELINE_REFERENCES
#: good frames per side within BASELINE_REACH slots (two hours), wide enough that a
#: cluster of half frames in a noon episode cannot be its own baseline.
BASELINE_REACH = 30
BASELINE_REFERENCES = 15
#: A side may stand alone for the baseline only if it is steady -- at least
#: STEADY_MIN frames, the brightest within STEADY of the faintest -- because a side
#: that is rising may be a flare, against which the frame before it looks dim.
STEADY = 1.2
STEADY_MIN = 3
#: Limits, from the user-reviewed labels and neighbour sheets:
#:
#: * ``dim``: disc median over the lower near side's. Both sides required: from
#: one side a flare onset (2024-10-02 05:28) and a dim frame look the same.
#: * ``half``: disc median over the baseline -- half-exposure frames sit at 0.50.
#: * ``sector``: the faintest sector against the same sector of the lower near
#: side, both sides required -- a grazing eclipse dims one side of the disc.
#: The user chose 0.3 (2026-10-11): user-fine frames reach 0.31, so a mildly
#: dimmed limb is kept, at the cost of passing the mildest dark-labelled grazes.
#: * ``stripe``: the smaller of the stripe_level and stripe_grain ratios over the
#: higher side -- the square-outline artifact reaches ~3.7.
#: * ``shift``: how far, in solar radii, the disc (``measures.ring_position``) has
#: moved from where the near references put it. Labelled clean frames, flares
#: included, move at most 0.073 and archive 171A jitter reaches 0.08; displaced
#: and double discs 0.15-0.69.
#: * ``echo``: how much more the frame repeats itself (``measures.echo``) than the
#: near references do. Clean frames up to 0.10, a 131A flare's spikes 0.14;
#: double exposures mostly 0.16-0.31.
#:
#: Position and echo are judged against neighbours, not absolutely: the Sun itself
#: moves the ring fit -- a lopsided corona held 284A discs 0.2-0.4R "off centre" for
#: whole days on both satellites -- and an active Sun repeats itself, so fixed
#: limits rejected ~3,000 clean frames across the archive (user-reviewed sheets,
#: 2026-10-11).
RELATIVE_LIMITS = {"dim": 0.85, "half": 0.6, "sector": 0.3, "stripe": 2.5,
"shift": 0.12, "echo": 0.15}
#: Bands whose disc position is judged. Not 284A: its ring fit jumps 0.1-0.4R
#: between consecutive clean frames, as the corona outshines the limb.
POSITION_BANDS = (94, 131, 171, 195, 304)
#: Frames whose line of sight to the Sun passes this close to Earth (km above the
#: surface, ``measures.earth_tangent_km``) are never references: grazing the
#: atmosphere dims them, and dimmed neighbours made a grazing frame look normal.
EARTH_REFERENCE_KM = 400.0
def _median(values):
values = [v for v in values if v is not None and v > 0]
return float(np.median(values)) if values else None
def _neighbours(times, ref_times, refs, i, count, reach, cadence=240):
"""Up to `count` reference indices either side of frame i within `reach`."""
lo = bisect.bisect_left(ref_times, times[i])
hi = bisect.bisect_right(ref_times, times[i])
before = [refs[j] for j in range(lo - 1, max(lo - count, 0) - 1, -1)
if times[i] - ref_times[j] <= reach * cadence]
after = [refs[j] for j in range(hi, min(hi + count, len(refs)))
if ref_times[j] - times[i] <= reach * cadence]
return before, after
def _relative_pass(series, references, limits, indices):
"""Verdicts for the frames at `indices`: {index: Verdict}."""
times = [f.t_start for f in series]
refs = [i for i, f in enumerate(series) if f.slot in references]
ref_times = [times[i] for i in refs]
value = lambda i, key: series[i].scores.get(key)
verdicts = {}
for i in indices:
frame = series[i]
before, after = _neighbours(times, ref_times, refs, i, RELATIVE_REFERENCES,
RELATIVE_REACH)
far_before, far_after = _neighbours(times, ref_times, refs, i,
BASELINE_REFERENCES, BASELINE_REACH)
if not (before or after or far_before or far_after):
verdicts[i] = Verdict(UNKNOWN, "no good neighbour within reach", {})
continue
scores, failed = {}, []
own = frame.scores.get("disc_median")
if own is not None and before and after:
near = [_median(value(j, "disc_median") for j in side) for side in (before, after)]
if all(near):
scores["dim"] = own / min(near)
if own is not None:
sides = []
for side in (far_before, far_after):
levels = [value(j, "disc_median") for j in side]
levels = [v for v in levels if v is not None and v > 0]
sides.append(levels)
if sides[0] and sides[1]:
level = min(_median(sides[0]), _median(sides[1]))
else:
alone = sides[0] or sides[1]
steady = len(alone) >= STEADY_MIN and max(alone) <= STEADY * min(alone)
level = _median(alone) if steady else None
if level:
scores["half"] = own / level
if before and after:
ratios = []
for k in range(measures.SECTORS):
mine = frame.scores.get(f"sector_{k}")
low = [_median(value(j, f"sector_{k}") for j in side) for side in (before, after)]
if mine is not None and all(low):
ratios.append(mine / min(low))
if ratios:
scores["sector"] = float(min(ratios))
scores["sector_index"] = float(int(np.argmin(ratios)))
stripe = []
for name in ("stripe_level", "stripe_grain"):
mine = frame.scores.get(name)
high = [m for m in (_median(value(j, name) for j in side)
for side in (before, after) if side) if m]
if mine is not None and high:
stripe.append(mine / max(high))
if len(stripe) == 2:
scores["stripe"] = float(min(stripe))
near = before + after
dx, dy = frame.scores.get("ring_dx"), frame.scores.get("ring_dy")
if dx is not None and dy is not None and frame.wavelength in POSITION_BANDS:
ref = [(value(j, "ring_dx"), value(j, "ring_dy")) for j in near]
ref = [r for r in ref if r[0] is not None and r[1] is not None]
if ref:
scores["shift"] = float(np.hypot(dx - np.median([r[0] for r in ref]),
dy - np.median([r[1] for r in ref])))
own_echo = frame.scores.get("echo")
ref = [value(j, "echo") for j in near if value(j, "echo") is not None]
if own_echo is not None and ref:
scores["echo"] = float(own_echo - np.median(ref))
if not scores:
verdicts[i] = Verdict(UNKNOWN, "nothing comparable", scores)
continue
if scores.get("dim", 1.0) < limits["dim"]:
failed.append(f"brightness {scores['dim']:.2f}x its good neighbours")
if scores.get("half", 1.0) < limits["half"]:
failed.append(f"half brightness: {scores['half']:.2f}x the two-hour level")
if scores.get("sector", 1.0) < limits["sector"]:
failed.append(f"sector {int(scores['sector_index'])} at {scores['sector']:.2f}x "
"its good neighbours")
if scores.get("stripe", 0.0) > limits["stripe"]:
failed.append(f"stripes {scores['stripe']:.2f}x its good neighbours")
if scores.get("shift", 0.0) > limits["shift"]:
failed.append(f"disc moved {scores['shift']:.2f}R from its good neighbours")
if scores.get("echo", 0.0) > limits["echo"]:
failed.append(f"double exposure: echo {scores['echo']:+.2f} over its good "
"neighbours")
scores = {f"relative_{k}": v for k, v in scores.items()}
verdicts[i] = (Verdict(BAD, "; ".join(failed), scores) if failed
else Verdict(GOOD, None, scores))
return verdicts
def relative_v2(series, good, limits=None, earth_reference_km=EARTH_REFERENCE_KM):
"""Judge each frame's brightness, by region, and its stripes against its good
neighbours, from the frame's stored measures.
Brightness is the disc's median (``measures.disc_brightness``), which a flare
kernel does not lift the way it lifts IMG_MEAN. Four tests, each against a
median of references so one bad frame cannot vouch for another: dim against
the lower near side and both sides required; half brightness against a
two-hour baseline, one side allowed only when steady; the faintest sector
against the same sector nearby, for a disc dimmed on one side; stripes; and
the disc's position and self-repetition, for displaced and double discs.
The pass repeats without the frames it flagged until it flags nothing new, so
a run of dim frames falls from the ends inwards; a frame once flagged stays
flagged. Frames grazing Earth's atmosphere are judged but never references.
`series` is one (satellite, wavelength) stream of FrameFeatures in time order
with the measures in `scores`; `good` the slots that passed every single-frame
check. Returns one Verdict per frame.
"""
limits = limits or RELATIVE_LIMITS
grazing = {f.slot for f in series
if (f.scores.get("earth_tangent_km") is not None
and f.scores["earth_tangent_km"] < earth_reference_km)}
good, flagged = set(good) - grazing, {}
times = [f.t_start for f in series]
verdicts = _relative_pass(series, good, limits, range(len(series)))
while True:
new = {i for i, v in verdicts.items()
if v.verdict == BAD and series[i].slot not in flagged}
if not new:
return [flagged.get(f.slot, verdicts[i]) for i, f in enumerate(series)]
flagged.update({series[i].slot: verdicts[i] for i in new})
# Only frames within reach of a newly flagged one have lost a reference.
reach = BASELINE_REACH * 240
changed = sorted({j for i in new
for j in range(bisect.bisect_left(times, times[i] - reach),
bisect.bisect_right(times, times[i] + reach))})
verdicts.update(_relative_pass(series, good - set(flagged), limits, changed))
# ---------------------------------------------------------------- pointing_v1
#: Bands the pointing check judges. A pointing fault composes two discs into one
#: frame, and in 94A that double disc is too faint for disc_v1's limb fit. The
#: other bands are left to their own checks: within one slot some bands come out
#: double while the rest are clean, and judging those by their neighbours was wrong
#: for 13 of 20 sampled frames, against 1 of 20 in 94A.
POINTING_BANDS = (94,)
#: How many of the satellite's other bands in the same slot must have failed.
POINTING_MIN_OTHERS = 3
#: Checks whose rejection of another band counts as evidence of a pointing fault.
#: Header rejections do not: in the daily noon episodes most bands are empty while
#: the 94A frame beside them is fine, and in eclipse season the others go dark.
POINTING_EVIDENCE = ("disc_v1", "field_v1", "temporal_v2", "relative_v2")
def pointing_v1(wavelength, others, bands=POINTING_BANDS, min_others=POINTING_MIN_OTHERS,
evidence=POINTING_EVIDENCE):
"""Judge a frame by the pixel checks its satellite's other bands failed in the
same slot. `others` maps each other band present in the slot to the check that
rejected it, or None if it passed. UNKNOWN for a band this check does not judge.
"""
if wavelength not in bands:
return Verdict(UNKNOWN, "band not judged by pointing", {})
failed = sorted((band, source) for band, source in others.items()
if band != wavelength and source in evidence)
scores = {"pointing_others": float(len(failed))}
if len(failed) >= min_others:
named = ", ".join(f"{band} {source}" for band, source in failed)
return Verdict(BAD, f"pointing fault: {len(failed)} other bands failed ({named})",
scores)
return Verdict(GOOD, None, scores)
# ------------------------------------------------------------------- lunar_v1
#: Extra separation, in arcminutes, before the Moon counts as clear of the Sun:
#: the corona the frame shows extends past the photospheric limb.
LUNAR_MARGIN_ARCMIN = 4.0
#: Geocentric Sun-Moon elongation, in degrees, beyond which no transit is possible
#: from geostationary orbit. The observer sits 42,164 km from Earth's centre, so
#: the Moon (>= 356,000 km away) shifts by at most 6.8 degrees of parallax; add both
#: radii (~0.55), the margin, and an hour of the Moon's ~0.55 deg/h drift -- the
#: elongation is computed once per hour and cached -- and 10 degrees is safe.
LUNAR_GATE_DEG = 10.0
_MOON_RADIUS_KM = 1737.4
_SUN_RADIUS_KM = 695700.0
@functools.lru_cache(maxsize=4096)
def _geocentric_elongation(hour):
"""Sun-Moon angle in degrees from Earth's centre, at the start of a UTC hour."""
from astropy.coordinates import get_body, solar_system_ephemeris
from astropy.time import Time
import astropy.units as u
moment = Time(hour * 3600, format="unix", scale="utc")
with solar_system_ephemeris.set("builtin"):
separation = get_body("sun", moment).separation(get_body("moon", moment))
return float(separation.to(u.deg).value)
def lunar_v1(cards, margin=LUNAR_MARGIN_ARCMIN):
"""Whether the Moon stood in front of the Sun as seen from the satellite.
Lunar transits are invisible to every image test that compares a frame with
its neighbours: the whole sequence dims smoothly together. But they are
predictable exactly -- the header carries the satellite's position
(OBSGEO-X/Y/Z, metres, ITRS) and the observation time -- so this computes
the apparent Sun-Moon separation from there. GOES-16 saw the 2023-10-14
annular eclipse (centres 22' apart, radii 16' and 17') while GOES-18, 60
degrees of longitude away, did not.
"""
from astropy.coordinates import EarthLocation, get_body, solar_system_ephemeris
from astropy.time import Time
import astropy.units as u
cards = cards or {}
position = [cards.get(k) for k in ("OBSGEO-X", "OBSGEO-Y", "OBSGEO-Z")]
when = cards.get("DATE-OBS") or cards.get("DATE-BEG")
if not when or not all(isinstance(v, (int, float)) for v in position):
return Verdict(UNKNOWN, "no observer position or time in header", {})
try:
location = EarthLocation.from_geocentric(*position, unit=u.m)
# An explicit format: left to guess, astropy keeps every failed parse's
# exception, whose traceback cycles hold the callers' frames -- and with
# them judge_frame's 6.5 MB image -- alive until a full GC pass.
moment = Time(str(when), format="isot", scale="utc")
elongation = _geocentric_elongation(int(moment.unix // 3600))
if min(elongation, _geocentric_elongation(int(moment.unix // 3600) + 1)) \
> LUNAR_GATE_DEG:
return Verdict(GOOD, None, {"moon_elongation": elongation})
with solar_system_ephemeris.set("builtin"):
sun = get_body("sun", moment, location)
moon = get_body("moon", moment, location)
except (ValueError, TypeError) as exc:
return Verdict(UNKNOWN, f"ephemeris failed: {exc}", {})
separation = float(sun.separation(moon).to(u.arcmin).value)
sun_radius = float(np.degrees(_SUN_RADIUS_KM / sun.distance.to(u.km).value)) * 60
moon_radius = float(np.degrees(_MOON_RADIUS_KM / moon.distance.to(u.km).value)) * 60
limit = sun_radius + moon_radius + margin
scores = {"moon_separation": separation, "moon_limit": limit}
if separation < limit:
return Verdict(BAD, f"Moon in front of the Sun: {separation:.1f}' < {limit:.1f}'", scores)
return Verdict(GOOD, None, scores)
# ---------------------------------------------------------------- crosssat_v1
#: Relative disagreement between satellites above which one of them is wrong.
CROSSSAT_MAX_DIFF = 0.35
#: Alignment shift beyond which the two views cannot be meaningfully compared.
#: Geostationary parallax is under ~7px at 2.5 arcsec/px; more means a pointing fault.
CROSSSAT_MAX_SHIFT = 12.0
#: Plausible range for the calibration gain between the two flight models. Wide
#: enough to absorb genuine instrument differences, narrow enough that a blackout or
#: a saturation blowout cannot be rescaled into agreement.
CROSSSAT_GAIN_RANGE = (0.2, 5.0)
def _gain_match(source, reference):
"""Least-squares gain and offset putting `source` on `reference`'s scale.
The two instruments are different flight models with different responses, so a
raw difference conflates calibration with genuine disagreement.
Returns (matched, gain, offset). The gain is returned rather than hidden
because matching is otherwise *too* effective: a frame uniformly scaled by 1e-4
fits perfectly after rescaling, so a detector that only looked at the residual
would call a total blackout a match. The size of the correction is itself
evidence.
"""
x = source.ravel().astype(np.float64)
y = reference.ravel().astype(np.float64)
finite = np.isfinite(x) & np.isfinite(y)
if finite.sum() < 16:
return source, 1.0, 0.0
x, y = x[finite], y[finite]
variance = float(((x - x.mean()) ** 2).sum())
if variance <= 0:
return source, 1.0, 0.0
gain = float(((x - x.mean()) * (y - y.mean())).sum() / variance)
offset = float(y.mean() - gain * x.mean())
return source * gain + offset, gain, offset
def align_shift(a, b):
"""Sub-pixel translation between two thumbnails, via phase correlation."""
if a is None or b is None or a.shape != b.shape:
return None
window = cv.createHanningWindow((a.shape[1], a.shape[0]), cv.CV_64F)
(dx, dy), _ = cv.phaseCorrelate(
np.nan_to_num(a.astype(np.float64)), np.nan_to_num(b.astype(np.float64)), window
)
return float(np.hypot(dx, dy))
def crosssat_v1(features_a, features_b, temporal_a=None, temporal_b=None,
max_diff=CROSSSAT_MAX_DIFF, max_shift=CROSSSAT_MAX_SHIFT,
gain_range=CROSSSAT_GAIN_RANGE):
"""Compare the two satellites' view of the same instant.
GOES-16 and GOES-18 observe the same Sun from geostationary orbit, so at 1 AU
their views differ only by a few pixels of parallax and by instrument
calibration. Sustained disagreement therefore means one of them is wrong.
Deciding *which* one needs outside information: `temporal_a`/`temporal_b` are the
corresponding :func:`temporal_v1` verdicts, and whichever frame also disagrees
with its own history is blamed. With no tiebreak available both are reported
``unknown`` rather than guessing -- a wrong attribution would discard a good
frame and keep a bad one.
Returns (verdict_a, verdict_b).
"""
if features_a is None or features_b is None:
return Verdict(UNKNOWN, "no counterpart", {}), Verdict(UNKNOWN, "no counterpart", {})
if features_a.error or features_b.error:
# Frame-level errors are not this detector's job; defer rather than double-count.
return Verdict(UNKNOWN, "counterpart unreadable", {}), Verdict(
UNKNOWN, "counterpart unreadable", {}
)
a, b = features_a.thumbnail, features_b.thumbnail
if a is None or b is None or a.shape != b.shape:
return Verdict(UNKNOWN, "no comparable thumbnail", {}), Verdict(
UNKNOWN, "no comparable thumbnail", {}
)
scores = {}
shift = align_shift(a, b)
if shift is not None:
scores["align_shift"] = shift
matched, gain, offset = _gain_match(b, a)
scale = float(np.abs(a).mean() + np.abs(matched).mean()) / 2.0
difference = float(np.abs(a - matched).mean()) / scale if scale > 0 else np.inf
scores["cross_diff"] = difference
scores["cross_gain"] = gain
scores["cross_offset"] = offset
# A correction this large means the two frames are not on comparable scales at
# all, whatever the residual says once it has been applied.
gain_ok = gain_range[0] <= gain <= gain_range[1]
agree = difference <= max_diff and gain_ok and (shift is None or shift <= max_shift)
if agree:
return Verdict(GOOD, None, scores), Verdict(GOOD, None, scores)
reason = f"cross-satellite disagreement {difference:.2f}"
if not gain_ok:
reason = f"cross-satellite gain {gain:.3g} outside {gain_range}"
elif shift is not None and shift > max_shift:
reason = f"cross-satellite misalignment {shift:.1f}px"
a_suspect = temporal_a is not None and temporal_a.verdict == BAD
b_suspect = temporal_b is not None and temporal_b.verdict == BAD
if a_suspect and not b_suspect:
return Verdict(BAD, reason, scores), Verdict(GOOD, None, scores)
if b_suspect and not a_suspect:
return Verdict(GOOD, None, scores), Verdict(BAD, reason, scores)
return Verdict(UNKNOWN, reason + " (source unclear)", scores), Verdict(
UNKNOWN, reason + " (source unclear)", scores
)
#: Detectors the drivers and bench expose by name.
FRAME_DETECTORS = {"header_v1": header_v1, "geometry_v1": geometry_v1, "disc_v1": disc_v1}
2026-10-14 00:27:18 -04:00
SERIES_DETECTORS = {"temporal_v1": temporal_v1, "temporal_v2": temporal_v2,
"crosssat_v1": crosssat_v1}
ALL_DETECTORS = tuple(FRAME_DETECTORS) + tuple(SERIES_DETECTORS)