# Licensed under a 3-clause BSD style license - see LICENSE.rst
"""
Provide astroquery API access to NSF NOIRLab Astro Data Archive.
This does DB access through web-services.
"""
import astropy.io.fits as fits
import astropy.table
from ..query import BaseQuery
from ..exceptions import RemoteServiceError
from . import conf
__all__ = ['NOIRLab', 'NOIRLabClass'] # specifies what to import
[docs]
class NOIRLabClass(BaseQuery):
"""Search functionality for the NSF NOIRLab Astro Data Archive.
"""
TIMEOUT = conf.timeout
NAT_URL = conf.server
def __init__(self):
self._api_version = None
super().__init__()
@property
def api_version(self):
"""Return version of REST API used by this module.
If the REST API changes such that the major version increases,
a new version of this module will likely need to be used.
"""
if self._api_version is None:
self._api_version = float(self._version())
return self._api_version
def _validate_version(self):
"""Ensure the API is compatible with the code.
"""
KNOWN_GOOD_API_VERSION = 7.0
if (int(self.api_version) - int(KNOWN_GOOD_API_VERSION)) >= 1:
msg = (f'The astroquery.noirlab module is expecting an older '
f'version of the {self.NAT_URL} API services. '
f'Please upgrade to latest astroquery. '
f'Expected version {KNOWN_GOOD_API_VERSION} but got '
f'{self.api_version} from the API.')
raise RemoteServiceError(msg)
[docs]
def sia_url(self, hdu=False):
"""Return the URL for SIA queries.
Parameters
----------
hdu : :class:`bool`, optional
If ``True`` return the URL for HDU-based queries.
Returns
-------
:class:`str`
The query URL.
Notes
-----
In other modules this is an attribute or property. However, NOIRLab has
two separate SIA URLs for File-based and HDU-based queries, thus a
method is needed here.
"""
return f'{self.NAT_URL}/api/sia/vohdu' if hdu else f'{self.NAT_URL}/api/sia/voimg'
def _fields_url(self, hdu=False, aux=False):
"""Return the URL for metadata queries.
Parameters
----------
hdu : :class:`bool`, optional
If ``True`` return the URL for HDU-based queries.
aux : :class:`bool`, optional
If ``True`` return metadata on AUX fields.
Returns
-------
:class:`str`
The query URL.
"""
file = 'hdu' if hdu else 'file'
core = 'aux' if aux else 'core'
return f'{self.NAT_URL}/api/adv_search/{core}_{file}_fields'
def _response_to_table(self, response_json, sia=False):
"""Convert a JSON response to a :class:`~astropy.table.Table`.
Parameters
----------
response_json : :class:`list`
A query response formatted as a list of objects. The query
metadata is the first item in the list.
sia : :class:`bool`, optional
If ``True``, `response_json` came from a SIA query.
Returns
-------
:class:`~astropy.table.Table`
The converted response. The column ordering will match the
ordering of the `HEADER` metadata.
Notes
-----
* Metadata queries return columns that are qualified with ``file:`` or ``hdu:``,
however SIA queries to not.
* HDU queries will label HDU-specific fields with ``hdu:`` but other
fields will be qualified with ``file:``.
"""
if sia:
raw_names = [k for k in response_json[0]['HEADER'].keys()]
names = raw_names
else:
raw_names = [k for k in response_json[0]['HEADER'].keys()
if k.startswith('file:') or k.startswith('hdu:')]
names = [n.split(':')[1] for n in raw_names]
rows = [[row[n] for n in raw_names] for row in response_json[1:]]
return astropy.table.Table(names=names, rows=rows)
def _service_metadata(self, hdu=False, cache=True):
"""A SIA metadata query: no images are requested; only metadata
should be returned.
This feature is described in more detail in:
https://www.ivoa.net/documents/PR/DAL/PR-SIA-1.0-20090521.html#mdquery
Parameters
----------
hdu : :class:`bool`, optional
If ``True`` return the URL for HDU-based queries.
cache : :class:`bool`, optional
If ``True`` cache the result locally.
Returns
-------
:class:`dict`
A dictionary containing SIA metadata.
"""
url = f'{self.sia_url(hdu=hdu)}?FORMAT=METADATA&format=json'
response = self._request('GET', url, timeout=self.TIMEOUT, cache=cache)
return response.json()
[docs]
def query_region(self, coordinate, *, radius=0.1, hdu=False, cache=True, async_=False):
"""Query for NOIRLab observations by region of the sky.
Given a sky coordinate and radius, returns a `~astropy.table.Table`
of NOIRLab observations.
Parameters
----------
coordinate : :class:`str` or `~astropy.coordinates` object
The target region which to search. It may be specified as a
string or as the appropriate `~astropy.coordinates` object.
radius : :class:`float` or :class:`str` or `~astropy.units.Quantity` object, optional
Default 0.1 degrees.
The string must be parsable by `~astropy.coordinates.Angle`. The
appropriate `~astropy.units.Quantity` object from
`~astropy.units` may also be used.
hdu : :class:`bool`, optional
If ``True``, perform the query on HDUs.
cache : :class:`bool`, optional
If ``True``, cache the result locally.
async_ : :class:`bool`, optional
If ``True``, return the raw query response instead of a Table.
Returns
-------
:class:`~astropy.table.Table`
A table containing the results.
"""
self._validate_version()
ra, dec = coordinate.to_string('decimal').split()
url = f'{self.sia_url(hdu=hdu)}?POS={ra},{dec}&SIZE={radius}&VERB=3&format=json'
response = self._request('GET', url, timeout=self.TIMEOUT, cache=cache)
if async_:
return response
response.raise_for_status()
return self._response_to_table(response.json(), sia=True)
[docs]
def list_fields(self, *, aux=False, instrument=None, proctype=None, hdu=False,
categorical=False, cache=True):
"""List the available fields for searches using
:meth:`~astroquery.noirlab.NOIRLabClass.query_metadata`.
The default is to return core fields for file-based queries.
Parameters
----------
aux : :class:`bool`, optional
If ``True``, return aux fields. ``instrument`` and ``proctype`` must also be specified.
instrument : :class:`str`, optional
The specific instrument, *e.g.* '90prime' or 'decam'.
proctype : :class:`str`, optional
A description of the type of image, *e.g.* 'raw' or 'instcal'.
hdu : :class:`bool`, optional
If ``True`` return the fields for HDU-based queries.
categorical : :class:`bool`, optional
If ``True`` return the categorical fields and their allowed values.
cache : :class:`bool`, optional
If ``True`` cache the result locally.
Returns
-------
:class:`list` or :class:`dict`
A list of field descriptions, each a :class:`dict`.
If ``categorical=True`` return a :class:`dict` describing the
allowed values of each categorical field.
Raises
------
ValueError
If ``aux=True`` and ``instrument`` or ``proctype`` are not specified.
Notes
-----
* Core fields are faster to search than Aux fields.
* The available fields depend on whether a File or a HDU query is requested.
* Categorical fields can only take on one of a set of values.
"""
if categorical:
url = f'{self.NAT_URL}/api/adv_search/cat_lists/?format=json'
else:
url = self._fields_url(hdu=hdu, aux=aux)
if aux:
if instrument is None:
raise ValueError("instrument must be specified if aux=True.")
if proctype is None:
raise ValueError("instrument must be specified if aux=True.")
url = f'{url}/{instrument}/{proctype}/'
response = self._request('GET', url, timeout=self.TIMEOUT, cache=cache)
response.raise_for_status()
return response.json()
[docs]
def get_file(self, fileid):
"""Simply fetch a file by MD5 ID.
Parameters
----------
fileid : :class:`str`
The MD5 ID of the file.
Returns
-------
:class:`~astropy.io.fits.HDUList`
The open FITS file. Call ``.close()`` on this object when done.
"""
url = f'{self.NAT_URL}/api/retrieve/{fileid}/'
hdulist = fits.open(url)
return hdulist
def _version(self, cache=False):
"""Return the version of the REST API.
Typically, users will use the ``api_version`` property instead
of this method.
Parameters
----------
cache : :class:`bool`, optional
If ``True`` cache the result locally.
Returns
-------
:class:`float`
The API version as a number.
"""
url = f'{self.NAT_URL}/api/version/'
response = self._request('GET', url, timeout=self.TIMEOUT, cache=cache)
response.raise_for_status()
return response.json()
[docs]
def get_token(self, email, password, cache=True):
"""Get an access token to use with proprietary data.
Parameters
----------
email : :class:`str`
Email for account access.
password : :class:`str`
Password associated with `email`. *Please* never hard-code your
password *anywhere*.
cache : :class:`bool`, optional
If ``True`` cache the result locally.
Returns
-------
:class:`str`
The access token as a string.
"""
url = f'{self.NAT_URL}/api/get_token/'
response = self._request('POST', url,
json={"email": email, "password": password},
timeout=self.TIMEOUT, cache=cache)
response.raise_for_status()
return response.json()
NOIRLab = NOIRLabClass()