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.
This commit is contained in:
laurence 2026-07-21 15:29:49 +01:00
commit 5286a2e81b
53 changed files with 8820 additions and 0 deletions

View file

@ -0,0 +1,228 @@
"""Figure: the transient search and why its six finalists are not transients.
Top block, one column per finalist: the 60 min luminance master beside a
Digitized Sky Survey red plate of the same patch of sky from the 1990s. If a
source is on both, it is not a transient, and that is a judgement a reader can
make from the picture without trusting any of the catalogue machinery.
Bottom left: where the catalogues run out. Almost every source brighter than
G = 19 has a Gaia DR3 counterpart; below that the unmatched fraction climbs
steeply, not because transients appear but because Gaia's G < 20.5 point-source
catalogue stops being complete against a bright galaxy. This is what sets the
honest limit of the search.
Bottom middle: the point-source selection. Half-light radius against magnitude,
with the image's own PSF measured from Gaia stars. Note that Centaurus A's
globular clusters sit inside the stellar locus - at 3.8 Mpc they are unresolved
in 2.7 arcsec seeing - so morphology cannot separate a cluster from a
supernova here, and five of the six finalists are clusters.
Bottom right: where the finalists are, on the master.
"""
import os
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from astropy import units as u
from astropy.coordinates import SkyCoord
from astropy.io import fits
from astropy.stats import sigma_clipped_stats
from astropy.wcs import WCS
import mo_common as C
OUTPNG = os.path.join(C.OUT, "NGC5128-mo-transient-search.png")
HALF = 26 # px in the master = 28 arcsec across
# Identifications established by cross-matching the six catalogue non-matches
# against published Centaurus A cluster catalogues, SIMBAD and the VizieR
# archive as a whole (see notes-transient-search.md).
IDENT = {
"201.672625": ("WHH-31", "Cen A globular cluster", "V=19.50 (Woodley+07)"),
"201.500583": ("pff_gc-081", "Cen A globular cluster",
"V=19.34 (Woodley+07)"),
"201.441625": ("WHH-24", "Cen A globular cluster", "V=19.63 (Woodley+07)"),
"201.407000": ("at CAVS 210",
"RESOLVED (1.34x PSF), archival 1996-2020",
"DENIS I=18.23; Gaia pt src G=21.03 at 1.32\" - see 4.3"),
"201.303542": ("WHH-10", "Cen A globular cluster", "V=19.57 (Woodley+07)"),
"201.256708": ("pff_gc-029", "Cen A globular cluster",
"V=19.37 (Woodley+07)"),
}
def ident_for(ra):
for k, v in IDENT.items():
if abs(float(k) - ra) < 1e-4:
return v
return ("unidentified", "", "")
def stretch(a, lo=25.0, hi=99.6):
a = np.asarray(a, dtype=float)
good = np.isfinite(a)
if not good.any():
return 0.0, 1.0
return np.percentile(a[good], lo), np.percentile(a[good], hi)
def main():
rows = list(np.load(os.path.join(C.OUT, "_mo_vetted.npy"),
allow_pickle=True))
rows.sort(key=lambda r: r["dist_cenA_arcmin"])
z = np.load(os.path.join(C.OUT, "_mo_transient.npz"))
with fits.open(os.path.join(C.OUT, "master-Luminance.fit")) as hd:
master = hd[0].data.astype(np.float32)
hdr = hd[0].header
wcs = WCS(hdr)
import sep
bkg = sep.Background(master, bw=64, bh=64, fw=3, fh=3)
msub = master - bkg.back()
n = len(rows)
fig = plt.figure(figsize=(15.0, 11.6))
gs = fig.add_gridspec(3, n, height_ratios=[1.0, 1.0, 1.55],
hspace=0.30, wspace=0.09,
left=0.055, right=0.985, top=0.845, bottom=0.075)
for i, r in enumerate(rows):
name, kind, phot = ident_for(r["ra_deg"])
x, y = int(round(r["x"])), int(round(r["y"]))
cut = msub[y - HALF:y + HALF + 1, x - HALF:x + HALF + 1]
ax = fig.add_subplot(gs[0, i])
vlo, vhi = stretch(cut, 20, 99.5)
ax.imshow(cut, origin="lower", cmap="gray", vmin=vlo, vmax=vhi)
ax.plot([HALF, HALF], [HALF + 6, HALF + 11], color="#f0c05a", lw=1.1)
ax.plot([HALF + 6, HALF + 11], [HALF, HALF], color="#f0c05a", lw=1.1)
ax.set_xticks([]); ax.set_yticks([])
for sp in ax.spines.values():
sp.set_color("#2f7d54" if kind.startswith("Cen") else "#b5484f")
sp.set_linewidth(1.4)
ax.set_title(f"{name}\n{r['ra_hms']} {r['dec_dms']}", fontsize=8.6,
color="#1b1e23", pad=5)
if i == 0:
ax.set_ylabel("this image\n60 min luminance", fontsize=9,
color="#3b3f45")
# archival plate
tag = f"{r['ra_deg']:.5f}{r['dec_deg']:+.5f}_mred.fits"
path = os.path.join(C.OUT, "_dss", tag)
ax2 = fig.add_subplot(gs[1, i])
if os.path.exists(path):
with fits.open(path) as h2:
d = np.asarray(h2[0].data, dtype=float)
v1, v2 = stretch(d, 20, 99.5)
ax2.imshow(d, origin="lower", cmap="gray", vmin=v1, vmax=v2)
m = d.shape[0] / 2.0
f = d.shape[0] / (2.0 * HALF + 1)
ax2.plot([m, m], [m + 6 * f, m + 11 * f], color="#f0c05a", lw=1.1)
ax2.plot([m + 6 * f, m + 11 * f], [m, m], color="#f0c05a", lw=1.1)
else:
ax2.text(0.5, 0.5, "no DSS cutout", ha="center", va="center",
fontsize=8, color="#8a9099", transform=ax2.transAxes)
ax2.set_xticks([]); ax2.set_yticks([])
for sp in ax2.spines.values():
sp.set_color("#9aa0a8")
sp.set_linewidth(0.9)
ax2.set_xlabel(f"{kind}\n{phot}\nG(this image) = {r['g_mag']:.2f}, "
f"{r['dist_cenA_arcmin']:.1f}' from nucleus",
fontsize=7.8, color="#4a4f57", labelpad=5)
if i == 0:
ax2.set_ylabel("archival\nDSS2 red, 1990s", fontsize=9,
color="#3b3f45")
# ---------------- magnitude completeness ----------------
axm = fig.add_subplot(gs[2, 0:2])
good = z["good"] & z["pointlike"]
mag = z["mag"][good]
matched = z["gaia_ok"][good]
bins = np.arange(14.0, 21.01, 0.25)
axm.hist([mag[matched], mag[~matched]], bins=bins, stacked=True,
color=["#3d7ba6", "#b5484f"],
label=["matched to Gaia DR3", "no Gaia counterpart"])
axm.axvline(20.5, color="#4a4f57", ls="--", lw=1.2)
axm.text(20.42, axm.get_ylim()[1] * 0.92, "Gaia cat.\nlimit G=20.5",
fontsize=7.6, color="#4a4f57", ha="right", va="top")
axm.set_xlabel("G magnitude (5 px aperture, master)", fontsize=9.5)
axm.set_ylabel("point-like sources per 0.25 mag", fontsize=9.5)
axm.set_title("where the reference catalogue runs out", fontsize=10.5,
color="#1b1e23", pad=7)
axm.legend(fontsize=8.2, frameon=False, loc="upper left")
axm.grid(alpha=0.22, lw=0.6)
for sp in ("top", "right"):
axm.spines[sp].set_visible(False)
# ---------------- morphology ----------------
axr = fig.add_subplot(gs[2, 2:4])
sel = z["good"]
axr.scatter(z["mag"][sel], z["r50"][sel] / z["r50_star"], s=2.5,
c="#9fb6c6", alpha=0.35, lw=0, label="all detections")
ci = z["cand_idx"]
axr.scatter(z["mag"][ci], z["r50"][ci] / z["r50_star"], s=9,
c="#e0a13c", alpha=0.8, lw=0,
label="no Gaia / SkyMapper match")
for r in rows:
axr.scatter([r["g_mag"]], [r["r50_over_psf"]], s=64,
facecolor="none",
edgecolor="#2f7d54" if ident_for(r["ra_deg"])[1]
.startswith("Cen") else "#b5484f", lw=1.5, zorder=5)
axr.axhline(1.0, color="#4a4f57", ls=":", lw=1.1)
axr.text(14.3, 1.03, "PSF (Gaia stars)", fontsize=7.6, color="#4a4f57")
axr.set_ylim(0.4, 3.2)
axr.set_xlim(14.0, 21.0)
axr.set_xlabel("G magnitude", fontsize=9.5)
axr.set_ylabel("half-light radius / PSF", fontsize=9.5)
axr.set_title("morphology cannot separate clusters from transients",
fontsize=10.5, color="#1b1e23", pad=7)
axr.legend(fontsize=8.0, frameon=False, loc="upper left",
markerscale=2.2)
axr.grid(alpha=0.22, lw=0.6)
for sp in ("top", "right"):
axr.spines[sp].set_visible(False)
# ---------------- field map ----------------
axf = fig.add_subplot(gs[2, 4:6])
sm = master[::6, ::6]
v1, v2 = np.percentile(sm[np.isfinite(sm)], [12, 99.75])
axf.imshow(np.arcsinh((sm - v1) / max(v2 - v1, 1e-6)), origin="lower",
cmap="gray", vmin=0, vmax=np.arcsinh(1.0))
for r in rows:
c = "#2f7d54" if ident_for(r["ra_deg"])[1].startswith("Cen") \
else "#b5484f"
axf.scatter([r["x"] / 6], [r["y"] / 6], s=90, facecolor="none",
edgecolor=c, lw=1.5)
axf.annotate(ident_for(r["ra_deg"])[0], (r["x"] / 6, r["y"] / 6),
textcoords="offset points", xytext=(9, 5), fontsize=7.4,
color=c)
axf.set_xticks([]); axf.set_yticks([])
axf.set_title("positions on the field (43' x 29')", fontsize=10.5,
color="#1b1e23", pad=7)
for sp in axf.spines.values():
sp.set_color("#9aa0a8")
fig.text(0.02, 0.962,
"Transient search in the 60 min luminance master of NGC 5128 "
"(Centaurus A), 2026-07-21", fontsize=14, weight="bold",
color="#1b1e23", ha="left")
fig.text(0.02, 0.936,
"7,519 point-like sources detected above SNR 10. 603 had no "
"Gaia DR3 counterpart; 567 also had none in SkyMapper DR2; 6 of "
"those survived the requirement of an independent detection in "
"at least 6 of the 12 subs.",
fontsize=9.4, color="#4a4f57", ha="left")
fig.text(0.02, 0.917,
"All 6 are identified below. Five are catalogued Centaurus A "
"globular clusters; the sixth is a resolved object with archival "
"detections from 1996 to 2020. No transient candidate remains.",
fontsize=9.4, color="#4a4f57", ha="left")
fig.savefig(OUTPNG, dpi=130, facecolor="white")
print(f"wrote {OUTPNG}")
if __name__ == "__main__":
main()