noaa-goes-visualization/suvi/pool.py

295 lines
13 KiB
Python
Raw Normal View History

2026-10-14 00:27:18 -04:00
"""A rotating in-memory training pool, generated from the archive while training runs.
The on-disk corpus holds ~80,000 slots (323 GB) because that is what fits on the
training host; the archive's pristine two-satellite era is ~5.6 times larger. So
instead of a fixed corpus the host can generate training data as it trains: a
producer (``pool_producer.py``) reads FITS straight from the archive share, writes
*slices* -- a day of one pristine segment, both satellites, with the model's span of
margin -- into a RAM directory, and retires the oldest when the pool is over budget.
``train.py --pool`` samples from whatever slices are resident. Validation and test
stay on the fixed on-disk corpus, so scores remain comparable between runs.
A slice is an ordinary little corpus: day-shards in the usual format plus
``slice.json`` naming its verified segments. It is built in ``.<name>.partial`` and
renamed into place complete, so a reader never sees half of one; a reader opens its
shards eagerly (:class:`suvi.dataset.Shard`), so a slice retired mid-visit stays
readable through the handles already open.
Measured on the host (2026-10-13): 16 workers produce ~8.5 slots/s from the share
(~90 MB/s, near gigabit); training at the v9 pace consumes ~5 new slots/s.
"""
import bisect
import datetime
import json
import os
import shutil
import time
import numpy as np
from . import corpus, dataset, paths, segments as segments_module
#: Targets per slice: one day of one satellite, so a slice holds one evolving view
#: of the Sun and the pool turns over many of them.
SLICE_TARGETS = 360
#: Slices kept clear of the evaluation segments, in seconds either side: a pristine
#: run separated from a val segment by a few bad slots shows the same Sun.
GUARD_SECONDS = 86400
#: The validation set (v10 on): one window of VAL_TARGETS targets in each of the
#: previous corpus's val segments and in VAL_NEW more spread evenly over the era --
#: ~5,800 targets per satellite over 64 segments, against v9's 1,714 over 16.
VAL_NEW = 48
VAL_TARGETS = 90
VAL_SEED = 11
#: Manifest format version.
MANIFEST_VERSION = 2
SLICE_FILE = "slice.json"
# ----------------------------------------------------------------- the manifest
def subtract(segments, cuts, minimum):
"""`segments` with every (low, high) of `cuts` removed, widened by
:data:`GUARD_SECONDS`; pieces shorter than `minimum` slots are dropped."""
out = []
for a, b in segments:
pieces = [(a, b)]
for low, high in cuts:
low, high = low - GUARD_SECONDS, high + GUARD_SECONDS
pieces = [part for start, end in pieces
for part in ((start, min(end, low)), (max(start, high), end))
if part[1] > part[0]]
out.extend((start, start + (end - start) // paths.CADENCE * paths.CADENCE)
for start, end in pieces
if (end - start) // paths.CADENCE >= minimum)
return out
def build_manifest(conn, model, run_id, span, old_val, test, holdout_days,
satellites=(16, 18), val_new=None, val_targets=None, seed=None,
old_train=()):
"""The pool's segments and a fixed validation set, with every slot's frame paths.
`model` is the availability model of judgment run `run_id`. Validation is one
window of `val_targets` targets (plus the span either side) in each of the
`old_val` segments -- the previous corpus's, so its days stay covered -- and in
`val_new` further segments spread evenly over the era, none of them touching the
previous corpus's `old_train` (so the previous model can be scored on the new
set fairly). The pool keeps :data:`GUARD_SECONDS` clear of every validation
window and `test` segment, and off the bench's `holdout_days`. Paths are the run's
good, non-stub frames, one per (satellite, band, slot); a slot missing one on
any satellite splits its segment or window, so the producer judges nothing.
The `test` segments are kept whole, as the evaluation set's own slices.
"""
val_new = VAL_NEW if val_new is None else val_new
val_targets = VAL_TARGETS if val_targets is None else val_targets
seed = VAL_SEED if seed is None else seed
minimum = 2 * span + 1
window = val_targets + 2 * span
held = set(holdout_days)
def near(segment, others):
a, b = segment
return any(a < high + GUARD_SECONDS and b > low - GUARD_SECONDS
for low, high in others)
candidates = [pair for pair in segments_module.from_availability(model, minimum)
if not set(segments_module.days_touched([pair])) & held]
old_val, test = [tuple(p) for p in old_val], [tuple(p) for p in test]
old_train = [tuple(p) for p in old_train]
eligible = [pair for pair in candidates if not near(pair, old_val + test + old_train)
and (pair[1] - pair[0]) // paths.CADENCE >= window]
count = min(val_new, len(eligible))
new_val = [eligible[int((i + 0.5) * len(eligible) / count)] for i in range(count)]
val_segments = sorted(old_val + new_val)
rng = np.random.default_rng(seed)
windows = []
for a, b in val_segments:
length = min(window, (b - a) // paths.CADENCE)
first = int(rng.integers(0, (b - a) // paths.CADENCE - length + 1))
windows.append((a + first * paths.CADENCE,
a + (first + length) * paths.CADENCE))
chosen = subtract(candidates, windows + test, minimum)
wanted = sorted(chosen + windows + test)
frames = {str(s): {} for s in satellites}
starts = [a for a, _ in wanted]
marks = ",".join("?" * len(satellites))
# One pass over the era, not a query per segment: the planner drives a range
# query from the run's detections, so each of ~700 would rescan all 3 M of them.
for row in conn.execute(
f"SELECT f.satellite, f.wavelength, f.t_start, f.path FROM detection d "
f"JOIN frame f ON f.id = d.frame_id "
f"WHERE d.run_id = ? AND d.verdict = 'good' AND f.satellite IN ({marks}) "
f"AND (f.size_bytes IS NULL OR f.size_bytes >= ?)",
(run_id, *satellites, dataset.MIN_FRAME_BYTES),
) if wanted else ():
t = row["t_start"]
index = bisect.bisect_right(starts, t) - 1
if index < 0 or t >= wanted[index][1]:
continue
slot = frames[str(row["satellite"])].setdefault(
str(t), [None] * len(paths.WAVELENGTHS))
slot[paths.WAVELENGTHS.index(row["wavelength"])] = row["path"]
def complete(t):
return all(None not in frames[str(s)].get(str(t), [None]) for s in satellites)
verified = segments_module.verify(chosen, complete, minimum_slots=minimum)
val = segments_module.verify(windows, complete, minimum_slots=minimum)
tested = segments_module.verify(test, complete, minimum_slots=minimum)
keep = {str(t) for a, b in verified + val + tested
for t in range(a, b, paths.CADENCE)}
frames = {s: {t: v for t, v in slots.items() if t in keep}
for s, slots in frames.items()}
return {"version": MANIFEST_VERSION, "satellites": list(satellites),
"span": span, "judgment_run": int(run_id),
"segments": [list(pair) for pair in verified],
"val": [list(pair) for pair in val],
"val_segments": [list(pair) for pair in val_segments],
"test": [list(pair) for pair in tested],
"frames": frames}
def save_manifest(manifest, path):
compress, _ = dataset._zstd()
payload = compress(json.dumps(manifest).encode(), 9)
temporary = f"{path}.partial"
with open(temporary, "wb") as handle:
handle.write(payload)
os.replace(temporary, path)
def load_manifest(path):
_, decompress = dataset._zstd()
with open(path, "rb") as handle:
manifest = json.loads(decompress(handle.read()))
if manifest.get("version") != MANIFEST_VERSION:
raise ValueError(f"{path}: pool manifest version {manifest.get('version')}, "
f"expected {MANIFEST_VERSION}; rebuild it with pool_manifest.py")
return manifest
def target_count(segments, span):
"""Targets the segments hold at `span`, per satellite."""
return sum(max((b - a) // paths.CADENCE - 2 * span, 0) for a, b in segments)
# ------------------------------------------------------------------- slices
def choose_slice(segments, span, rng, targets=SLICE_TARGETS):
"""A random (t0, t1) of at most `targets` targets plus `span` slots of margin
either side, inside one segment. Segments are drawn by target count, so every
target in the manifest is equally likely to be covered."""
weights = np.array([max((b - a) // paths.CADENCE - 2 * span, 0)
for a, b in segments], dtype=float)
if weights.sum() <= 0:
raise ValueError(f"no segment holds a target at span {span}")
a, b = segments[int(rng.choice(len(segments), p=weights / weights.sum()))]
length = min(targets + 2 * span, (b - a) // paths.CADENCE)
first = int(rng.integers(0, (b - a) // paths.CADENCE - length + 1))
t0 = a + first * paths.CADENCE
return t0, t0 + length * paths.CADENCE
def write_slice(directory, name, piece, satellites, blocks, span):
"""Write one slice: its day-shards, then ``slice.json``, then the rename.
`blocks(satellite, times)` yields (t, block or None) in ascending order -- None
for a slot that could not be read, which splits the slice's segment. Returns
the slice's path, or None if nothing in it is left to train on.
"""
partial = os.path.join(directory, f".{name}.partial")
final = os.path.join(directory, name)
os.makedirs(partial)
unusable = set()
try:
for (day, satellite), times in sorted(
corpus.wanted_slots({"train": [piece]}, satellites).items()):
with open(os.path.join(partial, dataset.shard_name(day, satellite)),
"wb") as handle:
def records():
for t, block in blocks(satellite, times):
if block is None:
unusable.add(t)
yield t, block
dataset.write_shard_stream(handle, day, satellite,
paths.WAVELENGTHS, records())
verified = segments_module.verify([piece], lambda t: t not in unusable,
minimum_slots=2 * span + 1)
if not verified:
shutil.rmtree(partial)
return None
with open(os.path.join(partial, SLICE_FILE), "w") as handle:
json.dump({"segments": [list(pair) for pair in verified],
"satellites": list(satellites), "made": time.time()}, handle)
os.rename(partial, final)
except BaseException:
shutil.rmtree(partial, ignore_errors=True)
raise
return final
def ready_slices(directory):
"""Complete slices, oldest first: [(path, bytes)]."""
found = []
for name in os.listdir(directory) if os.path.isdir(directory) else ():
path = os.path.join(directory, name)
if name.startswith(".") or not os.path.exists(os.path.join(path, SLICE_FILE)):
continue
try:
made = json.load(open(os.path.join(path, SLICE_FILE)))["made"]
size = sum(entry.stat().st_size for entry in os.scandir(path))
except (OSError, ValueError, KeyError):
continue # retired between listing and reading
found.append((made, path, size))
return [(path, size) for _, path, size in sorted(found)]
def retire(directory, budget_bytes):
"""Delete the oldest slices until the pool fits `budget_bytes`, never the newest:
an empty pool would starve training. Returns the deleted paths."""
slices = ready_slices(directory)
total = sum(size for _, size in slices)
gone = []
for path, size in slices[:-1]:
if total <= budget_bytes:
break
shutil.rmtree(path, ignore_errors=True)
total -= size
gone.append(path)
return gone
def clear_partials(directory):
"""Remove half-built slices a stopped producer left behind."""
for name in os.listdir(directory) if os.path.isdir(directory) else ():
if name.startswith(".") and name.endswith(".partial"):
shutil.rmtree(os.path.join(directory, name), ignore_errors=True)
def open_slice(path):
"""(shards {(day, satellite): Shard}, segments) of one ready slice. Raises
OSError if it was retired before every shard could be opened."""
info = json.load(open(os.path.join(path, SLICE_FILE)))
shards = {}
try:
for name in os.listdir(path):
if name.endswith(".shard"):
day, satellite = name[:-len(".shard")].rsplit("_g", 1)
shards[(day, int(satellite))] = dataset.Shard(os.path.join(path, name))
except BaseException:
for shard in shards.values():
shard.close()
raise
return shards, [tuple(pair) for pair in info["segments"]]
def slice_name(counter, piece):
"""Sortable, unique and self-describing: when made, and what it covers."""
day = datetime.datetime.fromtimestamp(piece[0], datetime.timezone.utc)
return f"{counter:07d}_{day:%Y%m%dT%H%M}_{(piece[1] - piece[0]) // paths.CADENCE}"