"""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