"""Render section of sb_residual.py (imported and executed by it). Three views of the same residual, each answering a different question: (1) raw residual -- how well does the ellipse model fit? (2) plane-removed residual -- restores the sky pedestal that the stacking plane fit swallowed (3) azimuthal-median-subtracted -- the shell-hunting view. Subtracting the residual's own median as a function of a forces zero mean at every radius, so ONLY azimuthal structure survives. Any perfectly circular feature is removed with it. """ import numpy as np from astropy.io import fits import matplotlib.pyplot as plt from scipy.ndimage import gaussian_filter, binary_erosion from sb_common import * def run(ns): g = ns # namespace dict from sb_residual res, model, a_map = g['res'], g['model'], g['a_map'] L, star, dust = g['L'], g['star'], g['dust'] XC, YC, PEDL = g['XC'], g['YC'], g['PEDL'] A_OUT, tab, binned = g['A_OUT'], g['tab'], g['binned'] # -- plane removal (restores the sky pedestal absorbed by the stacking fit) yy, xx = np.mgrid[0:3194, 0:4788] fitreg = (a_map > 1450) & ~star & ~dust A = np.c_[np.ones(fitreg.sum()), xx[fitreg].ravel()/1000., yy[fitreg].ravel()/1000.] coef, *_ = np.linalg.lstsq(A, res[fitreg].ravel(), rcond=None) plane = (coef[0] + coef[1]*xx/1000. + coef[2]*yy/1000.).astype(np.float32) print('residual plane removed: %+.2f %+.2f*x/1000 %+.2f*y/1000 ADU/px' % tuple(coef)) resf = (res - plane).astype(np.float32) fits.PrimaryHDU(resf).writeto(path('sb-residual-flat.fits'), overwrite=True) del xx, yy, plane, A # -- binned maps r4 = binned(resf, star, 4) r16 = binned(resf, star, 16) a16 = binned(a_map, np.zeros_like(star), 16) d16 = binned(dust.astype(np.float32), np.zeros_like(star), 16) # -- azimuthal median removal abin = np.geomspace(20, 3000, 80) ibn = np.digitize(a16, abin) azmed = np.full(len(abin)-1, np.nan) for k in range(1, len(abin)): m = (ibn == k) & np.isfinite(r16) & (d16 < 0.3) if m.sum() >= 12: azmed[k-1] = np.median(r16[m]) acb = np.sqrt(abin[1:]*abin[:-1]) gm = np.isfinite(azmed) resd = r16 - np.interp(a16, acb[gm], azmed[gm]) sig = float(np.nanstd(resd[np.isfinite(resd) & (a16 > 1500)])) smd = gaussian_filter(np.nan_to_num(resd), 1.0) smd[~np.isfinite(resd)] = np.nan np.save(path('_resd16.npy'), resd) np.save(path('_a16.npy'), a16) np.save(path('_s16.npy'), np.array([sig])) print('deep-residual noise (16x16 bins, a>1500 px): %.2f ADU/px -> mu %.2f' % (sig, mu(sig))) NV, EV = north_east_pixel() CUT = 1560 sl = (slice(int(YC)-CUT, int(YC)+CUT), slice(int(XC)-CUT, int(XC)+CUT)) ext = [-CUT*PIXSCALE/60, CUT*PIXSCALE/60]*2 fullext = [-4788/2*PIXSCALE/60, 4788/2*PIXSCALE/60, -3194/2*PIXSCALE/60, 3194/2*PIXSCALE/60] def compass(ax, x=0.885, y=0.115, Ln=0.07, c='k'): 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) def cutb(arr, B): return arr[int((YC-CUT)/B):int((YC+CUT)/B), int((XC-CUT)/B):int((XC+CUT)/B)] # ---------------------------------------------------------- 4-panel figure fig, axs = plt.subplots(2, 2, figsize=(15.5, 15.0)) axs = axs.ravel() axs[0].imshow(np.arcsinh(np.clip(L[sl]-PEDL, 0, None)/25), origin='lower', cmap='gray', extent=ext) axs[0].set_title('(a) luminance master, arcsinh stretch') axs[1].imshow(np.arcsinh(np.clip(model[sl], 0, None)/25), origin='lower', cmap='gray', extent=ext) axs[1].set_title('(b) smooth elliptical model built from the isophotes') im = axs[2].imshow(cutb(r4, 4), origin='lower', cmap='RdBu_r', vmin=-220, vmax=220, extent=ext) axs[2].set_title('(c) residual, 4x4 binned: the dust lane dominates') plt.colorbar(im, ax=axs[2], fraction=.046, label='ADU/px') im = axs[3].imshow(cutb(smd, 16), origin='lower', cmap='RdBu_r', vmin=-3*sig, vmax=3*sig, extent=ext) axs[3].contour(cutb(d16, 16), levels=[0.5], colors='0.35', linewidths=.8, extent=ext, origin='lower') axs[3].set_title('(d) residual, 16x16 binned, azimuthal median removed, ' '+-3 sigma' + chr(10) + '1 sigma = %.2f ADU/px = %.1f mag/arcsec2 ' '(grey outline = dust mask)' % (sig, mu(sig))) plt.colorbar(im, ax=axs[3], fraction=.046, label='ADU/px') for a in axs: a.set_xlabel('arcmin') a.set_ylabel('arcmin') compass(a, c='w' if a in (axs[0], axs[1]) else 'k') fig.suptitle('NGC 5128: smooth elliptical model and its residual', fontsize=14) fig.tight_layout() fig.savefig(path('NGC5128-sb-model-residual.png'), dpi=115) plt.close(fig) # ------------------------------------------------------- deep single panel fig, ax = plt.subplots(figsize=(13.5, 9.6)) im = ax.imshow(smd, origin='lower', cmap='RdBu_r', vmin=-3*sig, vmax=3*sig, extent=fullext) ax.contour(d16, levels=[0.5], colors='0.3', linewidths=.9, extent=fullext, origin='lower') th = np.linspace(0, 2*np.pi, 400) ax.plot(1250*np.cos(th)*PIXSCALE/60, 1000*np.sin(th)*PIXSCALE/60, 'k--', lw=1.1, alpha=.7, label='sky-plane fit exclusion ellipse (1250x1000 px)') ax.plot(A_OUT*np.cos(th)*PIXSCALE/60, A_OUT*0.765*np.sin(th)*PIXSCALE/60, '-', color='0.25', lw=1.1, alpha=.85, label='profile reliability limit, a = %.0f px' % A_OUT) ax.plot([], [], '-', color='0.3', lw=.9, label='dust-lane mask') ax.legend(fontsize=9, loc='lower left', framealpha=.9) plt.colorbar(im, ax=ax, fraction=.035, label='residual [ADU/px]') ax.set_xlabel('arcmin') ax.set_ylabel('arcmin') compass(ax, x=0.945, y=0.84, Ln=0.05) ax.set_title('NGC 5128: isophote model AND the residual azimuthal median ' 'removed' + chr(10) + '16x16 binned (8.6"/bin), stars masked, +-3 sigma; only ' 'azimuthal structure survives. 1 sigma = %.2f ADU/px = ' '%.1f mag/arcsec2' % (sig, mu(sig))) fig.tight_layout() fig.savefig(path('NGC5128-sb-residual-deep.png'), dpi=125) plt.close(fig) # ------------------------------------------------------ quantify structure print('') print('azimuthal residual statistics (16x16 bins, azimuthal median removed,') print('dust-lane bins excluded):') ok = np.isfinite(resd) & (d16 < 0.3) for lo, hi in [(100, 200), (200, 400), (400, 600), (600, 800), (800, 1000), (1000, 1250), (1250, 1600), (1600, 2200)]: m = ok & (a16 >= lo) & (a16 < hi) if m.sum() < 20: continue md = np.interp(np.clip(a16[m], tab['sma'][0], tab['sma'][-1]), tab['sma'], tab['intens']) print(' a=%4d-%4d px (%4.1f-%4.1f arcmin): rms %6.2f ADU/px = %4.1f%% ' 'of the model, max |dev| %4.1f sigma, n=%d' % (lo, hi, lo*PIXSCALE/60, hi*PIXSCALE/60, np.nanstd(resd[m]), 100*np.nanstd(resd[m])/np.mean(md), np.nanmax(np.abs(resd[m]))/sig, m.sum())) print('') print('wrote sb-model.fits, sb-residual.fits, sb-residual-flat.fits,') print(' NGC5128-sb-model-residual.png, NGC5128-sb-residual-deep.png')