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

View file

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