733 lines
30 KiB
Python
733 lines
30 KiB
Python
#!/usr/bin/env python
|
|
"""Index the SUVI archive and record per-frame quality verdicts.
|
|
|
|
Replaces the older version of this script, which encoded its verdict by renaming
|
|
files to ``_f``/``_e`` and wrote a diagnostic JPG next to every reject. Verdicts now
|
|
go to the SQLite index, so the archive keeps NOAA's filenames, several detectors can
|
|
disagree about a frame, the continuous scores survive for later re-thresholding, and
|
|
nothing has to be re-read to change a threshold.
|
|
|
|
Subcommands::
|
|
|
|
filter_FITS.py index # sync the index with the archive
|
|
filter_FITS.py scan # cache FITS header metadata (~18 KB read per frame)
|
|
filter_FITS.py detect # run detectors and record verdicts
|
|
filter_FITS.py plots # regenerate diagnostic images for flagged frames
|
|
|
|
``index`` and ``scan`` are incremental: they skip what they already have, so they can
|
|
be re-run as the puller brings in new data. ``index`` reads only the day-directories
|
|
whose mtime changed, which is what keeps re-indexing affordable -- see suvi/index.py
|
|
for why traversing this archive in full is something to avoid, not merely optimise.
|
|
|
|
A caution before using ``detect --detectors geometry_v1`` to gate anything: measured
|
|
over 20 sampled days of 2024, that method rejects 36.9% of frames, and does so
|
|
all-or-nothing -- 0% on most days, 73-100% on days with a large active region, almost
|
|
entirely for "failed centre". Its centre test uses the intensity-weighted centroid,
|
|
which a bright active region displaces even when the header reports the disc exactly
|
|
centred. Run bench.py before trusting any detector to discard data.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
from collections import defaultdict
|
|
from multiprocessing import Pool
|
|
|
|
import numpy as np
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
from suvi import db, detectors, fitsio, index, paths, vfs
|
|
|
|
|
|
def parse_int_list(text):
|
|
return tuple(int(part) for part in text.split(",") if part.strip())
|
|
|
|
|
|
# ---------------------------------------------------------------------------- index
|
|
|
|
|
|
def cmd_index(args):
|
|
"""Bring the index in step with the archive.
|
|
|
|
Reads only the day-directories whose mtime changed since the last run, so a
|
|
routine re-index costs ~7,400 directory stats rather than 2.65M file lookups.
|
|
That ratio is not just a speed matter: a full traversal exhausts the file
|
|
handles of the virtiofs mount this archive lives on. Use --full only for a cold
|
|
start or when the index is suspect.
|
|
"""
|
|
root = args.root or paths.data_root()
|
|
conn = db.connect(args.db)
|
|
satellites = parse_int_list(args.satellites)
|
|
wavelengths = parse_int_list(args.wavelengths)
|
|
|
|
print(f"Indexing {root}" + (" (full rescan)" if args.full else " (changed directories only)"))
|
|
summary = index.reconcile(
|
|
conn,
|
|
root,
|
|
satellites,
|
|
wavelengths,
|
|
years=args.year,
|
|
force=args.full,
|
|
progress=print if args.progress else None,
|
|
)
|
|
total = conn.execute("SELECT count(*) c FROM frame").fetchone()["c"]
|
|
print(
|
|
f" {summary['directories_changed']} of {summary['directories_checked']} "
|
|
f"directories changed in {summary['seconds']:.0f}s"
|
|
)
|
|
print(f" frames added {summary['frames_added']}, removed {summary['frames_removed']}")
|
|
if summary["directories_vanished"]:
|
|
print(f" directories that disappeared: {summary['directories_vanished']}")
|
|
dupes = summary.get("duplicate_slots") or []
|
|
if dupes:
|
|
print(f" {len(dupes)} files share an observation slot with another and were "
|
|
f"left out of the index (see --duplicates to list them)")
|
|
if args.duplicates:
|
|
for path in dupes:
|
|
print(f" {path}")
|
|
print(f" index now holds {total} frames")
|
|
|
|
suffixed = conn.execute(
|
|
"SELECT count(*) c FROM frame WHERE path LIKE '%\\_f.fits' ESCAPE '\\' "
|
|
" OR path LIKE '%\\_e.fits' ESCAPE '\\'"
|
|
).fetchone()["c"]
|
|
if suffixed:
|
|
print(f"\n{suffixed} indexed frames still carry _f/_e suffixes.")
|
|
print("Run migrate_unrename.py to fold those verdicts into the index.")
|
|
conn.close()
|
|
return 0
|
|
|
|
|
|
# ----------------------------------------------------------------------------- scan
|
|
|
|
|
|
def _scan_one(job):
|
|
frame_id, path = job
|
|
values, error = fitsio.scan_header(path)
|
|
return frame_id, values, error
|
|
|
|
|
|
def cmd_scan(args):
|
|
fitsio.quiet_astropy()
|
|
conn = db.connect(args.db)
|
|
root = args.root or paths.data_root()
|
|
pending = db.unscanned_frames(conn, args.limit)
|
|
if not pending:
|
|
print("Every indexed frame already has cached header metadata.")
|
|
return 0
|
|
|
|
print(f"Scanning headers for {len(pending)} frames with {args.workers} workers")
|
|
jobs = [(row["id"], paths.abspath(row["path"], root)) for row in pending]
|
|
started = time.time()
|
|
done = failed = 0
|
|
batch = []
|
|
reliever = vfs.Reliever(label="scan")
|
|
with Pool(args.workers, initializer=fitsio.quiet_astropy) as pool:
|
|
for frame_id, values, error in pool.imap_unordered(_scan_one, jobs, 32):
|
|
reliever.tick()
|
|
batch.append((frame_id, values, error))
|
|
done += 1
|
|
failed += bool(error)
|
|
if len(batch) >= 2000:
|
|
db.record_headers(conn, batch)
|
|
conn.commit()
|
|
batch.clear()
|
|
rate = done / max(time.time() - started, 1e-6)
|
|
print(f" {done}/{len(jobs)} ({rate:.0f}/s, {failed} unreadable)")
|
|
if batch:
|
|
db.record_headers(conn, batch)
|
|
conn.commit()
|
|
print(f"Scanned {done} frames in {time.time() - started:.0f}s; {failed} unreadable")
|
|
return 0
|
|
|
|
|
|
# --------------------------------------------------------------------------- detect
|
|
|
|
|
|
def _detect_one(job):
|
|
"""Run the frame-level detectors on one file."""
|
|
frame_id, slot, path, wanted = job
|
|
verdicts = {}
|
|
values, header_error = fitsio.scan_header(path)
|
|
features = detectors.FrameFeatures(slot=slot, header=values, error=header_error)
|
|
|
|
if "header_v1" in wanted:
|
|
started = time.perf_counter_ns()
|
|
verdict = detectors.header_v1(features)
|
|
verdicts["header_v1"] = (verdict, (time.perf_counter_ns() - started) // 1000)
|
|
|
|
pixel_detectors = [n for n in ("geometry_v1", "disc_v1") if n in wanted]
|
|
if pixel_detectors:
|
|
image, image_error = fitsio.read_image(path)
|
|
for name in pixel_detectors:
|
|
started = time.perf_counter_ns()
|
|
if image is None:
|
|
verdict = detectors.Verdict("bad", f"unreadable: {image_error}", {})
|
|
elif name == "geometry_v1":
|
|
verdict = detectors.geometry_v1(image, slot[1])
|
|
else:
|
|
verdict = detectors.disc_v1(image, slot[1], values)
|
|
verdicts[name] = (verdict, (time.perf_counter_ns() - started) // 1000)
|
|
|
|
return frame_id, verdicts
|
|
|
|
|
|
def cmd_detect(args):
|
|
fitsio.quiet_astropy()
|
|
conn = db.connect(args.db)
|
|
root = args.root or paths.data_root()
|
|
wanted = tuple(args.detectors.split(","))
|
|
unknown = [name for name in wanted if name not in detectors.ALL_DETECTORS]
|
|
if unknown:
|
|
print(f"Unknown detectors: {unknown}. Available: {list(detectors.ALL_DETECTORS)}")
|
|
return 1
|
|
|
|
frame_detectors = [n for n in wanted if n in detectors.FRAME_DETECTORS]
|
|
series_detectors = [n for n in wanted if n in detectors.SERIES_DETECTORS]
|
|
if series_detectors:
|
|
print(f"NOTE: {series_detectors} need a whole series in memory; use bench.py "
|
|
"for those, or run them here one band at a time via --wavelengths.")
|
|
|
|
where, params = ["1=1"], []
|
|
if args.start is not None:
|
|
where.append("t_start >= ?")
|
|
params.append(args.start)
|
|
if args.end is not None:
|
|
where.append("t_start < ?")
|
|
params.append(args.end)
|
|
if args.wavelengths:
|
|
bands = parse_int_list(args.wavelengths)
|
|
where.append(f"wavelength IN ({','.join('?' * len(bands))})")
|
|
params.extend(bands)
|
|
rows = conn.execute(
|
|
f"SELECT id, path, satellite, wavelength, t_start FROM frame "
|
|
f"WHERE {' AND '.join(where)} ORDER BY t_start",
|
|
params,
|
|
).fetchall()
|
|
if not rows:
|
|
print("No indexed frames match; run `filter_FITS.py index` first.")
|
|
return 1
|
|
|
|
runs = {
|
|
name: db.create_detector_run(conn, name, {"driver": "filter_FITS"}, notes=args.notes)
|
|
for name in frame_detectors
|
|
}
|
|
print(f"Running {frame_detectors} over {len(rows)} frames with {args.workers} workers")
|
|
|
|
jobs = [
|
|
(
|
|
row["id"],
|
|
(row["satellite"], row["wavelength"], row["t_start"]),
|
|
paths.abspath(row["path"], root),
|
|
frozenset(frame_detectors),
|
|
)
|
|
for row in rows
|
|
]
|
|
started = time.time()
|
|
pending = defaultdict(list)
|
|
tallies = {name: defaultdict(int) for name in frame_detectors}
|
|
done = 0
|
|
reliever = vfs.Reliever(label="detect")
|
|
|
|
with Pool(args.workers, initializer=fitsio.quiet_astropy) as pool:
|
|
for frame_id, verdicts in pool.imap_unordered(_detect_one, jobs, 16):
|
|
reliever.tick()
|
|
for name, (verdict, elapsed) in verdicts.items():
|
|
pending[name].append(
|
|
(frame_id, verdict.verdict, verdict.reason, verdict.scores, elapsed)
|
|
)
|
|
tallies[name][verdict.verdict] += 1
|
|
done += 1
|
|
if done % 5000 == 0:
|
|
for name, batch in pending.items():
|
|
db.record_detections(conn, runs[name], batch)
|
|
batch.clear()
|
|
conn.commit()
|
|
print(f" {done}/{len(jobs)} ({done / max(time.time() - started, 1e-6):.0f}/s)")
|
|
for name, batch in pending.items():
|
|
if batch:
|
|
db.record_detections(conn, runs[name], batch)
|
|
conn.commit()
|
|
|
|
print(f"\nDone in {time.time() - started:.0f}s")
|
|
for name in frame_detectors:
|
|
counts = tallies[name]
|
|
total = sum(counts.values()) or 1
|
|
print(f" {name}: run {runs[name]} -- "
|
|
+ ", ".join(f"{v} {c} ({c / total:.1%})" for v, c in sorted(counts.items())))
|
|
return 0
|
|
|
|
|
|
# ------------------------------------------------------------------------ calibrate
|
|
|
|
#: Absolute floor on each measurement's half-width, so a band whose sample happens to
|
|
#: be unusually tight does not get bounds narrower than the physics justifies. Set
|
|
#: from the observed cross-band spread of each quantity.
|
|
CALIBRATION_FLOORS = {"limb_contrast": 0.15, "radius_ratio": 0.06, "limb_width": 0.06}
|
|
|
|
#: Which side of each measurement actually diagnoses a fault. Bounding the other
|
|
#: side buys nothing and, on a wide-spread quantity, produces a negative limit that
|
|
#: can never trip -- which looks like a rule while being none.
|
|
#:
|
|
#: * limb_contrast falls towards zero as the disc disappears; it cannot exceed 1.
|
|
#: * radius_ratio is diagnostic in both directions -- too small or too large is wrong.
|
|
#: * limb_width grows as the limb smears; a sharper-than-usual limb is not a fault.
|
|
CALIBRATION_SIDES = {
|
|
"limb_contrast": ("lower", 0.02, 1.0),
|
|
"radius_ratio": ("both", None, None),
|
|
"limb_width": ("upper", 0.0, None),
|
|
}
|
|
|
|
|
|
def _sample_frames(conn, root, wavelength, count, satellites=(16, 18)):
|
|
"""Pick frames spread evenly across the whole time range of one band.
|
|
|
|
Queries the index rather than walking the archive -- which is the point of
|
|
having an index, and avoids the traversal that exhausts this mount (see
|
|
suvi/index.py).
|
|
|
|
Deliberately does *not* filter on the legacy ``_f``/``_e`` verdicts. Those were
|
|
written in May 2024 by a filter retuned that July, so selecting on them would
|
|
calibrate the new detector against the old one's mistakes -- including the very
|
|
active-region rejections this detector exists to stop.
|
|
"""
|
|
placeholders = ",".join("?" * len(satellites))
|
|
rows = conn.execute(
|
|
f"SELECT path FROM frame WHERE wavelength = ? AND satellite IN ({placeholders}) "
|
|
f"ORDER BY t_start",
|
|
(wavelength, *satellites),
|
|
).fetchall()
|
|
if not rows:
|
|
return []
|
|
stride = max(1, len(rows) // count)
|
|
return [paths.abspath(row["path"], root) for row in rows[::stride]][:count]
|
|
|
|
|
|
def _calibration_measure(path):
|
|
"""Measure one frame, skipping any the header already condemns."""
|
|
values, error = fitsio.scan_header(path)
|
|
if error or not values:
|
|
return None
|
|
if values.get("eclipse") or values.get("empty"):
|
|
return None # genuinely bad; must not widen the good-frame bounds
|
|
diameter = values.get("diam_sun")
|
|
mean = values.get("img_mean")
|
|
if not diameter or mean is None:
|
|
return None
|
|
low, high = detectors.HEADER_MEAN_BOUNDS.get(
|
|
int(values.get("wavelnth") or 0), (0.0, float("inf"))
|
|
)
|
|
if not (low <= mean <= high):
|
|
return None # dropout or blowout
|
|
image, image_error = fitsio.read_image(path)
|
|
if image is None:
|
|
return None
|
|
return detectors.disc_profile(image, float(diameter) / 4.0)
|
|
|
|
|
|
def cmd_calibrate(args):
|
|
fitsio.quiet_astropy()
|
|
root = args.root or paths.data_root()
|
|
conn = db.connect(args.db, readonly=True)
|
|
wavelengths = parse_int_list(args.wavelengths)
|
|
margin = args.margin
|
|
table = {}
|
|
|
|
print(f"Calibrating disc_v1 from {root}")
|
|
print(f" {args.samples_per_band} frames per band, margin {margin}x the observed span\n")
|
|
|
|
for wavelength in wavelengths:
|
|
candidates = _sample_frames(conn, root, wavelength, args.samples_per_band * 2)
|
|
if not candidates:
|
|
print(f"{wavelength:>5}: no frames found")
|
|
continue
|
|
results = []
|
|
reliever = vfs.Reliever(label=f"calibrate {wavelength}A")
|
|
with Pool(args.workers, initializer=fitsio.quiet_astropy) as pool:
|
|
for measured in pool.imap_unordered(_calibration_measure, candidates, 8):
|
|
reliever.tick()
|
|
if measured is None:
|
|
continue
|
|
if any(v is None for v in measured.values()):
|
|
results.append(measured) # keep, so unmeasurable rate is visible
|
|
continue
|
|
results.append(measured)
|
|
if len(results) >= args.samples_per_band:
|
|
break
|
|
|
|
usable = [r for r in results if all(v is not None for v in r.values())]
|
|
unmeasurable = len(results) - len(usable)
|
|
if len(usable) < 20:
|
|
print(f"{wavelength:>5}: only {len(usable)} usable frames; skipping")
|
|
continue
|
|
|
|
bounds = {}
|
|
print(f"{wavelength:>5}A n={len(usable)} unmeasurable={unmeasurable}")
|
|
for name in ("limb_contrast", "radius_ratio", "limb_width"):
|
|
values = np.array([r[name] for r in usable], dtype=float)
|
|
low, high = float(values.min()), float(values.max())
|
|
span = high - low
|
|
half = max(margin * span, CALIBRATION_FLOORS[name])
|
|
side, floor, ceiling = CALIBRATION_SIDES[name]
|
|
|
|
lower = low - half if side in ("lower", "both") else floor
|
|
upper = high + half if side in ("upper", "both") else ceiling
|
|
# Keep each limit on the physical side of zero. A negative floor on a
|
|
# non-negative quantity is not a loose rule, it is no rule at all.
|
|
if floor is not None:
|
|
lower = max(lower, floor)
|
|
if ceiling is not None:
|
|
upper = min(upper, ceiling)
|
|
bounds[name] = (round(lower, 4), round(upper, 4))
|
|
outside = int(((values < lower) | (values > upper)).sum())
|
|
print(f" {name:<14} observed {low:.4f}-{high:.4f} "
|
|
f"p50 {np.median(values):.4f} -> bounds {lower:.4f}-{upper:.4f} "
|
|
f"sample failures {outside}/{len(values)}")
|
|
table[wavelength] = bounds
|
|
|
|
if not table:
|
|
print("\nNothing calibrated.")
|
|
return 1
|
|
|
|
print("\n" + "=" * 78)
|
|
print("Paste into DISC_BOUNDS in suvi/detectors.py:\n")
|
|
print("DISC_BOUNDS = {")
|
|
for wavelength, bounds in sorted(table.items()):
|
|
print(f" {wavelength}: {{")
|
|
for name, (lower, upper) in bounds.items():
|
|
print(f' "{name}": ({lower}, {upper}),')
|
|
print(" },")
|
|
print("}")
|
|
if args.json:
|
|
with open(args.json, "w") as handle:
|
|
json.dump(table, handle, indent=2)
|
|
print(f"\nAlso written to {args.json}")
|
|
return 0
|
|
|
|
|
|
# --------------------------------------------------------------------------- sample
|
|
|
|
#: The production combination: a frame is bad if either detector says so.
|
|
SAMPLE_DETECTORS = ("header_v1", "disc_v1")
|
|
|
|
|
|
def _judge_timestamp(job):
|
|
"""Read all six bands for one (satellite, timestamp) and judge each.
|
|
|
|
Returns (satellite, timestamp, {band: (verdict, reason, scores)}), or None if
|
|
the set is incomplete -- a composite needs all six.
|
|
"""
|
|
satellite, timestamp, band_paths = job
|
|
verdicts = {}
|
|
for band, path in sorted(band_paths.items()):
|
|
values, header_error = fitsio.scan_header(path)
|
|
features = detectors.FrameFeatures(
|
|
slot=(satellite, band, timestamp), header=values, error=header_error
|
|
)
|
|
header = detectors.header_v1(features)
|
|
image, image_error = fitsio.read_image(path)
|
|
if image is None:
|
|
disc = detectors.Verdict("bad", f"unreadable: {image_error}", {})
|
|
else:
|
|
disc = detectors.disc_v1(image, band, values)
|
|
|
|
bad = [v for v in (header, disc) if v.verdict == "bad"]
|
|
verdict = "bad" if bad else "good"
|
|
reason = "; ".join(v.reason for v in bad if v.reason) or None
|
|
verdicts[band] = (verdict, reason, {**header.scores, **disc.scores})
|
|
return satellite, timestamp, verdicts
|
|
|
|
|
|
def _render_sample(job):
|
|
"""Render one annotated composite for review."""
|
|
import merger_FITS
|
|
from PIL import ImageDraw, ImageFont
|
|
|
|
satellite, timestamp, band_paths, caption, out_path = job
|
|
try:
|
|
arrays = []
|
|
for band in sorted(band_paths):
|
|
image, error = fitsio.read_image(band_paths[band])
|
|
if image is None:
|
|
return out_path, f"band {band}: {error}"
|
|
arrays.append(image)
|
|
picture = merger_FITS.composite_from_arrays(arrays, timestamp)
|
|
|
|
font_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
"OpenSans-Regular.ttf")
|
|
font = ImageFont.truetype(font_path, size=20)
|
|
draw = ImageDraw.Draw(picture)
|
|
# Caption along the bottom, where it covers no solar structure.
|
|
y = picture.size[1] - 26 * len(caption)
|
|
for line in caption:
|
|
draw.text((12, y), line, (255, 235, 120), font=font)
|
|
y += 26
|
|
picture.save(out_path, quality=95)
|
|
return out_path, None
|
|
except Exception as exc:
|
|
return out_path, f"{type(exc).__name__}: {exc}"
|
|
|
|
|
|
def cmd_sample(args):
|
|
"""Write folders of suspected-good and suspected-bad frames for manual review.
|
|
|
|
The bench measures detectors against damage it injected itself, which proves
|
|
they find what was planted but says nothing about what they do to real frames
|
|
nobody labelled. This is that check: a person looks at what the detector kept
|
|
and what it threw away, and says whether they agree.
|
|
|
|
Each image is the full composite for the frame's timestamp and satellite -- which
|
|
already tiles all six bands down its sides -- captioned with the verdict, the
|
|
band responsible, and the scores behind it.
|
|
"""
|
|
fitsio.quiet_astropy()
|
|
root = args.root or paths.data_root()
|
|
conn = db.connect(args.db, readonly=True)
|
|
satellites = parse_int_list(args.satellites)
|
|
|
|
# Candidate timestamps spread evenly across whatever the index holds, so the
|
|
# sample is not a picture of one week.
|
|
rows = conn.execute(
|
|
f"SELECT satellite, t_start, wavelength, path FROM frame "
|
|
f"WHERE satellite IN ({','.join('?' * len(satellites))}) "
|
|
f"ORDER BY t_start",
|
|
satellites,
|
|
).fetchall()
|
|
conn.close()
|
|
|
|
by_key = defaultdict(dict)
|
|
for row in rows:
|
|
by_key[(row["satellite"], row["t_start"])][row["wavelength"]] = paths.abspath(
|
|
row["path"], root
|
|
)
|
|
complete = [k for k, v in by_key.items() if len(v) == 6]
|
|
if not complete:
|
|
print("No timestamp has all six bands; cannot build composites.")
|
|
return 1
|
|
|
|
stride = max(1, len(complete) // max(args.scan, 1))
|
|
candidates = sorted(complete)[::stride][: args.scan]
|
|
print(f"{len(complete)} complete timestamps in the index; judging {len(candidates)}")
|
|
|
|
good, bad = [], []
|
|
reliever = vfs.Reliever(label="sample")
|
|
jobs = [(sat, when, by_key[(sat, when)]) for sat, when in candidates]
|
|
|
|
with Pool(args.workers, initializer=fitsio.quiet_astropy) as pool:
|
|
for result in pool.imap_unordered(_judge_timestamp, jobs, 4):
|
|
reliever.tick(6)
|
|
if result is None:
|
|
continue
|
|
satellite, timestamp, verdicts = result
|
|
flagged = {b: v for b, v in verdicts.items() if v[0] == "bad"}
|
|
if flagged and len(bad) < args.n:
|
|
bad.append((satellite, timestamp, flagged))
|
|
elif not flagged and len(good) < args.n:
|
|
good.append((satellite, timestamp, verdicts))
|
|
if len(good) >= args.n and len(bad) >= args.n:
|
|
break
|
|
reliever.finish()
|
|
|
|
print(f" collected {len(bad)} flagged and {len(good)} clean timestamps")
|
|
if len(bad) < args.n:
|
|
print(f" NOTE: only {len(bad)} flagged found in {len(candidates)} scanned; "
|
|
f"raise --scan for more.")
|
|
|
|
render_jobs = []
|
|
for folder, items in (("bad", bad), ("good", good)):
|
|
out_dir = os.path.join(args.out, folder)
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
for satellite, timestamp, verdicts in items:
|
|
stamp = time.strftime("%Y%m%dT%H%M%S", time.gmtime(timestamp))
|
|
if folder == "bad":
|
|
bands = ",".join(f"{b}A" for b in sorted(verdicts))
|
|
first = verdicts[sorted(verdicts)[0]]
|
|
# Lead the filename with the reason so the folder sorts by failure mode.
|
|
slug = "".join(
|
|
ch if ch.isalnum() else "_" for ch in (first[1] or "flagged")
|
|
)[:40]
|
|
caption = [
|
|
f"FLAGGED g{satellite} {stamp} bands: {bands}",
|
|
(first[1] or "")[:110],
|
|
" ".join(f"{k}={v:.3g}" for k, v in list(first[2].items())[:5]),
|
|
]
|
|
name = f"{slug}__g{satellite}_{stamp}.jpg"
|
|
else:
|
|
sample_scores = verdicts[sorted(verdicts)[0]][2]
|
|
caption = [
|
|
f"CLEAN g{satellite} {stamp} all six bands passed",
|
|
" ".join(f"{k}={v:.3g}" for k, v in list(sample_scores.items())[:5]),
|
|
]
|
|
name = f"g{satellite}_{stamp}.jpg"
|
|
render_jobs.append(
|
|
(satellite, timestamp, by_key[(satellite, timestamp)], caption,
|
|
os.path.join(out_dir, name))
|
|
)
|
|
|
|
print(f"Rendering {len(render_jobs)} composites...")
|
|
failures = 0
|
|
with Pool(args.workers, initializer=fitsio.quiet_astropy) as pool:
|
|
for path, error in pool.imap_unordered(_render_sample, render_jobs, 2):
|
|
if error:
|
|
failures += 1
|
|
if failures <= 3:
|
|
print(f" failed {os.path.basename(path)}: {error}")
|
|
print(f" wrote {len(render_jobs) - failures} images to {args.out}")
|
|
print(f" review {args.out}/bad -- nothing here should be a frame you would keep")
|
|
print(f" review {args.out}/good -- nothing here should be a frame you would discard")
|
|
return 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------- plots
|
|
|
|
|
|
def cmd_plots(args):
|
|
"""Regenerate the diagnostic image the old filter wrote beside every reject.
|
|
|
|
Kept as an on-demand tool rather than a side effect of filtering: the plots are
|
|
derivable from the index plus the FITS, and writing millions of them into the
|
|
archive was what made the old approach hard to undo.
|
|
"""
|
|
import matplotlib
|
|
|
|
matplotlib.use("Agg")
|
|
import numpy as np
|
|
from matplotlib import pyplot as plt
|
|
|
|
fitsio.quiet_astropy()
|
|
conn = db.connect(args.db, readonly=True)
|
|
root = args.root or paths.data_root()
|
|
run_id = db.latest_run_id(conn, args.detector)
|
|
if run_id is None:
|
|
print(f"No run named {args.detector!r}")
|
|
return 1
|
|
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT f.path, f.wavelength, d.reason FROM detection d
|
|
JOIN frame f ON f.id = d.frame_id
|
|
WHERE d.run_id = ? AND d.verdict = 'bad' ORDER BY f.t_start LIMIT ?
|
|
""",
|
|
(run_id, args.limit),
|
|
).fetchall()
|
|
os.makedirs(args.out, exist_ok=True)
|
|
print(f"Writing {len(rows)} diagnostic plots to {args.out}")
|
|
|
|
reference = detectors.ideal_disc_axis()
|
|
axis = list(range(detectors.HALF_DIMS))
|
|
for row in rows:
|
|
path = paths.abspath(row["path"], root)
|
|
image, error = fitsio.read_image(path)
|
|
if image is None:
|
|
continue
|
|
import cv2 as cv
|
|
|
|
threshold = detectors.GEOMETRY_THRESHOLDS[row["wavelength"]]
|
|
data = cv.resize(
|
|
np.nan_to_num(image),
|
|
(detectors.HALF_DIMS, detectors.HALF_DIMS),
|
|
interpolation=cv.INTER_LINEAR,
|
|
)
|
|
shown = np.clip(data, None, threshold) / threshold
|
|
xavg, yavg = np.average(shown, 0), np.average(shown, 1)
|
|
|
|
figure, axes = plt.subplots(
|
|
2, 2, width_ratios=(0.2, 1), height_ratios=(0.2, 1),
|
|
gridspec_kw={"hspace": 0.0, "wspace": 0.0}, figsize=(8, 8),
|
|
)
|
|
axes[0][0].axis("off")
|
|
axes[0][1].plot(axis, xavg)
|
|
axes[0][1].plot(axis, reference)
|
|
axes[1][0].plot(yavg, axis)
|
|
axes[1][0].plot(reference, axis)
|
|
axes[1][0].invert_xaxis()
|
|
axes[1][0].invert_yaxis()
|
|
axes[1][1].imshow(shown, aspect="auto", vmin=0, vmax=1)
|
|
axes[0][1].sharex(axes[1][1])
|
|
axes[1][0].sharey(axes[1][1])
|
|
figure.suptitle(f"{os.path.basename(row['path'])}\n{row['reason']}", fontsize=8)
|
|
figure.savefig(
|
|
os.path.join(args.out, os.path.basename(row["path"]).replace(".fits", ".jpg")),
|
|
dpi=110,
|
|
)
|
|
plt.close(figure)
|
|
return 0
|
|
|
|
|
|
# ------------------------------------------------------------------------------ cli
|
|
|
|
|
|
def build_parser():
|
|
parser = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("--db", default=None, help="SQLite index (default: $SUVI_DB)")
|
|
parser.add_argument("--root", default=None, help="archive root (default: $SUVI_DATA_ROOT)")
|
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
|
|
index_cmd = subparsers.add_parser(
|
|
"index", help="sync the index with the archive (only changed directories)"
|
|
)
|
|
index_cmd.add_argument("--satellites", default="16,18,19")
|
|
index_cmd.add_argument("--wavelengths", default="94,131,171,195,284,304")
|
|
index_cmd.add_argument("--year", type=int, action="append", default=None)
|
|
index_cmd.add_argument("--full", action="store_true",
|
|
help="re-read every directory, ignoring recorded mtimes")
|
|
index_cmd.add_argument("--progress", action="store_true")
|
|
index_cmd.add_argument("--duplicates", action="store_true",
|
|
help="list files that share an observation slot")
|
|
index_cmd.set_defaults(func=cmd_index)
|
|
|
|
scan = subparsers.add_parser("scan", help="cache FITS header metadata")
|
|
scan.add_argument("--workers", type=int, default=16)
|
|
scan.add_argument("--limit", type=int, default=None)
|
|
scan.set_defaults(func=cmd_scan)
|
|
|
|
detect = subparsers.add_parser("detect", help="run detectors and record verdicts")
|
|
detect.add_argument("--detectors", default="header_v1")
|
|
detect.add_argument("--wavelengths", default=None)
|
|
detect.add_argument("--start", type=int, default=None, help="Unix time, inclusive")
|
|
detect.add_argument("--end", type=int, default=None, help="Unix time, exclusive")
|
|
detect.add_argument("--workers", type=int, default=16)
|
|
detect.add_argument("--notes", default=None)
|
|
detect.set_defaults(func=cmd_detect)
|
|
|
|
calibrate = subparsers.add_parser(
|
|
"calibrate", help="derive per-band disc_v1 bounds from a sample of good frames"
|
|
)
|
|
calibrate.add_argument("--wavelengths", default="94,131,171,195,284,304")
|
|
calibrate.add_argument("--samples-per-band", type=int, default=300)
|
|
calibrate.add_argument("--margin", type=float, default=1.0,
|
|
help="half-width added either side, in multiples of the observed span")
|
|
calibrate.add_argument("--workers", type=int, default=16)
|
|
calibrate.add_argument("--json", default=None)
|
|
calibrate.set_defaults(func=cmd_calibrate)
|
|
|
|
sample = subparsers.add_parser(
|
|
"sample", help="write good/bad frame folders for manual review"
|
|
)
|
|
sample.add_argument("--n", type=int, default=100, help="frames per folder")
|
|
sample.add_argument("--out", required=True)
|
|
sample.add_argument("--scan", type=int, default=4000,
|
|
help="timestamps to judge while looking for N of each")
|
|
sample.add_argument("--satellites", default="16,18")
|
|
sample.add_argument("--workers", type=int, default=8)
|
|
sample.set_defaults(func=cmd_sample)
|
|
|
|
plots = subparsers.add_parser("plots", help="regenerate diagnostics for flagged frames")
|
|
plots.add_argument("--detector", default="geometry_v1")
|
|
plots.add_argument("--out", default="diagnostics")
|
|
plots.add_argument("--limit", type=int, default=200)
|
|
plots.set_defaults(func=cmd_plots)
|
|
return parser
|
|
|
|
|
|
def main(argv=None):
|
|
args = build_parser().parse_args(argv)
|
|
return args.func(args) or 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|