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

View file

@ -0,0 +1,311 @@
"""Step 3: surface-brightness profile, isophote geometry plots, derived numbers.
Produces
NGC5128-sb-profile.png mu(a) with every noise/systematic floor marked
NGC5128-sb-isophote-geometry.png mu, ellipticity and position angle vs semi-major axis
NGC5128-sb-colour-profile.png L, R, G, B profiles and the B-R colour gradient
sb-derived-quantities.txt the numbers worth quoting
The photutils Pass B table is the primary isophote result. An independent
sigma-clipped azimuthal-median extraction on the same elliptical grid is done
here as a cross-check and to get the R/G/B profiles on identical isophotes.
"""
import numpy as np
from astropy.io import fits
from scipy.optimize import curve_fit
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from sb_common import *
import sb_model
XC, YC = np.load(path('_geom.npy'))
rms = float(np.load(path('_rms.npy'))[0])
tB = dict(np.load(path('_isoB.npz')))
star = fits.getdata(path('sb-mask-stars.fits')).astype(bool)
dust = fits.getdata(path('sb-mask-dust.fits')).astype(bool)
# ---------------------------------------------------------------- geometry map
gfit = np.isfinite(tB['intens']) & (tB['ndata'] > 150) & (tB['sma'] > 90)
tg = {k: v[gfit] for k, v in tB.items()}
s, e, p = sb_model.smooth_geometry(tg['sma'], tg['eps'], tg['pa'])
a_map = sb_model.radius_map((3194, 4788), XC, YC, s, e, p)
np.save(path('_amap.npy'), a_map)
# ------------------------------------------------- independent profile extract
bins = np.geomspace(10, 2600, 90)
ib = np.digitize(a_map, bins)
ac = np.sqrt(bins[1:] * bins[:-1])
def azimuthal(img, mask):
v = np.full(len(bins) - 1, np.nan)
n = np.zeros(len(bins) - 1, int)
sd = np.full(len(bins) - 1, np.nan)
for k in range(1, len(bins)):
m = (ib == k) & ~mask
if m.sum() < 40:
continue
x = img[m].astype(float)
med = np.median(x)
for _ in range(3):
r = 1.4826 * np.median(np.abs(x - med))
if not np.isfinite(r) or r == 0:
break
x = x[np.abs(x - med) < 3 * r]
med = np.median(x)
v[k - 1] = med
n[k - 1] = x.size
sd[k - 1] = 1.4826 * np.median(np.abs(x - med))
return v, n, sd
prof, nprof, sdprof = {}, {}, {}
for ch in ['Luminance', 'Red', 'Green', 'Blue']:
img = load(ch)
prof[ch], nprof[ch], sdprof[ch] = azimuthal(img, star | dust)
if ch == 'Luminance':
# far-field pedestal: the frame is over-subtracted because the sky plane
# was fitted on tiles that still contained galaxy halo
Bk = 64
H, W = img.shape
lb = img[:H // Bk * Bk, :W // Bk * Bk].reshape(H // Bk, Bk, W // Bk, Bk)
mb = (~star)[:H // Bk * Bk, :W // Bk * Bk].reshape(H // Bk, Bk, W // Bk, Bk)
cnt = mb.sum(axis=(1, 3))
bm = np.where(cnt > 1500, np.where(mb, lb, 0).sum(axis=(1, 3)) /
np.maximum(cnt, 1), np.nan)
ab = a_map[:H // Bk * Bk, :W // Bk * Bk].reshape(H // Bk, Bk,
W // Bk, Bk).mean(axis=(1, 3))
PED = float(np.nanmean(bm[np.isfinite(bm) & (ab > 2400)]))
BLKRMS = float(np.nanstd(bm[np.isfinite(bm) & (ab > 1700)]))
del img
L = prof['Luminance']
print('far-field pedestal (a > 2400 px): %+.2f ADU/px -> mu %.2f' % (PED, mu(-PED)))
print('block-to-block scatter (a > 1700 px): %.2f ADU/px -> mu %.2f' % (BLKRMS, mu(BLKRMS)))
# noise / systematic floors, expressed as surface brightness
FLOOR = {
'per-pixel sky noise (1 sigma)': mu(rms),
'large-scale block scatter (1 sigma)': mu(BLKRMS),
'sky-pedestal systematic |offset|': mu(abs(PED)),
}
for k, v in FLOOR.items():
print(' floor: %-38s %.2f mag/arcsec2' % (k, v))
# ---------------------------------------------------------------- reliability
A_IN = 62.0 # inward limit: the dust lane fills 100% of the azimuth inside
A_OUT = float(ac[np.nanargmax(np.where(L > abs(PED), ac, -1))]) # last a with I > |PED|
print('adopted reliable range: %.0f - %.0f px (%.1f - %.1f arcsec)'
% (A_IN, A_OUT, A_IN * PIXSCALE, A_OUT * PIXSCALE))
# ---------------------------------------------------------------- Sersic fit
def sersic_mu(a, mue, re, n):
bn = 2 * n - 1 / 3. + 0.009876 / n
return mue + 2.5 * bn / np.log(10) * ((a / re) ** (1. / n) - 1.)
fitsel = np.isfinite(L) & (ac >= A_IN) & (ac <= 900) & (L > 0)
x, y = ac[fitsel] * PIXSCALE, mu(L[fitsel])
popt, pcov = curve_fit(sersic_mu, x, y, p0=[21.0, 300.0, 4.0], maxfev=40000)
mue, re_as, nser = popt
perr = np.sqrt(np.diag(pcov))
print('Sersic fit over %.0f-%.0f arcsec: n = %.2f +- %.2f, Re = %.1f +- %.1f arcsec '
'(%.2f kpc), mu_e = %.2f' % (x.min(), x.max(), nser, perr[2], re_as, perr[1],
re_as * KPC_PER_ARCSEC, mue))
resid_sersic = y - sersic_mu(x, *popt)
print(' rms of Sersic residual: %.3f mag' % resid_sersic.std())
# ---------------------------------------------------------------- growth curve
eint = np.interp(ac, s, e)
area = np.pi * (1 - eint) * bins[1:] ** 2 - np.pi * (1 - eint) * bins[:-1] ** 2
Lf = np.where(np.isfinite(L), L, 0.0)
# inside A_IN, extrapolate the Sersic fit (the real light there is dust-obscured)
inner = ac < A_IN
Lf[inner] = 10 ** ((MU0 - sersic_mu(ac[inner] * PIXSCALE, *popt)) / 2.5)
cum = np.cumsum(Lf * area)
mtot = -2.5 * np.log10(cum) + ZPTOT
for aa in [200, 400, 600, 800, 1000, 1200]:
i = np.argmin(np.abs(ac - aa))
print(' total mag within a = %5.0f px (%5.1f arcmin): G = %.3f'
% (aa, ac[i] * PIXSCALE / 60., mtot[i]))
iA = np.argmin(np.abs(ac - A_OUT))
half = cum[iA] / 2.
re_growth = np.interp(half, cum[:iA + 1], ac[:iA + 1]) * PIXSCALE
print(' half-light radius of the light enclosed within a=%.0f px: %.0f arcsec'
% (A_OUT, re_growth))
inner_frac = cum[np.argmin(np.abs(ac - A_IN))] / cum[iA]
print(' fraction of that light coming from the extrapolated a < %.0f px: %.3f'
% (A_IN, inner_frac))
# ================================================================== plot 1
fig, ax = plt.subplots(figsize=(9.5, 7.5))
ok = np.isfinite(L) & (L > 0)
aas = ac * PIXSCALE
# systematic band from the sky pedestal
lo = mu(np.where(L > 0, L, np.nan))
hi = mu(np.clip(np.where(np.isfinite(L), L, np.nan) - PED, 1e-6, None))
ax.fill_between(aas[ok], lo[ok], hi[ok], color='tab:orange', alpha=.22, lw=0,
label='sky-pedestal systematic (%+.1f ADU/px)' % PED)
ax.plot(aas[ok], lo[ok], 'k-', lw=1.6, label='luminance, dust+stars masked')
gb = np.isfinite(tB['intens']) & (tB['intens'] > 0)
ax.plot(tB['sma'][gb] * PIXSCALE, mu(tB['intens'][gb]), 'o', ms=4.5, mfc='none',
mec='tab:blue', label='photutils isophote fit')
aa = np.linspace(A_IN * PIXSCALE, 900 * PIXSCALE, 200)
ax.plot(aa, sersic_mu(aa, *popt), '--', color='tab:red', lw=1.4,
label='Sersic n=%.2f, Re=%.0f"' % (nser, re_as))
for lab, v, c in [('per-pixel 1 sigma', FLOOR['per-pixel sky noise (1 sigma)'], '0.45'),
('large-scale 1 sigma', FLOOR['large-scale block scatter (1 sigma)'], 'tab:green'),
('sky-pedestal systematic', FLOOR['sky-pedestal systematic |offset|'], 'tab:red')]:
ax.axhline(v, ls=':', color=c, lw=1.3)
ax.text(1150, v - 0.06, '%s (%.2f)' % (lab, v), color=c, fontsize=8.5,
va='bottom', ha='right')
ax.axvspan(20, A_IN * PIXSCALE, color='k', alpha=.11, lw=0)
ax.text(A_IN * PIXSCALE * 0.92, 17.35, 'dust lane fills the whole azimuth',
fontsize=8.5, ha='right', va='top', rotation=90, color='0.25')
ax.axvline(A_OUT * PIXSCALE, color='0.35', ls='-.', lw=1.2)
ax.text(A_OUT * PIXSCALE * 0.94, 17.35, 'systematic floor reached', fontsize=8.5,
color='0.3', ha='right', va='top', rotation=90)
ax.set_xscale('log')
ax.set_xlim(20, 1200)
ax.set_ylim(27.0, 17.2)
ax.set_xlabel('semi-major axis a [arcsec]')
ax.set_ylabel(r'$\mu$ [mag arcsec$^{-2}$, Gaia $G$ zero point]')
ax.set_title('NGC 5128 luminance surface-brightness profile\n'
'12 x 300 s, iTelescope T32, 0.5376"/px')
sec = ax.secondary_xaxis('top', functions=(lambda v: v * KPC_PER_ARCSEC,
lambda v: v / KPC_PER_ARCSEC))
sec.set_xlabel('projected radius [kpc, D = 3.8 Mpc]')
ax.grid(alpha=.25)
ax.legend(loc='lower left', fontsize=9, framealpha=.95)
fig.tight_layout()
fig.savefig(path('NGC5128-sb-profile.png'), dpi=150)
plt.close(fig)
# ================================================================== plot 2
fig, axs = plt.subplots(3, 1, figsize=(9, 10.5), sharex=True,
gridspec_kw=dict(hspace=.07))
axs[0].plot(aas[ok], lo[ok], 'k-', lw=1.5)
axs[0].fill_between(aas[ok], lo[ok], hi[ok], color='tab:orange', alpha=.22, lw=0)
axs[0].plot(aa, sersic_mu(aa, *popt), '--', color='tab:red', lw=1.2)
axs[0].set_ylim(27.0, 17.2)
axs[0].set_ylabel(r'$\mu$ [mag arcsec$^{-2}$]')
axs[0].set_title('NGC 5128 isophote fit (luminance; stars and dust lane masked)')
# Only isophotes whose geometry actually converged are plotted. Inside
# a ~ 240 px the dust lane leaves too little unmasked azimuth and photutils
# holds eps and PA at their previous values: those are not measurements and
# plotting them would look like a flat measured trend.
conv = np.isfinite(tB['eps']) & (tB['stop'] == 0)
A_GEO = float(tB['sma'][conv].min()) * PIXSCALE
for axi, key, kerr, lab in [(axs[1], 'eps', 'eps_err', 'ellipticity $\\epsilon = 1-b/a$'),
(axs[2], 'pa', 'pa_err', 'position angle [deg, CCW from +x]')]:
axi.errorbar(tB['sma'][conv] * PIXSCALE, tB[key][conv],
yerr=np.nan_to_num(tB[kerr][conv]), fmt='o', ms=5.5,
color='tab:blue', capsize=2, label='fit converged (stop code 0)')
axi.set_ylabel(lab)
axi.grid(alpha=.25)
axi.legend(fontsize=8.5, loc='upper left')
axs[1].set_ylim(0.02, 0.29)
axs[2].set_ylim(133, 175)
pax = axs[2].secondary_yaxis('right', functions=(sky_pa, sky_pa))
pax.set_ylabel('sky position angle [deg E of N]')
for axi in axs:
axi.axvspan(20, A_GEO, color='k', alpha=.11, lw=0)
axi.axvline(A_OUT * PIXSCALE, color='0.35', ls='-.', lw=1.2)
axs[0].axvspan(20, A_IN * PIXSCALE, color='k', alpha=.16, lw=0)
axs[1].text(A_GEO * 0.95, 0.275, 'no converged geometry inside here', fontsize=8.5,
ha='right', va='top', rotation=90, color='0.25')
axs[2].set_xscale('log')
axs[2].set_xlim(20, 1200)
axs[2].set_xlabel('semi-major axis a [arcsec]')
axs[0].grid(alpha=.25)
fig.savefig(path('NGC5128-sb-isophote-geometry.png'), dpi=150, bbox_inches='tight')
plt.close(fig)
# ================================================================== plot 3
fig, axs = plt.subplots(2, 1, figsize=(9, 8), sharex=True,
gridspec_kw=dict(hspace=.07, height_ratios=[2, 1]))
for ch, c in [('Luminance', 'k'), ('Red', 'tab:red'), ('Green', 'tab:green'),
('Blue', 'tab:blue')]:
v = prof[ch]
m = np.isfinite(v) & (v > 0)
axs[0].plot(aas[m], mu(v[m]), color=c, lw=1.4, label=ch)
axs[0].set_ylim(27.5, 17.0)
axs[0].set_ylabel(r'$\mu$ [mag arcsec$^{-2}$]')
axs[0].text(.02, .04, 'the L zero point is applied to every channel, so the '
'vertical offsets between R, G and B are arbitrary;' + chr(10) +
'only the shapes and the colour gradient below are meaningful',
transform=axs[0].transAxes, fontsize=8, color='0.3')
axs[0].legend(fontsize=9)
axs[0].grid(alpha=.25)
axs[0].set_title('NGC 5128 channel profiles on identical isophotes (dust masked)')
br = -2.5 * np.log10(np.where(prof['Blue'] > 0, prof['Blue'], np.nan) /
np.where(prof['Red'] > 0, prof['Red'], np.nan))
axs[1].plot(aas, br, 'k-', lw=1.5)
axs[1].set_ylabel('instrumental B - R')
axs[1].set_xlabel('semi-major axis a [arcsec]')
axs[1].set_xscale('log')
axs[1].set_xlim(20, 700)
axs[1].grid(alpha=.25)
for axi in axs:
axi.axvspan(20, A_IN * PIXSCALE, color='k', alpha=.11, lw=0)
axi.axvline(A_OUT * PIXSCALE, color='0.35', ls='-.', lw=1.2)
axs[1].set_ylim(-0.45, 0.05)
fig.savefig(path('NGC5128-sb-colour-profile.png'), dpi=150, bbox_inches='tight')
plt.close(fig)
np.savez(path('_profile.npz'), a=ac, **{k: prof[k] for k in prof},
ped=PED, blkrms=BLKRMS, sersic=popt, a_in=A_IN, a_out=A_OUT)
with open(path('sb-derived-quantities.txt'), 'w') as f:
w = lambda t: (f.write(t + '\n'), print(t))
w('NGC 5128 (Centaurus A) -- derived quantities')
w('=' * 62)
w('Photometry is on the Gaia G scale of the luminance master.')
w(' zero point (r=5 px aperture) %.3f mag' % ZP5)
w(' aperture correction r=5 -> r=25 %+.4f mag' % APCOR)
w(' zero point for total flux %.3f mag' % ZPTOT)
w(' mu = %.3f - 2.5*log10(I / ADU per px)' % MU0)
w(' pixel scale %.4f arcsec, %.5f arcsec^2 per pixel' % (PIXSCALE, PIXAREA))
w('')
w('Centre')
w(' Gaia/WCS nucleus x=%.1f y=%.1f' % (X0, Y0))
w(' outer-isophote centre x=%.1f y=%.1f (%.1f px = %.1f arcsec offset)'
% (XC, YC, np.hypot(XC - X0, YC - Y0), np.hypot(XC - X0, YC - Y0) * PIXSCALE))
w('')
w('Valid radial range')
w(' inner limit a = %.0f px = %.0f arcsec (dust lane fills the azimuth inside)'
% (A_IN, A_IN * PIXSCALE))
w(' outer limit a = %.0f px = %.0f arcsec = %.1f arcmin' %
(A_OUT, A_OUT * PIXSCALE, A_OUT * PIXSCALE / 60))
w(' the nucleus is NOT saturated: peak star-free galaxy signal 2498 ADU/px')
w(' vs a clip level of ~63000 ADU/px (factor 25 margin)')
w('')
w('Noise and systematic floors [mag/arcsec^2]')
for k, v in FLOOR.items():
w(' %-40s %.2f' % (k, v))
w(' far-field pedestal %+.2f ADU/px: the sky plane absorbed halo light' % PED)
w('')
w('Sersic fit, %.0f-%.0f arcsec' % (x.min(), x.max()))
w(' n = %.2f +- %.2f' % (nser, perr[2]))
w(' Re = %.1f +- %.1f arcsec = %.2f +- %.2f kpc'
% (re_as, perr[1], re_as * KPC_PER_ARCSEC, perr[1] * KPC_PER_ARCSEC))
w(' mu_e = %.2f +- %.2f mag/arcsec^2' % (mue, perr[0]))
w(' rms of fit residual %.3f mag' % resid_sersic.std())
w('')
w('Integrated light (elliptical apertures on the measured profile)')
for aa2 in [200, 400, 600, 800, 1000, 1200]:
i = np.argmin(np.abs(ac - aa2))
w(' a < %5.0f px (%5.2f arcmin): G = %.3f' % (aa2, ac[i] * PIXSCALE / 60., mtot[i]))
w(' half-light radius of the light within a=%.0f px: %.0f arcsec (%.2f kpc)'
% (A_OUT, re_growth, re_growth * KPC_PER_ARCSEC))
w(' fraction from the extrapolated a<%.0f px core: %.1f%%' % (A_IN, 100 * inner_frac))
w('')
w('Ellipticity / position angle trend (converged isophotes only)')
for i in np.nonzero(conv)[0]:
w(' a = %6.1f px (%6.1f") : eps = %.3f +- %.3f PA = %5.1f +- %.1f deg'
% (tB['sma'][i], tB['sma'][i] * PIXSCALE, tB['eps'][i],
tB['eps_err'][i], tB['pa'][i], tB['pa_err'][i]))
print('\nwrote NGC5128-sb-profile.png, NGC5128-sb-isophote-geometry.png, NGC5128-sb-colour-profile.png, '
'sb-derived-quantities.txt')