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,171 @@
"""
gc-detect.py -- source detection and photometry for the NGC 5128 globular cluster survey.
Step 1 of the pipeline. Reads the luminance master, builds a SPATIALLY VARYING
background model (this is the critical step: NGC 5128's own light is a steep,
structured background and a single global sky level would produce a spurious
central concentration of detections), detects sources above a locally-scaled
threshold, does aperture photometry, measures morphology, and runs forced
photometry at the same positions on the R/G/B masters.
Outputs (into the stacked directory):
_gc_raw.npz all detections + photometry + morphology
NGC5128-gc-background-check.png diagnostic of the background model
Memory: only one 61 MB master is held at a time.
"""
import os, sys, time
import numpy as np
from astropy.io import fits
from astropy.wcs import WCS
import sep
import warnings
import layout
warnings.filterwarnings("ignore")
STACKED = layout.SESSION
# --- configuration -----------------------------------------------------------
BW = 16 # background mesh, px. 8.6 arcsec = 3.2 x the 5 px FWHM. Chosen
# empirically: see the mesh trial in the notes -- a coarser mesh
# destroys recovery of known clusters inside ~8 arcmin.
FW = 3 # median filter over meshes
THRESH = 3.0 # sigma above the LOCAL rms
MINAREA = 5
APR = 5.0 # photometric aperture radius, px, matching the published ZP
ZP_L = 27.941 # G_mag = -2.5 log10(flux) + ZP_L
FWHM_PX = 5.0
# NGC 5128 nucleus, J2000 (NED)
NUC_RA, NUC_DEC = 201.365063, -43.019113
def gauss_kernel(fwhm, size=None):
sig = fwhm / 2.3548
if size is None:
size = int(2 * round(3 * sig) + 1)
r = np.arange(size) - size // 2
x, y = np.meshgrid(r, r)
k = np.exp(-(x * x + y * y) / (2 * sig * sig))
return (k / k.sum()).astype(np.float32)
def load(name):
d = fits.getdata(layout.path(name)).astype(np.float32)
if not d.dtype.isnative:
d = d.byteswap().newbyteorder()
return np.ascontiguousarray(d)
def main():
t0 = time.time()
hdr = fits.getheader(layout.path("master-Luminance.fit"))
w = WCS(hdr)
ny, nx = hdr["NAXIS2"], hdr["NAXIS1"]
data = load("master-Luminance.fit")
# ---------------------------------------------------------------- background
# Compare mesh sizes so the choice is defensible, then adopt BW.
print("background mesh comparison (median |back| inside r<2' of nucleus vs outer field):")
nx_c, ny_c = w.all_world2pix(NUC_RA, NUC_DEC, 0)
nx_c, ny_c = float(nx_c), float(ny_c)
print(" nucleus pixel = %.1f, %.1f" % (nx_c, ny_c))
yy, xx = np.mgrid[0:ny:8, 0:nx:8]
rr = np.hypot(xx - nx_c, yy - ny_c) * 0.5376 / 60.0 # arcmin
inner = rr < 2.0
outer = rr > 12.0
for bw in (16, 32, 64, 128, 512):
b = sep.Background(data, bw=bw, bh=bw, fw=FW, fh=FW)
bk = b.back()[::8, ::8]
rms = b.rms()[::8, ::8]
print(" bw=%4d back(in)=%8.1f back(out)=%7.2f rms(in)=%7.2f rms(out)=%6.2f"
% (bw, np.median(bk[inner]), np.median(bk[outer]),
np.median(rms[inner]), np.median(rms[outer])))
del b, bk, rms
bkg = sep.Background(data, bw=BW, bh=BW, fw=FW, fh=FW)
back = bkg.back()
rmsmap = bkg.rms()
data_sub = data - back
del data
np.save(layout.path("_gc_backmodel_thumb.npy"), back[::8, ::8])
del back
# ---------------------------------------------------------------- detection
sep.set_extract_pixstack(3000000)
kern = gauss_kernel(FWHM_PX)
objs, segmap = sep.extract(data_sub, THRESH, err=rmsmap, minarea=MINAREA,
filter_kernel=kern, filter_type="matched",
deblend_nthresh=32, deblend_cont=0.005,
clean=True, clean_param=1.0, segmentation_map=True)
print("detected %d sources (thresh=%.1f x LOCAL rms, minarea=%d)"
% (len(objs), THRESH, MINAREA))
del segmap
x, y = objs["x"], objs["y"]
# ---------------------------------------------------------------- photometry
flux, fluxerr, flag = sep.sum_circle(data_sub, x, y, APR, err=rmsmap,
gain=None, subpix=5)
# local-noise-only error; add Poisson from the source itself using EGAIN
egain = float(hdr.get("EGAIN", 0.2467))
nimg = 12.0 # 12 x 300 s stacked, normalised -> approximate
poiss = np.clip(flux, 0, None) / (egain * nimg)
fluxerr_tot = np.sqrt(fluxerr ** 2 + poiss)
# aperture at 2x radius, to catch extended light (galaxy discriminator)
flux2, _, _ = sep.sum_circle(data_sub, x, y, 2 * APR, err=rmsmap, subpix=5)
# half-light radius and a "PSF concentration" index
rhalf, rflag = sep.flux_radius(data_sub, x, y, np.full(len(x), 6 * APR),
0.5, normflux=flux2, subpix=5)
# peak-to-total sharpness
with np.errstate(divide="ignore", invalid="ignore"):
conc = -2.5 * np.log10(np.clip(flux, 1e-9, None) / np.clip(flux2, 1e-9, None))
# local background rms at each source (for SNR bookkeeping)
xi = np.clip(np.round(x).astype(int), 0, nx - 1)
yi = np.clip(np.round(y).astype(int), 0, ny - 1)
local_rms = rmsmap[yi, xi]
local_back = np.load(layout.path("_gc_backmodel_thumb.npy"))[
np.clip(yi // 8, 0, ny // 8 - 1), np.clip(xi // 8, 0, nx // 8 - 1)]
del rmsmap, data_sub
with np.errstate(divide="ignore", invalid="ignore"):
mag = -2.5 * np.log10(np.clip(flux, 1e-9, None)) + ZP_L
magerr = 1.0857 * fluxerr_tot / np.clip(flux, 1e-9, None)
snr = flux / np.clip(fluxerr_tot, 1e-9, None)
ra, dec = w.all_pix2world(x, y, 0)
out = dict(x=x, y=y, ra=ra, dec=dec,
flux=flux, fluxerr=fluxerr_tot, mag=mag, magerr=magerr, snr=snr,
flux2=flux2, rhalf=rhalf, conc=conc,
a=objs["a"], b=objs["b"], theta=objs["theta"],
npix=objs["npix"].astype(np.float64), peak=objs["peak"],
cflux=objs["cflux"], sepflag=objs["flag"].astype(np.float64),
apflag=flag.astype(np.float64),
local_rms=local_rms, local_back=local_back)
# ---------------------------------------------------------------- colours
for band, key in (("Red", "R"), ("Green", "V"), ("Blue", "B")):
d = load("master-%s.fit" % band)
b = sep.Background(d, bw=BW, bh=BW, fw=FW, fh=FW)
ds = d - b.back()
rm = b.rms()
del d
f, fe, fl = sep.sum_circle(ds, x, y, APR, err=rm, subpix=5)
out["flux_" + key] = f
out["fluxerr_" + key] = fe
print(" forced photometry on %s done" % band)
del ds, rm, b, f, fe, fl
np.savez(layout.path("_gc_raw.npz"), **out)
print("wrote _gc_raw.npz with %d rows in %.0f s" % (len(x), time.time() - t0))
if __name__ == "__main__":
main()