Merge pull request 'solve: prefer the estate's own Astrometry.net over nova for blind solves' (#2) from astrometry-net-fallback into main

This commit is contained in:
laurence 2026-07-21 23:00:48 +01:00
commit 88aaac854c
4 changed files with 269 additions and 39 deletions

View file

@ -245,8 +245,10 @@ def run(session, verbose=True):
centre = pointing(header) centre = pointing(header)
scale = plate_scale(header, session) scale = plate_scale(header, session)
if centre is None or not scale: if centre is None or not scale:
# Not fatal, and no longer the end of the road: returning None sends
# run.py to blind.py, which needs neither the pointing nor the scale.
print(" no pointing or plate scale in the header - " print(" no pointing or plate scale in the header - "
"cannot solve without a blind solver") "handing off to the blind solver")
return None return None
# Match over the frame's INSCRIBED circle, not its circumscribed one. A # Match over the frame's INSCRIBED circle, not its circumscribed one. A
# cone big enough to reach the corners also reaches well outside the # cone big enough to reach the corners also reaches well outside the

142
pipeline/astrometry_net.py Normal file
View file

@ -0,0 +1,142 @@
"""Client for the estate's own Astrometry.net service.
https://astrometry.ankh-morpork.discworld.network
This is solve-field running on docker1/2/3 against ~5 GB of local index files.
It exists so that blind solving costs seconds instead of minutes and, more to
the point, so that no image has to leave the house to get solved.
It is used by blind.py, which prefers this service and falls back to the hosted
nova.astrometry.net when it is unreachable. Everything a solution has to earn -
verification against Gaia, the star-count and residual gate, writing the WCS
into the masters - stays in blind.py, shared by both sources. This module only
gets a WCS out of the estate service; it does not decide whether to believe it.
Only the stdlib is used for the request. A solve is one POST; pulling in
requests to do it would add a dependency for no gain.
"""
import base64
import io
import os
import ssl
import time
import urllib.error
import urllib.request
from astropy.io import fits
from astropy.wcs import WCS
SERVICE = os.environ.get("ASTROMETRY_SERVICE",
"https://astrometry.ankh-morpork.discworld.network")
TIMEOUT = int(os.environ.get("ASTROMETRY_TIMEOUT", "180"))
def _context():
"""TLS context for the estate service.
The service presents a step-ca certificate from the estate's internal CA.
On a machine that trusts the Discworld root CA this verifies normally. On
one that does not, verification fails - and the right fix is to install the
CA, not to turn checking off, so that is what the error message says.
ASTROMETRY_CA_BUNDLE points at the root CA if it is not in the system store.
ASTROMETRY_INSECURE=1 exists as an explicit, visible escape hatch; it is
never the default and it prints when used.
"""
bundle = os.environ.get("ASTROMETRY_CA_BUNDLE")
if os.environ.get("ASTROMETRY_INSECURE") == "1":
print(" WARNING: TLS verification disabled "
"(ASTROMETRY_INSECURE=1)")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
return ssl.create_default_context(cafile=bundle) if bundle else \
ssl.create_default_context()
def available(timeout=5):
"""Is the service up and does it have indexes? Cheap enough to ask first."""
try:
with urllib.request.urlopen(SERVICE + "/healthz", timeout=timeout,
context=_context()) as resp:
return resp.status == 200
except Exception: # noqa: BLE001
return False
def solve(path, centre=None, scale=None, radius_deg=2.0, timeout=TIMEOUT,
verbose=True):
"""Solve one FITS file. Returns (WCS, info dict) or (None, reason).
`centre` and `scale` are hints, not requirements - pass whatever the header
offered. A hinted solve turns a whole-sky search into a cone and is an order
of magnitude faster, but the whole point of this fallback is that it still
works when there is nothing to hint with.
"""
params = []
if centre is not None:
params += [f"ra={centre.ra.deg:.6f}", f"dec={centre.dec.deg:.6f}",
f"radius={radius_deg:.3f}"]
if scale:
# +/-20% around the nominal scale: wide enough to absorb a focal
# reducer or a binning the header did not mention, tight enough to
# still rule out most index scales.
params += [f"scale_low={scale * 0.8:.4f}",
f"scale_high={scale * 1.2:.4f}"]
params.append(f"timeout={timeout}")
url = f"{SERVICE}/solve?" + "&".join(params)
with open(path, "rb") as handle:
body = handle.read()
started = time.time()
if verbose:
hint = "hinted" if (centre is not None or scale) else "blind"
print(f" astrometry.net ({hint}): "
f"{len(body) / 1e6:.1f} MB to {SERVICE}")
request = urllib.request.Request(
url, data=body, method="POST",
headers={"Content-Type": "application/octet-stream"})
try:
with urllib.request.urlopen(request, timeout=timeout + 30,
context=_context()) as resp:
import json
payload = json.load(resp)
except urllib.error.HTTPError as exc:
# 422 is the service saying "no solution", which is an answer, not a
# fault. Anything else is the service or the network being broken, and
# the two should not read the same in the log.
import json
try:
detail = json.load(exc)
except Exception: # noqa: BLE001
detail = {}
if exc.code == 422:
return None, "no solution found"
return None, f"service error HTTP {exc.code}: {detail.get('error', '')}"
except ssl.SSLCertVerificationError:
return None, ("TLS verification failed - install the Discworld root CA "
"or set ASTROMETRY_CA_BUNDLE to it")
except Exception as exc: # noqa: BLE001
return None, f"{type(exc).__name__}: {exc}"
if not payload.get("solved"):
return None, "no solution found"
# The service hands back the .wcs file - a bare FITS header - base64
# encoded. Parsing it here rather than trusting the summary numbers means
# the full distortion-free TAN solution is what gets written to the
# masters, not a re-derivation from ra/dec/scale.
with fits.open(io.BytesIO(base64.b64decode(payload["wcs_b64"]))) as hdul:
wcs = WCS(hdul[0].header)
info = {k: payload.get(k) for k in
("ra", "dec", "pixscale", "orientation", "parity",
"fieldw", "fieldh", "fieldunits")}
info["seconds"] = payload.get("seconds", round(time.time() - started, 1))
if verbose:
print(f" astrometry.net solved in {info['seconds']}s: "
f"centre {info['ra']:.5f} {info['dec']:+.5f}, "
f"{info['pixscale']:.3f} arcsec/px, "
f"{info['fieldw']:.1f}' x {info['fieldh']:.1f}'")
return wcs, info

