"""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()