diff --git a/CHANGES.rst b/CHANGES.rst index c6158ea041..1c150e927f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -72,6 +72,32 @@ vizier - Methods ``get_catalog``, ``get_catalog_async`` and ``query_*`` now always return UCD1+ instead of UCD1. [#3458] +fermi +^^^^^ + +- The module now uses the new Fermi LAT Data Query REST API + (``https://fermi.gsfc.nasa.gov/ssc/data/access/lat/query/api/v1``), which is replacing the + ``LATDataQuery.cgi`` endpoint whose HTML responses were previously scraped + with regular expressions. The user-facing ``FermiLAT.query_object()`` + signature is unchanged. [#3647] +- ``FermiLAT.query_object_async()`` now returns the server-assigned + ``query_id`` instead of the URL of an HTML results page. [#3647] +- New methods ``FermiLAT.get_status()``, ``FermiLAT.list_results()``, + ``FermiLAT.wait_for_completion()`` and ``FermiLAT.get_file_urls()`` expose + the individual steps of the asynchronous query workflow. [#3647] +- New keyword arguments: ``zenithangle`` (maximum zenith angle in degrees) and + ``coordsystem`` (``'J2000'``, ``'B1950'`` or ``'Galactic'``). All-sky + queries (radius > 60 deg, observation window <= 24 h) are now supported. [#3647] +- ``GetFermilatDatafile`` and ``get_fermilat_datafile`` are deprecated; they + now delegate to ``FermiLAT.get_file_urls()`` and take a ``query_id`` rather + than a results-page URL. [#3647] +- The module no longer emits an "Experimental" warning on import. [#3647] +- Failed queries now surface the server's error message: an HTTP error + response with a JSON body such as ``{"error": "..."}`` is raised as a + ``RemoteServiceError`` carrying that message, rather than a bare status + code. [#3647] + + mast ^^^^ - ``utils.mast_relative_path`` is now deprecated in favor of ``utils.get_cloud_paths``. [#3488] diff --git a/astroquery/fermi/__init__.py b/astroquery/fermi/__init__.py index 8db47889ca..97eedef047 100644 --- a/astroquery/fermi/__init__.py +++ b/astroquery/fermi/__init__.py @@ -4,6 +4,10 @@ https://fermi.gsfc.nasa.gov https://fermi.gsfc.nasa.gov/ssc/data/ + +As of 2026, the Fermi LAT Data Server exposes a JSON REST API. This module +targets that API; the legacy ``LATDataQuery.cgi`` HTML-scraping path has been +removed. """ from astropy import config as _config @@ -14,11 +18,18 @@ class Conf(_config.ConfigNamespace): """ url = _config.ConfigItem( - 'https://fermi.gsfc.nasa.gov/cgi-bin/ssc/LAT/LATDataQuery.cgi', - 'Fermi query URL.') + 'https://fermi.gsfc.nasa.gov/ssc/data/access/lat/query/api/v1', + 'Base URL of the Fermi LAT Data Query REST API. Endpoints ' + '(/query, /query/{id}/status, /query/{id}/results) are appended to it.') + file_base_url = _config.ConfigItem( + # NOTE: the results endpoint returns bare filenames; download URLs are + # reconstructed against this staging area. Currently a /test/ path - + # revisit if the staging location changes at production cutover. + 'https://fermi.gsfc.nasa.gov/FTP/fermi/data/lat/test/queries/', + 'Base URL under which query result files are staged.') timeout = _config.ConfigItem( 60, - 'Time limit for connecting to Fermi server.') + 'Time limit for connecting to the Fermi server.') retrieval_timeout = _config.ConfigItem( 120, 'Time limit for retrieving a data file once it has been located.') @@ -26,13 +37,9 @@ class Conf(_config.ConfigNamespace): conf = Conf() -from .core import FermiLAT, FermiLATClass, GetFermilatDatafile, get_fermilat_datafile +from .core import FermiLAT, FermiLATClass, GetFermilatDatafile, get_fermilat_datafile # noqa: E402 __all__ = ['FermiLAT', 'FermiLATClass', 'GetFermilatDatafile', 'get_fermilat_datafile', 'Conf', 'conf', ] - -import warnings -warnings.warn("Experimental: Fermi-LAT has not yet been refactored to have " - "its API match the rest of astroquery.") diff --git a/astroquery/fermi/core.py b/astroquery/fermi/core.py index 284678d382..96f94911ef 100644 --- a/astroquery/fermi/core.py +++ b/astroquery/fermi/core.py @@ -1,170 +1,392 @@ # Licensed under a 3-clause BSD style license - see LICENSE.rst -"""Download of Fermi LAT (Large Area Telescope) data""" +"""Download of Fermi LAT (Large Area Telescope) data. + +This module talks to the Fermi LAT Data Query REST API:: + + POST /query -> {"query_id": ...} + GET /query/{id}/status -> {"state": ...} + GET /query/{id}/results -> {"files": [{"name": ...}, ...]} + +which is replacing the legacy ``LATDataQuery.cgi`` form endpoint whose HTML +response pages this module used to scrape with regular expressions. +""" import re -import requests import time +from urllib.parse import urljoin + import astropy.units as u +from astropy.utils.decorators import deprecated + from ..query import BaseQuery from ..utils import commons, async_to_sync +from ..exceptions import RemoteServiceError, TimeoutError from . import conf __all__ = ['FermiLAT', 'FermiLATClass', 'GetFermilatDatafile', 'get_fermilat_datafile', ] +# The API reports free-form, human-readable states ("Query completed", +# "Query in progress", ...). Rather than hard-coding the exact strings - +# which is what made the previous scraping implementation so brittle - we +# match on substrings and treat anything unrecognised as "still running". +_DONE_TOKENS = ('complet', 'done', 'finished', 'ready') +_ERROR_TOKENS = ('error', 'fail', 'reject', 'invalid', 'abort', 'cancel') + +# "128.836,-45.1764" and friends: already a coordinate pair, no name +# resolution needed (also covers the all-sky "0.0,0.0" convention). +_COORD_PAIR_RE = re.compile(r'^\s*[-+]?\d+(\.\d*)?\s*,\s*[-+]?\d+(\.\d*)?\s*$') + @async_to_sync class FermiLATClass(BaseQuery): """ - TODO: document + Query the Fermi LAT Data Server. + + The server runs queries asynchronously: `query_object_async` submits a + query and returns its ``query_id``, and `query_object` additionally waits + for the query to finish and returns the URLs of the staged data files. """ - request_url = conf.url - result_url_re = re.compile(r'The results of your query may be found at ' - r' 60 (typically 180) + for an all-sky query - note that all-sky queries are restricted by + the server to observation windows of <= 24 hours. + .. warning:: - Defaults to 1 degree if left blank + Defaults to 1 degree if left blank. obsdates : str - Observation dates. - timesys: 'Gregorian' or 'MET' or 'MJD' - Time system associated with obsdates - energyrange_MeV: str - Energy range in MeV + Observation window, as ``"start,stop"``. + timesys : 'Gregorian' or 'MET' or 'MJD' + Time system associated with ``obsdates``. + energyrange_MeV : str + Energy range in MeV, as ``"min,max"``. + LATdatatype : 'Photon' or 'Extended' or 'None' + Which LAT event class to retrieve. + spacecraftdata : bool + Whether to also retrieve the spacecraft (SC) file. + coordsystem : 'J2000' or 'B1950' or 'Galactic' + Coordinate system of the submitted coordinates. Resolved names + and `~astropy.coordinates.SkyCoord` inputs are transformed into + this frame. + zenithangle : float, optional + Maximum zenith angle in degrees. The server default is 180. Returns ------- - payload_dict : Requests payload in a dictionary + payload : dict + The JSON payload posted to ``/query``. """ - - payload = {'shapefield': str(searchradius), - 'coordsystem': 'J2000', - 'coordfield': _parse_coordinates(name_or_coords), - 'destination': 'query', - 'timefield': obsdates, - 'timetype': timesys, - 'energyfield': energyrange_MeV, - 'photonOrExtendedOrNone': LATdatatype, - 'spacecraft': 'on' if spacecraftdata else 'off'} + # The API requires a radius; the CGI form silently defaulted to 1 deg, + # so preserve that behaviour rather than sending an empty string. + if searchradius in ('', None): + searchradius = 1 + + payload = { + 'coordfield': _parse_coordinates(name_or_coords, + coordsystem=coordsystem), + 'coordsystem': coordsystem, + # the API kept the legacy CGI-form name for the search radius + 'shapefield': searchradius, + 'timefield': obsdates, + 'timetype': timesys, + 'energyfield': energyrange_MeV, + 'photonOrExtendedOrNone': LATdatatype, + 'spacecraft': 'on' if spacecraftdata else 'off', + } + + if zenithangle is not None: + payload['zenithangle'] = zenithangle return payload + # ------------------------------------------------------------------ + # status / results + # ------------------------------------------------------------------ + def get_status(self, query_id): + """ + Return the raw status document for ``query_id``. + + Returns + ------- + status : dict + The parsed ``/query/{id}/status`` response. The overall state is + in ``status['state']``; per-server detail is in ``queue_status``, + ``running_status`` and ``servers_status``. + """ + response = self._request( + "GET", url=f"{self.base_url}/query/{query_id}/status", + timeout=self.TIMEOUT, cache=False) + _raise_for_status(response, context=f"Fermi LAT status ({query_id})") + return response.json() + + def list_results(self, query_id): + """ + Return the file metadata for a completed query. + + Returns + ------- + files : list of dict + The ``files`` entries of the ``/query/{id}/results`` response. + """ + response = self._request( + "GET", url=f"{self.base_url}/query/{query_id}/results", + timeout=self.RETRIEVAL_TIMEOUT, cache=False) + _raise_for_status(response, context=f"Fermi LAT results ({query_id})") + return response.json().get('files', []) + + def wait_for_completion(self, query_id, *, check_frequency=None, + max_wait=None, verbose=False): + """ + Poll ``/query/{id}/status`` until the query finishes. + + Parameters + ---------- + check_frequency : float, optional + Minutes between polls. Defaults to `check_frequency`. + max_wait : float, optional + Give up after this many minutes. ``None`` (default) waits + indefinitely. + verbose : bool + Print the elapsed time on completion. + + Returns + ------- + status : dict + The final status document. + """ + if check_frequency is None: + check_frequency = self.check_frequency + + elapsed_time = 0.0 + + while True: + status = self.get_status(query_id) + state = str(status.get('state', '')) + lowered = state.lower() + + if any(token in lowered for token in _ERROR_TOKENS): + raise RemoteServiceError( + f"Fermi LAT query {query_id} failed with state {state!r}") + + if any(token in lowered for token in _DONE_TOKENS): + if verbose: + print(f"Query completed in {elapsed_time:0.1f} minutes") + return status + + if max_wait is not None and elapsed_time >= max_wait: + raise TimeoutError( + f"Fermi LAT query {query_id} did not complete within " + f"{max_wait} minutes (last state: {state!r})") + + time.sleep(check_frequency * 60) + elapsed_time += check_frequency + + def get_file_urls(self, query_id, *, check_frequency=None, max_wait=None, + verbose=False): + """ + Wait for ``query_id`` to complete and return the result file URLs. + + Returns + ------- + urls : list of str + """ + self.wait_for_completion(query_id, check_frequency=check_frequency, + max_wait=max_wait, verbose=verbose) + return [_file_url(entry) for entry in self.list_results(query_id)] + def _parse_result(self, result, *, verbose=False, **kwargs): """ - Use get_fermilat_datafile to download a result URL + Turn the ``query_id`` returned by `query_object_async` into a list of + downloadable file URLs, waiting for the query to complete. """ - return get_fermilat_datafile(result) + return self.get_file_urls(result, verbose=verbose) FermiLAT = FermiLATClass() -def _parse_coordinates(coordinates): - try: - c = commons.parse_coordinates(coordinates) - # now c has some subclass of astropy.coordinate - # get ra, dec and frame - return _fermi_format_coords(c) - except (u.UnitsError, TypeError): - raise Exception("Coordinates not specified correctly") +def _raise_for_status(response, *, context): + """ + Raise a `RemoteServiceError` carrying the server's error message. + + The Fermi API signals bad requests with an HTTP error status and a JSON + body of the form ``{"error": "..."}``. ``requests.raise_for_status`` only + reports the status code, so this helper pulls the message out of the body + (trying the ``error``, ``detail`` and ``message`` keys, then falling back + to the raw text) and surfaces it, as recommended by the astroquery API + specification. + """ + status_code = getattr(response, 'status_code', None) + if status_code is None or status_code < 400: + return + message = None + try: + body = response.json() + except ValueError: + body = None + + if isinstance(body, dict): + for key in ('error', 'detail', 'message'): + if body.get(key): + message = body[key] + break + if message is None: + text = getattr(response, 'text', '') or '' + message = text.strip() or f"HTTP {status_code}" -def _fermi_format_coords(c): - c = c.transform_to('fk5') - return "{0:0.5f},{1:0.5f}".format(c.ra.degree, c.dec.degree) + raise RemoteServiceError(f"{context} failed ({status_code}): {message}") -class GetFermilatDatafile: +def _file_url(entry): """ - TODO: document - TODO: Fail with useful failure messages on genuine failures - (this doesn't need to be implemented as a class) + Build an absolute URL for one entry of the ``results`` file list. + + The API is documented as returning ``{"name": "..._PH00.fits"}``; if a + future revision also returns an absolute ``url``/``href``/``path``, prefer + that over reconstructing the location from `conf.file_base_url`. """ + if isinstance(entry, str): + name = entry + else: + for key in ('url', 'href', 'path', 'location'): + value = entry.get(key) + if value: + if value.startswith('http'): + return value + return urljoin(conf.file_base_url, value.lstrip('/')) + name = entry.get('name') + + if not name: + raise RemoteServiceError( + f"Could not determine a file URL from results entry {entry!r}") + + return urljoin(conf.file_base_url, name) + + +def _parse_coordinates(coordinates, *, coordsystem='J2000'): + # A literal "RA,Dec" pair (including the all-sky "0.0,0.0" convention) is + # passed straight through - no name resolution, no frame transform. + if isinstance(coordinates, str) and _COORD_PAIR_RE.match(coordinates): + return coordinates.replace(' ', '') - fitsfile_re = re.compile(r'Available') - fitsfile_re = re.compile(r'wget (https://fermi\.gsfc\.nasa\.gov/FTP/fermi/data/lat/queries/[A-Za-z0-9_]*.fits)') - # wget https://fermi.gsfc.nasa.gov/FTP/fermi/data/lat/queries/L1809182001077FA3883F37_SC00.fits + try: + c = commons.parse_coordinates(coordinates) + except (u.UnitsError, TypeError): + raise ValueError("Coordinates not specified correctly") - TIMEOUT = conf.retrieval_timeout + return _fermi_format_coords(c, coordsystem=coordsystem) - check_frequency = 1 # minutes - def __call__(self, result_url, *, check_frequency=1, verbose=False): - self.result_url = result_url +def _fermi_format_coords(c, *, coordsystem='J2000'): + frames = {'j2000': 'fk5', 'b1950': 'fk4', 'galactic': 'galactic'} - page_loaded = False + try: + frame = frames[coordsystem.lower()] + except KeyError: + raise ValueError( + f"Unsupported coordsystem {coordsystem!r}; " + f"expected one of {', '.join(sorted(frames))}") - elapsed_time = 0 + c = c.transform_to(frame) - while not (page_loaded): - page_loaded = fitsfile_urls = self._check_page() - if page_loaded: - # don't wait an extra N minutes for success - break - time.sleep(check_frequency * 60) - elapsed_time += check_frequency - # update progressbar here... + if frame == 'galactic': + lon, lat = c.l.degree, c.b.degree + else: + lon, lat = c.ra.degree, c.dec.degree - if verbose: - print("Query completed in %0.1f minutes" % (elapsed_time)) + return "{0:0.5f},{1:0.5f}".format(lon, lat) - return fitsfile_urls - def _check_page(self): - result_page = requests.post(url=self.result_url, - data=None, - timeout=self.TIMEOUT) +@deprecated(since='0.4.12', + alternative='FermiLAT.query_object or FermiLAT.get_file_urls') +class GetFermilatDatafile: + """ + Deprecated. Retained so that code written against the pre-REST module + keeps importing; it now delegates to `FermiLATClass.get_file_urls`. + """ - pagedata = result_page.text + TIMEOUT = conf.retrieval_timeout + check_frequency = 1 - fitsfile_urls = self.fitsfile_re.findall(pagedata) + def __call__(self, query_id, *, check_frequency=None, verbose=False): + if check_frequency is None: + check_frequency = self.check_frequency + return FermiLAT.get_file_urls(query_id, + check_frequency=check_frequency, + verbose=verbose) - if len(fitsfile_urls) == 0: - return False - else: - return fitsfile_urls +@deprecated(since='0.4.12', + alternative='FermiLAT.query_object or FermiLAT.get_file_urls') +def get_fermilat_datafile(query_id, *, check_frequency=1, verbose=False): + """ + Deprecated. Wait for a query to finish and return its data file URLs. -get_fermilat_datafile = GetFermilatDatafile() + Note that this takes a ``query_id`` - the pre-REST version took the URL of + an HTML results page, which no longer exists. + """ + return FermiLAT.get_file_urls(query_id, check_frequency=check_frequency, + verbose=verbose) diff --git a/astroquery/fermi/tests/__init__.py b/astroquery/fermi/tests/__init__.py index e69de29bb2..9dce85d06f 100644 --- a/astroquery/fermi/tests/__init__.py +++ b/astroquery/fermi/tests/__init__.py @@ -0,0 +1 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst diff --git a/astroquery/fermi/tests/data/query_result_m31.html b/astroquery/fermi/tests/data/query_result_m31.html deleted file mode 100644 index b3334a3050..0000000000 --- a/astroquery/fermi/tests/data/query_result_m31.html +++ /dev/null @@ -1,136 +0,0 @@ - - -
- - - - - - - - - - - - - - - - - - -Your search criteria were:
| Equatorial coordinates (degrees) | (10.6847,41.2687) |
| Time range (MET) | (384196376,399748376) |
| Time range (Gregorian) | (2013-03-05 17:12:56,2013-09-01 17:12:56) |
| Energy range (MeV) | (100,300000) |
| Search radius (degrees) | 15 |
The estimated time for your query to complete is 22 seconds. The results of your query may be found at https://fermi.gsfc.nasa.gov/cgi-bin/ssc/LAT/QueryResults.cgi?id=L13090120163429E469B432.
Your search criteria were:
| Equatorial coordinates (degrees) | (0.18648,0.72028) |
| Time range (MET) | (378691200,378777600) |
| Time range (Gregorian) | (2013-01-01 00:00:00,2013-01-02 00:00:00) |
| Energy range (MeV) | (1000,100000) |
| Search radius (degrees) | 15 |
The state of your query is 2 (Query complete)
| Server | ||
|---|---|---|
| Photon Server | ||
| Spacecraft Server |
The filenames of the result files consist of the query ID string with an identifier appended to indicate which database the file came from. The identifiers are of the form: _DDNN where DD indicates the database and NN is the file number. The file number will generally be '00' unless the query resulted in a large data volume. In that case the data is broken up into multiple files. The values of the database field are:
In the event that you do not see any files with the data type you requested listed below, you should try resubmitting your query as there may have been a problem.
| Filename | Number of Entries | Size (MB) | Status |
|---|---|---|---|
| L13090110364329E469B418_PH00.fits | Available | ||
| L13090110364329E469B418_SC00.fits | Available |
If you would like to download the files via wget, simply copy the following commands and paste them into a terminal window. The files will be downloaded to the current directory in the terminal window.
wget https://fermi.gsfc.nasa.gov/FTP/fermi/data/lat/queries/L13090110364329E469B418_PH00.fits -wget https://fermi.gsfc.nasa.gov/FTP/fermi/data/lat/queries/L13090110364329E469B418_SC00.fits -