Move the processing code under pipeline/

Preparing to merge this repository into a combined astrophotography
repo. session-scripts/ becomes pipeline/ because the scripts import
layout.py from their own directory and must stay together, and because
'pipeline' says what it is rather than how it came about. observing/
stays at the top level: observing plans are not processing code.
This commit is contained in:
laurence 2026-07-21 17:13:54 +01:00
parent 653ca103cd
commit c6299f41ab
53 changed files with 0 additions and 0 deletions

283
pipeline/mo_fig_moving.py Normal file
View file

@ -0,0 +1,283 @@
"""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()