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

View file

@ -1,232 +0,0 @@
"""Transient search: sources in the luminance master with no catalogue match.
The chain is deliberately conservative, because on this field the sky is very
crowded with things that are real but not transient.
1. Detect on the deep luminance master (60 min) and measure a 5 px aperture
magnitude on the master's own zero point.
2. Throw away everything that is not solidly detected. A transient claim rests
on a single night's data, so the bar is SNR > 10, not the SNR 5 the frame
nominally reaches.
3. Throw away everything that is not point-like. This is done against the
image's own PSF, measured from Gaia stars in the frame, using sep's
half-light radius. It removes background galaxies and the outer structure
of NGC 5128 itself, but note carefully that it does NOT remove Centaurus A's
globular clusters: at 3.8 Mpc a cluster with a 3 pc half-light radius spans
~0.2 arcsec against 2.7 arcsec seeing, so clusters are unresolved here and
look exactly like stars. Only catalogues can separate them.
4. Cross-match against Gaia DR3 (the cached 0.42 deg, G < 20.5 catalogue).
5. Cross-match whatever is left against SkyMapper DR2 through VizieR, which
covers this declination and goes deeper than Gaia for non-stellar objects.
6. Demand that survivors are real by requiring an independent detection in at
least six of the twelve individual luminance subs. A cosmic ray or a
stacking artefact cannot do that; anything astrophysical will.
7. What is left is examined one by one.
Writes mo-transient-candidates.csv and _mo_transient.npz.
"""
import csv
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.wcs import WCS
from scipy.spatial import cKDTree
import mo_common as C
SNR_MIN = 10.0
MATCH_RADIUS = 2.0 # arcsec, Gaia
SM_RADIUS = 3.0 # arcsec, SkyMapper (worse astrometry, wider PSF)
SUB_MIN = 6 # subs a source must independently appear in
EDGE = 40 # px
def detect_master():
with fits.open(os.path.join(C.OUT, "master-Luminance.fit")) as hd:
img = hd[0].data.astype(np.float32)
hdr = hd[0].header
bkg = sep.Background(img, bw=64, bh=64, fw=3, fh=3)
sub = img - bkg.back()
objs = sep.extract(sub, 3.0, err=bkg.globalrms, minarea=5,
deblend_cont=0.005)
flux, ferr, _ = sep.sum_circle(sub, objs["x"], objs["y"], C.APRAD,
err=bkg.globalrms, gain=1.0)
r50, _ = sep.flux_radius(sub, objs["x"], objs["y"],
6.0 * np.ones(len(objs)), 0.5, normflux=flux,
subpix=5)
return objs, np.asarray(flux), np.asarray(ferr), np.asarray(r50), \
hdr, sub, bkg.globalrms
def gaia_cached():
z = np.load(os.path.join(C.OUT, "_gaia_deep.npz"))
return z["ra"], z["dec"], z["g"]
def skymapper(ra0, dec0, radius_deg):
"""SkyMapper DR2 over the field, cached because it is a slow query."""
cache = os.path.join(C.OUT, "_skymapper.npz")
if os.path.exists(cache):
z = np.load(cache)
print(f"SkyMapper: {len(z['ra'])} cached sources")
return z["ra"], z["dec"], z["g"], z["r"], z["cls"]
from astroquery.vizier import Vizier
v = Vizier(columns=["RAICRS", "DEICRS", "gPSF", "rPSF", "ClassStar"],
row_limit=-1)
tbl = v.query_region(SkyCoord(ra0 * u.deg, dec0 * u.deg),
radius=radius_deg * u.deg,
catalog="II/358/smss")[0]
ra = np.asarray(tbl["RAICRS"], dtype=float)
dec = np.asarray(tbl["DEICRS"], dtype=float)
g = np.asarray(tbl["gPSF"], dtype=float)
r = np.asarray(tbl["rPSF"], dtype=float)
cls = np.asarray(tbl["ClassStar"], dtype=float)
np.savez_compressed(cache, ra=ra, dec=dec, g=g, r=r, cls=cls)
print(f"SkyMapper DR2: {len(ra)} sources retrieved")
return ra, dec, g, r, cls
def sky_match(sc, ra, dec, radius_arcsec):
"""Nearest-neighbour match; returns index and separation in arcsec."""
cat = SkyCoord(ra * u.deg, dec * u.deg)
idx, d2d, _ = sc.match_to_catalog_sky(cat)
return idx, d2d.arcsec, d2d.arcsec < radius_arcsec
def sub_support(xy_ref):
"""How many of the twelve luminance subs independently show each source."""
_, dets = C.load_dets()
tforms = C.frame_transforms()
n = np.zeros(len(xy_ref), dtype=int)
for key, _ in C.lum_frames():
d = dets[key]
p = tforms[key](d[:, 2:4].astype(float))
t = cKDTree(p)
dd, _ = t.query(xy_ref, distance_upper_bound=2.5)
n += np.isfinite(dd)
return n
def rgb_support(xy_ref, rms_scale=3.0):
"""Peak significance of each position in the R, G and B masters.
A real object on the sky is in every filter. A luminance-only artefact is
not. The masters are pixel-aligned by construction, so this is a direct
look-up rather than another registration.
"""
out = {}
for filt in ("Red", "Green", "Blue"):
with fits.open(os.path.join(C.OUT, f"master-{filt}.fit")) as hd:
img = hd[0].data.astype(np.float32)
bkg = sep.Background(img, bw=64, bh=64, fw=3, fh=3)
s = img - bkg.back()
fl, _, _ = sep.sum_circle(s, xy_ref[:, 0], xy_ref[:, 1], C.APRAD,
err=bkg.globalrms, gain=1.0)
noise = bkg.globalrms * np.sqrt(np.pi * C.APRAD ** 2)
out[filt] = np.asarray(fl) / noise
del img, s
return out
def main():
objs, flux, ferr, r50, hdr, _sub, mrms = detect_master()
wcs = WCS(hdr)
ny, nx = 3194, 4788
print(f"master detections: {len(objs)}")
snr = np.where(ferr > 0, flux / np.maximum(ferr, 1e-9), 0.0)
mag = np.where(flux > 0, -2.5 * np.log10(np.maximum(flux, 1e-9)) + C.ZP,
np.nan)
# PSF reference from the frame's own bright stars.
gra, gdec, gmag = gaia_cached()
sc_all = wcs.pixel_to_world(objs["x"], objs["y"])
gidx, gsep, gok = sky_match(sc_all, gra, gdec, MATCH_RADIUS)
star = gok & (gmag[gidx] > 15) & (gmag[gidx] < 18.5) & (snr > 30)
r50_star = float(np.median(r50[star]))
r50_sig = float(np.std(r50[star]))
print(f"PSF from {star.sum()} Gaia stars: r50 = {r50_star:.2f} +- "
f"{r50_sig:.2f} px ({r50_star * C.SCALE:.2f} arcsec)")
inframe = ((objs["x"] > EDGE) & (objs["x"] < nx - EDGE) &
(objs["y"] > EDGE) & (objs["y"] < ny - EDGE))
good = inframe & (snr > SNR_MIN) & np.isfinite(mag)
pointlike = np.abs(r50 - r50_star) < 3.0 * max(r50_sig, 0.25)
print(f"SNR > {SNR_MIN:.0f} and in frame: {good.sum()}")
print(f" ... of which point-like: {(good & pointlike).sum()}")
print(f" ... of which Gaia-matched: {(good & pointlike & gok).sum()}")
cand = good & pointlike & ~gok
print(f"\nno Gaia DR3 counterpart within {MATCH_RADIUS}\": {cand.sum()}")
# SkyMapper DR2
cen = wcs.pixel_to_world(nx / 2, ny / 2)
sra, sdec, sg, sr, scls = skymapper(cen.ra.deg, cen.dec.deg, 0.42)
sidx, ssep, sok = sky_match(sc_all, sra, sdec, SM_RADIUS)
cand2 = cand & ~sok
print(f"also no SkyMapper DR2 counterpart within {SM_RADIUS}\": "
f"{cand2.sum()}")
ci = np.where(cand2)[0]
xy = np.column_stack([objs["x"][ci], objs["y"][ci]])
nsub = sub_support(xy)
rgb = rgb_support(xy)
print(f"of those, detected in >= {SUB_MIN} individual subs: "
f"{(nsub >= SUB_MIN).sum()}")
sc = wcs.pixel_to_world(xy[:, 0], xy[:, 1])
# Distance from the centre of NGC 5128 - a supernova is expected on or
# near the galaxy, and this ranks the list accordingly.
cenA = SkyCoord("13h25m27.6s", "-43d01m09s")
dgal = sc.separation(cenA).arcmin
rows = []
for k, i in enumerate(ci):
rows.append(dict(
id=int(i), ra_deg=round(sc[k].ra.deg, 6),
dec_deg=round(sc[k].dec.deg, 6),
ra_hms=sc[k].ra.to_string(u.hour, sep=":", precision=2),
dec_dms=sc[k].dec.to_string(u.deg, sep=":", precision=1,
alwayssign=True),
x=round(float(xy[k, 0]), 2), y=round(float(xy[k, 1]), 2),
g_mag=round(float(mag[i]), 2), snr=round(float(snr[i]), 1),
r50_px=round(float(r50[i]), 2),
r50_over_psf=round(float(r50[i] / r50_star), 2),
n_subs=int(nsub[k]),
snr_R=round(float(rgb["Red"][k]), 1),
snr_G=round(float(rgb["Green"][k]), 1),
snr_B=round(float(rgb["Blue"][k]), 1),
gaia_sep_arcsec=round(float(gsep[i]), 2),
smss_sep_arcsec=round(float(ssep[i]), 2),
dist_from_cenA_arcmin=round(float(dgal[k]), 2)))
rows.sort(key=lambda r: -r["snr"])
out = os.path.join(C.OUT, "mo-transient-candidates.csv")
if rows:
with open(out, "w", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=list(rows[0].keys()))
w.writeheader()
w.writerows(rows)
print(f"\nwrote {out} ({len(rows)} rows)")
for r in rows[:40]:
print(f" {r['ra_hms']} {r['dec_dms']} G={r['g_mag']:5.2f} "
f"SNR={r['snr']:6.1f} r50/psf={r['r50_over_psf']:.2f} "
f"subs={r['n_subs']:2d} RGB=({r['snr_R']:.0f},{r['snr_G']:.0f},"
f"{r['snr_B']:.0f}) d={r['dist_from_cenA_arcmin']:.1f}'")
np.savez(os.path.join(C.OUT, "_mo_transient.npz"),
x=objs["x"], y=objs["y"], mag=mag, snr=snr, r50=r50,
gaia_ok=gok, gaia_sep=gsep, sm_ok=sok, sm_sep=ssep,
good=good, pointlike=pointlike, cand=cand, cand2=cand2,
cand_idx=ci, nsub=nsub, r50_star=r50_star, r50_sig=r50_sig,
snr_R=rgb["Red"], snr_G=rgb["Green"], snr_B=rgb["Blue"],
dgal=dgal)
print("saved _mo_transient.npz")
if __name__ == "__main__":
main()