astrophotography/session-scripts/gc-validate.py
laurence 5286a2e81b Processing and analysis code for remote-telescope imaging sessions
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.
2026-07-21 15:29:49 +01:00

156 lines
6.8 KiB
Python

"""
gc-validate.py -- step 5. External validation against published catalogues.
Two independent truth sets fall inside the field:
* SIMBAD, object type GlC -- confirmed/catalogued Cen A clusters
* SCABS (Taylor et al. 2017, MNRAS 469, 3444) -- deep DECam GC candidates,
with V magnitudes, so it can be binned in brightness
Neither is complete, especially in the inner few arcmin where the galaxy swamps
even professional data, so a candidate that matches nothing is UNCONFIRMED, not
false. Produces NGC5128-gc-recovery.png and prints the numbers used in the notes.
"""
import os, numpy as np, warnings
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from astropy.io import fits
from astropy.wcs import WCS
from astropy.coordinates import SkyCoord
import layout
warnings.filterwarnings("ignore")
S = layout.SESSION
PIXSCALE = 0.5376
NUC_RA, NUC_DEC = 201.365063, -43.019113
C_CAND, C_EXT, C_KNOWN, C_ACC = "#2563eb", "#e8710a", "#127a5a", "#8b5cf6"
C_INK, C_MUTED, C_GRID = "#1a1a1a", "#5c5c5c", "#d8d8d4"
SURF = "#fcfcfb"
plt.rcParams.update({
"figure.facecolor": SURF, "axes.facecolor": SURF, "axes.edgecolor": C_MUTED,
"text.color": C_INK, "xtick.color": C_MUTED, "ytick.color": C_MUTED,
"axes.grid": True, "grid.color": C_GRID, "grid.linewidth": 0.6,
"axes.axisbelow": True, "font.size": 10, "axes.spines.top": False,
"axes.spines.right": False, "legend.frameon": False, "axes.labelcolor": C_INK})
def main():
d = dict(np.load(layout.path("_gc_cat.npz"), allow_pickle=True))
hdr = fits.getheader(layout.path("master-Luminance.fit"))
w = WCS(hdr)
cx, cy = [float(v) for v in w.all_world2pix(NUC_RA, NUC_DEC, 0)]
cat = SkyCoord(d["ra"], d["dec"], unit="deg")
nuc = SkyCoord(NUC_RA, NUC_DEC, unit="deg")
# ---- reference sets restricted to the frame -----------------------------
def infield(ra, dec):
x, y = w.all_world2pix(ra, dec, 0)
return (x > 30) & (x < 4758) & (y > 30) & (y < 3164), x, y
z = np.load(layout.path("_simbad.npz"), allow_pickle=True)
m = z["otype"].astype(str) == "GlC"
sra, sdec = z["ra"][m].astype(float), z["dec"][m].astype(float)
inf, _, _ = infield(sra, sdec)
sim = SkyCoord(sra[inf], sdec[inf], unit="deg")
r = np.load(layout.path("_cena_gc_ref.npz"))
infr, _, _ = infield(r["ra"], r["dec"])
good_p = infr & (r["prob"] >= 0.9) & np.isfinite(r["vmag"])
sca = SkyCoord(r["ra"][good_p], r["dec"][good_p], unit="deg")
scav = r["vmag"][good_p]
print("in-field reference objects: SIMBAD GlC %d ; SCABS p>=0.9 with V %d"
% (len(sim), len(sca)))
cand = d["cand"]
ccand = cat[cand]
call = cat[d["base"]]
def recov(ref):
i, s, _ = ref.match_to_catalog_sky(ccand)
j, s2, _ = ref.match_to_catalog_sky(call)
return s.arcsec < 2.0, s2.arcsec < 2.0
hit_sim, det_sim = recov(sim)
hit_sca, det_sca = recov(sca)
print("SIMBAD GlC: detected at all %d/%d (%.0f%%), kept as candidate %d/%d (%.0f%%)"
% (det_sim.sum(), len(sim), 100 * det_sim.mean(),
hit_sim.sum(), len(sim), 100 * hit_sim.mean()))
print("SCABS p>=0.9: detected %d/%d (%.0f%%), kept %d/%d (%.0f%%)"
% (det_sca.sum(), len(sca), 100 * det_sca.mean(),
hit_sca.sum(), len(sca), 100 * hit_sca.mean()))
# ---- purity -------------------------------------------------------------
ci, cs, _ = ccand.match_to_catalog_sky(sim)
matched_sim = cs.arcsec < 2.0
ci2, cs2, _ = ccand.match_to_catalog_sky(
SkyCoord(r["ra"][infr], r["dec"][infr], unit="deg"))
matched_sca = cs2.arcsec < 2.0
any_match = matched_sim | matched_sca
print("\nPURITY: %d candidates; %d (%.0f%%) match SIMBAD GlC or SCABS; "
"%d (%.0f%%) unconfirmed"
% (cand.sum(), any_match.sum(), 100 * any_match.mean(),
(~any_match).sum(), 100 * (~any_match).mean()))
st = d["simbad_type"][cand]
bad = np.isin(st, ["*", "PM*", "V*", "RR*", "EB*", "LP*"])
print(" candidates matching a SIMBAD STAR-type object: %d (%.1f%%)"
% (bad.sum(), 100 * bad.mean()))
print(" matching a SIMBAD galaxy: %d ; Cepheid: %d ; X-ray/LXB: %d"
% ((st == "G").sum(), (st == "Ce*").sum(), np.isin(st, ["X", "LXB"]).sum()))
rcand = d["r_arcmin"][cand]
print(" unconfirmed fraction inside 8': %.0f%% ; outside 8': %.0f%%"
% (100 * (~any_match)[rcand < 8].mean(), 100 * (~any_match)[rcand >= 8].mean()))
# ---- figure -------------------------------------------------------------
fig, ax = plt.subplots(1, 2, figsize=(12.6, 5.0))
a = ax[0]
vb = np.arange(17.0, 22.6, 0.5)
vc = 0.5 * (vb[1:] + vb[:-1])
fd, fk, nn = [], [], []
for j in range(len(vb) - 1):
s = (scav >= vb[j]) & (scav < vb[j + 1])
nn.append(s.sum())
fd.append(det_sca[s].mean() if s.sum() > 4 else np.nan)
fk.append(hit_sca[s].mean() if s.sum() > 4 else np.nan)
a.plot(vc, fd, lw=2.2, marker="o", ms=7, color=C_KNOWN,
label="detected by our pipeline")
a.plot(vc, fk, lw=2.2, marker="s", ms=7, color=C_CAND,
label="detected AND kept as a candidate")
for x, y, n in zip(vc, fd, nn):
if np.isfinite(y) and n > 4:
a.annotate("%d" % n, (x, y), xytext=(0, 8), textcoords="offset points",
ha="center", fontsize=7.5, color=C_MUTED)
a.set_xlabel("SCABS V magnitude"); a.set_ylabel("fraction recovered")
a.set_ylim(0, 1.05)
a.set_title("a) recovery of published clusters vs brightness", fontsize=10.5, loc="left")
a.legend(fontsize=9, labelcolor=C_INK)
a.text(0.98, 0.9, "numbers = reference objects per bin", transform=a.transAxes,
ha="right", fontsize=8, color=C_MUTED)
b = ax[1]
rsca = sca.separation(nuc).arcmin
rb = np.array([0, 2, 4, 6, 9, 12, 16, 21, 27])
rc = 0.5 * (rb[1:] + rb[:-1])
for arr, c, lab, mk in ((det_sca, C_KNOWN, "detected", "o"),
(hit_sca, C_CAND, "kept as candidate", "s")):
f, nnr = [], []
for j in range(len(rb) - 1):
s = (rsca >= rb[j]) & (rsca < rb[j + 1])
nnr.append(s.sum())
f.append(arr[s].mean() if s.sum() > 4 else np.nan)
b.plot(rc, f, lw=2.2, marker=mk, ms=7, color=c, label=lab)
b.set_xlabel("projected radius (arcmin)"); b.set_ylabel("fraction recovered")
b.set_ylim(0, 1.05)
b.set_title("b) the same, vs radius (SCABS p$\\geq$0.9)", fontsize=10.5, loc="left")
b.legend(fontsize=9, labelcolor=C_INK)
fig.suptitle("External validation against SCABS (Taylor et al. 2017) and SIMBAD",
fontsize=12.5, x=0.008, ha="left")
fig.tight_layout()
fig.savefig(layout.path("NGC5128-gc-recovery.png"), dpi=115, bbox_inches="tight",
facecolor=SURF)
plt.close(fig)
print("wrote NGC5128-gc-recovery.png")
if __name__ == "__main__":
main()