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.
This commit is contained in:
laurence 2026-07-21 15:29:49 +01:00
commit 5286a2e81b
53 changed files with 8820 additions and 0 deletions

191
session-scripts/sb_dust.py Normal file
View file

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