solve: prefer the estate's own Astrometry.net over nova for blind solves

The estate now runs Astrometry.net itself (ankh-morpork-infra astrometry/):
solve-field against ~5 GB of local index files, sized from this repo's
TELESCOPES.md so the whole iTelescope fleet's fields of view are covered.

blind.py now tries it before nova.astrometry.net. Speed is the least of the
reasons - a blind solve of a real DSS field returns in 0.7s where nova queues
for minutes. The reasons that matter are that nova requires UPLOADING the
master to a third party and holding an API key, and neither is necessary any
more for the ordinary case. nova remains the fallback, so an estate outage
costs speed and privacy rather than the ability to solve.

pipeline/astrometry_net.py is the client and nothing more: stdlib-only POST,
parses the returned .wcs so the full TAN solution is used rather than a
re-derivation from the summary numbers. It deliberately does NOT decide whether
to trust a solution - blind.py already verifies every blind solve against Gaia
and applies a star-count and residual gate, and two gates that can disagree is
worse than one that is trusted.

The source ('estate' or 'nova') is threaded through the log lines, the returned
method, and the ASTRSOLV card, because a year from now that card is the only
way to tell whether a frame was solved in-house or uploaded.

Also corrected astrometry.py's 'cannot solve without a blind solver' message,
which has been untrue since run.py started falling through to blind.py.

Tested against the live service: blind solve of a DSS2 field with a known
centre returned within ~4 arcsec in 0.7s; the WCS round-trips through astropy;
and an unreachable service falls through to nova instead of raising.
This commit is contained in:
Laurence 2026-07-21 23:00:23 +01:00
parent 2f268c7ca9
commit 118dc36e92
4 changed files with 269 additions and 39 deletions

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