The scripts that processed the NGC 5128 session of 2026-07-21 previously lived inside the data directory and addressed it with absolute paths. Code and data are now separated: the code lives here, and a session is located at runtime through the ASTRO_SESSION environment variable. layout.py is what makes that work. It maps a FILENAME to the subdirectory that file belongs in, using the same rules the session directories are organised with, so a script can go on asking for 'master-Red.fit' or '_stars.npz' without any call site knowing the directory structure. Anything unrecognised resolves to the session root, which is visible and correctable rather than silently wrong. restructure.py reorganises a flat session directory into that layout. It is idempotent and dry-run by default. The 50 session scripts are kept as they were run rather than tidied into a library. They were written in sequence as the work went along, several of them by parallel agents, and they show it - but they are the honest provenance of a published set of results, and the productionised pipeline should be able to reproduce those results exactly. Verified before committing: all 51 files compile without warnings, and verify_core.py, closeup.py and triptych.py were run end to end against the reorganised session, correctly finding inputs across calibrated/, stacks/masters/ and final/ and writing outputs back to the right places.
423 lines
16 KiB
Python
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())
|