astrophotography/session-scripts/sb_render_tail.py
laurence 5286a2e81b Processing and analysis code for remote-telescope imaging sessions
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.
2026-07-21 15:29:49 +01:00

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')