Produce the four deliverable images and the science outputs for every session
The four-image set worked out on Centaurus A is now generated for any target, because its value is the comparison: the same data at four levels of treatment, so a viewer can see what processing did and did not add. colour.py's assembly is driven by flags - gradient, neutralise, denoise, saturation, hdr, protect-compact - so the baseline and the fully corrected version come from ONE code path and the only differences between them are the ones named. Image 3 applies what the measurements justify rather than a house style. Each item is there because measuring the first session caught the conventional version getting something wrong: a plane fit that had absorbed 17.9 ADU/px of galaxy halo, a core flattened by a white point set by field stars, deconvolution ringing around every bright star, and denoising erasing faint compact sources that turned out to be globular clusters. Compact sources are now explicitly protected from smoothing - 2374 of them on NGC 2030. The close-up revealed a real design error, caught by its own assertion. Forcing a square crop cannot contain a target wider than the frame is tall, which is the normal case for a nebula in a wide field, and the assertion fired rather than silently cutting the subject in half. Crops are no longer square, and when a target genuinely fills the field the close-up is skipped with that said plainly - re-saving image 3 under a name claiming to be a close-up would be worse than producing nothing. science.py adds the measurements that generalise to any target: photometric calibration from the field's own Gaia stars, the limiting magnitude actually reached, a source catalogue with calibrated magnitudes, an annotated field placed by the plate solution, and a radial surface-brightness profile. Object-specific analyses stay hand-driven, because a cluster survey suits a galaxy and is meaningless for a nebula. All of it depends on astrometry, so an unsolved session gets no science and says so instead of quietly producing less. NGC 2030 calibrates to a zero point of 24.794 with 0.202 mag scatter on 917 stars, 3470 sources, limiting G of 18.2.
This commit is contained in:
parent
38f7a81395
commit
3eb5ab6a03
4 changed files with 649 additions and 96 deletions
297
pipeline/science.py
Normal file
297
pipeline/science.py
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
"""The science outputs: what can be measured from a session, not just seen.
|
||||
|
||||
Only the analyses that are meaningful for ANY target go here. A globular
|
||||
cluster survey suits an elliptical galaxy and is meaningless for an emission
|
||||
nebula, so that sort of thing stays hand-driven. What generalises is:
|
||||
|
||||
photometric calibration a real magnitude scale, from the field's own stars
|
||||
depth the limiting magnitude actually achieved
|
||||
source catalogue every detection, with calibrated magnitudes
|
||||
annotated field what is in the frame, placed by the plate solution
|
||||
radial profile surface brightness against radius, for extended
|
||||
targets
|
||||
|
||||
All of it depends on the plate solve, so a session that failed astrometry gets
|
||||
none of it - and says so rather than silently producing less.
|
||||
|
||||
The photometric calibration is worth doing even when nothing else is: it turns
|
||||
"this pixel is bright" into "this source is magnitude 18.4", which is what makes
|
||||
a result comparable with anyone else's.
|
||||
"""
|
||||
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
|
||||
|
||||
import astrometry
|
||||
import layout
|
||||
|
||||
|
||||
def _figdir(session):
|
||||
d = os.path.join(session.root, layout.FIGURES)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _catdir(session):
|
||||
d = os.path.join(session.root, layout.CATALOGUES)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def detect_all(image, thresh=3.0):
|
||||
bkg = sep.Background(image, bw=64, bh=64, fw=3, fh=3)
|
||||
sub = image - bkg.back()
|
||||
objs = sep.extract(sub, thresh, err=bkg.globalrms, minarea=6,
|
||||
deblend_cont=0.005)
|
||||
objs = objs[(objs["flag"] == 0) & (objs["npix"] > 6)]
|
||||
flux, ferr, _ = sep.sum_circle(sub, objs["x"], objs["y"], 5.0,
|
||||
err=bkg.globalrms, subpix=5)
|
||||
snr = flux / np.maximum(ferr, 1e-9)
|
||||
keep = (flux > 0) & (snr > thresh)
|
||||
return objs[keep], flux[keep], snr[keep], bkg.globalrms
|
||||
|
||||
|
||||
def calibrate(session, verbose=True):
|
||||
"""Zero point and depth from the field's own Gaia stars."""
|
||||
masters = os.path.join(session.root, layout.MASTERS)
|
||||
deepest = session.filters[0]
|
||||
path = os.path.join(masters, f"master-{deepest}.fit")
|
||||
with fits.open(path) as hd:
|
||||
image = hd[0].data.astype(np.float32)
|
||||
header = hd[0].header
|
||||
if "CRVAL1" not in header:
|
||||
if verbose:
|
||||
print(" no astrometric solution - science stage skipped")
|
||||
return None
|
||||
wcs = WCS(header, naxis=2)
|
||||
ny, nx = image.shape
|
||||
|
||||
objs, flux, snr, rms = detect_all(image)
|
||||
if len(objs) < 30:
|
||||
if verbose:
|
||||
print(f" only {len(objs)} detections - too few to calibrate")
|
||||
return None
|
||||
sky = wcs.pixel_to_world(objs["x"], objs["y"])
|
||||
|
||||
centre = wcs.pixel_to_world(nx / 2, ny / 2)
|
||||
scale = float(np.sqrt(abs(np.linalg.det(wcs.pixel_scale_matrix * 3600.0))))
|
||||
radius = 1.15 * 0.5 * np.hypot(nx, ny) * scale / 3600.0
|
||||
cache = os.path.join(session.root, layout.INTERMEDIATES, "_gaia_deep.npz")
|
||||
ra, dec, gmag = astrometry.fetch_catalogue(centre, radius, cache,
|
||||
limit=20000, deep=True)
|
||||
if ra is None:
|
||||
return None
|
||||
|
||||
gcoord = SkyCoord(ra * u.deg, dec * u.deg)
|
||||
idx, sep2d, _ = sky.match_to_catalog_sky(gcoord)
|
||||
matched = sep2d.arcsec < 1.5
|
||||
if matched.sum() < 20:
|
||||
if verbose:
|
||||
print(f" only {matched.sum()} catalogue matches - "
|
||||
f"cannot calibrate")
|
||||
return None
|
||||
|
||||
inst = -2.5 * np.log10(flux[matched])
|
||||
gm = gmag[idx[matched]]
|
||||
# Fit on well exposed but unsaturated stars only.
|
||||
fit = (gm > gm.min() + 2) & (gm < np.percentile(gm, 90)) & \
|
||||
(snr[matched] > 20)
|
||||
if fit.sum() < 10:
|
||||
fit = snr[matched] > 10
|
||||
zp = float(np.median(gm[fit] - inst[fit]))
|
||||
scatter = float(np.std(gm[fit] - inst[fit]))
|
||||
|
||||
mag = -2.5 * np.log10(flux) + zp
|
||||
order = np.argsort(mag)
|
||||
ms, ss = mag[order], snr[order]
|
||||
win = max(11, len(ms) // 60)
|
||||
run_m = np.array([np.median(ms[i:i + win])
|
||||
for i in range(0, len(ms) - win, max(1, win // 2))])
|
||||
run_s = np.array([np.median(ss[i:i + win])
|
||||
for i in range(0, len(ss) - win, max(1, win // 2))])
|
||||
below = np.where(run_s < 5.0)[0]
|
||||
limit = float(run_m[below[0]]) if len(below) else float(run_m[-1])
|
||||
|
||||
if verbose:
|
||||
print(f" zero point {zp:.3f} (scatter {scatter:.3f} mag) on "
|
||||
f"{int(fit.sum())} stars; {len(objs)} sources; "
|
||||
f"limiting G ~ {limit:.2f}")
|
||||
|
||||
# Write the catalogue.
|
||||
cat = os.path.join(_catdir(session),
|
||||
f"{session.target.replace(' ', '')}-sources.csv")
|
||||
with open(cat, "w", encoding="utf-8") as fh:
|
||||
fh.write("id,ra_deg,dec_deg,x,y,mag_G,snr,fwhm_px,in_gaia\n")
|
||||
r50 = 2.0 * np.sqrt(objs["npix"] / np.pi)
|
||||
for i in range(len(objs)):
|
||||
fh.write(f"{i+1},{sky[i].ra.deg:.7f},{sky[i].dec.deg:.7f},"
|
||||
f"{objs['x'][i]:.2f},{objs['y'][i]:.2f},{mag[i]:.3f},"
|
||||
f"{snr[i]:.1f},{r50[i]:.2f},"
|
||||
f"{int(matched[i])}\n")
|
||||
if verbose:
|
||||
print(f" -> science/catalogues/{os.path.basename(cat)}")
|
||||
|
||||
return dict(zero_point=zp, scatter=scatter, limiting_mag=limit,
|
||||
nsources=int(len(objs)), nmatched=int(matched.sum()),
|
||||
scale=scale, catalogue=cat,
|
||||
image=image, wcs=wcs, mag=mag, objs=objs, matched=matched)
|
||||
|
||||
|
||||
def depth_plot(session, cal, verbose=True):
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
fig, ax = plt.subplots(figsize=(7, 5))
|
||||
ax.semilogy(cal["mag"], np.maximum(
|
||||
np.ones_like(cal["mag"]), np.ones_like(cal["mag"])), alpha=0)
|
||||
ax.clear()
|
||||
ax.scatter(cal["mag"], np.clip(
|
||||
(10 ** (-0.4 * (cal["mag"] - cal["zero_point"]))) * 0 + 1, 0, 1),
|
||||
s=1, alpha=0)
|
||||
# Simple, honest histogram of what was detected.
|
||||
ax.hist(cal["mag"], bins=40, color="#4c78a8")
|
||||
ax.axvline(cal["limiting_mag"], color="crimson", ls="--",
|
||||
label=f"limit G = {cal['limiting_mag']:.2f} (SNR 5)")
|
||||
ax.set_xlabel("calibrated magnitude (Gaia G scale)")
|
||||
ax.set_ylabel("sources detected")
|
||||
ax.set_title(f"{session.target} - depth reached\n"
|
||||
f"zero point {cal['zero_point']:.2f}, "
|
||||
f"scatter {cal['scatter']:.3f} mag, "
|
||||
f"{cal['nsources']} sources")
|
||||
ax.legend()
|
||||
fig.tight_layout()
|
||||
p = os.path.join(_figdir(session),
|
||||
f"{session.target.replace(' ', '')}-depth.png")
|
||||
fig.savefig(p, dpi=110)
|
||||
plt.close(fig)
|
||||
if verbose:
|
||||
print(f" -> science/figures/{os.path.basename(p)}")
|
||||
|
||||
|
||||
def annotate(session, cal, verbose=True):
|
||||
"""The finished image with a coordinate grid and catalogued objects."""
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
from PIL import Image
|
||||
|
||||
stem = session.target.replace(" ", "")
|
||||
src = os.path.join(session.root, layout.FINAL, f"{stem}-3-best.png")
|
||||
if not os.path.exists(src):
|
||||
return
|
||||
rgb = np.asarray(Image.open(src))
|
||||
ny, nx = rgb.shape[:2]
|
||||
scale_factor = 3
|
||||
small = np.asarray(Image.fromarray(rgb).resize(
|
||||
(nx // scale_factor, ny // scale_factor), Image.LANCZOS))
|
||||
wcs = cal["wcs"][::scale_factor, ::scale_factor]
|
||||
|
||||
fig = plt.figure(figsize=(small.shape[1] / 100, small.shape[0] / 100),
|
||||
dpi=100)
|
||||
ax = fig.add_axes([0, 0, 1, 1])
|
||||
ax.imshow(small, origin="upper")
|
||||
ax.set_axis_off()
|
||||
|
||||
# Graticule drawn by hand rather than with WCSAxes, which insists on
|
||||
# origin='lower' and would publish this mirrored against every other image.
|
||||
corners = wcs.pixel_to_world([0, small.shape[1], 0, small.shape[1]],
|
||||
[0, 0, small.shape[0], small.shape[0]])
|
||||
ra_lo, ra_hi = corners.ra.deg.min(), corners.ra.deg.max()
|
||||
dec_lo, dec_hi = corners.dec.deg.min(), corners.dec.deg.max()
|
||||
for r in np.linspace(ra_lo, ra_hi, 5)[1:-1]:
|
||||
t = np.linspace(dec_lo, dec_hi, 200)
|
||||
px, py = wcs.world_to_pixel(SkyCoord(np.full_like(t, r) * u.deg,
|
||||
t * u.deg))
|
||||
ax.plot(px, py, color="#5fa8ff", alpha=0.25, ls=":", lw=0.8)
|
||||
for d in np.linspace(dec_lo, dec_hi, 5)[1:-1]:
|
||||
t = np.linspace(ra_lo, ra_hi, 200)
|
||||
px, py = wcs.world_to_pixel(SkyCoord(t * u.deg,
|
||||
np.full_like(t, d) * u.deg))
|
||||
ax.plot(px, py, color="#5fa8ff", alpha=0.25, ls=":", lw=0.8)
|
||||
|
||||
# Scale bar, measured through the solution rather than assumed.
|
||||
px_per_arcmin = 60.0 / (cal["scale"] * scale_factor)
|
||||
bx, by = 40, small.shape[0] - 40
|
||||
ax.plot([bx, bx + px_per_arcmin], [by, by], color="white", lw=2.5)
|
||||
ax.text(bx, by - 10, "1 arcmin", color="white", fontsize=9)
|
||||
|
||||
ax.text(18, 24, f"{session.target} {session.telescope} "
|
||||
f"{session.palette}", color="white", fontsize=11)
|
||||
ax.text(18, 42, f"plate solved: {cal['nmatched']} Gaia matches, "
|
||||
f"{cal['scale']:.3f} arcsec/px, "
|
||||
f"limiting G ~ {cal['limiting_mag']:.1f}",
|
||||
color="#9fb8d0", fontsize=8)
|
||||
|
||||
p = os.path.join(_figdir(session), f"{stem}-annotated.jpg")
|
||||
fig.savefig(p, dpi=100, pil_kwargs={"quality": 92})
|
||||
plt.close(fig)
|
||||
if verbose:
|
||||
print(f" -> science/figures/{os.path.basename(p)}")
|
||||
|
||||
|
||||
def radial_profile(session, cal, verbose=True):
|
||||
"""Surface brightness against radius - meaningful for extended targets."""
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
image = cal["image"]
|
||||
ny, nx = image.shape
|
||||
yy, xx = np.mgrid[0:ny, 0:nx]
|
||||
r = np.hypot(xx - nx / 2.0, yy - ny / 2.0)
|
||||
sky = float(np.median(image))
|
||||
rmax = min(nx, ny) / 2.0
|
||||
edges = np.linspace(0, rmax, 40)
|
||||
prof, rad = [], []
|
||||
for a, b in zip(edges[:-1], edges[1:]):
|
||||
sel = (r >= a) & (r < b)
|
||||
if sel.sum() < 50:
|
||||
continue
|
||||
prof.append(float(np.median(image[sel]) - sky))
|
||||
rad.append((a + b) / 2.0 * cal["scale"] / 60.0)
|
||||
prof, rad = np.array(prof), np.array(rad)
|
||||
good = prof > 0
|
||||
if good.sum() < 5:
|
||||
if verbose:
|
||||
print(" no extended signal above sky - profile skipped")
|
||||
return
|
||||
pixarea = cal["scale"] ** 2
|
||||
mu = cal["zero_point"] - 2.5 * np.log10(prof[good] / pixarea)
|
||||
|
||||
fig, ax = plt.subplots(figsize=(7, 5))
|
||||
ax.plot(rad[good], mu, "o-", color="#4c78a8")
|
||||
ax.invert_yaxis()
|
||||
ax.set_xlabel("radius from frame centre (arcmin)")
|
||||
ax.set_ylabel("surface brightness (mag / arcsec$^2$)")
|
||||
ax.set_title(f"{session.target} - radial surface brightness")
|
||||
ax.grid(alpha=0.3)
|
||||
fig.tight_layout()
|
||||
p = os.path.join(_figdir(session),
|
||||
f"{session.target.replace(' ', '')}-radial-profile.png")
|
||||
fig.savefig(p, dpi=110)
|
||||
plt.close(fig)
|
||||
|
||||
out = os.path.join(_catdir(session),
|
||||
f"{session.target.replace(' ', '')}-radial-profile.csv")
|
||||
with open(out, "w", encoding="utf-8") as fh:
|
||||
fh.write("radius_arcmin,surface_brightness_mag_arcsec2\n")
|
||||
for rr, mm in zip(rad[good], mu):
|
||||
fh.write(f"{rr:.4f},{mm:.4f}\n")
|
||||
if verbose:
|
||||
print(f" -> science/figures/{os.path.basename(p)} + catalogue")
|
||||
|
||||
|
||||
def run(session, verbose=True):
|
||||
cal = calibrate(session, verbose=verbose)
|
||||
if cal is None:
|
||||
return None
|
||||
depth_plot(session, cal, verbose)
|
||||
annotate(session, cal, verbose)
|
||||
radial_profile(session, cal, verbose)
|
||||
return {k: v for k, v in cal.items()
|
||||
if k not in ("image", "wcs", "mag", "objs", "matched")}
|
||||
Loading…
Add table
Add a link
Reference in a new issue