|
| 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() |
0 commit comments