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.
283 lines
12 KiB
Python
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()
|