"""Turning shards into training samples: stacks, masks, and damage applied on the fly. One sample is a target slot -- one satellite, one instant, all six bands -- plus the stack of frames the model may draw on and a per-frame conditioning vector. This module is the only place that knows how a stack is laid out, so :mod:`suvi.model` and :mod:`bench` cannot disagree about it. Three things here decide what the model can learn. **Offsets are exponentially spaced.** Gaps in this archive run from one slot to three hundred, and a fixed +/-K window either misses the far end of a long outage or wastes most of its inputs on a short one. Sampling at ``+/-{1, 4, 16, 64, 256}`` slots covers four minutes to seventeen hours with ten frames per satellite, and an *anchor* -- the nearest usable frame in each direction, however far -- guarantees the stack is never empty even beyond that. **Damage is applied here, not baked into the shards.** Every epoch draws fresh corruptions from :mod:`suvi.corruptions`, so the model sees far more variety than a frozen dataset could hold, and the true mode is known exactly for the auxiliary head. **Outages are episodic as well as scattered.** Half of all samples carry a contiguous simulated outage -- up to 400 slots, on one satellite or both -- because that is how the real archive fails: measured over the two-satellite era, 11% of slots have both spacecraft dark at once, in long runs. Independent per-frame drops alone would never construct that case, and a model cannot learn a regime it has never seen. **Context frames get damaged too, not just the target.** This is the requirement that any supplied frame may itself be invalid. A model trained on clean context and deployed on an archive where 31% of slots are bad on both satellites would meet, at inference, a distribution it had never seen. """ import math import numpy as np from . import align, corruptions, dataset, paths #: Slot offsets sampled on each satellite, in both directions. Exponentially spaced, #: so one fixed-size stack serves every gap length: 4 minutes to 17 hours in five #: steps, without needing hundreds of input frames to cover a 300-slot outage. The #: anchors (nearest usable frame either way, at any distance) still guarantee the #: stack is never empty beyond the ladder's reach. Cost is linear in stack depth in #: the current model (no attention), so the two extra rungs are affordable where they #: were not for the attention-based design this replaced. OFFSETS = (1, 4, 16, 64, 256) #: How often a sample carries a simulated *episodic* outage -- a contiguous run of #: missing slots -- on top of the scattered per-frame drops. Real outages are runs, #: not confetti: measured against the archive, 11% of the two-satellite era has BOTH #: spacecraft dark simultaneously, in 1,096 contiguous runs of up to 26 days. With #: independent per-frame drops the probability of reproducing that configuration in #: a training stack is roughly p^14 -- the model would face at evaluation a regime #: it had never once seen. EPISODIC_PROBABILITY = 0.5 #: Given an episodic outage, how often it takes down *both* satellites at once (a #: ground-segment or space-weather event) rather than one. DUAL_OUTAGE_PROBABILITY = 0.4 #: Longest simulated outage, in slots; matches the anchor search limit, so a stack #: always retains something real beyond the outage's edge. MAX_OUTAGE_SLOTS = 400 #: Modes that leave a frame with no usable signal at all. A frame damaged this way is #: presented as `missing`; everything else is presented as `suspect` with its pixels. NO_SIGNAL = frozenset({"all_zero", "drop_image_hdu", "nan_fill", "zblank_fill"}) #: Auxiliary-head classes: clean plus every catalogued mode, in a fixed order so a #: checkpoint's class indices stay meaningful. CLASSES = ("clean",) + tuple(sorted(corruptions.CATALOG)) CLASS_INDEX = {name: index for index, name in enumerate(CLASSES)} def stack_layout(satellites, target_satellite): """The (satellite, offset) slots a stack is built from, in a fixed order. Deterministic and independent of what happens to be available, so a frame's position in the stack always means the same thing and the model can rely on it. The target satellite's own frame at offset 0 is the frame being reconstructed and is never an input. The *counterpart's* frame at offset 0 is the single most valuable entry in the stack -- a real observation of the right Sun at the right instant, worth ~46 dB on its own once its instrument gain is known. """ others = [s for s in satellites if s != target_satellite] layout = [] for satellite in [target_satellite] + others: for offset in OFFSETS: layout.append((satellite, -offset)) layout.append((satellite, offset)) return layout + [(other, 0) for other in others] class Sampler: """Builds training samples from a set of shards. `shards` maps (day, satellite) -> :class:`suvi.dataset.Shard`. """ def __init__(self, shards, satellites=(16, 18), damage_probability=0.30, drop_probability=0.40, seed=0): self.shards = shards self.satellites = tuple(satellites) #: Ceilings, not rates. Each *sample* draws its own severity uniformly up to #: these, rather than every frame being damaged at one fixed probability. #: #: Fixed rates of 0.25 drop / 0.35 damage left barely half the stack clean in #: every single sample, so the model never saw the easy case and learned a #: hedging average instead of "trust the counterpart". It showed up as a model #: that gained 5 dB on its own harsh validation set and ~1 dB on the bench, #: where neighbours are mostly clean. Real outages are episodic -- long clean #: stretches broken by bad runs -- so drawing severity per sample covers both #: regimes, and roughly a fifth of samples come through almost untouched. self.damage_probability = damage_probability self.drop_probability = drop_probability self.rng = np.random.default_rng(seed) self._by_satellite = {} self._day_of = {} for (day, satellite), shard in shards.items(): self._day_of[id(shard)] = day self._by_satellite.setdefault(satellite, {}).update( {time: shard for time in shard.times()} ) #: (day, target satellite, other satellite, pair time) -> per-band (gains, #: offsets). See :meth:`_calibration`. self._transfers = {} def targets(self): """Every (satellite, time) that could be reconstructed, in a stable order.""" found = [] for satellite in self.satellites: for time in sorted(self._by_satellite.get(satellite, {})): found.append((satellite, time)) return found def _read(self, satellite, time): """One slot in *model* space -- the cheap path, used for every clean frame. Shards store the asinh representation already, so this is a rescale rather than the sinh/arcsinh round trip that going via radiance would cost. """ shard = self._by_satellite.get(satellite, {}).get(time) if shard is None: return None return shard.coded(time) if hasattr(shard, "coded") else encode_for_model( shard.frames(time) ) def _donor(self, satellite, time, rng, reach=60): """Another real frame, for the modes that splice one in. ``frozen`` repeats a neighbour, ``wrong_time`` files one under the wrong stamp and ``torn_frame`` merges two. All three need a *plausible* second observation, so the donor is drawn from nearby on the same satellite rather than invented. """ available = self._by_satellite.get(satellite, {}) for _ in range(8): offset = int(rng.integers(-reach, reach + 1)) if offset == 0: continue candidate = time + offset * paths.CADENCE if candidate in available: return self._read(satellite, candidate) return None def _anchor(self, satellite, time, direction, exclude=frozenset(), limit=400): """Nearest slot with a frame outside `exclude`, up to `limit` slots away. `exclude` holds the times inside a simulated outage. The production stack builder picks anchors from *clean* times, so the sampler must too: an anchor that lands inside the outage and arrives `missing` would leave the stack with no real long-range frame at all, which is not what the pipeline would see. """ available = self._by_satellite.get(satellite, {}) for step in range(1, limit + 1): candidate = time + direction * step * paths.CADENCE if candidate in available and candidate not in exclude: return candidate return None def _outages(self, rng, time): """Per-satellite times inside this sample's simulated episodic outage. One contiguous run per affected satellite, log-uniform in length from one slot to :data:`MAX_OUTAGE_SLOTS`, positioned so the target usually sits inside it -- reproducing the archive's real failure mode of equipment and comms dropping out for hours to days, sometimes on both spacecraft at once. """ blocked = {satellite: frozenset() for satellite in self.satellites} # Gated on the drop setting: drop_probability=0 means "no synthetic losses # of any kind", which diagnostics and tests rely on. if self.drop_probability <= 0 or rng.random() >= EPISODIC_PROBABILITY: return blocked length = int(round(math.exp(rng.uniform(0.0, math.log(MAX_OUTAGE_SLOTS))))) centre = time + int(rng.integers(-length, length + 1)) * paths.CADENCE start = centre - (length // 2) * paths.CADENCE interval = frozenset(start + i * paths.CADENCE for i in range(length)) if rng.random() < DUAL_OUTAGE_PROBABILITY: hit = self.satellites else: hit = (self.satellites[int(rng.integers(len(self.satellites)))],) for satellite in hit: blocked[satellite] = interval return blocked def _calibration(self, day, satellite, other, exclude): """Per-band (gains, offsets) putting `other`'s radiance on `satellite`'s scale. Fitted from one simultaneous pair per day -- the transfer drifts ~12% per *week*, so a fit at most 24 hours stale is well inside its own noise, and one pair per day is what a production pipeline could equally afford. `exclude` is the time being reconstructed. A pair at that instant would fit the transfer against the answer itself -- the oracle gain the whole exercise exists to estimate honestly -- so the fit steps to a neighbouring pair instead. Returns identity when the day has no usable pair. """ local = self.shards.get((day, satellite)) counterpart = self.shards.get((day, other)) if local is None or counterpart is None: return None candidates = sorted(set(local.times()) & set(counterpart.times()) - {exclude}) if not candidates: return None pair_time = candidates[len(candidates) // 2] key = (day, satellite, other, pair_time) if key not in self._transfers: self._transfers[key] = align.fit_photometry( counterpart.frames(pair_time), local.frames(pair_time) ) return self._transfers[key] def build(self, satellite, time, rng=None): """One sample: the stack, its per-frame metadata, and the withheld target. `frames` is (S, 6, H, W) in *model* space -- roughly [-1, 1] asinh radiance -- with zeros where a frame is missing; `target` is the withheld frame in the same space. `gains`/`offsets` are the per-frame radiance transfers that put a cross-satellite frame on the target instrument's scale (identity rows for same-satellite and missing frames), and `b0`/`radius` the solar geometry the rotation warp needs -- both consumed by :func:`suvi.align.align_stack` rather than here, so the CPU loader stays cheap. Returns None if the target itself is absent, or if not one frame in the stack carries pixels -- there is nothing to reconstruct *from*, and the model's contract is to decline rather than emit a fabricated frame. """ rng = rng if rng is not None else self.rng target = self._read(satellite, time) if target is None: return None day = self._day_of.get(id(self._by_satellite[satellite][time])) # This sample's severity, drawn once and applied to every frame in its stack. drop_probability = float(rng.uniform(0.0, self.drop_probability)) damage_probability = float(rng.uniform(0.0, self.damage_probability)) outages = self._outages(rng, time) layout = list(stack_layout(self.satellites, satellite)) # Anchors: the nearest usable frame either way on the target satellite -- # outside any simulated outage, as the production stack builder would pick # them -- so a long gap still has something real to work from. for direction in (-1, 1): anchor = self._anchor(satellite, time, direction, exclude=outages[satellite]) if anchor is not None: offset = (anchor - time) // paths.CADENCE if (satellite, offset) not in layout: layout.append((satellite, offset)) frames, states, dts, same, classes, transfers = [], [], [], [], [], [] for source_satellite, offset in layout: when = time + offset * paths.CADENCE image = self._read(source_satellite, when) state, label = "available", "clean" if image is None: state = "missing" elif when in outages[source_satellite]: # Inside this sample's simulated episodic outage. image, state = None, "missing" elif rng.random() < drop_probability: # A scattered single-slot loss, independent of the episodic runs. image, state = None, "missing" elif rng.random() < damage_probability: mode = str(rng.choice(sorted(corruptions.CATALOG))) corruption = corruptions.CATALOG[mode] label = mode if corruption.kind != "array": # A file-level corruption destroys the container, so by the time a # frame would reach the model there is nothing left of it. image, state = None, "missing" elif mode in NO_SIGNAL: image, state = None, "missing" else: donor = self._donor(source_satellite, when, rng) \ if corruption.needs_donor else None if corruption.needs_donor and donor is None: label, state = "clean", "available" # nothing to draw from else: image = _damage(image, mode, int(rng.integers(0, 2**31 - 1)), float(rng.uniform(0.5, 1.0)), donor) state = "suspect" shape = target.shape frames.append(np.zeros(shape, dtype=np.float32) if image is None else image) states.append(state) dts.append(float(offset * paths.CADENCE)) same.append(source_satellite == satellite) classes.append(CLASS_INDEX[label]) transfer = None if source_satellite != satellite and image is not None: transfer = self._calibration(day, satellite, source_satellite, time) transfers.append(transfer) if all(state == "missing" for state in states): return None identity = (np.ones(6, dtype=np.float32), np.zeros(6, dtype=np.float32)) b0, radius = align.solar_ephemeris(float(time)) return { "frames": np.stack(frames), "states": states, "dts": np.array(dts, dtype=np.float32), "same_satellite": np.array(same, dtype=bool), "classes": np.array(classes, dtype=np.int64), "gains": np.stack([(t or identity)[0] for t in transfers]), "offsets": np.stack([(t or identity)[1] for t in transfers]), "b0": float(b0), "radius": float(radius), "target": target, "slot": (satellite, time), } def _damage(coded, mode, seed, severity, donor=None): """Apply one array corruption to all six bands of a frame, in model space. Corruptions are *defined* on radiance -- ``gain_shift`` multiplies a physical quantity, ``saturate`` clips against a physical ceiling -- so the frame is decoded, damaged, and re-encoded. This is the expensive path, which is why only the frames actually being corrupted take it. Each band gets the same seed, so a fault hits the frame coherently rather than differently per band, which is how the real ones behave: one instrument produces all six. ``apply_array`` returns (pixels, header_overrides); the overrides describe a FITS header this pipeline does not carry, so only the pixels are kept. """ radiance = decode_from_model(coded) donor_radiance = None if donor is None else decode_from_model(donor) bands = [] for index, band in enumerate(radiance): damaged, _ = corruptions.apply_array( mode, band, seed, severity, donor=None if donor_radiance is None else donor_radiance[index], ) bands.append(damaged) return encode_for_model(np.stack(bands)) def encode_for_model(radiance): """Radiance -> the roughly-[-1, 1] asinh space the model works in. Corruptions are applied in *radiance*, because that is where they are defined -- a ``gain_shift`` multiplies a physical quantity and a ``saturate`` clips against a physical ceiling. Applying them after the transform would model a different fault. So the encoding happens here, once, on the way into the network. """ coded = np.arcsinh( np.nan_to_num(np.asarray(radiance, dtype=np.float32), nan=0.0, posinf=0.0, neginf=0.0) / dataset.ASINH_SCALE ) / dataset.ASINH_RANGE return np.clip(coded, -1.0, 1.0).astype(np.float32) def decode_from_model(coded): """Inverse of :func:`encode_for_model`, back to radiance.""" scaled = np.asarray(coded, dtype=np.float64) * dataset.ASINH_RANGE return (np.sinh(np.clip(scaled, -dataset.ASINH_RANGE, dataset.ASINH_RANGE)) * dataset.ASINH_SCALE).astype(np.float32) def to_tensors(sample, torch): """Pack a sample into tensors for :func:`suvi.align.align_stack` and the model. The stack is *unaligned* here: alignment is elementwise math plus a warp, which the GPU does in milliseconds and the CPU loader should not spend its budget on. """ from . import model as model_module condition = torch.stack([ model_module.frame_conditioning( sample["states"][i], bool(sample["same_satellite"][i]), float(sample["dts"][i]) ) for i in range(len(sample["states"])) ]) # Already in model space: the sampler works there throughout, so nothing to convert. return { "stack": torch.from_numpy(np.ascontiguousarray(sample["frames"])), "condition": condition, "dts": torch.from_numpy(np.ascontiguousarray(sample["dts"])), "gains": torch.from_numpy(np.ascontiguousarray(sample["gains"])), "offsets": torch.from_numpy(np.ascontiguousarray(sample["offsets"])), "b0": torch.tensor(sample["b0"], dtype=torch.float32), "radius": torch.tensor(sample["radius"], dtype=torch.float32), "target": torch.from_numpy(np.ascontiguousarray(sample["target"])), }