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.
This commit is contained in:
commit
5286a2e81b
53 changed files with 8820 additions and 0 deletions
232
session-scripts/mo_transient.py
Normal file
232
session-scripts/mo_transient.py
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
"""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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue