Move the processing code under pipeline/

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.
This commit is contained in:
laurence 2026-07-21 17:13:54 +01:00
parent 653ca103cd
commit c6299f41ab
53 changed files with 0 additions and 0 deletions

163
pipeline/sb_render_tail.py Normal file
View file

@ -0,0 +1,163 @@
"""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')