The scripts that processed the NGC 5128 session of 2026-07-21 previously lived inside the data directory and addressed it with absolute paths. Code and data are now separated: the code lives here, and a session is located at runtime through the ASTRO_SESSION environment variable. layout.py is what makes that work. It maps a FILENAME to the subdirectory that file belongs in, using the same rules the session directories are organised with, so a script can go on asking for 'master-Red.fit' or '_stars.npz' without any call site knowing the directory structure. Anything unrecognised resolves to the session root, which is visible and correctable rather than silently wrong. restructure.py reorganises a flat session directory into that layout. It is idempotent and dry-run by default. The 50 session scripts are kept as they were run rather than tidied into a library. They were written in sequence as the work went along, several of them by parallel agents, and they show it - but they are the honest provenance of a published set of results, and the productionised pipeline should be able to reproduce those results exactly. Verified before committing: all 51 files compile without warnings, and verify_core.py, closeup.py and triptych.py were run end to end against the reorganised session, correctly finding inputs across calibrated/, stacks/masters/ and final/ and writing outputs back to the right places.
163 lines
7.9 KiB
Python
163 lines
7.9 KiB
Python
"""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')
|