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,211 @@
"""Where the one known minor planet was, and how deep digital tracking goes.
MPChecker and JPL's sb_ident agree that exactly one catalogued minor planet lay
within 30 arcmin of the field centre on this night: (427494) 2002 BK26, at
RA 13 27 00.6, Dec -43 11 26 at 09:22 UTC, V = 21.7, moving 53.2 arcsec/hr
toward position angle 71.7 deg.
The first thing this script does is check whether it was actually inside the
frame, which is not obvious from the offsets alone. The field is 42.9' x 28.6'
at position angle -89 deg - the long axis runs very nearly along declination,
not right ascension - so the RA half-width is only about 14.6 arcmin. The
asteroid is 17.0 arcmin east of centre. It was outside the frame, by roughly
2.4 arcmin, and there is therefore nothing known to recover.
The second thing is to measure how deep the search could have gone at that
rate, by shift-and-stack ("digital tracking"): each sub is shifted by the
target's own motion before combining, so a source moving at exactly that rate
adds coherently while the stars trail. Because the field position no longer
matters for the depth measurement, this is done on an empty patch of sky, and
a synthetic source of V = 21.7 is injected to show directly whether such an
object would have been recovered had it been in frame.
Output: _mo_shiftstack.npz
"""
import os
import numpy as np
import sep
from astropy import units as u
from astropy.coordinates import SkyCoord
from astropy.io import fits
from astropy.stats import sigma_clipped_stats
from astropy.wcs import WCS
from scipy.ndimage import shift as ndshift
from scipy.spatial import cKDTree
import mo_common as C
NAME = "(427494) 2002 BK26"
EPH_RA = 201.752708 # deg, 13 27 00.65, at 09:22 UTC
EPH_DEC = -43.190667 # deg, -43 11 26.4
EPH_UTC_HOURS = 9.0 + 22.0 / 60.0
DRA_COSDEC = 50.5 # arcsec/hr
DDEC = 16.7 # arcsec/hr
RATE = np.hypot(DRA_COSDEC, DDEC) # 53.2 arcsec/hr
VMAG = 21.7
HALF = 60 # px half-size of the extracted stamp
NX, NY = 4788, 3194
def utc_hours(path):
d = fits.getheader(path)["DATE-OBS"]
hh, mm, ss = d.split("T")[1].split(":")
return int(hh) + int(mm) / 60.0 + float(ss) / 3600.0 + 150.0 / 3600.0
def footprint_check(wcs):
tgt = SkyCoord(EPH_RA * u.deg, EPH_DEC * u.deg)
px, py = wcs.world_to_pixel(tgt)
cen = wcs.pixel_to_world(NX / 2, NY / 2)
dra, ddec = cen.spherical_offsets_to(tgt)
corners = [wcs.pixel_to_world(x, y) for x, y in
((0, 0), (NX - 1, 0), (0, NY - 1), (NX - 1, NY - 1))]
ras = [c.ra.deg for c in corners]
decs = [c.dec.deg for c in corners]
inside = (0 <= px < NX) and (0 <= py < NY)
print(f"{NAME}: V={VMAG}, {RATE:.1f}\"/hr at PA "
f"{np.degrees(np.arctan2(DRA_COSDEC, DDEC)):.1f} deg")
print(f" offset from field centre: {dra.to_value(u.arcmin):+.2f}' in RA, "
f"{ddec.to_value(u.arcmin):+.2f}' in Dec")
print(f" frame RA span {min(ras):.4f} to {max(ras):.4f} deg "
f"({(max(ras) - min(ras)) * np.cos(np.radians(EPH_DEC)) * 60:.1f}' "
f"on sky)")
print(f" frame Dec span {min(decs):.4f} to {max(decs):.4f} deg "
f"({(max(decs) - min(decs)) * 60:.1f}')")
print(f" predicted pixel ({px:.0f}, {py:.0f}) in a "
f"{NX} x {NY} frame -> "
f"{'INSIDE' if inside else 'OUTSIDE the frame'}")
return inside, float(px), float(py)
def blank_patch(mobjs, times, wcs, galx=2394.0, galy=1597.0, galrad=1000.0):
"""A patch of sky with no source near the whole track, off the galaxy.
Keeping clear of NGC 5128 itself matters more than it looks: the galaxy's
smooth light has a steep gradient that swamps the sky noise and would make
any depth measured on top of it meaningless.
"""
tree = cKDTree(np.column_stack([mobjs["x"], mobjs["y"]]))
# Track length in reference pixels over the sequence.
vx = (DRA_COSDEC / C.SCALE)
vy = (DDEC / C.SCALE)
span = times[-1]
rng = np.random.default_rng(7)
for _ in range(20000):
x0 = rng.uniform(400, NX - 400)
y0 = rng.uniform(400, NY - 400)
track = [(x0 + vx * t, y0 + vy * t) for t in times]
if not all(300 < x < NX - 300 and 300 < y < NY - 300
for x, y in track):
continue
if any(np.hypot(x - galx, y - galy) < galrad for x, y in track):
continue
d, _ = tree.query(np.array(track), distance_upper_bound=22.0)
if np.all(~np.isfinite(d)):
return x0, y0, vx, vy, span
raise RuntimeError("no empty patch found")
def stamp(img, x, y, half=HALF):
xi, yi = int(round(x)), int(round(y))
if not (half < xi < img.shape[1] - half and
half < yi < img.shape[0] - half):
return None
cut = img[yi - half:yi + half + 1, xi - half:xi + half + 1].astype(float)
return ndshift(cut, (yi - y, xi - x), order=3, mode="nearest")
def main():
hdr = fits.getheader(os.path.join(C.OUT, "master-Luminance.fit"))
wcs = WCS(hdr)
inside, epx, epy = footprint_check(wcs)
times = C.lum_times()
tforms = C.frame_transforms()
mobjs, map_, _, _, mimg = C.master_sources()
del mimg
x0, y0, vx, vy, span = blank_patch(mobjs, times, wcs)
print(f"\ndigital-tracking test patch: reference pixel "
f"({x0:.0f}, {y0:.0f}), track {np.hypot(vx, vy) * span:.0f} px "
f"over {span * 60:.0f} min")
tracked, fixed, zps = [], [], []
for i, (key, path) in enumerate(C.lum_frames()):
xr, yr = x0 + vx * times[i], y0 + vy * times[i]
inv = tforms[key].inverse
nx_, ny_ = inv(np.array([[xr, yr]]))[0]
fx_, fy_ = inv(np.array([[x0, y0]]))[0]
with fits.open(path, memmap=False) as hd:
img = hd[0].data.astype(np.float32)
objs, ap, _ = C.detect(img)
zp, _ = C.frame_zeropoint(objs["x"], objs["y"], ap, mobjs, map_,
tforms[key])
zps.append(zp)
# A source at V=21.7 moving at the asteroid's rate, injected into the
# raw sub, trailing across the 300 s exposure exactly as it would.
lin = np.linalg.inv(tforms[key].params[:2, :2])
dxy = lin @ np.array([vx * 300.0 / 3600.0, vy * 300.0 / 3600.0])
img2 = img.copy()
C.add_source(img2, nx_, ny_,
10 ** ((zp - VMAG) / 2.5) / C.APFRAC, dxy[0], dxy[1])
a = stamp(img2, nx_, ny_)
b = stamp(img, nx_, ny_) # same patch, no synthetic
del img, img2
tracked.append(a - np.median(a))
fixed.append(b - np.median(b))
print(f" {key}: zp {zp:.3f}")
def combine(cube):
m, _, _ = sigma_clipped_stats(np.array(cube), sigma=3.0, maxiters=2,
axis=0)
return np.asarray(m, dtype=np.float32)
def flatten(stack):
"""Remove any residual sky gradient before measuring noise."""
bkg = sep.Background(stack, bw=24, bh=24, fw=3, fh=3)
return stack - bkg.back()
with_src = flatten(combine(tracked))
empty = flatten(combine(fixed))
zp_eff = float(np.mean(zps))
_, _, sd = sigma_clipped_stats(empty, sigma=3.0)
noise_ap = sd * np.sqrt(np.pi * C.APRAD ** 2)
lim5 = -2.5 * np.log10(5.0 * noise_ap) + zp_eff
c = float(HALF)
f_src, _, _ = sep.sum_circle(with_src, np.array([c]), np.array([c]),
C.APRAD, err=float(sd), gain=1.0)
f_emp, _, _ = sep.sum_circle(empty, np.array([c]), np.array([c]),
C.APRAD, err=float(sd), gain=1.0)
snr_src = float(f_src[0]) / noise_ap
print(f"\nstacked 12 x 300 s along the track")
print(f" effective zero point {zp_eff:.3f}, pixel sigma {sd:.3f}")
print(f" 5 sigma point-source limit of the tracked stack: "
f"G = {lim5:.2f}")
# The untrailed limit above is optimistic: at 53"/hr the object smears
# 8.2 px within each 300 s sub, and a 5 px aperture cannot hold a streak
# that long. Scaling the injected source's recovered significance to
# 5 sigma gives the limit that actually applies at this rate.
lim5_trail = VMAG - 2.5 * np.log10(5.0 / max(snr_src, 1e-3))
print(f" injected V = {VMAG} source recovered at "
f"{snr_src:.2f} sigma "
f"({'DETECTED' if snr_src > 5 else 'NOT detectable'})")
print(f" -> 5 sigma limit for a source trailing at {RATE:.0f}\"/hr: "
f"G = {lim5_trail:.2f} (vs {lim5:.2f} for an untrailed source)")
print(f" same aperture on the un-injected stack: "
f"{float(f_emp[0]) / noise_ap:.2f} sigma (blank, as expected)")
print(f"\n{NAME} at V={VMAG} is {VMAG - lim5:+.2f} mag relative to that "
f"limit.")
np.savez(os.path.join(C.OUT, "_mo_shiftstack.npz"),
with_src=with_src, empty=empty, zp_eff=zp_eff, lim5=lim5,
sd=sd, snr_src=snr_src, lim5_trail=lim5_trail, vmag=VMAG, rate=RATE, name=NAME,
inside=inside, eph_px=epx, eph_py=epy, x0=x0, y0=y0,
vx=vx, vy=vy, times=times)
print("\nsaved _mo_shiftstack.npz")
if __name__ == "__main__":
main()