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:
parent
2f268c7ca9
commit
118dc36e92
4 changed files with 269 additions and 39 deletions
|
|
@ -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
|
||||
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
|
||||
implementation and this uses the hosted nova.astrometry.net service through
|
||||
astroquery.
|
||||
implementation, and there are now two of it available:
|
||||
|
||||
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
|
||||
(Profile -> API Key). Provide it as the ASTROMETRY_API_KEY environment
|
||||
variable, or in a file named `astrometry.key` beside the session or in
|
||||
c:\\temp\\claudetemp. Without one this module reports that clearly and the
|
||||
pipeline carries on unsolved rather than failing.
|
||||
* **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
|
||||
anything you would not publish.
|
||||
* **It needs an API key.** Free, from an account at
|
||||
https://nova.astrometry.net (Profile -> API Key). Provide it as the
|
||||
ASTROMETRY_API_KEY environment variable, or in a file named
|
||||
`astrometry.key` beside the session or in c:\\temp\\claudetemp. Without one
|
||||
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.
|
||||
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
|
||||
solution has to be good, not merely returned.
|
||||
Whichever source answers, the solution is put through the same quality gate as
|
||||
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
|
||||
|
||||
|
|
@ -58,31 +69,50 @@ def find_key(session=None):
|
|||
return None
|
||||
|
||||
|
||||
def run(session, scale_hint=None, verbose=True):
|
||||
"""Solve the deepest master blind. Returns the same dict as the seeded
|
||||
solver, or None."""
|
||||
def _solve_via_estate(path, scale_hint=None, verbose=True):
|
||||
"""Try the estate's own solver first. Returns a WCS header, 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)
|
||||
if not key:
|
||||
if verbose:
|
||||
print(" blind solve unavailable: no astrometry.net API key."
|
||||
"\n Get one free at https://nova.astrometry.net "
|
||||
"(Profile -> API Key), then set ASTROMETRY_API_KEY or write "
|
||||
"it to c:\\temp\\claudetemp\\astrometry.key")
|
||||
print(" nova blind solve unavailable: no astrometry.net API"
|
||||
" key.\n Get one free at https://nova.astrometry.net"
|
||||
" (Profile -> API Key), then set ASTROMETRY_API_KEY or write"
|
||||
" it to c:\\temp\\claudetemp\\astrometry.key")
|
||||
return None
|
||||
|
||||
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.api_key = key
|
||||
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,
|
||||
**kwargs)
|
||||
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
|
||||
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
|
||||
|
||||
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")
|
||||
if nmatch < MIN_STARS or not np.isfinite(med) or med > MAX_RESID_PX:
|
||||
print(f" blind solution REJECTED on verification: {nmatch} "
|
||||
f"Gaia stars at {med:.2f} px "
|
||||
print(f" {source} blind solution REJECTED on verification: "
|
||||
f"{nmatch} Gaia stars at {med:.2f} px "
|
||||
f"(need >= {MIN_STARS} and <= {MAX_RESID_PX})")
|
||||
return None
|
||||
|
||||
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)")
|
||||
print(f" centre {centre.to_string('hmsdms')}, "
|
||||
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:
|
||||
for card in wcs.to_header().cards:
|
||||
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"] = (
|
||||
f"astrometry.net blind / {nmatch} Gaia stars / "
|
||||
f"astrometry.net blind ({source}) / {nmatch} Gaia stars / "
|
||||
f"{med * solved_scale:.2f} arcsec", "blind plate solve")
|
||||
hd.flush()
|
||||
|
||||
|
|
@ -177,4 +240,4 @@ def run(session, scale_hint=None, verbose=True):
|
|||
residual_arcsec=med * solved_scale, scale=solved_scale,
|
||||
position_angle=rot, centre=centre.to_string("hmsdms"),
|
||||
fov_arcmin=(nx * solved_scale / 60, ny * solved_scale / 60),
|
||||
method="astrometry.net blind")
|
||||
method=f"astrometry.net blind ({source})")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue