"""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())