Preparing to merge this repository into a combined astrophotography repo. session-scripts/ becomes pipeline/ because the scripts import layout.py from their own directory and must stay together, and because 'pipeline' says what it is rather than how it came about. observing/ stays at the top level: observing plans are not processing code.
311 lines
15 KiB
Python
311 lines
15 KiB
Python
"""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')
|