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.
80 lines
3.4 KiB
Python
80 lines
3.4 KiB
Python
"""Smooth elliptical model builder shared by the later steps.
|
|
|
|
Given an isophote table (sma, intens, eps, pa) and a fixed centre, assign every
|
|
pixel the semi-major axis a of the isophote passing through it. Because eps(a)
|
|
and pa(a) vary slowly this is solved by fixed-point iteration starting from the
|
|
circular radius, which converges in a handful of passes. The model intensity is
|
|
then a log-log interpolation of intens(a).
|
|
|
|
This is used instead of photutils.isophote.build_ellipse_model because it is
|
|
much faster on a 4788x3194 frame and because it guarantees a strictly smooth,
|
|
monotonic-in-a model with no interpolation artefacts to confuse the residual.
|
|
"""
|
|
import numpy as np
|
|
from scipy.interpolate import interp1d
|
|
from scipy.ndimage import gaussian_filter1d, zoom
|
|
|
|
|
|
def smooth_geometry(sma, eps, pa_deg, sig=2.0):
|
|
"""Return (sma, eps, pa_rad) with eps and PA lightly smoothed along a."""
|
|
ok = np.isfinite(eps) & np.isfinite(pa_deg) & np.isfinite(sma)
|
|
s = sma[ok]
|
|
e = gaussian_filter1d(np.clip(eps[ok], 0.0, 0.7), sig, mode='nearest')
|
|
p = np.unwrap(np.radians(pa_deg[ok]) * 2.0) / 2.0 # PA is defined mod 180
|
|
p = gaussian_filter1d(p, sig, mode='nearest')
|
|
return s, e, p
|
|
|
|
|
|
def radius_map(shape, xc, yc, sma, eps, pa_rad, nit=15, dtype=np.float32):
|
|
"""Semi-major axis of the isophote through each pixel."""
|
|
fe = interp1d(sma, eps, bounds_error=False, fill_value=(eps[0], eps[-1]))
|
|
fp = interp1d(sma, pa_rad, bounds_error=False, fill_value=(pa_rad[0], pa_rad[-1]))
|
|
ny, nx = shape
|
|
Y, X = np.mgrid[0:ny, 0:nx].astype(np.float64)
|
|
X -= xc
|
|
Y -= yc
|
|
a = np.maximum(np.hypot(X, Y), 0.3)
|
|
for _ in range(nit):
|
|
q = 1.0 - fe(a)
|
|
th = fp(a)
|
|
c, s = np.cos(th), np.sin(th)
|
|
xp = X * c + Y * s
|
|
yp = -X * s + Y * c
|
|
a = np.maximum(np.sqrt(xp * xp + (yp / q) ** 2), 0.3)
|
|
return a.astype(dtype)
|
|
|
|
|
|
def build(shape, xc, yc, tab, nit=15, block=1):
|
|
"""Return (model, a_map) at full resolution.
|
|
|
|
block > 1 computes the radius map on a coarser grid and bilinearly
|
|
upsamples it; the model is smooth on scales far larger than block so this
|
|
costs nothing in accuracy and a lot less in time and memory.
|
|
"""
|
|
s, e, p = smooth_geometry(tab['sma'], tab['eps'], tab['pa'])
|
|
if block > 1:
|
|
sh = (shape[0] // block, shape[1] // block)
|
|
a = radius_map(sh, (xc - (block - 1) / 2.) / block,
|
|
(yc - (block - 1) / 2.) / block, s / block, e, p, nit)
|
|
# radius_map worked in block units: convert back to full-resolution pixels
|
|
a = zoom(a.astype(np.float32) * block,
|
|
(shape[0] / sh[0], shape[1] / sh[1]), order=1)
|
|
if a.shape != tuple(shape):
|
|
b = np.zeros(shape, np.float32)
|
|
n0, n1 = min(a.shape[0], shape[0]), min(a.shape[1], shape[1])
|
|
b[:n0, :n1] = a[:n0, :n1]
|
|
if n0 < shape[0]:
|
|
b[n0:, :] = b[n0 - 1, :]
|
|
if n1 < shape[1]:
|
|
b[:, n1:] = b[:, [n1 - 1]]
|
|
a = b
|
|
else:
|
|
a = radius_map(shape, xc, yc, s, e, p, nit)
|
|
|
|
ok = np.isfinite(tab['intens']) & (tab['intens'] > 1e-3)
|
|
ls, li = np.log10(tab['sma'][ok]), np.log10(tab['intens'][ok])
|
|
o = np.argsort(ls)
|
|
ls, li = ls[o], li[o]
|
|
mod = (10 ** np.interp(np.log10(np.maximum(a, 0.3)), ls, li,
|
|
left=li[0], right=-3.0)).astype(np.float32)
|
|
return mod, a
|