"""The rotating training pool: slices chosen, written, read, retired, and the producer and trainer that share them. What must hold: a slice lies inside one pristine segment with the model's span of margin; a reader never sees a half-built slice and keeps reading one retired under it; unreadable slots split a slice instead of entering it; the pool stays inside its budget, oldest out first; and the manifest keeps evaluation and holdout data out. """ import datetime import os import numpy as np import pytest import pool_producer from conftest import solar_disc, write_fits from suvi import availability, dataset, db, detectors, paths, pool BASE = 1715400000 // paths.CADENCE * paths.CADENCE SPAN = 4 SATELLITES = (16, 18) def block(value=1000): return np.full((6, dataset.SHARD_SIZE, dataset.SHARD_SIZE), value, dtype=np.uint16) def all_good(satellite, times): for t in times: yield t, block() def slots(count, start=0): return (BASE + start * paths.CADENCE, BASE + (start + count) * paths.CADENCE) # --------------------------------------------------------------------- choosing def test_a_slice_lies_inside_one_segment_with_its_margin(): rng = np.random.default_rng(0) segments = [slots(40), slots(30, start=100)] for _ in range(200): t0, t1 = pool.choose_slice(segments, SPAN, rng, targets=10) assert any(a <= t0 and t1 <= b for a, b in segments) assert (t1 - t0) // paths.CADENCE == 10 + 2 * SPAN def test_a_short_segment_gives_its_whole_length(): t0, t1 = pool.choose_slice([slots(12)], SPAN, np.random.default_rng(1), targets=50) assert (t0, t1) == slots(12) def test_slices_are_drawn_by_target_count_and_never_from_spanless_segments(): rng = np.random.default_rng(2) short = slots(2 * SPAN, start=500) # holds no target at this span segments = [slots(20), slots(200, start=50), short] picks = [pool.choose_slice(segments, SPAN, rng, targets=4) for _ in range(600)] inside = [sum(a <= t0 < b for t0, _ in picks) for a, b in segments] assert inside[2] == 0 assert inside[1] > 5 * inside[0] # 192 targets against 12 def test_choosing_from_nothing_trainable_is_an_error(): with pytest.raises(ValueError): pool.choose_slice([slots(2 * SPAN)], SPAN, np.random.default_rng(0)) # ---------------------------------------------------------------------- slices def test_a_written_slice_reads_back_whole(tmp_path): piece = slots(20) path = pool.write_slice(str(tmp_path), "0000001_x", piece, SATELLITES, all_good, SPAN) shards, segments = pool.open_slice(path) assert segments == [piece] assert {satellite for _, satellite in shards} == set(SATELLITES) for shard in shards.values(): assert len(shard.times()) == 20 np.testing.assert_array_equal(shard.raw(shard.times()[0]), block()) shard.close() def test_a_slice_across_midnight_has_a_shard_per_day(tmp_path): midnight = (BASE // 86400 + 1) * 86400 piece = (midnight - 5 * paths.CADENCE, midnight + 15 * paths.CADENCE) path = pool.write_slice(str(tmp_path), "s", piece, SATELLITES, all_good, SPAN) shards, _ = pool.open_slice(path) assert len(shards) == 4 assert sum(len(s.times()) for s in shards.values()) == 2 * 20 for shard in shards.values(): shard.close() def test_an_unreadable_slot_splits_the_slice(tmp_path): piece = slots(30) hole = BASE + 15 * paths.CADENCE def holed(satellite, times): for t in times: yield t, None if (satellite == 18 and t == hole) else block() path = pool.write_slice(str(tmp_path), "s", piece, SATELLITES, holed, SPAN) _, segments = pool.open_slice(path) assert segments == [(BASE, hole), (hole + paths.CADENCE, piece[1])] def test_a_slice_with_nothing_left_to_train_on_is_dropped(tmp_path): def nothing(satellite, times): for t in times: yield t, None assert pool.write_slice(str(tmp_path), "s", slots(20), SATELLITES, nothing, SPAN) is None assert os.listdir(tmp_path) == [] def test_a_failed_write_leaves_nothing_behind(tmp_path): def broken(satellite, times): yield times[0], block() raise OSError("share went away") with pytest.raises(OSError): pool.write_slice(str(tmp_path), "s", slots(20), SATELLITES, broken, SPAN) assert os.listdir(tmp_path) == [] def test_readers_see_only_complete_slices_oldest_first(tmp_path): first = pool.write_slice(str(tmp_path), "b", slots(20), SATELLITES, all_good, SPAN) second = pool.write_slice(str(tmp_path), "a", slots(20, 40), SATELLITES, all_good, SPAN) os.makedirs(tmp_path / ".c.partial") assert [path for path, _ in pool.ready_slices(str(tmp_path))] == [first, second] assert pool.ready_slices(str(tmp_path / "absent")) == [] def test_retiring_drops_the_oldest_until_the_pool_fits(tmp_path): made = [pool.write_slice(str(tmp_path), f"{i}", slots(20, 40 * i), SATELLITES, all_good, SPAN) for i in range(4)] size = pool.ready_slices(str(tmp_path))[0][1] gone = pool.retire(str(tmp_path), budget_bytes=2.5 * size) assert gone == made[:2] assert [p for p, _ in pool.ready_slices(str(tmp_path))] == made[2:] assert pool.retire(str(tmp_path), budget_bytes=10 * size) == [] def test_partials_from_a_stopped_producer_are_cleared(tmp_path): os.makedirs(tmp_path / ".x.partial") kept = pool.write_slice(str(tmp_path), "k", slots(20), SATELLITES, all_good, SPAN) pool.clear_partials(str(tmp_path)) assert os.listdir(tmp_path) == [os.path.basename(kept)] def test_the_newest_slice_is_never_retired(tmp_path): only = pool.write_slice(str(tmp_path), "s", slots(20), SATELLITES, all_good, SPAN) assert pool.retire(str(tmp_path), budget_bytes=0) == [] assert [p for p, _ in pool.ready_slices(str(tmp_path))] == [only] def test_a_slice_retired_mid_visit_stays_readable(tmp_path): path = pool.write_slice(str(tmp_path), "s", slots(20), SATELLITES, all_good, SPAN) pool.write_slice(str(tmp_path), "t", slots(20, 40), SATELLITES, all_good, SPAN) shards, _ = pool.open_slice(path) pool.retire(str(tmp_path), budget_bytes=0) assert not os.path.exists(path) for shard in shards.values(): np.testing.assert_array_equal(shard.raw(shard.times()[-1]), block()) shard.close() with pytest.raises(OSError): pool.open_slice(path) def test_slice_names_sort_by_counter(): names = [pool.slice_name(i, slots(20)) for i in (9, 10, 100)] assert names == sorted(names) # -------------------------------------------------------------------- manifest def test_a_manifest_round_trips_and_a_foreign_version_is_refused(tmp_path): manifest = {"version": pool.MANIFEST_VERSION, "segments": [[1, 2]], "frames": {}} pool.save_manifest(manifest, str(tmp_path / "m.zst")) assert pool.load_manifest(str(tmp_path / "m.zst")) == manifest pool.save_manifest(dict(manifest, version=0), str(tmp_path / "old.zst")) with pytest.raises(ValueError, match="pool_manifest.py"): pool.load_manifest(str(tmp_path / "old.zst")) def index(tmp_path, count, bad=(), missing=()): """`count` slots per satellite from BASE, every band judged good unless named.""" conn = db.connect(str(tmp_path / "index.sqlite")) run = db.create_detector_run(conn, detectors.JUDGMENT_RUN, detectors.judgment_config()) rows = [] for satellite in SATELLITES: for slot in range(count): for wavelength in paths.WAVELENGTHS: key = (satellite, slot, wavelength) if key in missing: continue when = BASE + slot * paths.CADENCE name = paths.FrameName(satellite=satellite, wavelength=wavelength, t_start=when, t_end=when + paths.CADENCE, version="1-0-2") frame_id = db.upsert_frame(conn, name, name.relpath(), size_bytes=1_500_000) rows.append((frame_id, "bad" if key in bad else "good", None, None, 0)) db.record_detections(conn, run, rows) conn.commit() return conn, run def pristine(count, cuts=()): """Every band of both satellites GOOD, except one band BAD at each slot in `cuts` -- which splits the era into segments there.""" states = np.full((2, len(availability.BANDS), count), availability.GOOD, np.uint8) for cut in cuts: states[0, 0, cut] = availability.BAD return availability.Availability(states=states, t0=BASE, cadence=paths.CADENCE, satellites=SATELLITES) def manifest_for(conn, run, model, old_val=(), test=(), holdout=(), **kwargs): kwargs.setdefault("val_new", 0) return pool.build_manifest(conn, model, run, SPAN, list(old_val), list(test), list(holdout), SATELLITES, **kwargs) def test_the_manifest_lists_every_frame_of_its_segments(tmp_path): conn, run = index(tmp_path, 30) manifest = manifest_for(conn, run, pristine(30)) assert manifest["segments"] == [list(slots(30))] assert manifest["val"] == [] and manifest["val_segments"] == [] assert len(manifest["frames"]["16"]) == len(manifest["frames"]["18"]) == 30 first = manifest["frames"]["18"][str(BASE)] assert len(first) == 6 and all("g18" in path for path in first) def test_a_slot_without_a_good_frame_of_every_band_splits_its_segment(tmp_path): conn, run = index(tmp_path, 30, bad={(16, 12, 171)}, missing={(18, 20, 304)}) manifest = manifest_for(conn, run, pristine(30)) # 13..20 is 7 slots, below the 2*span+1 a target needs, so it goes too. assert manifest["segments"] == [list(slots(12)), [BASE + 21 * paths.CADENCE, BASE + 30 * paths.CADENCE]] assert str(BASE + 12 * paths.CADENCE) not in manifest["frames"]["16"] def test_test_segments_and_holdout_days_stay_out_of_the_pool(tmp_path): conn, run = index(tmp_path, 30) model = pristine(30) near = (BASE + 200 * paths.CADENCE, BASE + 300 * paths.CADENCE) # 12 h later assert manifest_for(conn, run, model, test=[near])["segments"] == [] far = (BASE + 10 * 86400, BASE + 10 * 86400 + 100 * paths.CADENCE) assert manifest_for(conn, run, model, test=[far])["segments"] == [list(slots(30))] day = datetime.datetime.fromtimestamp(BASE, datetime.timezone.utc).strftime("%Y-%m-%d") assert manifest_for(conn, run, model, holdout=[day])["segments"] == [] #: Four segments: slots 0..49, 50..99 and 100..149 (49 each), and 150..200 (50). CUTS = (49, 99, 149) def segment(index): end = 50 * index + 49 if index < 3 else 200 return [BASE + 50 * index * paths.CADENCE, BASE + end * paths.CADENCE] def test_validation_spreads_over_the_era_and_leaves_the_pool(tmp_path, monkeypatch): monkeypatch.setattr(pool, "GUARD_SECONDS", 0) conn, run = index(tmp_path, 200) manifest = manifest_for(conn, run, pristine(200, CUTS), val_new=2, val_targets=10) assert manifest["val_segments"] == [segment(1), segment(3)] assert segment(0) in manifest["segments"] and segment(2) in manifest["segments"] for a, b in manifest["segments"]: # the rest of 1 and 3 stays trainable assert not any(a < high and b > low for low, high in manifest["val"]) assert len(manifest["val"]) == 2 for (a, b), (low, high) in zip(manifest["val"], manifest["val_segments"]): assert low <= a and b <= high and (b - a) // paths.CADENCE == 10 + 2 * SPAN for a, b in manifest["val"]: assert all(str(t) in manifest["frames"]["16"] for t in range(a, b, paths.CADENCE)) def test_the_old_validation_segments_stay_in_validation(tmp_path, monkeypatch): monkeypatch.setattr(pool, "GUARD_SECONDS", 0) conn, run = index(tmp_path, 200) manifest = manifest_for(conn, run, pristine(200, CUTS), old_val=[segment(0)], val_new=1, val_targets=10) assert manifest["val_segments"] == [segment(0), segment(2)] assert segment(1) in manifest["segments"] and segment(3) in manifest["segments"] def test_new_validation_avoids_the_previous_models_training_data(tmp_path, monkeypatch): """So the previous model can be scored on the new set without having seen it.""" monkeypatch.setattr(pool, "GUARD_SECONDS", 0) conn, run = index(tmp_path, 200) manifest = manifest_for(conn, run, pristine(200, CUTS), val_new=4, val_targets=10, old_train=[segment(2)]) assert manifest["val_segments"] == [segment(0), segment(1), segment(3)] assert segment(2) in manifest["segments"] def test_subtract_cuts_out_the_guarded_spans_and_drops_crumbs(monkeypatch): monkeypatch.setattr(pool, "GUARD_SECONDS", 2 * paths.CADENCE) a, b = slots(100) cut = (a + 40 * paths.CADENCE, a + 50 * paths.CADENCE) assert pool.subtract([(a, b)], [cut], minimum=9) == [ (a, a + 38 * paths.CADENCE), (a + 52 * paths.CADENCE, b)] near_end = (b - 5 * paths.CADENCE, b) assert pool.subtract([(a, b)], [near_end], minimum=9) == [(a, b - 7 * paths.CADENCE)] assert pool.subtract([(a, a + 10 * paths.CADENCE)], [(a + 4 * paths.CADENCE, a + 5 * paths.CADENCE)], 9) == [] def test_a_validation_window_never_comes_from_a_test_segment(tmp_path, monkeypatch): monkeypatch.setattr(pool, "GUARD_SECONDS", 0) conn, run = index(tmp_path, 200) manifest = manifest_for(conn, run, pristine(200, CUTS), test=[segment(1)], val_new=3, val_targets=10) assert segment(1) not in manifest["val_segments"] assert segment(1) not in manifest["segments"] def test_test_segments_are_kept_whole_with_their_frames(tmp_path, monkeypatch): monkeypatch.setattr(pool, "GUARD_SECONDS", 0) conn, run = index(tmp_path, 200) manifest = manifest_for(conn, run, pristine(200, CUTS), test=[segment(1)]) assert manifest["test"] == [segment(1)] a, b = segment(1) assert all(str(t) in manifest["frames"]["18"] for t in range(a, b, paths.CADENCE)) def test_a_short_validation_segment_gives_its_whole_length(tmp_path, monkeypatch): monkeypatch.setattr(pool, "GUARD_SECONDS", 0) conn, run = index(tmp_path, 200) old = [BASE + 3 * paths.CADENCE, BASE + 15 * paths.CADENCE] # 12 slots manifest = manifest_for(conn, run, pristine(200, CUTS), old_val=[old], val_targets=40) assert manifest["val"] == [old] def test_the_pool_keeps_a_day_clear_of_validation(tmp_path): conn, run = index(tmp_path, 200) manifest = manifest_for(conn, run, pristine(200, CUTS), val_new=1, val_targets=10) assert manifest["val_segments"] and manifest["segments"] == [] # -------------------------------------------------------------------- producer def archive(tmp_path, count): """A FITS archive of `count` slots per satellite plus its pool manifest.""" root = tmp_path / "Data" frames = {str(s): {} for s in SATELLITES} image = solar_disc(size=dataset.SHARD_SIZE, radius=200) for satellite in SATELLITES: for slot in range(count): when = BASE + slot * paths.CADENCE files = [] for wavelength in paths.WAVELENGTHS: rel = f"g{satellite}/{wavelength}/{when}.fits" os.makedirs(root / os.path.dirname(rel), exist_ok=True) write_fits(str(root / rel), image) files.append(rel) frames[str(satellite)][str(when)] = files manifest = {"version": pool.MANIFEST_VERSION, "satellites": list(SATELLITES), "span": SPAN, "judgment_run": 1, "segments": [list(slots(count))], "val": [list(slots(count))], "val_segments": [list(slots(count))], "test": [list(slots(count // 2 + SPAN))], "frames": frames} pool.save_manifest(manifest, str(tmp_path / "manifest.zst")) return root def test_the_producer_fills_the_pool_and_keeps_it_in_budget(tmp_path): root = archive(tmp_path, 12) directory = tmp_path / "pool" pool_producer.main(["--manifest", str(tmp_path / "manifest.zst"), "--archive", str(root), "--pool", str(directory), "--workers", "2", "--slices", "3", "--seed", "0", "--budget-gb", "0"]) resident = pool.ready_slices(str(directory)) assert len(resident) == 1 # a zero budget keeps only the newest assert os.path.basename(resident[0][0]).startswith("0000003") shards, segments = pool.open_slice(resident[0][0]) assert segments == [slots(12)] frame = next(iter(shards.values())).frames(BASE) assert frame.shape == (6, dataset.SHARD_SIZE, dataset.SHARD_SIZE) for shard in shards.values(): shard.close() def test_an_unreadable_archive_file_becomes_a_hole_not_a_crash(tmp_path): root = archive(tmp_path, 12) os.remove(root / f"g18/171/{BASE + 6 * paths.CADENCE}.fits") directory = tmp_path / "pool" pool_producer.main(["--manifest", str(tmp_path / "manifest.zst"), "--archive", str(root), "--pool", str(directory), "--workers", "1", "--slices", "1", "--seed", "0"]) assert pool.ready_slices(str(directory)) == [] # 6 + 5 slots: below 2*span+1 each def test_fixed_mode_writes_each_validation_window_once(tmp_path): root = archive(tmp_path, 12) directory = tmp_path / "val" argv = ["--manifest", str(tmp_path / "manifest.zst"), "--archive", str(root), "--pool", str(directory), "--workers", "1", "--fixed", "val"] pool_producer.main(argv) assert sorted(os.listdir(directory)) == ["val000"] made = os.path.getmtime(directory / "val000" / pool.SLICE_FILE) pool_producer.main(argv) # a rerun skips what is complete assert os.path.getmtime(directory / "val000" / pool.SLICE_FILE) == made test_dir = tmp_path / "test" pool_producer.main(["--manifest", str(tmp_path / "manifest.zst"), "--archive", str(root), "--pool", str(test_dir), "--workers", "1", "--fixed", "test"]) _, segments = pool.open_slice(str(test_dir / "test000")) assert segments == [slots(12 // 2 + SPAN)] def test_encode_slot_declines_a_band_it_cannot_read(tmp_path): assert pool_producer.encode_slot([str(tmp_path / "missing.fits")] * 6) is None # ------------------------------------------------------------------ the trainer torch = pytest.importorskip("torch") def test_the_pool_set_yields_model_ready_samples(tmp_path): import train root = archive(tmp_path, 14) directory = tmp_path / "pool" pool_producer.main(["--manifest", str(tmp_path / "manifest.zst"), "--archive", str(root), "--pool", str(directory), "--workers", "1", "--slices", "1", "--seed", "0"]) data = train.PoolSet(str(directory), SATELLITES, offsets=(1, 4), per_visit=3) stream = iter(data) items = [next(stream) for _ in range(5)] # crosses a visit boundary for item in items: assert item["stack"].shape[1:] == (6, dataset.SHARD_SIZE, dataset.SHARD_SIZE) assert item["lost"].sum() >= 1 batch = train.collate(items[:2]) assert batch["stack"].shape[0] == 2 def test_the_slice_set_is_a_fixed_validation_set(tmp_path): import train root = archive(tmp_path, 14) directory = tmp_path / "val" pool_producer.main(["--manifest", str(tmp_path / "manifest.zst"), "--archive", str(root), "--pool", str(directory), "--workers", "1", "--fixed", "val"]) data = train.SliceSet(str(directory), SATELLITES, offsets=(1, 4)) assert len(data) == 2 * (14 - 2 * SPAN) first, again = data[3], data[3] assert torch.equal(first["stack"], again["stack"]) # damage pinned to the index assert torch.equal(first["lost"], again["lost"]) def test_the_slice_set_refuses_an_empty_directory(tmp_path): import train with pytest.raises(SystemExit): train.SliceSet(str(tmp_path), SATELLITES) def test_the_pool_set_gives_up_when_no_producer_appears(tmp_path, monkeypatch): import train monkeypatch.setattr(train.time, "sleep", lambda seconds: None) data = train.PoolSet(str(tmp_path / "empty"), SATELLITES, wait=10.0) with pytest.raises(RuntimeError, match="producer"): next(iter(data)) def test_gpu_train_runs_the_producer_beside_a_pool_run(): import gpu_train args = gpu_train.argparse.Namespace( shards_dir="shards", name="v10", epochs=6, batch=4, lr=2e-4, base=32, depth=3, workers=6, val_samples=96, eval_every=500, steps=8923, lr_range=None, span=64, warmup=None, relative=False, corr_flow=False, attention=True, max_steps=57000, overfit=None, resume=False, pool=True, pool_manifest="pool_manifest.json.zst", pool_budget_gb=48.0, pool_workers=16, val_dir="val10", evaluate=None) assert f"--pool {gpu_train.POOL_DIR}" in gpu_train.command(args) assert "--val-slices /work/val10" in gpu_train.command(args) producer = gpu_train.producer_command(args) assert "--archive /archive/Data" in producer and gpu_train.POOL_DIR in producer args.pool = False assert "--pool" not in gpu_train.command(args)