View file

@ -10,22 +10,33 @@ and no amount of window-sliding fixes a population mismatch.
A blind solver does not care. It builds geometric hashes from the image's own A blind solver does not care. It builds geometric hashes from the image's own
stars and looks them up in pre-built index files, so it needs no pointing, no stars and looks them up in pre-built index files, so it needs no pointing, no
scale and no orientation - only the pixels. astrometry.net is the standard scale and no orientation - only the pixels. astrometry.net is the standard
implementation and this uses the hosted nova.astrometry.net service through implementation, and there are now two of it available:
astroquery.
Two things to know: 1. **The estate's own** (`astrometry_net.py`) - solve-field on docker1/2/3
against local index files. Tried first: the image never leaves the house, it
needs no API key, and it answers in seconds rather than queueing. Its index
set covers 5.6 arcmin to 33 degree quads, which is the whole iTelescope
fleet's range, but NOT arbitrarily narrow fields.
2. **The hosted nova.astrometry.net**, through astroquery. The fallback, for
when the estate service is unreachable or its index set cannot reach the
field. Two things to know about it:
* **It needs an API key.** Free, from an account at https://nova.astrometry.net * **It needs an API key.** Free, from an account at
(Profile -> API Key). Provide it as the ASTROMETRY_API_KEY environment https://nova.astrometry.net (Profile -> API Key). Provide it as the
variable, or in a file named `astrometry.key` beside the session or in ASTROMETRY_API_KEY environment variable, or in a file named
c:\\temp\\claudetemp. Without one this module reports that clearly and the `astrometry.key` beside the session or in c:\\temp\\claudetemp. Without one
pipeline carries on unsolved rather than failing. this module says so and the pipeline carries on unsolved rather than
* **It uploads the image.** A stacked master goes to a third-party service. failing.
That is fine for these targets and worth knowing before pointing it at * **It uploads the image.** A stacked master goes to a third-party service.
anything you would not publish. Fine for these targets, and worth knowing before pointing it at anything
you would not publish. This is the main reason the estate solver is tried
first.
Results are still put through the same quality gate as the seeded solver: a Whichever source answers, the solution is put through the same quality gate as
solution has to be good, not merely returned. the seeded solver - verified against Gaia, with a star count and residual it
has to meet. A solution has to be good, not merely returned. The source is
recorded in the ASTRSOLV card and in the returned `method`, so it is always
possible to tell afterwards whether a frame was solved in-house or uploaded.
""" """
import os import os
@ -58,31 +69,50 @@ def find_key(session=None):
return None return None
def run(session, scale_hint=None, verbose=True): def _solve_via_estate(path, scale_hint=None, verbose=True):
"""Solve the deepest master blind. Returns the same dict as the seeded """Try the estate's own solver first. Returns a WCS header, or None.
solver, or None."""
Preferred over nova for three reasons, in order of how much they matter:
the image never leaves the house, it needs no API key, and it answers in
seconds rather than sitting in a public queue. If the service is down or
unreachable this returns None and the caller goes to nova, so the estate
being offline costs speed and privacy, not the ability to solve.
"""
try:
import astrometry_net
except ImportError:
return None
if not astrometry_net.available():
if verbose:
print(f" estate solver unreachable "
f"({astrometry_net.SERVICE}) - falling back to nova")
return None
wcs, info = astrometry_net.solve(path, scale=scale_hint, verbose=verbose)
if wcs is None:
# `info` is the reason string here. A genuine "no solution" is worth
# reporting but is not a reason to then try nova with the same pixels
# and the same index scales - nova's indexes are wider, so it is worth
# exactly one more attempt, and the caller makes it.
if verbose:
print(f" estate solver: {info}")
return None
return wcs.to_header()
def _solve_via_nova(path, session, scale_hint=None, verbose=True):
"""The hosted service. Needs an API key and uploads the image."""
key = find_key(session) key = find_key(session)
if not key: if not key:
if verbose: if verbose:
print(" blind solve unavailable: no astrometry.net API key." print(" nova blind solve unavailable: no astrometry.net API"
"\n Get one free at https://nova.astrometry.net " " key.\n Get one free at https://nova.astrometry.net"
"(Profile -> API Key), then set ASTROMETRY_API_KEY or write " " (Profile -> API Key), then set ASTROMETRY_API_KEY or write"
"it to c:\\temp\\claudetemp\\astrometry.key") " it to c:\\temp\\claudetemp\\astrometry.key")
return None return None
from astroquery.astrometry_net import AstrometryNet from astroquery.astrometry_net import AstrometryNet
masters = os.path.join(session.root, layout.MASTERS)
deepest = session.filters[0]
path = os.path.join(masters, f"master-{deepest}.fit")
if not os.path.exists(path):
return None
with fits.open(path) as hd:
image = hd[0].data.astype(np.float32)
header = hd[0].header
ny, nx = image.shape
ast = AstrometryNet() ast = AstrometryNet()
ast.api_key = key ast.api_key = key
ast.TIMEOUT = 600 ast.TIMEOUT = 600
@ -104,10 +134,40 @@ def run(session, scale_hint=None, verbose=True):
wcs_header = ast.solve_from_image(path, force_image_upload=True, wcs_header = ast.solve_from_image(path, force_image_upload=True,
**kwargs) **kwargs)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
print(f" blind solve failed: {type(exc).__name__}: {exc}") print(f" nova blind solve failed: {type(exc).__name__}: {exc}")
return None return None
if not wcs_header: if not wcs_header:
print(" blind solve returned no solution") print(" nova blind solve returned no solution")
return None
return wcs_header
def run(session, scale_hint=None, verbose=True):
"""Solve the deepest master blind. Returns the same dict as the seeded
solver, or None.
Two possible sources, tried in that order: the estate's own solver, then
nova. Whichever answers, the solution then earns its place the same way -
verified against Gaia and put through the same gate as the seeded solver.
A blind solve that is merely *returned* is not a blind solve that is right.
"""
masters = os.path.join(session.root, layout.MASTERS)
deepest = session.filters[0]
path = os.path.join(masters, f"master-{deepest}.fit")
if not os.path.exists(path):
return None
with fits.open(path) as hd:
image = hd[0].data.astype(np.float32)
ny, nx = image.shape
source = "estate"
wcs_header = _solve_via_estate(path, scale_hint=scale_hint, verbose=verbose)
if wcs_header is None:
source = "nova"
wcs_header = _solve_via_nova(path, session, scale_hint=scale_hint,
verbose=verbose)
if wcs_header is None:
return None return None
wcs = WCS(wcs_header) wcs = WCS(wcs_header)
@ -150,13 +210,13 @@ def run(session, scale_hint=None, verbose=True):
med = float(np.median(resid)) if resid is not None else float("nan") med = float(np.median(resid)) if resid is not None else float("nan")
if nmatch < MIN_STARS or not np.isfinite(med) or med > MAX_RESID_PX: if nmatch < MIN_STARS or not np.isfinite(med) or med > MAX_RESID_PX:
print(f" blind solution REJECTED on verification: {nmatch} " print(f" {source} blind solution REJECTED on verification: "
f"Gaia stars at {med:.2f} px " f"{nmatch} Gaia stars at {med:.2f} px "
f"(need >= {MIN_STARS} and <= {MAX_RESID_PX})") f"(need >= {MIN_STARS} and <= {MAX_RESID_PX})")
return None return None
if verbose: if verbose:
print(f" blind solve verified: {nmatch} Gaia stars, " print(f" {source} blind solve verified: {nmatch} Gaia stars, "
f"{med:.2f} px ({med * solved_scale:.2f} arcsec)") f"{med:.2f} px ({med * solved_scale:.2f} arcsec)")
print(f" centre {centre.to_string('hmsdms')}, " print(f" centre {centre.to_string('hmsdms')}, "
f"{solved_scale:.4f} arcsec/px, PA {rot:.2f} deg") f"{solved_scale:.4f} arcsec/px, PA {rot:.2f} deg")
@ -168,8 +228,11 @@ def run(session, scale_hint=None, verbose=True):
with fits.open(p, mode="update") as hd: with fits.open(p, mode="update") as hd:
for card in wcs.to_header().cards: for card in wcs.to_header().cards:
hd[0].header[card.keyword] = (card.value, card.comment) hd[0].header[card.keyword] = (card.value, card.comment)
# Name the SOURCE, not just "astrometry.net": a year from now the
# only way to tell whether a frame was solved in-house or uploaded
# to a public service is this card.
hd[0].header["ASTRSOLV"] = ( hd[0].header["ASTRSOLV"] = (
f"astrometry.net blind / {nmatch} Gaia stars / " f"astrometry.net blind ({source}) / {nmatch} Gaia stars / "
f"{med * solved_scale:.2f} arcsec", "blind plate solve") f"{med * solved_scale:.2f} arcsec", "blind plate solve")
hd.flush() hd.flush()
@ -177,4 +240,4 @@ def run(session, scale_hint=None, verbose=True):
residual_arcsec=med * solved_scale, scale=solved_scale, residual_arcsec=med * solved_scale, scale=solved_scale,
position_angle=rot, centre=centre.to_string("hmsdms"), position_angle=rot, centre=centre.to_string("hmsdms"),
fov_arcmin=(nx * solved_scale / 60, ny * solved_scale / 60), fov_arcmin=(nx * solved_scale / 60, ny * solved_scale / 60),
method="astrometry.net blind") method=f"astrometry.net blind ({source})")

