"""Step 5: dust-lane extinction map, with an independent colour-excess check. Method 1. The smooth isophote model of the luminance master gives the light the galaxy would show with no dust. A_L = -2.5 log10(observed / model). This is a foreground-screen approximation: the lane is a warped disk seen nearly edge-on across the near side of the bulge, so the screen assumption is good for the lane itself but underestimates the true optical depth wherever stars sit in front of the dust. 2. The same is done for R, G and B on the SAME elliptical isophotes, using the sigma-clipped azimuthal median of the dust-free azimuths as each channel's unobscured model. E(B-R) = A_B - A_R is then a completely independent, model-ratio-based reddening measurement, and A_L / E(B-R) is a measured extinction-law ratio rather than an assumed one. Outputs sb-extinction.fits A_L map (mag), NaN outside the measurable region NGC5128-sb-extinction-map.png 4-panel extinction / reddening figure NGC5128-sb-extinction-law.png A_L against E(B-R) with the fitted ratio """ import numpy as np from astropy.io import fits import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt from scipy.ndimage import gaussian_filter, median_filter from sb_common import * XC, YC = np.load(path('_geom.npy')) P = np.load(path('_profile.npz')) star = fits.getdata(path('sb-mask-stars.fits')).astype(bool) dust = fits.getdata(path('sb-mask-dust.fits')).astype(bool) a_map = np.load(path('_amap.npy')) ac = P['a'] A = {} for ch in ['Luminance', 'Red', 'Green', 'Blue']: pr = P[ch] ok = np.isfinite(pr) & (pr > 0) mdl = np.interp(a_map, ac[ok], pr[ok], left=pr[ok][0], right=np.nan) img = gaussian_filter(load(ch), 2.0) # match the colour smoothing with np.errstate(all='ignore'): A[ch] = np.where((img > 0) & (mdl > 0), -2.5*np.log10(img/mdl), np.nan).astype(np.float32) del img, mdl print('%-10s A map built' % ch) AL = A['Luminance'] EBR = (A['Blue'] - A['Red']).astype(np.float32) # region where both are trustworthy: inside the lane, bright enough, not a star Lmod = np.interp(a_map, ac[np.isfinite(P['Luminance'])], P['Luminance'][np.isfinite(P['Luminance'])]) valid = (~star) & (a_map < 700) & (Lmod > 60) & np.isfinite(AL) & np.isfinite(EBR) lane = valid & dust print('valid extinction pixels: %d (%.1f arcmin^2); inside the lane: %d (%.1f arcmin^2)' % (valid.sum(), valid.sum()*PIXAREA/3600., lane.sum(), lane.sum()*PIXAREA/3600.)) x, y = EBR[lane].astype(float), AL[lane].astype(float) sel = (x > 0.1) & (x < 2.0) & (y > -0.3) & (y < 3.5) k = float(np.median(y[sel]/x[sel])) klsq = float(np.sum(x[sel]*y[sel])/np.sum(x[sel]**2)) print('extinction-law ratio A_L / E(B-R) = %.2f (median of ratios), %.2f (lsq)' % (k, klsq)) ALs = median_filter(np.nan_to_num(AL, nan=0.0), 9) ALs[~valid] = np.nan pk = np.nanpercentile(ALs[lane], [50, 90, 99, 99.9]) print('A_L inside the lane mask (9x9 median filtered): ' 'median %.2f, p90 %.2f, p99 %.2f, p99.9 %.2f mag' % tuple(pk)) iy, ix = np.unravel_index(np.nanargmax(np.where(lane, ALs, np.nan)), ALs.shape) w = wcs() rr, dd = w.pixel_to_world_values(ix, iy) print('peak A_L = %.2f mag at pixel (%d, %d) = %.5f %+.5f deg, %.0f arcsec from ' 'the nucleus' % (ALs[iy, ix], ix, iy, rr, dd, np.hypot(ix-XC, iy-YC)*PIXSCALE)) hdu = fits.PrimaryHDU(np.where(valid, AL, np.nan).astype(np.float32), header=w.to_header()) hdu.header['BUNIT'] = 'mag' hdu.header['COMMENT'] = 'A_L = -2.5 log10(observed / smooth isophote model)' hdu.writeto(path('sb-extinction.fits'), overwrite=True) # obscured light: how much luminance flux the lane removes Lraw = load('Luminance') lost = float(np.nansum((Lmod - Lraw)[lane])) tot = float(np.nansum(Lmod[valid & (a_map < 700)])) print('the lane hides %.3e ADU, i.e. %.1f%% of the modelled light inside a=700 px' % (lost, 100*lost/tot)) print(' that is %.2f mag of integrated light removed from the lane region' % (-2.5*np.log10(1 - lost/max(np.nansum(Lmod[lane]), 1)))) # ------------------------------------------------------------------- figure 1 NV, EV = north_east_pixel() CUT = 620 sl = (slice(int(YC)-CUT, int(YC)+CUT), slice(int(XC)-CUT, int(XC)+CUT)) ext = [-CUT*PIXSCALE/60, CUT*PIXSCALE/60]*2 def compass(ax, c='k', x=0.885, y=0.10, Ln=0.075): for v, lab in [(NV, 'N'), (EV, 'E')]: ax.annotate('', xy=(x+Ln*v[0], y+Ln*v[1]), xytext=(x, y), xycoords='axes fraction', textcoords='axes fraction', arrowprops=dict(arrowstyle='->', color=c, lw=1.4)) ax.annotate(lab, xy=(x+1.45*Ln*v[0], y+1.45*Ln*v[1]), color=c, xycoords='axes fraction', ha='center', va='center', fontsize=10) fig, axs = plt.subplots(2, 2, figsize=(15, 14.4)) axs = axs.ravel() axs[0].imshow(np.arcsinh(np.clip(Lraw[sl], 0, None)/30), origin='lower', cmap='gray', extent=ext) axs[0].set_title('(a) luminance master') compass(axs[0], 'w') im = axs[1].imshow(np.where(valid, ALs, np.nan)[sl], origin='lower', cmap='magma_r', vmin=0, vmax=2.0, extent=ext) axs[1].set_title('(b) extinction $A_L$ from the smooth model [mag]') plt.colorbar(im, ax=axs[1], fraction=.046, label='mag') compass(axs[1]) im = axs[2].imshow(np.where(valid, gaussian_filter(np.nan_to_num(EBR), 2), np.nan)[sl], origin='lower', cmap='inferno_r', vmin=0, vmax=1.4, extent=ext) axs[2].set_title('(c) colour excess E(B-R) from the R and B models [mag]') plt.colorbar(im, ax=axs[2], fraction=.046, label='mag') compass(axs[2]) im = axs[3].imshow(np.where(valid, ALs - k*EBR, np.nan)[sl], origin='lower', cmap='RdBu_r', vmin=-0.6, vmax=0.6, extent=ext) axs[3].set_title('(d) $A_L$ - %.2f E(B-R): agreement of the two methods' % k) plt.colorbar(im, ax=axs[3], fraction=.046, label='mag') compass(axs[3]) for a in axs: a.set_xlabel('arcmin') a.set_ylabel('arcmin') fig.suptitle('NGC 5128 dust lane: extinction and reddening ' '(central %.1f x %.1f arcmin)' % (2*CUT*PIXSCALE/60, 2*CUT*PIXSCALE/60), fontsize=14) fig.tight_layout() fig.savefig(path('NGC5128-sb-extinction-map.png'), dpi=125) plt.close(fig) # ------------------------------------------------------------------- figure 2 fig, axs = plt.subplots(1, 2, figsize=(13.5, 5.6)) h = axs[0].hist2d(x[sel], y[sel], bins=(140, 140), range=[[0, 1.6], [-0.3, 3.0]], cmap='viridis', norm=matplotlib.colors.LogNorm()) plt.colorbar(h[3], ax=axs[0], label='pixels') xs = np.linspace(0, 1.6, 50) axs[0].plot(xs, k*xs, 'r-', lw=2, label='$A_L$ = %.2f E(B-R) (median ratio)' % k) axs[0].plot(xs, klsq*xs, 'w--', lw=1.6, label='least squares: %.2f' % klsq) axs[0].set_xlabel('E(B-R) [mag, instrumental]') axs[0].set_ylabel('$A_L$ [mag]') axs[0].legend(fontsize=9) axs[0].set_title('extinction law measured inside the lane (%d px)' % sel.sum()) bb = np.geomspace(20, 700, 26) ib = np.digitize(a_map, bb) med, p90 = [], [] for kk in range(1, len(bb)): m = (ib == kk) & lane med.append(np.nanmedian(ALs[m]) if m.sum() > 200 else np.nan) p90.append(np.nanpercentile(ALs[m], 90) if m.sum() > 200 else np.nan) bc = np.sqrt(bb[1:]*bb[:-1])*PIXSCALE axs[1].plot(bc, med, 'o-', color='tab:purple', label='median $A_L$ in the lane') axs[1].plot(bc, p90, 's--', color='tab:orange', label='90th percentile') axs[1].set_xscale('log') axs[1].set_xlabel('semi-major axis a [arcsec]') axs[1].set_ylabel('$A_L$ [mag]') axs[1].grid(alpha=.3) axs[1].legend(fontsize=9) axs[1].set_title('extinction against radius along the lane') fig.tight_layout() fig.savefig(path('NGC5128-sb-extinction-law.png'), dpi=140) plt.close(fig) with open(path('sb-derived-quantities.txt'), 'a') as f: wr = lambda t: (f.write(t + chr(10)), print(t)) wr('') wr('Dust lane') wr(' lane mask area %.1f arcmin^2' % (dust.sum()*PIXAREA/3600.)) wr(' measurable extinction area %.1f arcmin^2' % (lane.sum()*PIXAREA/3600.)) wr(' A_L median / p90 / p99 / p99.9 %.2f / %.2f / %.2f / %.2f mag' % tuple(pk)) wr(' peak A_L (9x9 median filtered) %.2f mag at RA %.4f Dec %+.4f' % (ALs[iy, ix], rr, dd)) wr(' peak is %.0f arcsec from the nucleus' % (np.hypot(ix-XC, iy-YC)*PIXSCALE)) wr(' extinction law A_L / E(B-R) %.2f (median of ratios), %.2f (lsq)' % (k, klsq)) wr(' luminance flux hidden by the lane %.1f%% of the modelled light inside a=700 px' % (100*lost/tot)) print('') print('wrote sb-extinction.fits, NGC5128-sb-extinction-map.png, NGC5128-sb-extinction-law.png')