astrophotography/pipeline/mo_gccheck.py
laurence c6299f41ab Move the processing code under pipeline/
Preparing to merge this repository into a combined astrophotography
repo. session-scripts/ becomes pipeline/ because the scripts import
layout.py from their own directory and must stay together, and because
'pipeline' says what it is rather than how it came about. observing/
stays at the top level: observing plans are not processing code.
2026-07-21 17:13:54 +01:00

423 lines
16 KiB
Python

"""Cross-match six unidentified point-like detections in an amateur NGC 5128
(Centaurus A) image against published catalogues, to establish whether each is an
already-catalogued object (globular cluster of Cen A, background galaxy, foreground
star, X-ray/UV source, planetary nebula, ...) or genuinely uncatalogued.
Context: the six sources have already been shown NOT to match Gaia DR3 (G<20.5)
or SkyMapper DR2. The purpose here is to rule out a supernova / transient, so a
firm identification with a static catalogued source is the desired outcome and a
"no match" is a result that must be reported honestly rather than manufactured.
Three independent searches are run for every position:
1. TARGETED -- a hand-picked list of VizieR catalogues covering Cen A globular
clusters and compact stellar systems (Woodley+, Harris+, Taylor+ SCABS,
Hughes+, Dumont+, Voggel+, Mieske+ CCOS), plus Cen A X-ray, UV, planetary
nebula and variable-star catalogues, at a 5 arcsec cone radius. The exact
VizieR identifiers are resolved at runtime with Vizier.find_catalogs /
get_catalog_metadata rather than being assumed correct.
2. BLANKET -- Vizier.query_region(..., catalog=None), i.e. every table in
VizieR, at a 5 arcsec cone radius. Every table returning a row is reported
with its designation, separation and any magnitude-like columns.
3. SIMBAD -- astroquery.simbad cone search at 10 arcsec, reporting main
identifier, object type and separation.
Astrometry of the input positions is good to well under 1 arcsec; 3-5 arcsec is
used because older ground-based Cen A cluster catalogues can be off by 1-2 arcsec.
Output: a plain-text report at
C:\\Users\\lhorrocks-barlow\\Downloads\\NGC5128\\20260721\\stacked\\_gc_crossmatch.txt
Run: python mo_gccheck.py
"""
import io
import sys
import time
import warnings
import contextlib
import numpy as np
import astropy.units as u
from astropy.coordinates import SkyCoord
from astroquery.vizier import Vizier
import layout
warnings.filterwarnings("ignore")
OUT = layout.path("_gc_crossmatch.txt")
# id, RA deg, Dec deg, luminance G, r50/psf, note
SOURCES = [
(1, 201.672625, -43.190250, 19.18, 1.08, ""),
(2, 201.500583, -42.816944, 19.04, 1.04, ""),
(3, 201.441625, -42.948111, 19.42, 1.02, ""),
(4, 201.407000, -43.041639, 18.20, 1.33, "brightest, 2.3' from nucleus"),
(5, 201.303542, -42.950000, 19.24, 1.05, ""),
(6, 201.256708, -42.911417, 19.06, 1.03, ""),
]
# Targeted catalogues. (VizieR id, short human label)
TARGETS = [
("J/AJ/134/494", "Woodley+ 2007, Cen A GC catalogue (kinematics/dyn.)"),
("J/AJ/139/1871", "Woodley+ 2010, Cen A GC ages/metallicities"),
("J/AJ/143/84", "Harris+ 2012, new candidate GCs in NGC 5128"),
("J/ApJ/682/199", "Woodley+ 2008, Cen A globulars with X-ray sources"),
("J/MNRAS/469/3444", "Taylor+ 2017, SCABS II GC candidates"),
("J/ApJ/914/16", "Hughes+ 2021, NGC 5128 GCs (PISCeS/Gaia DR2/NSC)"),
("J/ApJ/929/147", "Dumont+ 2022, luminous GCs in NGC 5128"),
("J/ApJ/899/140", "Voggel+ 2020, Gaia UCD candidates"),
("J/A+A/472/111", "Mieske+ 2007, Centaurus Compact Object Survey"),
("J/MNRAS/389/1150", "Spitler+ 2008, Spitzer photometry of globulars"),
("J/A+A/447/71", "Voss+ 2006, Chandra X-ray point sources in Cen A"),
("J/ApJ/560/675", "Kraft+ 2001, Chandra X-ray point sources in Cen A"),
("J/ApJ/766/88", "Burke+ 2013, Chandra X-ray binaries in Cen A"),
("J/MNRAS/516/2300", "Joseph+ 2022, UV sources in the Cen A nuclear region"),
("J/A+A/574/A109", "Walsh+ 2015, planetary nebulae in NGC 5128"),
("J/A+A/478/755", "de Jong+ 2008, variable stars in Cen A"),
("J/A+A/657/A41", "Rejkuba+ 2022, stellar halo of NGC 5128"),
("J/A+A/448/983", "Rejkuba+ 2006, VIJsKs in AM1339-445 / AM1343-452"),
("J/A+A/705/A112", "Faucher+ 2026, distances of Cen A / M83 galaxies"),
("J/AJ/133/504", "Karachentsev+ 2007, galaxies around CenA/M83"),
]
RAD_TARGET = 5.0 * u.arcsec
RAD_BLANKET = 5.0 * u.arcsec
RAD_SIMBAD = 10.0 * u.arcsec
MAGISH = ("mag", "MAG", "Vmag", "Bmag", "Rmag", "Imag", "gmag", "rmag", "imag",
"umag", "zmag", "Kmag", "Jmag", "Hmag", "Gmag", "FUV", "NUV", "flux",
"Flux", "F(")
class Tee:
def __init__(self, *streams):
self.streams = streams
def write(self, s):
for st in self.streams:
st.write(s)
def flush(self):
for st in self.streams:
st.flush()
def sky(rec):
return SkyCoord(rec[1] * u.deg, rec[2] * u.deg, frame="icrs")
def row_coord(tab, row):
"""Best-effort ICRS coordinate for a VizieR result row."""
cn = tab.colnames
for rk, dk in (("_RAJ2000", "_DEJ2000"), ("RAJ2000", "DEJ2000"),
("RA_ICRS", "DE_ICRS"), ("_RA", "_DE"), ("RAB1950", "DEB1950")):
if rk in cn and dk in cn:
rv, dv = row[rk], row[dk]
if rv is None or dv is None:
continue
try:
if isinstance(rv, str) and (":" in rv or " " in rv.strip()):
return SkyCoord(rv, dv, unit=(u.hourangle, u.deg), frame="icrs")
fr, fd = float(rv), float(dv)
if not (np.isfinite(fr) and np.isfinite(fd)):
continue
fr_ = "fk4" if rk == "RAB1950" else "icrs"
return SkyCoord(fr * u.deg, fd * u.deg,
frame=fr_).icrs if fr_ == "fk4" else \
SkyCoord(fr * u.deg, fd * u.deg, frame="icrs")
except Exception:
continue
return None
def designation(tab, row):
cn = tab.colnames
prefer = ["Name", "ID", "GC", "Cluster", "Seq", "Source", "SimbadName",
"OName", "Object", "Obj", "CXO", "PN", "UCD", "GCID", "recno",
"HGHH", "WHH", "AAT", "f_Name", "No", "Nr"]
parts = []
for k in prefer:
if k in cn and row[k] is not None:
v = str(row[k]).strip()
if v and v not in ("--", "nan", "None"):
parts.append(f"{k}={v}")
if len(parts) >= 2:
break
return " ".join(parts) if parts else "(no name column)"
def mags(tab, row, limit=8):
out = []
for c in tab.colnames:
if c.startswith("_"):
continue
if any(m in c for m in MAGISH):
v = row[c]
if v is None:
continue
try:
fv = float(v)
if not np.isfinite(fv):
continue
out.append(f"{c}={fv:.3f}")
except (TypeError, ValueError):
s = str(v).strip()
if s and s not in ("--", "nan"):
out.append(f"{c}={s}")
if len(out) >= limit:
break
return ", ".join(out) if out else "(no magnitudes)"
def classification(tab, row):
out = []
for c in ("Class", "Type", "otype", "OType", "Cl", "Note", "Notes", "Flag",
"Prob", "p", "Member", "n_Name", "Com", "Comm", "Sp", "SpType"):
if c in tab.colnames and row[c] is not None:
v = str(row[c]).strip()
if v and v not in ("--", "nan", "None"):
out.append(f"{c}={v}")
return "; ".join(out) if out else ""
def verify_catalogues():
"""Confirm which of TARGETS actually exist on VizieR."""
print("\n" + "=" * 78)
print("STEP 0 -- verifying targeted VizieR catalogue identifiers")
print("=" * 78)
live, dead = [], []
for cid, label in TARGETS:
try:
with contextlib.redirect_stdout(io.StringIO()):
meta = Vizier.get_catalog_metadata(catalog=cid)
desc = ""
try:
desc = str(meta["description"][0])
except Exception:
desc = "(metadata returned, no description field)"
print(f" OK {cid:20s} {desc[:78]}")
live.append((cid, label))
except Exception as e:
print(f" ABSENT/FAILED {cid:20s} ({label})")
print(f" -> {type(e).__name__}: {str(e)[:140]}")
dead.append((cid, label, f"{type(e).__name__}: {e}"))
return live, dead
def query_targeted(live, coords):
"""Return {src_id: [ (cid,label,tabname,sep,desig,mags,cls) ]}"""
res = {s[0]: [] for s in SOURCES}
print("\n" + "=" * 78)
print(f"STEP 1 -- targeted catalogue cone search, r = {RAD_TARGET}")
print("=" * 78)
v = Vizier(columns=["**", "+_r"], row_limit=200)
for cid, label in live:
print(f"\n [{cid}] {label}")
for rec in SOURCES:
sid = rec[0]
c = coords[sid]
try:
tl = v.query_region(c, radius=RAD_TARGET, catalog=cid)
except Exception as e:
print(f" src {sid}: QUERY FAILED {type(e).__name__}: {str(e)[:110]}")
continue
n = 0
for tab in tl:
tname = tab.meta.get("name", cid)
for row in tab:
rc = row_coord(tab, row)
if rc is not None:
sep = c.separation(rc).arcsec
elif "_r" in tab.colnames and row["_r"] is not None:
try:
sep = float(row["_r"]) * 60.0
except Exception:
sep = float("nan")
else:
sep = float("nan")
if np.isfinite(sep) and sep > RAD_TARGET.to_value(u.arcsec) + 0.5:
continue
d = designation(tab, row)
m = mags(tab, row)
cl = classification(tab, row)
res[sid].append((cid, label, tname, sep, d, m, cl))
n += 1
print(f" src {sid}: MATCH {tname} sep={sep:.2f}\" {d}")
print(f" mags: {m}")
if cl:
print(f" class: {cl}")
if n == 0:
print(f" src {sid}: no match")
time.sleep(0.15)
return res
def query_blanket(coords):
res = {s[0]: [] for s in SOURCES}
print("\n" + "=" * 78)
print(f"STEP 2 -- BLANKET all-VizieR cone search, r = {RAD_BLANKET}")
print("=" * 78)
v = Vizier(columns=["**", "+_r"], row_limit=50, timeout=600)
for rec in SOURCES:
sid = rec[0]
c = coords[sid]
print(f"\n --- source {sid} {c.to_string('hmsdms', sep=':', precision=2)} ---")
try:
tl = v.query_region(c, radius=RAD_BLANKET, catalog=None)
except Exception as e:
print(f" BLANKET QUERY FAILED: {type(e).__name__}: {str(e)[:200]}")
continue
if tl is None or len(tl) == 0:
print(" no tables returned")
continue
print(f" {len(tl)} table(s) with entries")
for tab in tl:
tname = tab.meta.get("name", "?")
best = None
for row in tab:
rc = row_coord(tab, row)
sep = c.separation(rc).arcsec if rc is not None else float("nan")
if not np.isfinite(sep) and "_r" in tab.colnames:
try:
sep = float(row["_r"]) * 60.0
except Exception:
pass
item = (tname, sep, designation(tab, row), mags(tab, row),
classification(tab, row))
if best is None or (np.isfinite(sep) and
(not np.isfinite(best[1]) or sep < best[1])):
best = item
res[sid].append(item)
if best:
print(f" {best[0]:28s} sep={best[1]:6.2f}\" {best[2]}")
print(f" {best[3]}")
if best[4]:
print(f" {best[4]}")
time.sleep(0.3)
return res
def query_simbad(coords):
res = {s[0]: [] for s in SOURCES}
print("\n" + "=" * 78)
print(f"STEP 3 -- SIMBAD cone search, r = {RAD_SIMBAD}")
print("=" * 78)
try:
from astroquery.simbad import Simbad
except Exception as e:
print(f" SIMBAD IMPORT FAILED: {e}")
return res
sb = Simbad()
sb.TIMEOUT = 180
for extra in ("otype", "otypes", "V", "B", "R", "G", "sp_type", "rvz_redshift",
"distance"):
try:
sb.add_votable_fields(extra)
except Exception:
pass
for rec in SOURCES:
sid = rec[0]
c = coords[sid]
print(f"\n --- source {sid} ---")
try:
t = sb.query_region(c, radius=RAD_SIMBAD)
except Exception as e:
print(f" SIMBAD QUERY FAILED: {type(e).__name__}: {str(e)[:160]}")
continue
if t is None or len(t) == 0:
print(" no SIMBAD object within 10\"")
continue
rk = "ra" if "ra" in t.colnames else "RA"
dk = "dec" if "dec" in t.colnames else "DEC"
for row in t:
try:
rc = SkyCoord(float(row[rk]) * u.deg, float(row[dk]) * u.deg)
sep = c.separation(rc).arcsec
except Exception:
sep = float("nan")
mid = str(row["main_id"] if "main_id" in t.colnames else row["MAIN_ID"])
ot = str(row["otype"]) if "otype" in t.colnames else "?"
ots = str(row["otypes"]) if "otypes" in t.colnames else ""
mg = []
for k in ("V", "B", "R", "G"):
if k in t.colnames and row[k] is not None:
try:
fv = float(row[k])
if np.isfinite(fv):
mg.append(f"{k}={fv:.2f}")
except Exception:
pass
res[sid].append((mid, ot, ots, sep, ", ".join(mg)))
print(f" {mid:30s} otype={ot:12s} sep={sep:6.2f}\" {', '.join(mg)}")
if ots:
print(f" otypes: {ots}")
time.sleep(0.3)
return res
def summarise(targ, blank, simb, dead):
print("\n\n" + "#" * 78)
print("# PER-SOURCE SUMMARY")
print("#" * 78)
for rec in SOURCES:
sid, ra, dec, g, r50, note = rec
c = SkyCoord(ra * u.deg, dec * u.deg)
print("\n" + "-" * 78)
print(f"SOURCE {sid} RA={ra:.6f} Dec={dec:+.6f} "
f"({c.to_string('hmsdms', sep=':', precision=2)})")
print(f" G={g:.2f} r50/psf={r50:.2f} {note}")
t, b, s = targ[sid], blank[sid], simb[sid]
print(f" targeted catalogue matches : {len(t)}")
for cid, label, tname, sep, d, m, cl in t:
print(f" * {tname} sep={sep:.2f}\"")
print(f" {label}")
print(f" {d}")
print(f" {m}")
if cl:
print(f" {cl}")
print(f" blanket VizieR matches : {len(b)}")
for tname, sep, d, m, cl in b:
print(f" * {tname} sep={sep:.2f}\" {d}")
print(f" {m}" + (f" | {cl}" if cl else ""))
print(f" SIMBAD objects <10\" : {len(s)}")
for mid, ot, ots, sep, mg in s:
print(f" * {mid} otype={ot} sep={sep:.2f}\" {mg}")
if dead:
print("\n" + "-" * 78)
print("CATALOGUES THAT COULD NOT BE QUERIED (absent from VizieR or errored):")
for cid, label, err in dead:
print(f" {cid} ({label})")
print(f" {err[:200]}")
def main():
coords = {rec[0]: sky(rec) for rec in SOURCES}
buf = io.StringIO()
real = sys.stdout
sys.stdout = Tee(real, buf)
try:
print("Cen A (NGC 5128) cross-match of six unidentified detections")
print("Generated by mo_gccheck.py at " +
time.strftime("%Y-%m-%d %H:%M:%S"))
print("Input positions J2000/ICRS; no Gaia DR3 (G<20.5) or SkyMapper DR2 match.")
for rec in SOURCES:
sid, ra, dec, g, r50, note = rec
print(f" {sid}: {ra:.6f} {dec:+.6f} G={g:.2f} r50/psf={r50:.2f} {note}")
live, dead = verify_catalogues()
targ = query_targeted(live, coords)
blank = query_blanket(coords)
simb = query_simbad(coords)
summarise(targ, blank, simb, dead)
finally:
sys.stdout = real
with open(OUT, "w", encoding="utf-8") as fh:
fh.write(buf.getvalue())
print(f"\nWROTE {OUT}")
return 0
if __name__ == "__main__":
raise SystemExit(main())