# Licensed under a 3-clause BSD style license - see LICENSE.rst
import json
from astropy.io import fits
from astroquery import log
from astropy.stats import sigma_clipped_stats
from astropy.coordinates import SkyCoord
try:
from astropy.nddata import CCDData
except ImportError:
_HAVE_CCDDATA = False
else:
_HAVE_CCDDATA = True
try:
from photutils import DAOStarFinder
except ImportError:
_HAVE_SOURCE_DETECTION = False
else:
_HAVE_SOURCE_DETECTION = True
from ..query import BaseQuery
from ..utils import async_to_sync, url_helpers
from ..exceptions import TimeoutError
from . import conf
import time
# export all the public classes and methods
__all__ = ['AstrometryNet', 'AstrometryNetClass']
[docs]@async_to_sync
class AstrometryNetClass(BaseQuery):
"""
Perform queries to the astrometry.net service to fit WCS to images
or source lists.
"""
URL = conf.server
TIMEOUT = conf.timeout
API_URL = url_helpers.join(URL, 'api')
# These are drawn from http://astrometry.net/doc/net/api.html#submitting-a-url
_constraints = {
'allow_commercial_use': {'default': 'd', 'type': str, 'allowed': ('d', 'y', 'n')},
'allow_modifications': {'default': 'd', 'type': str, 'allowed': ('d', 'y', 'n')},
'publicly_visible': {'default': 'y', 'type': str, 'allowed': ('y', 'n')},
'scale_units': {'default': None, 'type': str, 'allowed': ('degwidth', 'arcminwidth', 'arcsecperpix')},
'scale_type': {'default': None, 'type': str, 'allowed': ('ev', 'ul')},
'scale_lower': {'default': None, 'type': float, 'allowed': (0,)},
'scale_upper': {'default': None, 'type': float, 'allowed': (0,)},
'scale_est': {'default': None, 'type': float, 'allowed': (0,)},
'scale_err': {'default': None, 'type': float, 'allowed': (0, 100)},
'center_ra': {'default': None, 'type': float, 'allowed': (0, 360)},
'center_dec': {'default': None, 'type': float, 'allowed': (-90, 90)},
'radius': {'default': None, 'type': float, 'allowed': (0,)},
'downsample_factor': {'default': None, 'type': int, 'allowed': (1,)},
'tweak_order': {'default': 2, 'type': int, 'allowed': (0,)},
'use_sextractor': {'default': False, 'type': bool, 'allowed': ()},
'crpix_center': {'default': None, 'type': bool, 'allowed': ()},
'parity': {'default': None, 'type': int, 'allowed': (0, 2)},
'positional_error': {'default': None, 'type': float, 'allowed': (0,)},
}
_no_source_detector = not _HAVE_SOURCE_DETECTION
@property
def api_key(self):
""" Return the Astrometry.net API key. """
if not conf.api_key:
log.error("Astrometry.net API key not in configuration file")
return conf.api_key
@api_key.setter
def api_key(self, value):
""" Temporarily set the API key. """
conf.api_key = value
@property
def empty_settings(self):
"""
Construct a dict of settings using the defaults
"""
return {k: self._constraints[k]['default'] for k in self._constraints.keys()}
[docs] def show_allowed_settings(self):
"""
There are a ton of options available for solving. This displays
them in a nice way.
"""
keys = sorted(self._constraints.keys())
for key in keys:
key_info = self._constraints[key]
print('{key}: type {type!r}, '
'default value {default}, '
'allowed values {values}'
''.format(key=key,
type=key_info['type'].__name__,
default=key_info['default'],
values=key_info['allowed']))
def __init__(self):
""" Show a warning message if the API key is not in the configuration file. """
super(AstrometryNetClass, self).__init__()
if not conf.api_key:
log.warning("Astrometry.net API key not found in configuration file")
log.warning("You need to manually edit the configuration file and add it")
log.warning(
"You may also register it for this session with AstrometryNet.key = 'XXXXXXXX'")
self._session_id = None
def _login(self):
if not self.api_key:
raise RuntimeError('You must set the API key before using this service.')
login_url = url_helpers.join(self.API_URL, 'login')
payload = self._construct_payload({'apikey': self.api_key})
result = self._request('POST', login_url,
data=payload,
cache=False)
result_dict = result.json()
if result_dict['status'] != 'success':
raise RuntimeError('Unable to log in to astrometry.net')
self._session_id = result_dict['session']
def _construct_payload(self, settings):
return {'request-json': json.dumps(settings)}
def _validate_settings(self, settings):
"""
Check whether the current settings are consistent with the choices available
from astrometry.net.
"""
# Check the types and values
for key, value in settings.items():
if key not in self._constraints or value is None:
message = ('Setting {} is not allowed. Display all of '
'the allowed settings with: '
'AstrometryNet.show_allowed_settings()'.format(key))
raise ValueError(message)
if not isinstance(value, self._constraints[key]['type']):
failed = True
# Try coercing the type...
if self._constraints[key]['type'] == float:
try:
_ = self._constraints[key]['type'](value)
except ValueError:
pass
else:
failed = False
if failed:
raise ValueError('Value for {} must be of type {}'.format(key, self._constraints[key]['type']))
# Switching on the types here...not fond of this, but it works.
allowed = self._constraints[key]['allowed']
if allowed:
if self._constraints[key]['type'] == str:
# Allowed values is a list of choices.
good_value = value in self._constraints[key]['allowed']
elif self._constraints[key]['type'] == bool:
# bool is easy to check...
good_value = isinstance(value, bool)
else:
# Assume the parameter is a number which has a minimum and
# optionally a maximum.
bounds = self._constraints[key]['allowed']
good_value = value >= bounds[0]
try:
good_value = good_value and good_value <= bounds[1]
except IndexError:
# No upper bound to check
pass
if not good_value:
raise ValueError('Value {} for {} is invalid. '
'The valid '
'values are {}'.format(value, key, allowed))
# Check some special cases, in which the presence of one value means
# others are needed.
if 'scale_type' in settings:
scale_type = settings['scale_type']
if scale_type == 'ev':
required_keys = ['scale_est', 'scale_err', 'scale_units']
else:
required_keys = ['scale_lower', 'scale_upper', 'scale_units']
good = all(req in settings for req in required_keys)
if not good:
raise ValueError('Scale type {} requires '
'values for {}'.format(scale_type, required_keys))
[docs] def monitor_submission(self, submission_id,
solve_timeout=TIMEOUT):
"""
Monitor the submission for completion.
Parameters
----------
submission_id : ``int`` or ``str``
Submission ID number from astrometry.net.
solve_timeout : ``int``
Time, in seconds, to wait for the astrometry.net solver to find
a solution.
Returns
-------
None or `astropy.io.fits.Header`
The contents of the returned object depend on whether the solve
succeeds or fails. If the solve succeeds the header with
the WCS solution generated by astrometry.net is returned. If the solve
fails then an empty dictionary is returned. See below for the outcome
if the solve times out.
Raises
------
``TimeoutError``
Raised if `astroquery.astrometry_net.AstrometryNetClass.TIMEOUT` is
exceeded before the solve either succeeds or fails. The second
argument in the exception is the submission ID.
"""
has_completed = False
job_id = None
print('Solving', end='', flush=True)
start_time = time.time()
status = ''
while not has_completed:
time.sleep(1)
sub_stat_url = url_helpers.join(self.API_URL, 'submissions', str(submission_id))
sub_stat = self._request('GET', sub_stat_url, cache=False)
jobs = sub_stat.json()['jobs']
if jobs:
job_id = jobs[0]
if job_id:
job_stat_url = url_helpers.join(self.API_URL, 'jobs',
str(job_id), 'info')
job_stat = self._request('GET', job_stat_url, cache=False)
status = job_stat.json()['status']
now = time.time()
elapsed = now - start_time
timed_out = elapsed > solve_timeout
has_completed = (status in ['success', 'failure'] or timed_out)
print('.', end='', flush=True)
if status == 'success':
wcs_url = url_helpers.join(self.URL, 'wcs_file', str(job_id))
wcs_response = self._request('GET', wcs_url)
wcs = fits.Header.fromstring(wcs_response.text)
elif status == 'failure':
wcs = {}
elif timed_out:
raise TimeoutError('Solve timed out without success or failure',
submission_id)
else:
# Try to future-proof a little bit
raise RuntimeError('Unrecognized status {}'.format(status))
return wcs
[docs] def solve_from_source_list(self, x, y, image_width, image_height,
solve_timeout=TIMEOUT,
**settings
):
"""
Plate solve from a list of source positions.
Parameters
----------
x : list-like
List of x-coordinate of source positions.
y : list-like
List of y-coordinate of source positions.
image_width : int
Size of the image in the x-direction.
image_height : int
Size of the image in the y-direction.
solve_timeout : int
Time, in seconds, to wait for the astrometry.net solver to find
a solution.
For a list of the remaining settings, use the method
`~AstrometryNetClass.show_allowed_settings`.
"""
settings = {k: v for k, v in settings.items() if v is not None}
self._validate_settings(settings)
if self._session_id is None:
self._login()
# Add the settings required for solving from a source list to the list
# after validating the common settings applicable in all cases.
settings['x'] = [float(v) for v in x]
settings['y'] = [float(v) for v in y]
settings['image_width'] = image_width
settings['image_height'] = image_height
settings['session'] = self._session_id
payload = self._construct_payload(settings)
url = url_helpers.join(self.API_URL, 'url_upload')
response = self._request('POST', url, data=payload, cache=False)
if response.status_code != 200:
raise RuntimeError('Post of job failed')
response_d = response.json()
submission_id = response_d['subid']
return self.monitor_submission(submission_id,
solve_timeout=solve_timeout)
[docs] def solve_from_image(self, image_file_path, force_image_upload=False,
ra_key=None, dec_key=None,
ra_dec_units=None,
fwhm=3, detect_threshold=5,
solve_timeout=TIMEOUT,
**settings):
"""
Plate solve from an image, either by uploading the image to
astrometry.net or by finding sources locally using
`photutils <https://photutils.rtfd.io>`_ and solving with source
locations.
Parameters
----------
image_file_path : str or Path object
Path to the image.
force_image_upload : bool, optional
If ``True``, upload the image to astrometry.net even if it is
possible to detect sources in the image locally. This option will
almost always take longer than finding sources locally. It will even
take longer than installing photutils and then rerunning this.
Even if this is ``False`` the image will be upload unless
photutils is installed.
ra_key : str, optional
Name of the key in the FITS header that contains right ascension of the image.
The ra can be specified using the ``center_ra`` setting instead if
desired.
dec_key : str, optional
Name of the key in the FITS header that contains declination of the image.
The dec can be specified using the ``center_dec`` setting instead if
desired.
ra_dec_units : tuple, optional
Tuple specifying the units of the right ascension and declination in
the header. The default value is ``('hour', 'degree')``.
solve_timeout : int
Time, in seconds, to wait for the astrometry.net solver to find
a solution.
For a list of the remaining settings, use the method
`~AstrometryNetClass.show_allowed_settings`.
"""
if ra_key and dec_key:
with fits.open(image_file_path) as f:
hdr = f[0].header
# The error here if one of these fails should be pretty clear
ra = hdr[ra_key]
dec = hdr[dec_key]
# Convert these to degrees in appropriate range
center = SkyCoord(ra, dec, unit=('hour', 'degree'))
settings['center_ra'] = center.ra.degree
settings['center_dec'] = center.dec.degree
settings = {k: v for k, v in settings.items() if v is not None}
self._validate_settings(settings)
if force_image_upload or self._no_source_detector:
if self._session_id is None:
self._login()
settings['session'] = self._session_id
payload = self._construct_payload(settings)
url = url_helpers.join(self.API_URL, 'upload')
with open(image_file_path, 'rb') as f:
response = self._request('POST', url, data=payload,
cache=False,
files={'file': f})
else:
# Detect sources and delegate to solve_from_source_list
if _HAVE_CCDDATA:
# CCDData requires a unit, so provide one. It has absolutely
# no impact on source detection. The reader for CCDData
# tries to find the first ImageHDU in a FITS file, so it
# is the preferred way to get the data.
ccd = CCDData.read(image_file_path, unit='adu')
data = ccd.data
else:
with fits.open(image_file_path) as f:
data = f[0].data
print("Determining background stats", flush=True)
mean, median, std = sigma_clipped_stats(data, sigma=3.0,
maxiters=5)
daofind = DAOStarFinder(fwhm=fwhm,
threshold=detect_threshold * std)
print("Finding sources", flush=True)
sources = daofind(data - median)
print('Found {} sources'.format(len(sources)), flush=True)
# astrometry.net wants a sorted list of sources
# Sort first (which puts things in ascending order)
sources.sort('flux')
# Reverse to get descending order
sources.reverse()
print(sources)
return self.solve_from_source_list(sources['xcentroid'],
sources['ycentroid'],
ccd.header['naxis1'],
ccd.header['naxis2'],
**settings)
if response.status_code != 200:
raise RuntimeError('Post of job failed')
response_d = response.json()
submission_id = response_d['subid']
return self.monitor_submission(submission_id,
solve_timeout=solve_timeout)
# the default tool for users to interact with is an instance of the Class
AstrometryNet = AstrometryNetClass()