astrophotography/session-scripts/solve.py
laurence 5286a2e81b 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.
2026-07-21 15:29:49 +01:00

185 lines
7.6 KiB
Python

"""Pass 3: plate solve the luminance master and copy the WCS to every master.
iTelescope's calibrated frames arrive with a PinPoint HISTORY line but no WCS
keywords at all, so the astrometry has to be redone locally. A blind solve is
not needed: the header gives the pointing to arcminutes and the plate scale to
four figures, so this fetches a Gaia DR3 catalogue for that patch of sky and
matches it to the detected stars.
The match itself is asterism-based (astroalign), which is invariant to rotation
and scale, so the roll angle never has to be guessed. It is NOT invariant to a
mirror flip, so both parities are tried and the one that matches wins. The
final WCS is a least-squares TAN fit to the matched pairs, and the residual it
reports is the honest measure of whether the solve is real.
"""
import os
import sys
import astroalign as aa
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 astropy.wcs.utils import fit_wcs_from_points
import layout
SRC = layout.SESSION
OUT = layout.SESSION
MASTERS = ["Luminance", "Red", "Green", "Blue"]
CATCACHE = layout.path("_gaia.npz")
def detect(image, nmax=600):
bkg = sep.Background(image, bw=64, bh=64, fw=3, fh=3)
sub = image - bkg.back()
objs = sep.extract(sub, 8.0, err=bkg.globalrms, minarea=9,
deblend_cont=0.005)
objs = objs[(objs["flag"] == 0) & (objs["npix"] > 12) &
(objs["npix"] < 3000)]
objs = objs[np.argsort(objs["flux"])[::-1][:nmax]]
return np.column_stack([objs["x"], objs["y"]]), objs["flux"]
def gaia_catalogue(ra_deg, dec_deg, radius_deg, nmax=600):
"""Gaia DR3 sources around the pointing, brightest first, cached to disk."""
if os.path.exists(CATCACHE):
z = np.load(CATCACHE)
print(f"catalogue: {len(z['ra'])} cached Gaia sources")
return z["ra"], z["dec"], z["g"]
from astroquery.gaia import Gaia
Gaia.ROW_LIMIT = nmax
query = f"""
SELECT TOP {nmax} ra, dec, phot_g_mean_mag
FROM gaiadr3.gaia_source
WHERE 1 = CONTAINS(POINT('ICRS', ra, dec),
CIRCLE('ICRS', {ra_deg}, {dec_deg}, {radius_deg}))
AND phot_g_mean_mag IS NOT NULL
ORDER BY phot_g_mean_mag ASC
"""
tbl = Gaia.launch_job_async(query).get_results()
ra = np.asarray(tbl["ra"], dtype=float)
dec = np.asarray(tbl["dec"], dtype=float)
g = np.asarray(tbl["phot_g_mean_mag"], dtype=float)
np.savez_compressed(CATCACHE, ra=ra, dec=dec, g=g)
print(f"catalogue: {len(ra)} Gaia DR3 sources, G {g.min():.1f}-{g.max():.1f}")
return ra, dec, g
def project(ra, dec, ra0, dec0, scale_arcsec, parity):
"""Gnomonic projection to pixel-like coordinates for asterism matching."""
c = SkyCoord(ra * u.deg, dec * u.deg)
centre = SkyCoord(ra0 * u.deg, dec0 * u.deg)
dx, dy = centre.spherical_offsets_to(c)
x = dx.to_value(u.arcsec) / scale_arcsec * parity
y = dy.to_value(u.arcsec) / scale_arcsec
return np.column_stack([x, y])
def main():
path = layout.path("master-Luminance.fit")
with fits.open(path) as hd:
image = hd[0].data.astype(np.float32)
hdr = hd[0].header
ny, nx = image.shape
centre = SkyCoord(hdr["OBJCTRA"], hdr["OBJCTDEC"],
unit=(u.hourangle, u.deg))
scale = float(hdr["HIERARCH iTelescopePlateScaleH"])
radius = 1.15 * 0.5 * np.hypot(nx, ny) * scale / 3600.0
print(f"pointing {centre.to_string('hmsdms')} scale {scale:.4f}\"/px "
f"search radius {radius:.3f} deg")
xy, flux = detect(image)
print(f"detected {len(xy)} stars in the luminance master")
ra, dec, gmag = gaia_catalogue(centre.ra.deg, centre.dec.deg, radius)
best = None
for parity in (-1.0, 1.0):
cat_xy = project(ra, dec, centre.ra.deg, centre.dec.deg, scale, parity)
try:
tform, (src, dst) = aa.find_transform(cat_xy, xy)
except Exception as exc: # noqa: BLE001
print(f" parity {parity:+.0f}: no match ({exc})")
continue
print(f" parity {parity:+.0f}: matched {len(src)} stars, "
f"rotation {np.degrees(tform.rotation):.3f} deg, "
f"scale {tform.scale:.5f}")
if best is None or len(src) > best[0]:
best = (len(src), parity, src, dst)
if best is None:
sys.exit("plate solve failed: no asterism match in either parity")
nmatch, parity, cat_pts, img_pts = best
# Recover which catalogue rows were matched so the fit uses sky coordinates
# rather than the projected proxy.
cat_xy = project(ra, dec, centre.ra.deg, centre.dec.deg, scale, parity)
idx = [int(np.argmin(np.hypot(cat_xy[:, 0] - px, cat_xy[:, 1] - py)))
for px, py in cat_pts]
world = SkyCoord(ra[idx] * u.deg, dec[idx] * u.deg)
wcs = fit_wcs_from_points((img_pts[:, 0], img_pts[:, 1]), world,
proj_point="center", projection="TAN")
pred = wcs.world_to_pixel(world)
resid = np.hypot(pred[0] - img_pts[:, 0], pred[1] - img_pts[:, 1])
print(f"seed fit on {nmatch} stars: residual median {np.median(resid):.2f} px "
f"({np.median(resid) * scale:.2f}\"), max {resid.max():.2f} px")
# The asterism match only ever returns a handful of stars. Now that an
# approximate solution exists, every catalogue source can be pushed through
# it and paired with the nearest detection, which grows the fit from a
# dozen stars to hundreds and averages down the centroid noise. Two passes
# with a shrinking tolerance is enough to converge.
all_world = SkyCoord(ra * u.deg, dec * u.deg)
for tol in (4.0, 2.0):
px, py = wcs.world_to_pixel(all_world)
pairs = []
for i, (cx, cy) in enumerate(zip(px, py)):
if not (0 <= cx < nx and 0 <= cy < ny):
continue
d = np.hypot(xy[:, 0] - cx, xy[:, 1] - cy)
j = int(np.argmin(d))
if d[j] <= tol:
pairs.append((i, j))
if len(pairs) < 20:
print(f" refine (tol {tol} px): only {len(pairs)} pairs, kept seed")
break
ci = np.array([p[0] for p in pairs])
ii = np.array([p[1] for p in pairs])
wcs = fit_wcs_from_points((xy[ii, 0], xy[ii, 1]), all_world[ci],
proj_point="center", projection="TAN")
qx, qy = wcs.world_to_pixel(all_world[ci])
resid = np.hypot(qx - xy[ii, 0], qy - xy[ii, 1])
nmatch = len(pairs)
print(f" refine (tol {tol} px): {nmatch} stars, residual median "
f"{np.median(resid):.2f} px ({np.median(resid) * scale:.2f}\"), "
f"max {resid.max():.2f} px")
cen = wcs.pixel_to_world(nx / 2.0, ny / 2.0)
cd = wcs.pixel_scale_matrix * 3600.0
solved_scale = np.sqrt(abs(np.linalg.det(cd)))
rot = np.degrees(np.arctan2(cd[0, 1], cd[1, 1]))
print(f"field centre {cen.to_string('hmsdms')}")
print(f"solved scale {solved_scale:.4f}\"/px, position angle {rot:.2f} deg")
print(f"field of view {nx * solved_scale / 60:.1f}' x "
f"{ny * solved_scale / 60:.1f}'")
whdr = wcs.to_header()
for name in MASTERS:
p = layout.path(f"master-{name}.fit")
with fits.open(p, mode="update") as hd:
for card in whdr.cards:
hd[0].header[card.keyword] = (card.value, card.comment)
hd[0].header["ASTRSOLV"] = (
f"Gaia DR3 / {nmatch} stars / {np.median(resid) * scale:.2f} arcsec",
"local plate solution")
hd.flush()
print(f" WCS written to {os.path.basename(p)}")
if __name__ == "__main__":
main()