astrophotography/pipeline/run.py
laurence 3eb5ab6a03 Produce the four deliverable images and the science outputs for every session
The four-image set worked out on Centaurus A is now generated for any
target, because its value is the comparison: the same data at four levels
of treatment, so a viewer can see what processing did and did not add.
colour.py's assembly is driven by flags - gradient, neutralise, denoise,
saturation, hdr, protect-compact - so the baseline and the fully
corrected version come from ONE code path and the only differences
between them are the ones named.

Image 3 applies what the measurements justify rather than a house style.
Each item is there because measuring the first session caught the
conventional version getting something wrong: a plane fit that had
absorbed 17.9 ADU/px of galaxy halo, a core flattened by a white point
set by field stars, deconvolution ringing around every bright star, and
denoising erasing faint compact sources that turned out to be globular
clusters. Compact sources are now explicitly protected from smoothing -
2374 of them on NGC 2030.

The close-up revealed a real design error, caught by its own assertion.
Forcing a square crop cannot contain a target wider than the frame is
tall, which is the normal case for a nebula in a wide field, and the
assertion fired rather than silently cutting the subject in half. Crops
are no longer square, and when a target genuinely fills the field the
close-up is skipped with that said plainly - re-saving image 3 under a
name claiming to be a close-up would be worse than producing nothing.

science.py adds the measurements that generalise to any target:
photometric calibration from the field's own Gaia stars, the limiting
magnitude actually reached, a source catalogue with calibrated
magnitudes, an annotated field placed by the plate solution, and a radial
surface-brightness profile. Object-specific analyses stay hand-driven,
because a cluster survey suits a galaxy and is meaningless for a nebula.

All of it depends on astrometry, so an unsolved session gets no science
and says so instead of quietly producing less. NGC 2030 calibrates to a
zero point of 24.794 with 0.202 mag scatter on 917 stars, 3470 sources,
limiting G of 18.2.
2026-07-21 22:18:35 +01:00

170 lines
6.3 KiB
Python

"""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",
"science", "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 "science" in stages:
import science
print(" science")
try:
results["science"] = science.run(s, verbose=verbose)
except Exception as exc: # noqa: BLE001
# Measurements are a bonus on top of the images; losing them
# must not lose the pictures too.
print(f" science failed: {type(exc).__name__}: {exc}")
results["science"] = None
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())