153 lines
5.8 KiB
Python
153 lines
5.8 KiB
Python
"""Pristine contiguous segments: the unit the training corpus is built from.
|
|
|
|
A segment is a maximal run of slots in which *both* satellites are GOOD -- all
|
|
six wavelengths present and detector-clean -- taken from the archive profile
|
|
(:mod:`suvi.availability`) and then verified against the extracted shards. The
|
|
model trains only on targets whose entire input span lies inside one segment, so
|
|
every frame it is fed is pre-verified pristine and every hole or damage it sees
|
|
is simulated, against complete ground truth. Day boundaries mean nothing here;
|
|
the minimum length is the model's own input requirement, ``2*span + 1`` frames.
|
|
|
|
Segments are stored as ``[t0, t1)`` unix-time pairs in ``segments.json`` beside
|
|
the shard manifest, and the train/val/test split assigns whole segments, so the
|
|
two sides of a split can never share a contiguous stretch of Sun.
|
|
"""
|
|
|
|
import datetime
|
|
import json
|
|
import os
|
|
|
|
import numpy as np
|
|
|
|
from . import paths
|
|
|
|
|
|
def from_availability(model, minimum_slots):
|
|
"""Maximal jointly-pristine runs of at least `minimum_slots`, as (t0, t1)."""
|
|
pristine = model.pristine()
|
|
edges = np.diff(pristine.astype(np.int8))
|
|
starts = list(np.flatnonzero(edges == 1) + 1)
|
|
ends = list(np.flatnonzero(edges == -1) + 1)
|
|
if pristine.size and pristine[0]:
|
|
starts.insert(0, 0)
|
|
if pristine.size and pristine[-1]:
|
|
ends.append(pristine.size)
|
|
# int(), not numpy's: these bounds are written to JSON and compared against
|
|
# shard keys, and a stray int64 breaks json.dump at the very end of a
|
|
# multi-hour build.
|
|
return [
|
|
(int(model.t0 + a * model.cadence), int(model.t0 + b * model.cadence))
|
|
for a, b in zip(starts, ends)
|
|
if (b - a) >= minimum_slots
|
|
]
|
|
|
|
|
|
def decorrelation_slots(cadence=paths.CADENCE):
|
|
"""Frames after which a segment is showing an essentially different Sun.
|
|
|
|
Half a synodic rotation: by then the hemisphere that was facing us has gone
|
|
round the back and the one that was hidden is in view, so two frames that far
|
|
apart share no surface features. This is the honest scale for "how much
|
|
diversity does one contiguous segment hold" -- a segment shorter than this is
|
|
one evolving view of one hemisphere, and truncating it below this length
|
|
discards genuinely new Sun rather than redundancy.
|
|
|
|
Equatorial synodic rotation is the fastest and so the conservative choice:
|
|
26.1 days, giving ~4,700 frames at the 4-minute cadence. Higher latitudes
|
|
take half again as long.
|
|
"""
|
|
from . import fillers
|
|
|
|
period_days = 360.0 / fillers.rotation_rate(0.0, synodic=True)
|
|
return int(period_days / 2 * 86400 / cadence)
|
|
|
|
|
|
def days_touched(segments, cadence=paths.CADENCE):
|
|
"""Every calendar day any segment's slots fall on -- the days to extract."""
|
|
days = set()
|
|
for t0, t1 in segments:
|
|
for t in range(t0, t1, cadence):
|
|
days.add(datetime.datetime.fromtimestamp(
|
|
t, datetime.timezone.utc).strftime("%Y-%m-%d"))
|
|
return sorted(days)
|
|
|
|
|
|
def verify(segments, usable, cadence=paths.CADENCE, minimum_slots=1):
|
|
"""Split segments wherever extraction disagrees with the profile.
|
|
|
|
The profile is header-only; ``disc_v1`` runs at extraction and can reject a
|
|
slot inside a nominally pristine run (measured ~0.3%). `usable(t)` says
|
|
whether every band on every satellite actually made it into the shards at
|
|
`t`. Any slot that did not splits its segment; fragments shorter than
|
|
`minimum_slots` are dropped. The corpus is only final after this pass.
|
|
"""
|
|
verified = []
|
|
for t0, t1 in segments:
|
|
start = t0
|
|
for t in range(t0, t1, cadence):
|
|
if not usable(t):
|
|
if (t - start) // cadence >= minimum_slots:
|
|
verified.append((start, t))
|
|
start = t + cadence
|
|
if (t1 - start) // cadence >= minimum_slots:
|
|
verified.append((start, t1))
|
|
return verified
|
|
|
|
|
|
def targets(segments, span_slots, cadence=paths.CADENCE):
|
|
"""Every slot time at least `span_slots` from both ends of its segment."""
|
|
margin = span_slots * cadence
|
|
out = []
|
|
for t0, t1 in segments:
|
|
out.extend(range(t0 + margin, t1 - margin, cadence))
|
|
return out
|
|
|
|
|
|
def split(segments, seed, val_targets, test_targets, span_slots,
|
|
cadence=paths.CADENCE):
|
|
"""Assign whole segments to val/test until each holds enough targets.
|
|
|
|
Deterministic in `seed`. Splitting by segment, not by day or slot, is the
|
|
leakage rule: two targets in one contiguous run share most of their context,
|
|
so a run must land entirely on one side.
|
|
"""
|
|
order = list(segments)
|
|
np.random.default_rng(seed).shuffle(order)
|
|
|
|
def mass(segment):
|
|
t0, t1 = segment
|
|
return max((t1 - t0) // cadence - 2 * span_slots, 0)
|
|
|
|
splits = {"val": [], "test": [], "train": []}
|
|
need = {"val": val_targets, "test": test_targets}
|
|
for segment in order:
|
|
for name in ("val", "test"):
|
|
if need[name] > 0:
|
|
splits[name].append(tuple(segment))
|
|
need[name] -= mass(segment)
|
|
break
|
|
else:
|
|
splits["train"].append(tuple(segment))
|
|
return {name: sorted(chosen) for name, chosen in splits.items()}
|
|
|
|
|
|
def containing(segments, t):
|
|
"""The segment holding time `t`, or None."""
|
|
for t0, t1 in segments:
|
|
if t0 <= t < t1:
|
|
return (t0, t1)
|
|
return None
|
|
|
|
|
|
def save(path, segments):
|
|
payload = {"segments": [[int(a), int(b)] for a, b in sorted(segments)]}
|
|
# Whole-file write, then rename: a half-written segments.json is worse than
|
|
# none, because the next run would load it and train on a truncated corpus.
|
|
with open(path + ".tmp", "w") as handle:
|
|
json.dump(payload, handle)
|
|
os.replace(path + ".tmp", path)
|
|
|
|
|
|
def load(path):
|
|
with open(path) as handle:
|
|
return [tuple(pair) for pair in json.load(handle)["segments"]]
|