View file

@ -3,6 +3,29 @@
> A dated, append only log of decisions and their rationale. Newest at the top. Never > A dated, append only log of decisions and their rationale. Newest at the top. Never
> rewrite past entries; if a decision is reversed, add a new entry that says so. > rewrite past entries; if a decision is reversed, add a new entry that says so.
## 2026-07-21: blind solving goes to the estate's own solver first, nova second
The estate now runs Astrometry.net itself
(`https://astrometry.ankh-morpork.discworld.network`, config in
ankh-morpork-infra `astrometry/`): solve-field against ~5 GB of local index
files, sized from this repo's own TELESCOPES.md so it covers the whole
iTelescope fleet's fields of view (21.5 arcmin to 10.4 degrees).
`blind.py` now tries it before nova.astrometry.net. The order is not about
speed, though it is far faster - a blind solve of a real DSS field came back in
0.7s against nova's minutes-in-a-queue. It is that **nova requires uploading
the image to a third party and an API key**, and neither is now necessary for
the ordinary case. nova stays as the fallback for when the estate service is
unreachable or the field is narrower than the local index set reaches, so
losing the estate costs speed and privacy, not the ability to solve at all.
What deliberately did NOT change: the seeded solver in `astrometry.py` remains
the primary path (a good header beats a blind search), and every blind solution
- from either source - still has to pass the same Gaia verification and
star-count/residual gate. A wrong WCS is worse than no WCS, and a second solver
does not change that. The source is recorded in the ASTRSOLV card so it is
always possible to tell later which one produced a frame's astrometry.
## 2026-07-21: the pipeline reads headers, not filenames ## 2026-07-21: the pipeline reads headers, not filenames
The first pipeline parsed `calibrated-T32-...-Luminance-BIN2-W-300-001.fit` to The first pipeline parsed `calibrated-T32-...-Luminance-BIN2-W-300-001.fit` to