astrophotography/session-scripts/mo_fig_moving.py
laurence 5286a2e81b 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.
2026-07-21 15:29:49 +01:00

283 lines
12 KiB
Python

"""Figure: the moving-object search, its sensitivity, and its failure mode.
The blind search found nothing, so the figure has to show that the search
worked rather than showing a discovery. Four things are plotted.
Rows 1-2 are postage-stamp strips across the twelve luminance subs, cut at a
sky position that is FIXED on the sky (the object's position in the first sub).
A real moving object drifts across the strip. So does a hot pixel, because
registration holds the sky still and therefore drags anything fixed to the
detector in the opposite direction. The two are indistinguishable here, which
is the whole problem.
Rows 3-4 are the same two objects, cut instead at a FIXED DETECTOR position.
Now they separate cleanly: the real object still moves, the hot pixel does not
move at all. This is the cut that took the search from 141 confident false
detections to zero.
The bottom panels are the sensitivity: recovery fraction of synthetic movers as
a function of magnitude and apparent rate, from three independent injection
runs (twelve injections per cell), and a slice through it.
"""
import os
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from astropy.io import fits
from matplotlib.colors import LinearSegmentedColormap
from scipy.spatial import cKDTree
import mo_common as C
HALF = 13
OUTPNG = os.path.join(C.OUT, "NGC5128-mo-moving-object-search.png")
INJ_MAG = 18.5
INJ_RATE = 25.0 # px/hr = 13.4 arcsec/hr
def load_grids():
mags = rates = None
grids = []
for seed in (1, 2, 3):
p = os.path.join(C.OUT, f"_mo_sensitivity_{seed}.npz")
if not os.path.exists(p):
continue
z = np.load(p)
mags, rates = z["mags"], z["rates"]
grids.append(z["grid"])
return mags, rates, np.mean(grids, axis=0), len(grids) * int(
np.load(os.path.join(C.OUT, "_mo_sensitivity_1.npz"))["nrep"])
# A detector defect verified by hand: reproducing the search with the
# detector-frame cuts switched off yields 141 tracklets, and this one is
# typical. Its detected centroid sits at native pixel (224.00, 787.00) in
# every one of subs 002-009 - not merely close, but the same pixel centre to
# 0.02 px - while its registered position walks 18.6 px across the sequence
# at 15.9 arcsec/hr with a brightness stable to 0.06 mag. It is a hot pixel.
HOT_NATIVE = np.array([224.00, 787.00])
HOT_REF_KEY = "Luminance_002"
def find_hot_pixel(tforms, keys):
return HOT_NATIVE, 8
def stamps(inj_ref0, inj_v, hot_native, tforms, keys, times):
"""Cut all four strips in a single pass over the subs."""
out = {"inj_sky": [], "inj_det": [], "hot_sky": [], "hot_det": []}
hot_ref0 = tforms[keys[0]](hot_native[None, :])[0]
inj_native0 = tforms[keys[0]].inverse(inj_ref0[None, :])[0]
def cut(img, x, y):
xi, yi = int(round(x)), int(round(y))
return img[yi - HALF:yi + HALF + 1, xi - HALF:xi + HALF + 1].copy()
for i, (key, path) in enumerate(C.lum_frames()):
with fits.open(path, memmap=False) as hd:
img = hd[0].data.astype(np.float32)
tf = tforms[key]
inv = tf.inverse
lin = np.linalg.inv(tf.params[:2, :2])
# Inject the synthetic at its true position in this sub.
true_ref = inj_ref0 + inj_v * times[i]
tx, ty = inv(true_ref[None, :])[0]
objs, ap, _ = C.detect(img)
mobjs, map_, _, _, _ = MASTER
zp, _ = C.frame_zeropoint(objs["x"], objs["y"], ap, mobjs, map_, tf)
d = lin @ (inj_v * 300.0 / 3600.0)
C.add_source(img, tx, ty, 10 ** ((zp - INJ_MAG) / 2.5) / C.APFRAC,
d[0], d[1])
# Strip 1: cut at the sky position the object had at t=0.
sx, sy = inv(inj_ref0[None, :])[0]
out["inj_sky"].append(cut(img, sx, sy))
# Strip 2: cut at the detector position it had at t=0.
out["inj_det"].append(cut(img, inj_native0[0], inj_native0[1]))
# Strip 3: hot pixel, cut at its t=0 sky position.
hx, hy = inv(hot_ref0[None, :])[0]
out["hot_sky"].append(cut(img, hx, hy))
# Strip 4: hot pixel, cut at its fixed detector position.
out["hot_det"].append(cut(img, hot_native[0], hot_native[1]))
del img
print(f" stamps from {key}")
return out
def show(ax, s, vlo, vhi, edge="#7a7f87"):
ax.imshow(s, origin="lower", cmap="gray", vmin=vlo, vmax=vhi,
interpolation="nearest")
n = s.shape[0]
c = (n - 1) / 2.0
# A faint crosshair marks the centre of the stamp, i.e. the position the
# cut is held fixed at. Whether the source stays on it is the whole point.
ax.plot([c, c], [c - 0.30 * n, c - 0.12 * n], color="#f0c05a", lw=0.8)
ax.plot([c - 0.30 * n, c - 0.12 * n], [c, c], color="#f0c05a", lw=0.8)
ax.set_xlim(-0.5, n - 0.5)
ax.set_ylim(-0.5, n - 0.5)
ax.set_xticks([])
ax.set_yticks([])
for sp in ax.spines.values():
sp.set_color(edge)
sp.set_linewidth(0.8)
def main():
global MASTER
MASTER = C.master_sources()
times = C.lum_times()
keys = [k for k, _ in C.lum_frames()]
tforms = C.frame_transforms()
hot, hotn = find_hot_pixel(tforms, keys)
print(f"hot pixel at detector ({hot[0]:.1f}, {hot[1]:.1f}), "
f"present in {hotn + 1}/12 subs")
ang = np.radians(35.0)
inj_v = INJ_RATE * np.array([np.cos(ang), np.sin(ang)])
mobjs = MASTER[0]
mtree = cKDTree(np.column_stack([mobjs["x"], mobjs["y"]]))
rng = np.random.default_rng(11)
for _ in range(50000):
p0 = np.array([rng.uniform(600, 4200), rng.uniform(600, 2600)])
track = np.array([p0 + inj_v * t for t in times])
if np.hypot(*(p0 - np.array([2394.0, 1597.0]))) < 1100:
continue
d, _ = mtree.query(track, distance_upper_bound=30.0)
if np.all(~np.isfinite(d)):
inj_ref0 = p0
break
else:
inj_ref0 = np.array([1500.0, 2500.0])
print(f"synthetic injected at reference pixel "
f"({inj_ref0[0]:.0f}, {inj_ref0[1]:.0f})")
st = stamps(inj_ref0, inj_v, hot, tforms, keys, times)
mags, rates, grid, nrep = load_grids()
from astropy.stats import sigma_clipped_stats
fig = plt.figure(figsize=(15.0, 11.4))
gs = fig.add_gridspec(
5, 12, height_ratios=[0.78, 0.78, 0.78, 0.78, 2.5],
hspace=0.16, wspace=0.06,
left=0.175, right=0.985, top=0.878, bottom=0.08)
labels = [
("inj_sky", "synthetic mover", "held at fixed SKY position",
"#2f7d54", "moves -> could be real"),
("hot_sky", "hot pixel", "held at fixed SKY position",
"#b5484f", "also moves -> looks identical"),
("inj_det", "synthetic mover", "held at fixed DETECTOR position",
"#2f7d54", "still moves -> REAL"),
("hot_det", "hot pixel", "held at fixed DETECTOR position",
"#b5484f", "does not move -> DEFECT"),
]
for r, (kk, who, how, col, verdict) in enumerate(labels):
arr = np.array(st[kk], dtype=float)
med, _, sd = sigma_clipped_stats(arr, sigma=3.0)
vlo, vhi = med - 1.5 * sd, med + 14.0 * sd
for c in range(12):
ax = fig.add_subplot(gs[r, c])
show(ax, st[kk][c], vlo, vhi, edge=col)
if r == 0:
ax.set_title(f"{times[c] * 60:.0f} min", fontsize=8.5,
color="#3b3f45", pad=4)
if c == 0:
bb = ax.get_position()
fig.text(0.170, bb.y0 + bb.height * 0.80, who, fontsize=10.5,
color=col, ha="right", va="center", weight="bold")
fig.text(0.170, bb.y0 + bb.height * 0.50, how, fontsize=8.4,
color="#5a6068", ha="right", va="center")
fig.text(0.170, bb.y0 + bb.height * 0.18, verdict,
fontsize=8.6, color=col, ha="right", va="center",
style="italic")
fig.text(0.02, 0.962,
"Moving-object search: 12 x 300 s luminance subs of NGC 5128, "
"2026-07-21 08:56-10:00 UTC",
fontsize=14, color="#1b1e23", weight="bold", ha="left")
fig.text(0.02, 0.937,
"Each strip is a 14 x 14 arcsec cutout, one per sub, with the "
"cut position held fixed (yellow crosshair). Registration holds "
"the sky still, so it drags anything fixed to the",
fontsize=9.4, color="#4a4f57", ha="left")
fig.text(0.02, 0.918,
"detector across the registered frame - a hot pixel therefore "
"mimics a minor planet perfectly. Cutting at a fixed detector "
"position separates them. Synthetic source: G = "
f"{INJ_MAG}, {INJ_RATE * C.SCALE:.1f} arcsec/hr.",
fontsize=9.4, color="#4a4f57", ha="left")
# ---- sensitivity heat map ----
axg = fig.add_subplot(gs[4, 0:6])
cmap = LinearSegmentedColormap.from_list(
"rec", ["#f5f6f8", "#cfe0ec", "#7fb0cd", "#3d7ba6", "#17456b"])
im = axg.imshow(grid, origin="lower", aspect="auto", cmap=cmap,
vmin=0, vmax=1, interpolation="nearest")
axg.set_xticks(range(len(rates)))
axg.set_xticklabels([f"{r * C.SCALE:.1f}" for r in rates], fontsize=8.5)
axg.set_yticks(range(len(mags)))
axg.set_yticklabels([f"{m:g}" for m in mags], fontsize=9)
axg.set_xlabel("apparent rate (arcsec/hr)", fontsize=10)
axg.set_ylabel("G magnitude", fontsize=10)
axg.set_title(f"recovery of injected synthetic movers "
f"({nrep} per cell, 3 independent runs)", fontsize=10.5,
color="#1b1e23", pad=8)
for i in range(len(mags)):
for j in range(len(rates)):
v = grid[i, j]
axg.text(j, i, f"{v:.2f}".lstrip("0") if v else ".",
ha="center", va="center", fontsize=7.0,
color="white" if v > 0.55 else "#585d65")
cb = fig.colorbar(im, ax=axg, fraction=0.032, pad=0.014)
cb.set_label("recovered", fontsize=9)
cb.ax.tick_params(labelsize=8)
# ---- slices ----
axs = fig.add_subplot(gs[4, 7:12])
band = (rates * C.SCALE >= 8) & (rates * C.SCALE <= 40)
prof = grid[:, band].mean(axis=1)
axs.plot(mags, prof, "-o", color="#2e6f9e", lw=2.2, ms=5.5,
label="8-40 arcsec/hr (main-belt range)")
prof2 = grid[:, rates * C.SCALE > 60].mean(axis=1)
axs.plot(mags, prof2, "-s", color="#b5484f", lw=1.8, ms=4.5,
label="> 60 arcsec/hr (trailing losses)")
axs.axhline(0.5, color="#9aa0a8", ls=":", lw=1.2)
axs.text(mags[0] + 0.02, 0.53, "50% recovery", fontsize=8,
color="#6b7079")
axs.set_xlabel("G magnitude", fontsize=10)
axs.set_ylabel("fraction recovered", fontsize=10)
axs.set_ylim(-0.03, 1.08)
axs.set_title("sensitivity vs magnitude", fontsize=10.5,
color="#1b1e23", pad=8)
axs.legend(fontsize=8.5, frameon=False, loc="lower left")
axs.grid(alpha=0.25, lw=0.6)
for sp in ("top", "right"):
axs.spines[sp].set_visible(False)
order = np.argsort(prof)
g50 = float(np.interp(0.5, prof[order], mags[order]))
print(f"50% recovery for main-belt rates at G = {g50:.2f}")
fig.text(0.02, 0.024,
"Result: 0 candidates from the blind search. Time-permuted "
"control on the real residuals: 0 tracklets in 60 trials. "
f"50% recovery at G = {g50:.1f} for main-belt rates. The only "
"catalogued minor planet within 30' of the pointing,",
fontsize=9, color="#4a4f57", ha="left")
fig.text(0.02, 0.006,
"(427494) 2002 BK26 at V = 21.7, fell 2.4 arcmin outside the "
"frame - the field's long axis runs along declination, not right "
"ascension.",
fontsize=9, color="#4a4f57", ha="left")
fig.savefig(OUTPNG, dpi=125, facecolor="white")
print(f"wrote {OUTPNG}")
MASTER = None
if __name__ == "__main__":
main()