192 lines
7.8 KiB
Python
192 lines
7.8 KiB
Python
|
|
#!/usr/bin/env python
|
||
|
|
"""Build training shards from the archive and stream them to the training host.
|
||
|
|
|
||
|
|
dataset_build.py --days 60 --out /tmp/shards --push htpc@192.168.1.66:/var/home/htpc/suvi
|
||
|
|
|
||
|
|
This is a ~260,000-frame traversal of a filesystem that has repeatedly run out of file
|
||
|
|
handles under exactly this kind of load, on a VM with 184 GB free on the mount and 3.8 GB
|
||
|
|
on root against a dataset that measures 168 GB. Both facts shape the design:
|
||
|
|
|
||
|
|
* every read goes through :class:`suvi.vfs.Reliever`, which hands handles back as it goes;
|
||
|
|
* a shard is written to `--out` (point it at tmpfs), pushed, and deleted before the next
|
||
|
|
one starts, so peak local usage is one day rather than the whole set.
|
||
|
|
|
||
|
|
Days are chosen from the index, not the filesystem, and the split is written first. If
|
||
|
|
extraction dies half way the manifest still describes the intended experiment, and a
|
||
|
|
resumed run fills in the shards that are missing rather than choosing different days.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import datetime
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
import time
|
||
|
|
from multiprocessing import Pool
|
||
|
|
|
||
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
|
|
|
||
|
|
from suvi import dataset, db, fitsio, paths, vfs
|
||
|
|
|
||
|
|
#: The bench's ground-truth window. Excluded from training so it stays a clean holdout.
|
||
|
|
HOLDOUT = ("2024-05-08", "2024-05-16")
|
||
|
|
#: GOES-18 joins the archive in 2022-08; before that there is no second satellite.
|
||
|
|
DEFAULT_FROM = "2022-09-01"
|
||
|
|
DEFAULT_TO = "2025-03-20"
|
||
|
|
|
||
|
|
|
||
|
|
def _day_bounds(day):
|
||
|
|
start = datetime.datetime.fromisoformat(day).replace(tzinfo=datetime.timezone.utc)
|
||
|
|
return int(start.timestamp()), int(start.timestamp()) + 86400
|
||
|
|
|
||
|
|
|
||
|
|
def holdout_days(first=HOLDOUT[0], last=HOLDOUT[1]):
|
||
|
|
start = datetime.date.fromisoformat(first)
|
||
|
|
end = datetime.date.fromisoformat(last)
|
||
|
|
days = []
|
||
|
|
while start <= end:
|
||
|
|
days.append(start.isoformat())
|
||
|
|
start += datetime.timedelta(days=1)
|
||
|
|
return days
|
||
|
|
|
||
|
|
|
||
|
|
def _extract(job):
|
||
|
|
"""Worker: one slot -> encoded block. Runs in a subprocess, so it opens its own files."""
|
||
|
|
root, time_start, rows = job
|
||
|
|
fitsio.quiet_astropy()
|
||
|
|
try:
|
||
|
|
return time_start, dataset.read_slot(root, rows)
|
||
|
|
except (OSError, ValueError):
|
||
|
|
return time_start, None
|
||
|
|
|
||
|
|
|
||
|
|
def build_day(conn, root, day, satellite, out_dir, workers, reliever):
|
||
|
|
"""Extract one (day, satellite) shard. Returns (path, bytes, slots_with_frames)."""
|
||
|
|
start, end = _day_bounds(day)
|
||
|
|
by_slot = {}
|
||
|
|
for row in conn.execute(
|
||
|
|
"""
|
||
|
|
SELECT wavelength, t_start, path FROM frame
|
||
|
|
WHERE satellite = ? AND t_start >= ? AND t_start < ?
|
||
|
|
""",
|
||
|
|
(satellite, start, end),
|
||
|
|
):
|
||
|
|
by_slot.setdefault(row["t_start"], {})[row["wavelength"]] = row["path"]
|
||
|
|
|
||
|
|
jobs = [(root, t, rows) for t, rows in sorted(by_slot.items())]
|
||
|
|
records = {}
|
||
|
|
with Pool(workers) as pool:
|
||
|
|
for time_start, block in pool.imap_unordered(_extract, jobs, chunksize=1):
|
||
|
|
records[time_start] = block
|
||
|
|
reliever.tick(len(paths.WAVELENGTHS))
|
||
|
|
|
||
|
|
path = os.path.join(out_dir, dataset.shard_name(day, satellite))
|
||
|
|
size = dataset.write_shard(path, day, satellite, paths.WAVELENGTHS, records)
|
||
|
|
return path, size, sum(1 for block in records.values() if block is not None)
|
||
|
|
|
||
|
|
|
||
|
|
def push(path, destination, retries=3):
|
||
|
|
"""Copy a shard to the training host and remove the local copy."""
|
||
|
|
for attempt in range(retries):
|
||
|
|
result = subprocess.run(
|
||
|
|
["rsync", "-q", "--partial", "--inplace", path, destination],
|
||
|
|
stderr=subprocess.PIPE,
|
||
|
|
)
|
||
|
|
if result.returncode == 0:
|
||
|
|
os.remove(path)
|
||
|
|
return True
|
||
|
|
print(f" rsync failed (attempt {attempt + 1}): "
|
||
|
|
f"{result.stderr.decode(errors='replace').strip()[:200]}")
|
||
|
|
time.sleep(5)
|
||
|
|
return False
|
||
|
|
|
||
|
|
|
||
|
|
def main(argv=None):
|
||
|
|
parser = argparse.ArgumentParser(description=__doc__,
|
||
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||
|
|
parser.add_argument("--days", type=int, default=60)
|
||
|
|
parser.add_argument("--out", default="/tmp/shards",
|
||
|
|
help="staging directory; put this on tmpfs")
|
||
|
|
parser.add_argument("--push", default=None,
|
||
|
|
help="rsync destination, e.g. host:/path (omit to keep locally)")
|
||
|
|
parser.add_argument("--from", dest="from_day", default=DEFAULT_FROM)
|
||
|
|
parser.add_argument("--to", dest="to_day", default=DEFAULT_TO)
|
||
|
|
parser.add_argument("--satellites", default="16,18")
|
||
|
|
parser.add_argument("--workers", type=int, default=6)
|
||
|
|
parser.add_argument("--db", default=None)
|
||
|
|
parser.add_argument("--manifest", default=None,
|
||
|
|
help="reuse an existing manifest instead of choosing days afresh")
|
||
|
|
args = parser.parse_args(argv)
|
||
|
|
|
||
|
|
os.makedirs(args.out, exist_ok=True)
|
||
|
|
satellites = tuple(int(s) for s in args.satellites.split(","))
|
||
|
|
conn = db.connect(args.db, readonly=True)
|
||
|
|
root = paths.data_root()
|
||
|
|
|
||
|
|
manifest_path = args.manifest or os.path.join(args.out, "manifest.json")
|
||
|
|
if args.manifest and os.path.exists(args.manifest):
|
||
|
|
manifest = json.load(open(args.manifest))
|
||
|
|
splits = manifest["splits"]
|
||
|
|
print(f"Reusing manifest {args.manifest} (digest {manifest['digest']})")
|
||
|
|
else:
|
||
|
|
chosen = dataset.choose_days(
|
||
|
|
conn, args.days,
|
||
|
|
*[_day_bounds(d)[0] for d in (args.from_day, args.to_day)],
|
||
|
|
exclude=holdout_days(), satellites=satellites,
|
||
|
|
)
|
||
|
|
if len(chosen) < args.days:
|
||
|
|
print(f"WARNING: only {len(chosen)} complete days available, wanted {args.days}")
|
||
|
|
splits = dataset.split_days(chosen)
|
||
|
|
manifest = dataset.manifest(splits, {"satellites": list(satellites),
|
||
|
|
"holdout": list(HOLDOUT)})
|
||
|
|
with open(manifest_path, "w") as handle:
|
||
|
|
json.dump(manifest, handle, indent=2)
|
||
|
|
print(f"Wrote {manifest_path} (digest {manifest['digest']})")
|
||
|
|
|
||
|
|
adjacent = dataset.check_split(splits)
|
||
|
|
for name, group in sorted(splits.items()):
|
||
|
|
print(f" {name:>5}: {len(group)} days {group[0] if group else '-'}"
|
||
|
|
f" .. {group[-1] if group else '-'}")
|
||
|
|
if adjacent:
|
||
|
|
print(f" NOTE: {len(adjacent)} split boundaries fall on consecutive days; "
|
||
|
|
"frames four minutes apart sit either side of them.")
|
||
|
|
|
||
|
|
if args.push:
|
||
|
|
subprocess.run(["rsync", "-q", manifest_path, args.push], check=False)
|
||
|
|
|
||
|
|
days = sorted(sum(splits.values(), []))
|
||
|
|
total_bytes = 0
|
||
|
|
total_slots = 0
|
||
|
|
started = time.time()
|
||
|
|
reliever = vfs.Reliever(label="dataset")
|
||
|
|
try:
|
||
|
|
for index, day in enumerate(days, 1):
|
||
|
|
for satellite in satellites:
|
||
|
|
name = dataset.shard_name(day, satellite)
|
||
|
|
path, size, slots = build_day(
|
||
|
|
conn, root, day, satellite, args.out, args.workers, reliever
|
||
|
|
)
|
||
|
|
total_bytes += size
|
||
|
|
total_slots += slots
|
||
|
|
elapsed = time.time() - started
|
||
|
|
done = (index - 1) * len(satellites) + satellites.index(satellite) + 1
|
||
|
|
remaining = (len(days) * len(satellites) - done) * elapsed / max(done, 1)
|
||
|
|
print(f" [{done:>3}/{len(days) * len(satellites)}] {name}: "
|
||
|
|
f"{slots} slots, {size / 1e6:.0f} MB, "
|
||
|
|
f"{total_bytes / 1e9:.1f} GB total, "
|
||
|
|
f"eta {remaining / 3600:.1f} h", flush=True)
|
||
|
|
if args.push and not push(path, args.push):
|
||
|
|
raise SystemExit(f"could not push {name}; stopping rather than "
|
||
|
|
"filling the staging disk")
|
||
|
|
finally:
|
||
|
|
reliever.finish()
|
||
|
|
|
||
|
|
print(f"\n{total_slots} slots, {total_bytes / 1e9:.1f} GB, "
|
||
|
|
f"{(time.time() - started) / 3600:.2f} h")
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
sys.exit(main())
|