Skip to content

Commit 79e0425

Browse files
authored
Merge pull request #3359 from weaverba137/noirlab-archive
Access to NOIRLab Astro Data Archive
2 parents 222c771 + 8457192 commit 79e0425

9 files changed

Lines changed: 1287 additions & 2 deletions

File tree

CHANGES.rst

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44
New Tools and Services
55
----------------------
66

7+
noirlab
8+
^^^^^^^
79

10+
- Restore access to the `NSF NOIRLab <https://noirlab.edu>`_
11+
`Astro Data Archive <https://astroarchive.noirlab.edu>`_ [#3359].
812

913
API changes
1014
-----------
@@ -71,10 +75,10 @@ mast
7175

7276
- Improved robustness of PanSTARRS column metadata parsing. This prevents metadata-related query errors. [#3485]
7377

74-
- The ``select_cols`` parameter in ``MastMissions`` query functions now accepts an iterable of column names, a comma-delimited
78+
- The ``select_cols`` parameter in ``MastMissions`` query functions now accepts an iterable of column names, a comma-delimited
7579
string of column names, or the special values 'all' or '*' to return all available columns. [#3492]
7680

77-
- Improved robustness of product downloads for ``MastMissions``, including support for subscription-service JSON inputs and
81+
- Improved robustness of product downloads for ``MastMissions``, including support for subscription-service JSON inputs and
7882
clearer validation of MAST URIs and product metadata. [#3517]
7983

8084
- Added full support for the International Ultraviolet Explorer (IUE) mission in ``MastMissions``. [#3517]

astroquery/noirlab/__init__.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Licensed under a 3-clause BSD style license - see LICENSE.rst
2+
"""
3+
NSF NOIRLab Astro Data Archive Query Tool
4+
-----------------------------------------
5+
"""
6+
from astropy import config as _config
7+
8+
9+
class Conf(_config.ConfigNamespace):
10+
"""
11+
Configuration parameters for `astroquery.noirlab`.
12+
"""
13+
server = _config.ConfigItem(['https://astroarchive.noirlab.edu',],
14+
'Name of the NSF NOIRLab server to use.')
15+
timeout = _config.ConfigItem(30,
16+
'Time limit for connecting to NSF NOIRLab server.')
17+
18+
19+
conf = Conf()
20+
21+
from .core import NOIRLab, NOIRLabClass # noqa
22+
23+
__all__ = ['NOIRLab', 'NOIRLabClass',
24+
'conf', 'Conf']

astroquery/noirlab/core.py

Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
# Licensed under a 3-clause BSD style license - see LICENSE.rst
2+
"""
3+
Provide astroquery API access to NSF NOIRLab Astro Data Archive.
4+
5+
This does DB access through web-services.
6+
"""
7+
import astropy.io.fits as fits
8+
import astropy.table
9+
from ..query import BaseQuery
10+
from ..exceptions import RemoteServiceError
11+
from . import conf
12+
13+
14+
__all__ = ['NOIRLab', 'NOIRLabClass'] # specifies what to import
15+
16+
17+
class NOIRLabClass(BaseQuery):
18+
"""Search functionality for the NSF NOIRLab Astro Data Archive.
19+
"""
20+
TIMEOUT = conf.timeout
21+
NAT_URL = conf.server
22+
23+
def __init__(self):
24+
self._api_version = None
25+
super().__init__()
26+
27+
@property
28+
def api_version(self):
29+
"""Return version of REST API used by this module.
30+
31+
If the REST API changes such that the major version increases,
32+
a new version of this module will likely need to be used.
33+
"""
34+
if self._api_version is None:
35+
self._api_version = float(self._version())
36+
return self._api_version
37+
38+
def _validate_version(self):
39+
"""Ensure the API is compatible with the code.
40+
"""
41+
KNOWN_GOOD_API_VERSION = 7.0
42+
if (int(self.api_version) - int(KNOWN_GOOD_API_VERSION)) >= 1:
43+
msg = (f'The astroquery.noirlab module is expecting an older '
44+
f'version of the {self.NAT_URL} API services. '
45+
f'Please upgrade to latest astroquery. '
46+
f'Expected version {KNOWN_GOOD_API_VERSION} but got '
47+
f'{self.api_version} from the API.')
48+
raise RemoteServiceError(msg)
49+
50+
def sia_url(self, hdu=False):
51+
"""Return the URL for SIA queries.
52+
53+
Parameters
54+
----------
55+
hdu : :class:`bool`, optional
56+
If ``True`` return the URL for HDU-based queries.
57+
58+
Returns
59+
-------
60+
:class:`str`
61+
The query URL.
62+
63+
Notes
64+
-----
65+
In other modules this is an attribute or property. However, NOIRLab has
66+
two separate SIA URLs for File-based and HDU-based queries, thus a
67+
method is needed here.
68+
"""
69+
return f'{self.NAT_URL}/api/sia/vohdu' if hdu else f'{self.NAT_URL}/api/sia/voimg'
70+
71+
def _fields_url(self, hdu=False, aux=False):
72+
"""Return the URL for metadata queries.
73+
74+
Parameters
75+
----------
76+
hdu : :class:`bool`, optional
77+
If ``True`` return the URL for HDU-based queries.
78+
aux : :class:`bool`, optional
79+
If ``True`` return metadata on AUX fields.
80+
81+
Returns
82+
-------
83+
:class:`str`
84+
The query URL.
85+
"""
86+
file = 'hdu' if hdu else 'file'
87+
core = 'aux' if aux else 'core'
88+
return f'{self.NAT_URL}/api/adv_search/{core}_{file}_fields'
89+
90+
def _response_to_table(self, response_json, sia=False):
91+
"""Convert a JSON response to a :class:`~astropy.table.Table`.
92+
93+
Parameters
94+
----------
95+
response_json : :class:`list`
96+
A query response formatted as a list of objects. The query
97+
metadata is the first item in the list.
98+
sia : :class:`bool`, optional
99+
If ``True``, `response_json` came from a SIA query.
100+
101+
Returns
102+
-------
103+
:class:`~astropy.table.Table`
104+
The converted response. The column ordering will match the
105+
ordering of the `HEADER` metadata.
106+
107+
Notes
108+
-----
109+
* Metadata queries return columns that are qualified with ``file:`` or ``hdu:``,
110+
however SIA queries to not.
111+
* HDU queries will label HDU-specific fields with ``hdu:`` but other
112+
fields will be qualified with ``file:``.
113+
"""
114+
if sia:
115+
raw_names = [k for k in response_json[0]['HEADER'].keys()]
116+
names = raw_names
117+
else:
118+
raw_names = [k for k in response_json[0]['HEADER'].keys()
119+
if k.startswith('file:') or k.startswith('hdu:')]
120+
names = [n.split(':')[1] for n in raw_names]
121+
rows = [[row[n] for n in raw_names] for row in response_json[1:]]
122+
return astropy.table.Table(names=names, rows=rows)
123+
124+
def _service_metadata(self, hdu=False, cache=True):
125+
"""A SIA metadata query: no images are requested; only metadata
126+
should be returned.
127+
128+
This feature is described in more detail in:
129+
https://www.ivoa.net/documents/PR/DAL/PR-SIA-1.0-20090521.html#mdquery
130+
131+
Parameters
132+
----------
133+
hdu : :class:`bool`, optional
134+
If ``True`` return the URL for HDU-based queries.
135+
cache : :class:`bool`, optional
136+
If ``True`` cache the result locally.
137+
138+
Returns
139+
-------
140+
:class:`dict`
141+
A dictionary containing SIA metadata.
142+
"""
143+
url = f'{self.sia_url(hdu=hdu)}?FORMAT=METADATA&format=json'
144+
response = self._request('GET', url, timeout=self.TIMEOUT, cache=cache)
145+
return response.json()
146+
147+
def query_region(self, coordinate, *, radius=0.1, hdu=False, cache=True, async_=False):
148+
"""Query for NOIRLab observations by region of the sky.
149+
150+
Given a sky coordinate and radius, returns a `~astropy.table.Table`
151+
of NOIRLab observations.
152+
153+
Parameters
154+
----------
155+
coordinate : :class:`str` or `~astropy.coordinates` object
156+
The target region which to search. It may be specified as a
157+
string or as the appropriate `~astropy.coordinates` object.
158+
radius : :class:`float` or :class:`str` or `~astropy.units.Quantity` object, optional
159+
Default 0.1 degrees.
160+
The string must be parsable by `~astropy.coordinates.Angle`. The
161+
appropriate `~astropy.units.Quantity` object from
162+
`~astropy.units` may also be used.
163+
hdu : :class:`bool`, optional
164+
If ``True``, perform the query on HDUs.
165+
cache : :class:`bool`, optional
166+
If ``True``, cache the result locally.
167+
async_ : :class:`bool`, optional
168+
If ``True``, return the raw query response instead of a Table.
169+
170+
Returns
171+
-------
172+
:class:`~astropy.table.Table`
173+
A table containing the results.
174+
"""
175+
self._validate_version()
176+
ra, dec = coordinate.to_string('decimal').split()
177+
url = f'{self.sia_url(hdu=hdu)}?POS={ra},{dec}&SIZE={radius}&VERB=3&format=json'
178+
response = self._request('GET', url, timeout=self.TIMEOUT, cache=cache)
179+
if async_:
180+
return response
181+
response.raise_for_status()
182+
return self._response_to_table(response.json(), sia=True)
183+
184+
def list_fields(self, *, aux=False, instrument=None, proctype=None, hdu=False,
185+
categorical=False, cache=True):
186+
"""List the available fields for searches using
187+
:meth:`~astroquery.noirlab.NOIRLabClass.query_metadata`.
188+
189+
The default is to return core fields for file-based queries.
190+
191+
Parameters
192+
----------
193+
aux : :class:`bool`, optional
194+
If ``True``, return aux fields. ``instrument`` and ``proctype`` must also be specified.
195+
instrument : :class:`str`, optional
196+
The specific instrument, *e.g.* '90prime' or 'decam'.
197+
proctype : :class:`str`, optional
198+
A description of the type of image, *e.g.* 'raw' or 'instcal'.
199+
hdu : :class:`bool`, optional
200+
If ``True`` return the fields for HDU-based queries.
201+
categorical : :class:`bool`, optional
202+
If ``True`` return the categorical fields and their allowed values.
203+
cache : :class:`bool`, optional
204+
If ``True`` cache the result locally.
205+
206+
Returns
207+
-------
208+
:class:`list` or :class:`dict`
209+
A list of field descriptions, each a :class:`dict`.
210+
If ``categorical=True`` return a :class:`dict` describing the
211+
allowed values of each categorical field.
212+
213+
Raises
214+
------
215+
ValueError
216+
If ``aux=True`` and ``instrument`` or ``proctype`` are not specified.
217+
218+
Notes
219+
-----
220+
* Core fields are faster to search than Aux fields.
221+
* The available fields depend on whether a File or a HDU query is requested.
222+
* Categorical fields can only take on one of a set of values.
223+
"""
224+
if categorical:
225+
url = f'{self.NAT_URL}/api/adv_search/cat_lists/?format=json'
226+
else:
227+
url = self._fields_url(hdu=hdu, aux=aux)
228+
if aux:
229+
if instrument is None:
230+
raise ValueError("instrument must be specified if aux=True.")
231+
if proctype is None:
232+
raise ValueError("instrument must be specified if aux=True.")
233+
url = f'{url}/{instrument}/{proctype}/'
234+
response = self._request('GET', url, timeout=self.TIMEOUT, cache=cache)
235+
response.raise_for_status()
236+
return response.json()
237+
238+
def query_metadata(self, qspec=None, sort=None, limit=1000, hdu=False, cache=True):
239+
"""Query the archive database for details on available files.
240+
241+
``qspec`` should minimally contain a list of output columns and a list of
242+
search parameters, which could be empty. For example::
243+
244+
qspec = {"outfields": ["md5sum", ], "search": []}
245+
246+
There are more details in the :ref:`NOIRLab overview document <astroquery.noirlab>`.
247+
248+
Parameters
249+
----------
250+
qspec : :class:`dict`, optional
251+
The query that will be passed to the API.
252+
sort : :class:`str`, optional
253+
Sort the results on one of the columns in ``qspec``.
254+
limit : :class:`int`, optional
255+
The number of results to return, default 1000.
256+
hdu : :class:`bool`, optional
257+
If ``True`` return the URL for HDU-based queries.
258+
cache : :class:`bool`, optional
259+
If ``True`` cache the result locally.
260+
261+
Returns
262+
-------
263+
:class:`~astropy.table.Table`
264+
A Table containing the results.
265+
"""
266+
self._validate_version()
267+
rectype = 'hdu' if hdu else 'file'
268+
url = f'{self.NAT_URL}/api/adv_search/find/?rectype={rectype}&limit={limit}'
269+
if sort:
270+
# TODO: write a test for this, which may involve refactoring async versus sync.
271+
url += f'&sort={sort}'
272+
273+
if qspec is None:
274+
jdata = {"outfields": ["md5sum", ], "search": []}
275+
else:
276+
jdata = qspec
277+
278+
response = self._request('POST', url, json=jdata,
279+
timeout=self.TIMEOUT, cache=cache)
280+
response.raise_for_status()
281+
return self._response_to_table(response.json())
282+
283+
def get_file(self, fileid):
284+
"""Simply fetch a file by MD5 ID.
285+
286+
Parameters
287+
----------
288+
fileid : :class:`str`
289+
The MD5 ID of the file.
290+
291+
Returns
292+
-------
293+
:class:`~astropy.io.fits.HDUList`
294+
The open FITS file. Call ``.close()`` on this object when done.
295+
"""
296+
url = f'{self.NAT_URL}/api/retrieve/{fileid}/'
297+
hdulist = fits.open(url)
298+
return hdulist
299+
300+
def _version(self, cache=False):
301+
"""Return the version of the REST API.
302+
303+
Typically, users will use the ``api_version`` property instead
304+
of this method.
305+
306+
Parameters
307+
----------
308+
cache : :class:`bool`, optional
309+
If ``True`` cache the result locally.
310+
311+
Returns
312+
-------
313+
:class:`float`
314+
The API version as a number.
315+
"""
316+
url = f'{self.NAT_URL}/api/version/'
317+
response = self._request('GET', url, timeout=self.TIMEOUT, cache=cache)
318+
response.raise_for_status()
319+
return response.json()
320+
321+
def get_token(self, email, password, cache=True):
322+
"""Get an access token to use with proprietary data.
323+
324+
Parameters
325+
----------
326+
email : :class:`str`
327+
Email for account access.
328+
password : :class:`str`
329+
Password associated with `email`. *Please* never hard-code your
330+
password *anywhere*.
331+
cache : :class:`bool`, optional
332+
If ``True`` cache the result locally.
333+
334+
Returns
335+
-------
336+
:class:`str`
337+
The access token as a string.
338+
"""
339+
url = f'{self.NAT_URL}/api/get_token/'
340+
response = self._request('POST', url,
341+
json={"email": email, "password": password},
342+
timeout=self.TIMEOUT, cache=cache)
343+
response.raise_for_status()
344+
return response.json()
345+
346+
347+
NOIRLab = NOIRLabClass()

astroquery/noirlab/tests/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)