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:
commit
5286a2e81b
53 changed files with 8820 additions and 0 deletions
171
session-scripts/gc-detect.py
Normal file
171
session-scripts/gc-detect.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue