""" 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()