Add the report generator and the push-button entry point

run.py is the one command the whole exercise was aimed at:

    python run.py --all PATH

It finds every session beneath a directory and takes each through
ingest, measure, register, solve, colour and report. Stages are skipped
when their output already exists, so an interrupted run continues rather
than restarting, and --force overrides that.

A failure in one stage of one session does not stop the others. In a
batch of twenty the useful outcome is nineteen results and one clear
error, not nothing - so failures are caught, recorded and summarised at
the end, and the plate solve in particular is allowed to fail without
taking the images down with it. An unsolved session still produces a
perfectly good picture; it just cannot produce positions.

report.py writes each session its own METHODS.md, generated from what
the stages actually returned rather than from what they were supposed to
return. That matters for the honest parts: a session whose plate solve
failed says so in its own documentation, and the limitations section is
derived from the data - too few frames for outlier rejection, an
integration too short to recover, a missing luminance channel, seeing
that caps the achievable detail. A hand-written note is written once and
then rots; this is rebuilt on every run.
This commit is contained in:
laurence 2026-07-21 21:40:50 +01:00
parent fbd1eb8ec5
commit 38f7a81395
2 changed files with 346 additions and 0 deletions

158
pipeline/run.py Normal file
View file

@ -0,0 +1,158 @@
"""The push-button entry point: process a session, or a whole archive.
python run.py # process $ASTRO_SESSION
python run.py PATH # process one session
python run.py --all PATH # find and process every session
python run.py --stage register PATH # run one stage
python run.py --force PATH # ignore existing outputs
Stages run in order and each is skipped when its output already exists, so an
interrupted run continues rather than starting again. A failure in one stage of
one session does not stop the others: the failure is recorded and the batch
carries on, because in a batch of twenty the useful outcome is nineteen results
and one clear error, not nothing.
"""
import argparse
import os
import sys
import time
import traceback
import layout
import session as session_mod
STAGES = ("ingest", "measure", "register", "solve", "colour", "report")
def _exists(path):
return os.path.exists(path) and os.path.getsize(path) > 0
def process(root, stages=STAGES, force=False, verbose=True):
"""Run the requested stages for one session. Returns a result summary."""
s = session_mod.Session(root)
result = {"root": root, "stages": {}, "ok": True}
t0 = time.time()
if "ingest" in stages:
s.ingest(verbose=verbose)
s.scan(verbose=verbose)
if not s.frames:
print(" no light frames found - skipping")
result["ok"] = False
result["error"] = "no frames"
return result
s.save_manifest()
result["target"] = s.target
result["telescope"] = s.telescope
result["palette"] = s.palette
result["frames"] = len(s.frames)
results = {}
try:
if "measure" in stages:
import measure
cache = os.path.join(root, layout.INTERMEDIATES, "_measure.npz")
if force or not _exists(cache):
print(" measure")
measure.run(s, force=force, verbose=False)
else:
print(" measure (cached)")
_, results["stats"] = measure.load(s)
if "register" in stages:
import measure
import register
masters = os.path.join(root, layout.MASTERS)
have = _exists(os.path.join(masters, f"master-{s.filters[0]}.fit"))
if force or not have:
print(" register")
_, ref = register.run(s, verbose=verbose)
else:
print(" register (cached)")
_, st = measure.load(s)
ref = measure.choose_reference(s, st)
results["reference"] = ref
if "solve" in stages:
import astrometry
print(" solve")
try:
results["solve"] = astrometry.run(s, verbose=verbose)
except Exception as exc: # noqa: BLE001
# An unsolved session still produces images; record and go on.
print(f" solve failed: {type(exc).__name__}: {exc}")
results["solve"] = None
if "colour" in stages:
import colour
print(" colour")
results["final"] = colour.run(s, verbose=verbose)
if "report" in stages:
import report
print(" report")
report.write(s, results, verbose=verbose)
except Exception as exc: # noqa: BLE001
result["ok"] = False
result["error"] = f"{type(exc).__name__}: {exc}"
print(f" FAILED: {result['error']}")
if verbose:
traceback.print_exc(limit=3)
result["solved"] = bool(results.get("solve"))
result["seconds"] = time.time() - t0
return result
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("path", nargs="?", default=None,
help="session directory, or a tree with --all")
ap.add_argument("--all", action="store_true",
help="find and process every session beneath PATH")
ap.add_argument("--stage", action="append", choices=STAGES,
help="run only this stage (repeatable)")
ap.add_argument("--force", action="store_true",
help="re-run stages even if their output exists")
args = ap.parse_args(argv)
root = args.path or layout.SESSION
stages = tuple(args.stage) if args.stage else STAGES
targets = session_mod.discover(root) if args.all else [root]
if not targets:
print(f"no sessions found under {root}")
return 1
print(f"{len(targets)} session(s), stages: {', '.join(stages)}\n")
results = []
for i, t in enumerate(targets, 1):
print(f"[{i}/{len(targets)}] {os.path.relpath(t, root)}")
results.append(process(t, stages=stages, force=args.force))
print()
print("=" * 72)
print(f"{'target':16s} {'scope':16s} {'palette':8s} "
f"{'frames':>6s} {'solved':>7s} {'time':>7s}")
for r in results:
if not r.get("ok") and "target" not in r:
print(f"{os.path.basename(r['root']):16s} "
f"FAILED: {r.get('error', 'unknown')}")
continue
print(f"{r.get('target', '?')[:16]:16s} "
f"{r.get('telescope', '?')[:16]:16s} "
f"{r.get('palette', '?'):8s} {r.get('frames', 0):6d} "
f"{'yes' if r.get('solved') else 'no':>7s} "
f"{r.get('seconds', 0):6.0f}s")
failed = [r for r in results if not r.get("ok")]
unsolved = [r for r in results if r.get("ok") and not r.get("solved")]
print(f"\n{len(results) - len(failed)}/{len(results)} processed"
+ (f", {len(unsolved)} without an astrometric solution" if unsolved
else ""))
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())