noaa-goes-visualization/suvi/cases.py

398 lines
16 KiB
Python
Raw Permalink Normal View History

"""Building bench cases: finding clean windows, planning injections, resolving reads.
The bench never modifies the archive. A *case* is a plan plus an overlay directory:
slots the plan corrupts are written as new files under the overlay, slots it deletes
resolve to nothing, and every other slot resolves straight through to the real file.
:class:`Overlay` is the indirection that makes that work, so detectors and fillers can
be written as if they were reading the archive directly.
Everything here is deterministic in the case seed, so a run can be reproduced or
resumed exactly.
"""
import os
from dataclasses import dataclass, field
import numpy as np
from . import corruptions, paths
#: A slot with no good frame either side cannot be filled or scored, so cases leave
#: this many good slots untouched at each end of the window.
EDGE_MARGIN = 8
#: Minimum good slots left between two injected gaps, so every gap has clean brackets.
MIN_SEPARATION = 4
def gap_separation(length):
"""Clean slots to leave either side of a gap of `length`.
A fixed four slots was fine when gaps were one to six frames. It is not fine at
three hundred: the frames a long gap is reconstructed from would themselves sit
within a few slots of the next gap, so the "clean brackets" a fill is scored
against would be neighbours of other damage. Scale the guard with the gap.
"""
return max(MIN_SEPARATION, length // 4)
def scan_window(root, satellites, wavelengths, t_start, t_end):
"""Index the archive for one time window, by filename only.
Returns {(satellite, wavelength, t_start): (abs_path, legacy_label)}. Reads no
file contents, so it is cheap enough to run over a week of data interactively.
"""
found = {}
for satellite in satellites:
for wavelength in wavelengths:
base = os.path.join(
root, f"goes{satellite:d}", "l2", "data", f"suvi-l2-ci{wavelength:03d}"
)
if not os.path.isdir(base):
continue
for day_dir in _candidate_days(base, t_start, t_end):
try:
entries = os.listdir(day_dir)
except OSError:
continue
for entry in entries:
name = paths.parse_frame_filename(entry)
if name is None:
continue
if not (t_start <= name.t_start < t_end):
continue
if name.satellite != satellite or name.wavelength != wavelength:
continue
found[name.slot] = (os.path.join(day_dir, entry), name.label)
return found
def _candidate_days(base, t_start, t_end):
"""Day directories the window could touch, without walking the whole tree."""
import datetime
day = datetime.datetime.fromtimestamp(t_start, datetime.timezone.utc).date()
last = datetime.datetime.fromtimestamp(t_end, datetime.timezone.utc).date()
while day <= last:
path = os.path.join(base, f"{day.year:04d}", f"{day.month:02d}", f"{day.day:02d}")
if os.path.isdir(path):
yield path
day += datetime.timedelta(days=1)
def timeline(t_start, t_end, cadence=paths.CADENCE):
"""Every slot the cadence says should exist in a window."""
return list(range(t_start, t_end, cadence))
def slot_is_good(found, satellite, wavelength, time):
"""Whether a slot holds a frame the legacy filter passed.
An unlabelled frame counts as unknown, not good: roughly a quarter of the
archive was never processed, and treating that as clean would quietly seed the
ground truth with unvetted frames.
Note that these labels are *stale*. The archive's suffixes were written in
May 2024; filter_FITS.py was substantially retuned that July (max_center_skew
5 -> 7, ratio_above_thresh_max 0.5 -> 0.4, a minimum added). They record what
some earlier filter thought, which is useful as an availability hint and as
history, but is not ground truth. Vetting uses headers instead.
"""
entry = found.get((satellite, wavelength, time))
if entry is None:
return None
return entry[1] == "f" if entry[1] else None
def find_runs(found, satellites, wavelengths, t_start, t_end, minimum=120,
require_good_label=False):
"""Longest stretches where every requested band and satellite has a frame.
Returns (length_in_slots, start_time, end_time) tuples, longest first. Needs no
file contents, only filenames, so it is fast enough to run interactively over
weeks of archive.
By default this asks only that a frame *exists*, because the legacy labels are
stale (see :func:`slot_is_good`) and filtering on them would both miss good data
and admit bad. Judging quality is ``vet-window``'s job, from the headers.
"""
slots = timeline(t_start, t_end)
runs = []
current = 0
start = None
for time in slots:
if require_good_label:
complete = all(
slot_is_good(found, satellite, wavelength, time) is True
for satellite in satellites
for wavelength in wavelengths
)
else:
complete = all(
(satellite, wavelength, time) in found
for satellite in satellites
for wavelength in wavelengths
)
if complete:
if current == 0:
start = time
current += 1
else:
if current >= minimum:
runs.append((current, start, time - paths.CADENCE))
current = 0
if current >= minimum:
runs.append((current, start, slots[-1]))
return sorted(runs, reverse=True)
# --------------------------------------------------------------------------- plans
@dataclass
class InjectionPlan:
"""What a case does to its window.
The axes here are the experiment's independent variables: how much is damaged,
in what size runs, on which satellites, and how.
"""
#: Fraction of slots to damage, in [0, 1).
fraction: float = 0.10
#: Gap widths to sample, in slots. Fill quality degrades with gap length, so the
#: spread matters more than the mean.
gap_lengths: tuple = (1, 2, 3, 5, 10, 30, 60)
#: 'g16', 'g18', 'both', or 'mixed'. 'both' removes the same slots from each
#: satellite at once, which is the only case where cross-satellite fill is
#: unavailable -- and it is 31% of real outages, so it is not a corner case.
satellite_scope: str = "mixed"
#: 'all' damages every band in a slot; 'one' damages a single band, which is the
#: case a per-wavelength repair can actually exploit.
wavelength_scope: str = "all"
#: Corruption modes to sample from, plus the pseudo-mode 'delete'.
modes: tuple = ("delete",) + tuple(corruptions.CATALOG)
#: Severity range sampled per gap.
severity: tuple = (0.5, 1.0)
#: Gaps to place at *each* length. When set this governs instead of `fraction`.
#:
#: A budget expressed as a fraction is dominated by whichever gaps are longest:
#: one 300-slot gap consumes half a 0.25 budget on a 2,600-slot window, so the
#: run ends up with a single gap per length and a single corruption mode per
#: gap. 84% of one such case was one 300-slot `truncate`, which is not a test of
#: anything but that mode at that length. Asking for a count per length gives an
#: experiment with a designed shape instead of an emergent one.
gaps_per_length: int | None = None
def validate(self):
if not 0.0 <= self.fraction < 1.0:
raise ValueError(f"fraction must be in [0, 1), got {self.fraction}")
if not self.gap_lengths or any(length < 1 for length in self.gap_lengths):
raise ValueError("gap_lengths must all be >= 1")
if self.satellite_scope not in ("g16", "g18", "both", "mixed"):
raise ValueError(f"unknown satellite_scope {self.satellite_scope!r}")
if self.wavelength_scope not in ("all", "one"):
raise ValueError(f"unknown wavelength_scope {self.wavelength_scope!r}")
unknown = [
mode for mode in self.modes if mode != "delete" and mode not in corruptions.CATALOG
]
if unknown:
raise ValueError(f"unknown corruption modes: {unknown}")
low, high = self.severity
if not 0.0 < low <= high <= 1.0:
raise ValueError(f"severity must satisfy 0 < low <= high <= 1, got {self.severity}")
return self
def as_dict(self):
return {
"fraction": self.fraction,
"gap_lengths": list(self.gap_lengths),
"satellite_scope": self.satellite_scope,
"wavelength_scope": self.wavelength_scope,
"modes": list(self.modes),
"severity": list(self.severity),
"gaps_per_length": self.gaps_per_length,
}
@classmethod
def from_dict(cls, payload):
return cls(
fraction=payload["fraction"],
gap_lengths=tuple(payload["gap_lengths"]),
satellite_scope=payload["satellite_scope"],
wavelength_scope=payload["wavelength_scope"],
modes=tuple(payload["modes"]),
severity=tuple(payload["severity"]),
gaps_per_length=payload.get("gaps_per_length"),
)
@dataclass(frozen=True)
class Injection:
"""One damaged slot."""
slot: tuple
mode: str
severity: float
#: Position within its gap, and the gap's total width -- fill quality is reported
#: against gap length, so this has to survive into the results.
gap_index: int
gap_length: int
#: Stable per-slot seed, so a corruption is reproducible in isolation.
seed: int
def plan_injections(plan, window_slots, satellites, wavelengths, seed):
"""Choose which slots to damage and how.
Gaps are laid down as non-overlapping runs separated by clean slots, with the
window's edges left intact, so every damaged slot has good frames to be
reconstructed from. Returns a list of :class:`Injection`.
"""
plan.validate()
rng = np.random.default_rng(seed)
# Gaps are laid down along the timeline, so collapse the (satellite, band, time)
# slots to the distinct timestamps they cover.
times = sorted({slot[2] for slot in window_slots})
usable = times[EDGE_MARGIN : len(times) - EDGE_MARGIN]
if not usable:
return []
# Refuse a window that cannot hold the gaps asked for, rather than quietly
# planning fewer (or none) and reporting a result for gap lengths that were
# never actually tested.
longest = max(plan.gap_lengths)
required = longest + 2 * gap_separation(longest)
if len(usable) < required:
raise ValueError(
f"window has {len(usable)} usable slots but a gap of {longest} needs "
f"{required} with its guard bands; use a longer window or shorter gaps"
)
lengths = sorted(plan.gap_lengths, reverse=True)
if plan.gaps_per_length:
wanted = {int(length): plan.gaps_per_length for length in lengths}
target = sum(length * count for length, count in wanted.items())
else:
wanted = None
target = int(len(times) * plan.fraction)
if target <= 0:
return []
# Lay down gaps at random starts, rejecting any that would touch an existing one.
# Longest first: a 300-slot gap placed last would rarely find room, so the mix
# would silently skew towards the short lengths it is meant to be compared with.
taken = set()
gaps = []
budget = 0
attempts = max(len(usable) * 4, 8000)
for attempt in range(attempts):
if wanted is None:
if budget >= target:
break
elif not any(wanted.values()):
break
length = int(lengths[attempt % len(lengths)])
if wanted is not None and not wanted.get(length):
continue
separation = gap_separation(length)
start_index = int(rng.integers(0, max(1, len(usable) - length)))
span = usable[start_index : start_index + length]
if len(span) < length:
continue
guard = range(start_index - separation, start_index + length + separation)
if any(index in taken for index in guard):
continue
taken.update(guard)
gaps.append(span)
budget += length
if wanted is not None:
wanted[length] -= 1
injections = []
mode_order = list(plan.modes)
rng.shuffle(mode_order)
for gap_number, gap in enumerate(gaps):
# Cycle rather than sample: with only a handful of gaps, independent draws
# repeat modes and leave most of the catalogue untested.
mode = str(mode_order[gap_number % len(mode_order)])
low, high = plan.severity
severity = float(rng.uniform(low, high))
targets = _gap_targets(plan, satellites, wavelengths, rng)
for index, time in enumerate(gap):
for satellite, wavelength in targets:
slot = (satellite, wavelength, time)
if slot not in window_slots:
continue
injections.append(
Injection(
slot=slot,
mode=mode,
severity=severity,
gap_index=index,
gap_length=len(gap),
seed=int(rng.integers(0, 2**31 - 1)),
)
)
return injections
def _gap_targets(plan, satellites, wavelengths, rng):
"""Which (satellite, wavelength) pairs one gap applies to."""
if plan.satellite_scope == "both":
chosen_satellites = list(satellites)
elif plan.satellite_scope == "mixed":
chosen_satellites = [satellites[int(rng.integers(0, len(satellites)))]]
else:
wanted = int(plan.satellite_scope[1:])
chosen_satellites = [wanted] if wanted in satellites else list(satellites[:1])
if plan.wavelength_scope == "all":
chosen_bands = list(wavelengths)
else:
chosen_bands = [wavelengths[int(rng.integers(0, len(wavelengths)))]]
return [(s, w) for s in chosen_satellites for w in chosen_bands]
# ------------------------------------------------------------------------- overlay
@dataclass
class Overlay:
"""Resolves a slot to the file a case should read for it.
Damaged slots point at the overlay copy, deleted slots resolve to None, and
everything else falls through to the archive untouched. Nothing here can write
to the archive, which is the property that makes the bench safe to run against
live data.
"""
#: slot -> absolute path in the real archive.
archive: dict = field(default_factory=dict)
#: slot -> absolute path of a corrupted replacement.
overrides: dict = field(default_factory=dict)
#: slots the case removed entirely.
deleted: frozenset = frozenset()
def path(self, slot):
"""The file to read for `slot`, or None if the case removed it."""
if slot in self.deleted:
return None
if slot in self.overrides:
return self.overrides[slot]
entry = self.archive.get(slot)
return entry[0] if isinstance(entry, tuple) else entry
def truth_path(self, slot):
"""The original file, regardless of what the case did -- for scoring."""
entry = self.archive.get(slot)
return entry[0] if isinstance(entry, tuple) else entry
def slots(self):
return sorted(self.archive)
def series(self, satellite, wavelength):
"""Slots for one (satellite, band) stream, in time order."""
return sorted(
slot for slot in self.archive if slot[0] == satellite and slot[1] == wavelength
)