solve: prefer the estate's own Astrometry.net over nova for blind solves #2
4 changed files with 269 additions and 39 deletions
|
|
@ -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
142
pipeline/astrometry_net.py
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
failing.
|
||||||
* **It uploads the image.** A stacked master goes to a third-party service.
|
* **It uploads the image.** A stacked master goes to a third-party service.
|
||||||
That is fine for these targets and worth knowing before pointing it at
|
Fine for these targets, and worth knowing before pointing it at anything
|
||||||
anything you would not publish.
|
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})")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue