"""Step 3: surface-brightness profile, isophote geometry plots, derived numbers. Produces NGC5128-sb-profile.png mu(a) with every noise/systematic floor marked NGC5128-sb-isophote-geometry.png mu, ellipticity and position angle vs semi-major axis NGC5128-sb-colour-profile.png L, R, G, B profiles and the B-R colour gradient sb-derived-quantities.txt the numbers worth quoting The photutils Pass B table is the primary isophote result. An independent sigma-clipped azimuthal-median extraction on the same elliptical grid is done here as a cross-check and to get the R/G/B profiles on identical isophotes. """ import numpy as np from astropy.io import fits from scipy.optimize import curve_fit import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt from sb_common import * import sb_model XC, YC = np.load(path('_geom.npy')) rms = float(np.load(path('_rms.npy'))[0]) tB = dict(np.load(path('_isoB.npz'))) star = fits.getdata(path('sb-mask-stars.fits')).astype(bool) dust = fits.getdata(path('sb-mask-dust.fits')).astype(bool) # ---------------------------------------------------------------- geometry map gfit = np.isfinite(tB['intens']) & (tB['ndata'] > 150) & (tB['sma'] > 90) tg = {k: v[gfit] for k, v in tB.items()} s, e, p = sb_model.smooth_geometry(tg['sma'], tg['eps'], tg['pa']) a_map = sb_model.radius_map((3194, 4788), XC, YC, s, e, p) np.save(path('_amap.npy'), a_map) # ------------------------------------------------- independent profile extract bins = np.geomspace(10, 2600, 90) ib = np.digitize(a_map, bins) ac = np.sqrt(bins[1:] * bins[:-1]) def azimuthal(img, mask): v = np.full(len(bins) - 1, np.nan) n = np.zeros(len(bins) - 1, int) sd = np.full(len(bins) - 1, np.nan) for k in range(1, len(bins)): m = (ib == k) & ~mask if m.sum() < 40: continue x = img[m].astype(float) med = np.median(x) for _ in range(3): r = 1.4826 * np.median(np.abs(x - med)) if not np.isfinite(r) or r == 0: break x = x[np.abs(x - med) < 3 * r] med = np.median(x) v[k - 1] = med n[k - 1] = x.size sd[k - 1] = 1.4826 * np.median(np.abs(x - med)) return v, n, sd prof, nprof, sdprof = {}, {}, {} for ch in ['Luminance', 'Red', 'Green', 'Blue']: img = load(ch) prof[ch], nprof[ch], sdprof[ch] = azimuthal(img, star | dust) if ch == 'Luminance': # far-field pedestal: the frame is over-subtracted because the sky plane # was fitted on tiles that still contained galaxy halo Bk = 64 H, W = img.shape lb = img[:H // Bk * Bk, :W // Bk * Bk].reshape(H // Bk, Bk, W // Bk, Bk) mb = (~star)[:H // Bk * Bk, :W // Bk * Bk].reshape(H // Bk, Bk, W // Bk, Bk) cnt = mb.sum(axis=(1, 3)) bm = np.where(cnt > 1500, np.where(mb, lb, 0).sum(axis=(1, 3)) / np.maximum(cnt, 1), np.nan) ab = a_map[:H // Bk * Bk, :W // Bk * Bk].reshape(H // Bk, Bk, W // Bk, Bk).mean(axis=(1, 3)) PED = float(np.nanmean(bm[np.isfinite(bm) & (ab > 2400)])) BLKRMS = float(np.nanstd(bm[np.isfinite(bm) & (ab > 1700)])) del img L = prof['Luminance'] print('far-field pedestal (a > 2400 px): %+.2f ADU/px -> mu %.2f' % (PED, mu(-PED))) print('block-to-block scatter (a > 1700 px): %.2f ADU/px -> mu %.2f' % (BLKRMS, mu(BLKRMS))) # noise / systematic floors, expressed as surface brightness FLOOR = { 'per-pixel sky noise (1 sigma)': mu(rms), 'large-scale block scatter (1 sigma)': mu(BLKRMS), 'sky-pedestal systematic |offset|': mu(abs(PED)), } for k, v in FLOOR.items(): print(' floor: %-38s %.2f mag/arcsec2' % (k, v)) # ---------------------------------------------------------------- reliability A_IN = 62.0 # inward limit: the dust lane fills 100% of the azimuth inside A_OUT = float(ac[np.nanargmax(np.where(L > abs(PED), ac, -1))]) # last a with I > |PED| print('adopted reliable range: %.0f - %.0f px (%.1f - %.1f arcsec)' % (A_IN, A_OUT, A_IN * PIXSCALE, A_OUT * PIXSCALE)) # ---------------------------------------------------------------- Sersic fit def sersic_mu(a, mue, re, n): bn = 2 * n - 1 / 3. + 0.009876 / n return mue + 2.5 * bn / np.log(10) * ((a / re) ** (1. / n) - 1.) fitsel = np.isfinite(L) & (ac >= A_IN) & (ac <= 900) & (L > 0) x, y = ac[fitsel] * PIXSCALE, mu(L[fitsel]) popt, pcov = curve_fit(sersic_mu, x, y, p0=[21.0, 300.0, 4.0], maxfev=40000) mue, re_as, nser = popt perr = np.sqrt(np.diag(pcov)) print('Sersic fit over %.0f-%.0f arcsec: n = %.2f +- %.2f, Re = %.1f +- %.1f arcsec ' '(%.2f kpc), mu_e = %.2f' % (x.min(), x.max(), nser, perr[2], re_as, perr[1], re_as * KPC_PER_ARCSEC, mue)) resid_sersic = y - sersic_mu(x, *popt) print(' rms of Sersic residual: %.3f mag' % resid_sersic.std()) # ---------------------------------------------------------------- growth curve eint = np.interp(ac, s, e) area = np.pi * (1 - eint) * bins[1:] ** 2 - np.pi * (1 - eint) * bins[:-1] ** 2 Lf = np.where(np.isfinite(L), L, 0.0) # inside A_IN, extrapolate the Sersic fit (the real light there is dust-obscured) inner = ac < A_IN Lf[inner] = 10 ** ((MU0 - sersic_mu(ac[inner] * PIXSCALE, *popt)) / 2.5) cum = np.cumsum(Lf * area) mtot = -2.5 * np.log10(cum) + ZPTOT for aa in [200, 400, 600, 800, 1000, 1200]: i = np.argmin(np.abs(ac - aa)) print(' total mag within a = %5.0f px (%5.1f arcmin): G = %.3f' % (aa, ac[i] * PIXSCALE / 60., mtot[i])) iA = np.argmin(np.abs(ac - A_OUT)) half = cum[iA] / 2. re_growth = np.interp(half, cum[:iA + 1], ac[:iA + 1]) * PIXSCALE print(' half-light radius of the light enclosed within a=%.0f px: %.0f arcsec' % (A_OUT, re_growth)) inner_frac = cum[np.argmin(np.abs(ac - A_IN))] / cum[iA] print(' fraction of that light coming from the extrapolated a < %.0f px: %.3f' % (A_IN, inner_frac)) # ================================================================== plot 1 fig, ax = plt.subplots(figsize=(9.5, 7.5)) ok = np.isfinite(L) & (L > 0) aas = ac * PIXSCALE # systematic band from the sky pedestal lo = mu(np.where(L > 0, L, np.nan)) hi = mu(np.clip(np.where(np.isfinite(L), L, np.nan) - PED, 1e-6, None)) ax.fill_between(aas[ok], lo[ok], hi[ok], color='tab:orange', alpha=.22, lw=0, label='sky-pedestal systematic (%+.1f ADU/px)' % PED) ax.plot(aas[ok], lo[ok], 'k-', lw=1.6, label='luminance, dust+stars masked') gb = np.isfinite(tB['intens']) & (tB['intens'] > 0) ax.plot(tB['sma'][gb] * PIXSCALE, mu(tB['intens'][gb]), 'o', ms=4.5, mfc='none', mec='tab:blue', label='photutils isophote fit') aa = np.linspace(A_IN * PIXSCALE, 900 * PIXSCALE, 200) ax.plot(aa, sersic_mu(aa, *popt), '--', color='tab:red', lw=1.4, label='Sersic n=%.2f, Re=%.0f"' % (nser, re_as)) for lab, v, c in [('per-pixel 1 sigma', FLOOR['per-pixel sky noise (1 sigma)'], '0.45'), ('large-scale 1 sigma', FLOOR['large-scale block scatter (1 sigma)'], 'tab:green'), ('sky-pedestal systematic', FLOOR['sky-pedestal systematic |offset|'], 'tab:red')]: ax.axhline(v, ls=':', color=c, lw=1.3) ax.text(1150, v - 0.06, '%s (%.2f)' % (lab, v), color=c, fontsize=8.5, va='bottom', ha='right') ax.axvspan(20, A_IN * PIXSCALE, color='k', alpha=.11, lw=0) ax.text(A_IN * PIXSCALE * 0.92, 17.35, 'dust lane fills the whole azimuth', fontsize=8.5, ha='right', va='top', rotation=90, color='0.25') ax.axvline(A_OUT * PIXSCALE, color='0.35', ls='-.', lw=1.2) ax.text(A_OUT * PIXSCALE * 0.94, 17.35, 'systematic floor reached', fontsize=8.5, color='0.3', ha='right', va='top', rotation=90) ax.set_xscale('log') ax.set_xlim(20, 1200) ax.set_ylim(27.0, 17.2) ax.set_xlabel('semi-major axis a [arcsec]') ax.set_ylabel(r'$\mu$ [mag arcsec$^{-2}$, Gaia $G$ zero point]') ax.set_title('NGC 5128 luminance surface-brightness profile\n' '12 x 300 s, iTelescope T32, 0.5376"/px') sec = ax.secondary_xaxis('top', functions=(lambda v: v * KPC_PER_ARCSEC, lambda v: v / KPC_PER_ARCSEC)) sec.set_xlabel('projected radius [kpc, D = 3.8 Mpc]') ax.grid(alpha=.25) ax.legend(loc='lower left', fontsize=9, framealpha=.95) fig.tight_layout() fig.savefig(path('NGC5128-sb-profile.png'), dpi=150) plt.close(fig) # ================================================================== plot 2 fig, axs = plt.subplots(3, 1, figsize=(9, 10.5), sharex=True, gridspec_kw=dict(hspace=.07)) axs[0].plot(aas[ok], lo[ok], 'k-', lw=1.5) axs[0].fill_between(aas[ok], lo[ok], hi[ok], color='tab:orange', alpha=.22, lw=0) axs[0].plot(aa, sersic_mu(aa, *popt), '--', color='tab:red', lw=1.2) axs[0].set_ylim(27.0, 17.2) axs[0].set_ylabel(r'$\mu$ [mag arcsec$^{-2}$]') axs[0].set_title('NGC 5128 isophote fit (luminance; stars and dust lane masked)') # Only isophotes whose geometry actually converged are plotted. Inside # a ~ 240 px the dust lane leaves too little unmasked azimuth and photutils # holds eps and PA at their previous values: those are not measurements and # plotting them would look like a flat measured trend. conv = np.isfinite(tB['eps']) & (tB['stop'] == 0) A_GEO = float(tB['sma'][conv].min()) * PIXSCALE for axi, key, kerr, lab in [(axs[1], 'eps', 'eps_err', 'ellipticity $\\epsilon = 1-b/a$'), (axs[2], 'pa', 'pa_err', 'position angle [deg, CCW from +x]')]: axi.errorbar(tB['sma'][conv] * PIXSCALE, tB[key][conv], yerr=np.nan_to_num(tB[kerr][conv]), fmt='o', ms=5.5, color='tab:blue', capsize=2, label='fit converged (stop code 0)') axi.set_ylabel(lab) axi.grid(alpha=.25) axi.legend(fontsize=8.5, loc='upper left') axs[1].set_ylim(0.02, 0.29) axs[2].set_ylim(133, 175) pax = axs[2].secondary_yaxis('right', functions=(sky_pa, sky_pa)) pax.set_ylabel('sky position angle [deg E of N]') for axi in axs: axi.axvspan(20, A_GEO, color='k', alpha=.11, lw=0) axi.axvline(A_OUT * PIXSCALE, color='0.35', ls='-.', lw=1.2) axs[0].axvspan(20, A_IN * PIXSCALE, color='k', alpha=.16, lw=0) axs[1].text(A_GEO * 0.95, 0.275, 'no converged geometry inside here', fontsize=8.5, ha='right', va='top', rotation=90, color='0.25') axs[2].set_xscale('log') axs[2].set_xlim(20, 1200) axs[2].set_xlabel('semi-major axis a [arcsec]') axs[0].grid(alpha=.25) fig.savefig(path('NGC5128-sb-isophote-geometry.png'), dpi=150, bbox_inches='tight') plt.close(fig) # ================================================================== plot 3 fig, axs = plt.subplots(2, 1, figsize=(9, 8), sharex=True, gridspec_kw=dict(hspace=.07, height_ratios=[2, 1])) for ch, c in [('Luminance', 'k'), ('Red', 'tab:red'), ('Green', 'tab:green'), ('Blue', 'tab:blue')]: v = prof[ch] m = np.isfinite(v) & (v > 0) axs[0].plot(aas[m], mu(v[m]), color=c, lw=1.4, label=ch) axs[0].set_ylim(27.5, 17.0) axs[0].set_ylabel(r'$\mu$ [mag arcsec$^{-2}$]') axs[0].text(.02, .04, 'the L zero point is applied to every channel, so the ' 'vertical offsets between R, G and B are arbitrary;' + chr(10) + 'only the shapes and the colour gradient below are meaningful', transform=axs[0].transAxes, fontsize=8, color='0.3') axs[0].legend(fontsize=9) axs[0].grid(alpha=.25) axs[0].set_title('NGC 5128 channel profiles on identical isophotes (dust masked)') br = -2.5 * np.log10(np.where(prof['Blue'] > 0, prof['Blue'], np.nan) / np.where(prof['Red'] > 0, prof['Red'], np.nan)) axs[1].plot(aas, br, 'k-', lw=1.5) axs[1].set_ylabel('instrumental B - R') axs[1].set_xlabel('semi-major axis a [arcsec]') axs[1].set_xscale('log') axs[1].set_xlim(20, 700) axs[1].grid(alpha=.25) for axi in axs: axi.axvspan(20, A_IN * PIXSCALE, color='k', alpha=.11, lw=0) axi.axvline(A_OUT * PIXSCALE, color='0.35', ls='-.', lw=1.2) axs[1].set_ylim(-0.45, 0.05) fig.savefig(path('NGC5128-sb-colour-profile.png'), dpi=150, bbox_inches='tight') plt.close(fig) np.savez(path('_profile.npz'), a=ac, **{k: prof[k] for k in prof}, ped=PED, blkrms=BLKRMS, sersic=popt, a_in=A_IN, a_out=A_OUT) with open(path('sb-derived-quantities.txt'), 'w') as f: w = lambda t: (f.write(t + '\n'), print(t)) w('NGC 5128 (Centaurus A) -- derived quantities') w('=' * 62) w('Photometry is on the Gaia G scale of the luminance master.') w(' zero point (r=5 px aperture) %.3f mag' % ZP5) w(' aperture correction r=5 -> r=25 %+.4f mag' % APCOR) w(' zero point for total flux %.3f mag' % ZPTOT) w(' mu = %.3f - 2.5*log10(I / ADU per px)' % MU0) w(' pixel scale %.4f arcsec, %.5f arcsec^2 per pixel' % (PIXSCALE, PIXAREA)) w('') w('Centre') w(' Gaia/WCS nucleus x=%.1f y=%.1f' % (X0, Y0)) w(' outer-isophote centre x=%.1f y=%.1f (%.1f px = %.1f arcsec offset)' % (XC, YC, np.hypot(XC - X0, YC - Y0), np.hypot(XC - X0, YC - Y0) * PIXSCALE)) w('') w('Valid radial range') w(' inner limit a = %.0f px = %.0f arcsec (dust lane fills the azimuth inside)' % (A_IN, A_IN * PIXSCALE)) w(' outer limit a = %.0f px = %.0f arcsec = %.1f arcmin' % (A_OUT, A_OUT * PIXSCALE, A_OUT * PIXSCALE / 60)) w(' the nucleus is NOT saturated: peak star-free galaxy signal 2498 ADU/px') w(' vs a clip level of ~63000 ADU/px (factor 25 margin)') w('') w('Noise and systematic floors [mag/arcsec^2]') for k, v in FLOOR.items(): w(' %-40s %.2f' % (k, v)) w(' far-field pedestal %+.2f ADU/px: the sky plane absorbed halo light' % PED) w('') w('Sersic fit, %.0f-%.0f arcsec' % (x.min(), x.max())) w(' n = %.2f +- %.2f' % (nser, perr[2])) w(' Re = %.1f +- %.1f arcsec = %.2f +- %.2f kpc' % (re_as, perr[1], re_as * KPC_PER_ARCSEC, perr[1] * KPC_PER_ARCSEC)) w(' mu_e = %.2f +- %.2f mag/arcsec^2' % (mue, perr[0])) w(' rms of fit residual %.3f mag' % resid_sersic.std()) w('') w('Integrated light (elliptical apertures on the measured profile)') for aa2 in [200, 400, 600, 800, 1000, 1200]: i = np.argmin(np.abs(ac - aa2)) w(' a < %5.0f px (%5.2f arcmin): G = %.3f' % (aa2, ac[i] * PIXSCALE / 60., mtot[i])) w(' half-light radius of the light within a=%.0f px: %.0f arcsec (%.2f kpc)' % (A_OUT, re_growth, re_growth * KPC_PER_ARCSEC)) w(' fraction from the extrapolated a<%.0f px core: %.1f%%' % (A_IN, 100 * inner_frac)) w('') w('Ellipticity / position angle trend (converged isophotes only)') for i in np.nonzero(conv)[0]: w(' a = %6.1f px (%6.1f") : eps = %.3f +- %.3f PA = %5.1f +- %.1f deg' % (tB['sma'][i], tB['sma'][i] * PIXSCALE, tB['eps'][i], tB['eps_err'][i], tB['pa'][i], tB['pa_err'][i])) print('\nwrote NGC5128-sb-profile.png, NGC5128-sb-isophote-geometry.png, NGC5128-sb-colour-profile.png, ' 'sb-derived-quantities.txt')