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

102
session-scripts/depth.py Normal file
View file

@ -0,0 +1,102 @@
"""How deep did the luminance master actually go?
Calibrates instrumental magnitudes against Gaia G, then reports the faintest
star still detected at 5 sigma. That number decides which follow-up analyses
are worth attempting on this data and which are wishful thinking.
"""
import os
import numpy as np
import sep
from astropy import units as u
from astropy.coordinates import SkyCoord
from astropy.io import fits
from astropy.wcs import WCS
import layout
OUT = layout.SESSION
CACHE = layout.path("_gaia_deep.npz")
with fits.open(layout.path("master-Luminance.fit")) as hd:
img = hd[0].data.astype(np.float32)
hdr = hd[0].header
wcs = WCS(hdr, naxis=2)
ny, nx = img.shape
bkg = sep.Background(img, bw=64, bh=64, fw=3, fh=3)
sub = img - bkg.back()
objs = sep.extract(sub, 3.0, err=bkg.globalrms, minarea=6, deblend_cont=0.005)
objs = objs[(objs["flag"] == 0) & (objs["npix"] > 6)]
flux, fluxerr, _ = sep.sum_circle(sub, objs["x"], objs["y"], 5.0,
err=bkg.globalrms, subpix=5)
snr = flux / np.maximum(fluxerr, 1e-9)
keep = (flux > 0) & (snr > 3)
objs, flux, snr = objs[keep], flux[keep], snr[keep]
print(f"{len(objs)} sources detected at SNR > 3 (rms {bkg.globalrms:.2f} ADU)")
sky = wcs.pixel_to_world(objs["x"], objs["y"])
centre = wcs.pixel_to_world(nx / 2, ny / 2)
if os.path.exists(CACHE):
z = np.load(CACHE)
gra, gdec, gmag = z["ra"], z["dec"], z["g"]
else:
from astroquery.gaia import Gaia
Gaia.ROW_LIMIT = 60000
job = Gaia.launch_job_async(f"""
SELECT ra, dec, phot_g_mean_mag FROM gaiadr3.gaia_source
WHERE 1 = CONTAINS(POINT('ICRS', ra, dec),
CIRCLE('ICRS', {centre.ra.deg}, {centre.dec.deg}, 0.42))
AND phot_g_mean_mag IS NOT NULL AND phot_g_mean_mag < 20.5
""")
t = job.get_results()
gra = np.asarray(t["ra"], float)
gdec = np.asarray(t["dec"], float)
gmag = np.asarray(t["phot_g_mean_mag"], float)
np.savez_compressed(CACHE, ra=gra, dec=gdec, g=gmag)
print(f"{len(gmag)} Gaia sources in the field, G down to {gmag.max():.2f}")
gcoord = SkyCoord(gra * u.deg, gdec * u.deg)
idx, sep2d, _ = sky.match_to_catalog_sky(gcoord)
matched = sep2d.arcsec < 1.5
print(f"{matched.sum()} detections matched to Gaia within 1.5\"")
inst = -2.5 * np.log10(flux[matched])
gm = gmag[idx[matched]]
# Fit the zero point on well exposed, unsaturated stars only.
fit = (gm > 12) & (gm < 17) & (snr[matched] > 20)
zp = float(np.median(gm[fit] - inst[fit]))
scatter = float(np.std(gm[fit] - inst[fit] - 0.0))
print(f"zero point {zp:.3f} (G = inst + zp) from {fit.sum()} stars, "
f"scatter {scatter:.3f} mag")
mag_all = -2.5 * np.log10(flux) + zp
# SNR falls monotonically with magnitude, so read the SNR=5 crossing off a
# running median rather than requiring sources to land in a narrow SNR bin.
order = np.argsort(mag_all)
ms, ss = mag_all[order], snr[order]
win = max(11, len(ms) // 60)
run_m = np.array([np.median(ms[i:i + win]) for i in range(0, len(ms) - win, win // 2)])
run_s = np.array([np.median(ss[i:i + win]) for i in range(0, len(ss) - win, win // 2)])
below = np.where(run_s < 5.0)[0]
lim5 = float(run_m[below[0]]) if len(below) else float(run_m[-1])
print(f"limiting magnitude at SNR 5: G ~ {lim5:.2f} "
f"(SNR range {snr.min():.1f}-{snr.max():.0f})")
print(f"faintest detection: G ~ {mag_all.max():.2f} (SNR "
f"{snr[np.argmax(mag_all)]:.1f})")
unmatched = ~matched
print(f"{unmatched.sum()} detections with NO Gaia counterpart "
f"({100 * unmatched.mean():.1f}% of sources)")
r_gal = np.hypot(objs["x"] - nx / 2, objs["y"] - ny / 2) * 0.5376 / 60.0
near = unmatched & (r_gal < 12.0) & (mag_all > 18.0) & (mag_all < 22.0)
print(f" of those, {near.sum()} lie within 12' of the galaxy at "
f"G 18-22: the magnitude and radius range of Centaurus A's "
f"globular cluster system")
# Surface brightness of the sky, a fair summary of how much the moon cost.
pixarea = 0.5376 ** 2
sky_adu = float(np.median(bkg.back()))
print(f"sky background {sky_adu:.1f} ADU/px -> "
f"{zp - 2.5 * np.log10(max(sky_adu, 1e-6) / pixarea):.2f} mag/arcsec^2")