diff --git a/CHANGES.rst b/CHANGES.rst index 6971af3077..9eff47458a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -43,6 +43,14 @@ utils.tap - The method ``upload_table`` accepts file formats accepted by astropy's ``Table.read()``. [#3295] +mast +^^^^ + +- Added ``resolver`` parameter to query methods to specify the resolver to use when resolving object names to coordinates. [#3292] + +- Added ``resolve_all`` parameter to ``MastClass.resolve_object`` to resolve object names and return + coordinates for all available resolvers. [#3292] + Infrastructure, Utility and Other Changes and Additions ------------------------------------------------------- diff --git a/astroquery/mast/collections.py b/astroquery/mast/collections.py index 3eea6eb806..bd14943a9c 100644 --- a/astroquery/mast/collections.py +++ b/astroquery/mast/collections.py @@ -294,7 +294,7 @@ def query_region_async(self, coordinates, *, radius=0.2*u.deg, catalog="Hsc", @class_or_instance def query_object_async(self, objectname, *, radius=0.2*u.deg, catalog="Hsc", - pagesize=None, page=None, version=None, **criteria): + pagesize=None, page=None, version=None, resolver=None, **criteria): """ Given an object name, returns a list of catalog entries. See column documentation for specific catalogs `here `__. @@ -316,11 +316,16 @@ def query_object_async(self, objectname, *, radius=0.2*u.deg, catalog="Hsc", Can be used to override the default pagesize for (set in configs) this query only. E.g. when using a slow internet connection. page : int, optional - Defaulte None. + Default None. Can be used to override the default behavior of all results being returned to obtain a specific page of results. version : int, optional Version number for catalogs that have versions. Default is highest version. + resolver : str, optional + The resolver to use when resolving a named target into coordinates. Valid options are "SIMBAD" and "NED". + If not specified, the default resolver order will be used. Please see the + `STScI Archive Name Translation Application (SANTA) `__ + for more information. Default is None. **criteria Catalog-specific keyword args. These can be found in the `service documentation `__. @@ -339,7 +344,7 @@ def query_object_async(self, objectname, *, radius=0.2*u.deg, catalog="Hsc", response : list of `~requests.Response` """ - coordinates = utils.resolve_object(objectname) + coordinates = utils.resolve_object(objectname, resolver=resolver) return self.query_region_async(coordinates, radius=radius, @@ -350,7 +355,7 @@ def query_object_async(self, objectname, *, radius=0.2*u.deg, catalog="Hsc", **criteria) @class_or_instance - def query_criteria_async(self, catalog, *, pagesize=None, page=None, **criteria): + def query_criteria_async(self, catalog, *, pagesize=None, page=None, resolver=None, **criteria): """ Given an set of filters, returns a list of catalog entries. See column documentation for specific catalogs `here `__. @@ -365,6 +370,11 @@ def query_criteria_async(self, catalog, *, pagesize=None, page=None, **criteria) page : int, optional Can be used to override the default behavior of all results being returned to obtain one specific page of results. + resolver : str, optional + The resolver to use when resolving a named target into coordinates. Valid options are "SIMBAD" and "NED". + If not specified, the default resolver order will be used. Please see the + `STScI Archive Name Translation Application (SANTA) `__ + for more information. Default is None. **criteria Criteria to apply. At least one non-positional criteria must be supplied. Valid criteria are coordinates, objectname, radius (as in `query_region` and `query_object`), @@ -395,7 +405,9 @@ def query_criteria_async(self, catalog, *, pagesize=None, page=None, **criteria) radius = criteria.pop('radius', 0.2*u.deg) if objectname or coordinates: - coordinates = utils.parse_input_location(coordinates, objectname) + coordinates = utils.parse_input_location(coordinates=coordinates, + objectname=objectname, + resolver=resolver) # if radius is just a number we assume degrees radius = coord.Angle(radius, u.deg) diff --git a/astroquery/mast/core.py b/astroquery/mast/core.py index d68de05559..16e0759512 100644 --- a/astroquery/mast/core.py +++ b/astroquery/mast/core.py @@ -110,7 +110,7 @@ def disable_cloud_dataset(self): """ pass - def resolve_object(self, objectname): + def resolve_object(self, objectname, *, resolver=None, resolve_all=False): """ Resolves an object name to a position on the sky. @@ -118,10 +118,23 @@ def resolve_object(self, objectname): ---------- objectname : str Name of astronomical object to resolve. + resolver : str, optional + The resolver to use when resolving a named target into coordinates. Valid options are "SIMBAD" and "NED". + If not specified, the default resolver order will be used. Please see the + `STScI Archive Name Translation Application (SANTA) `__ + for more information. If ``resolve_all`` is True, this parameter will be ignored. Default is None. + resolve_all : bool, optional + If True, will try to resolve the object name using all available resolvers ("NED", "SIMBAD"). + Function will return a dictionary where the keys are the resolver names and the values are the + resolved coordinates. Default is False. Returns ------- - response : `~astropy.coordinates.SkyCoord` - The sky position of the given object. + response : `~astropy.coordinates.SkyCoord`, dict + If ``resolve_all`` is False, returns a `~astropy.coordinates.SkyCoord` object with the resolved coordinates. + If ``resolve_all`` is True, returns a dictionary where the keys are the resolver names and the values are + `~astropy.coordinates.SkyCoord` objects with the resolved coordinates. """ - return utils.resolve_object(objectname) + return utils.resolve_object(objectname, + resolver=resolver, + resolve_all=resolve_all) diff --git a/astroquery/mast/cutouts.py b/astroquery/mast/cutouts.py index 24186adc59..2da8833dbf 100644 --- a/astroquery/mast/cutouts.py +++ b/astroquery/mast/cutouts.py @@ -107,7 +107,7 @@ def __init__(self): self._service_api_connection.set_service_params(services, "tesscut") def get_sectors(self, *, coordinates=None, radius=0*u.deg, product='SPOC', objectname=None, - moving_target=False, mt_type=None): + moving_target=False, mt_type=None, resolver=None): """ Get a list of the TESS data sectors whose footprints intersect with the given search area. @@ -152,6 +152,11 @@ def get_sectors(self, *, coordinates=None, radius=0*u.deg, product='SPOC', objec first majorbody is tried and then smallbody if a matching majorbody is not found. NOTE: If moving_target is supplied, this argument is ignored. + resolver : str, optional + The resolver to use when resolving a named target into coordinates. Valid options are "SIMBAD" and "NED". + If not specified, the default resolver order will be used. Please see the + `STScI Archive Name Translation Application (SANTA) `__ + for more information. Default is None. Returns ------- @@ -187,7 +192,9 @@ def get_sectors(self, *, coordinates=None, radius=0*u.deg, product='SPOC', objec else: # Get Skycoord object for coordinates/object - coordinates = parse_input_location(coordinates, objectname) + coordinates = parse_input_location(coordinates=coordinates, + objectname=objectname, + resolver=resolver) # If radius is just a number we assume degrees radius = Angle(radius, u.deg) @@ -223,7 +230,8 @@ def get_sectors(self, *, coordinates=None, radius=0*u.deg, product='SPOC', objec return Table(sector_dict) def download_cutouts(self, *, coordinates=None, size=5, sector=None, product='SPOC', path=".", - inflate=True, objectname=None, moving_target=False, mt_type=None, verbose=False): + inflate=True, objectname=None, moving_target=False, mt_type=None, resolver=None, + verbose=False): """ Download cutout target pixel file(s) around the given coordinates with indicated size. @@ -280,6 +288,11 @@ def download_cutouts(self, *, coordinates=None, size=5, sector=None, product='SP first majorbody is tried and then smallbody if a matching majorbody is not found. NOTE: If moving_target is supplied, this argument is ignored. + resolver : str, optional + The resolver to use when resolving a named target into coordinates. Valid options are "SIMBAD" and "NED". + If not specified, the default resolver order will be used. Please see the + `STScI Archive Name Translation Application (SANTA) `__ + for more information. Default is None. Returns ------- @@ -310,7 +323,9 @@ def download_cutouts(self, *, coordinates=None, size=5, sector=None, product='SP else: # Get Skycoord object for coordinates/object - coordinates = parse_input_location(coordinates, objectname) + coordinates = parse_input_location(coordinates=coordinates, + objectname=objectname, + resolver=resolver) astrocut_request = f"astrocut?ra={coordinates.ra.deg}&dec={coordinates.dec.deg}" @@ -359,7 +374,7 @@ def download_cutouts(self, *, coordinates=None, size=5, sector=None, product='SP return localpath_table def get_cutouts(self, *, coordinates=None, size=5, product='SPOC', sector=None, - objectname=None, moving_target=False, mt_type=None): + objectname=None, moving_target=False, mt_type=None, resolver=None): """ Get cutout target pixel file(s) around the given coordinates with indicated size, and return them as a list of `~astropy.io.fits.HDUList` objects. @@ -408,6 +423,11 @@ def get_cutouts(self, *, coordinates=None, size=5, product='SPOC', sector=None, first majorbody is tried and then smallbody if a matching majorbody is not found. NOTE: If moving_target is supplied, this argument is ignored. + resolver : str, optional + The resolver to use when resolving a named target into coordinates. Valid options are "SIMBAD" and "NED". + If not specified, the default resolver order will be used. Please see the + `STScI Archive Name Translation Application (SANTA) `__ + for more information. Default is None. Returns ------- @@ -457,7 +477,9 @@ def get_cutouts(self, *, coordinates=None, size=5, product='SPOC', sector=None, param_dict['product'] = product.upper() # Get Skycoord object for coordinates/object - coordinates = parse_input_location(coordinates, objectname) + coordinates = parse_input_location(coordinates=coordinates, + objectname=objectname, + resolver=resolver) param_dict["ra"] = coordinates.ra.deg param_dict["dec"] = coordinates.dec.deg @@ -531,7 +553,7 @@ def get_surveys(self, coordinates, *, radius="0d"): """ # Get Skycoord object for coordinates/object - coordinates = parse_input_location(coordinates) + coordinates = parse_input_location(coordinates=coordinates) radius = Angle(radius, u.deg) params = {"ra": coordinates.ra.deg, @@ -596,7 +618,7 @@ def download_cutouts(self, coordinates, *, size=5, survey=None, cutout_format="f Cutout file(s) for given coordinates """ # Get Skycoord object for coordinates/object - coordinates = parse_input_location(coordinates) + coordinates = parse_input_location(coordinates=coordinates) size_dict = _parse_cutout_size(size) path = os.path.join(path, '') @@ -675,7 +697,7 @@ def get_cutouts(self, coordinates, *, size=5, survey=None): """ # Get Skycoord object for coordinates/object - coordinates = parse_input_location(coordinates) + coordinates = parse_input_location(coordinates=coordinates) param_dict = _parse_cutout_size(size) param_dict["ra"] = coordinates.ra.deg @@ -760,7 +782,7 @@ def download_cutouts(self, coordinates, *, size=5, path=".", inflate=True, verbo """ # Get Skycoord object for coordinates/object - coordinates = parse_input_location(coordinates) + coordinates = parse_input_location(coordinates=coordinates) # Build initial astrocut request astrocut_request = f"astrocut?ra={coordinates.ra.deg}&dec={coordinates.dec.deg}" @@ -829,7 +851,7 @@ def get_cutouts(self, coordinates, *, size=5): """ # Get Skycoord object for coordinates/object - coordinates = parse_input_location(coordinates) + coordinates = parse_input_location(coordinates=coordinates) param_dict = _parse_cutout_size(size) diff --git a/astroquery/mast/missions.py b/astroquery/mast/missions.py index 02736d218f..91b7f69612 100644 --- a/astroquery/mast/missions.py +++ b/astroquery/mast/missions.py @@ -222,7 +222,7 @@ def query_region_async(self, coordinates, *, radius=3*u.arcmin, limit=5000, offs @class_or_instance def query_criteria_async(self, *, coordinates=None, objectname=None, radius=3*u.arcmin, - limit=5000, offset=0, select_cols=None, **criteria): + limit=5000, offset=0, select_cols=None, resolver=None, **criteria): """ Given a set of search criteria, returns a list of mission metadata. @@ -246,6 +246,11 @@ def query_criteria_async(self, *, coordinates=None, objectname=None, radius=3*u. the number of records you wish to skip before selecting records. select_cols: list, None Default None. Names of columns that will be included in the astropy table + resolver : str, optional + The resolver to use when resolving a named target into coordinates. Valid options are "SIMBAD" and "NED". + If not specified, the default resolver order will be used. Please see the + `STScI Archive Name Translation Application (SANTA) `__ + for more information. Default is None. **criteria Criteria to apply. At least one non-positional criterion must be supplied. Valid criteria are coordinates, objectname, radius (as in @@ -268,7 +273,9 @@ def query_criteria_async(self, *, coordinates=None, objectname=None, radius=3*u. # Parse user input location if objectname or coordinates: - coordinates = utils.parse_input_location(coordinates, objectname) + coordinates = utils.parse_input_location(coordinates=coordinates, + objectname=objectname, + resolver=resolver) # if radius is just a number we assume degrees radius = coord.Angle(radius, u.arcmin) @@ -300,7 +307,7 @@ def query_criteria_async(self, *, coordinates=None, objectname=None, radius=3*u. @class_or_instance def query_object_async(self, objectname, *, radius=3*u.arcmin, limit=5000, offset=0, - select_cols=None, **criteria): + select_cols=None, resolver=None, **criteria): """ Given an object name, returns a list of matching rows. @@ -321,6 +328,11 @@ def query_object_async(self, objectname, *, radius=3*u.arcmin, limit=5000, offse the number of records you wish to skip before selecting records. select_cols: list, None Default None. Names of columns that will be included in the astropy table + resolver : str, optional + The resolver to use when resolving a named target into coordinates. Valid options are "SIMBAD" and "NED". + If not specified, the default resolver order will be used. Please see the + `STScI Archive Name Translation Application (SANTA) `__ + for more information. Default is None. **criteria Other mission-specific criteria arguments. All valid filters can be found using `~astroquery.mast.missions.MastMissionsClass.get_column_list` @@ -332,7 +344,7 @@ def query_object_async(self, objectname, *, radius=3*u.arcmin, limit=5000, offse response : list of `~requests.Response` """ - coordinates = utils.resolve_object(objectname) + coordinates = utils.resolve_object(objectname, resolver=resolver) return self.query_region_async(coordinates, radius=radius, limit=limit, offset=offset, select_cols=select_cols, **criteria) diff --git a/astroquery/mast/observations.py b/astroquery/mast/observations.py index 90647f5495..4ed2b46735 100644 --- a/astroquery/mast/observations.py +++ b/astroquery/mast/observations.py @@ -119,13 +119,18 @@ def get_metadata(self, query_type): return self._portal_api_connection._get_columnsconfig_metadata(colconf_name) - def _parse_caom_criteria(self, **criteria): + def _parse_caom_criteria(self, *, resolver=None, **criteria): """ Helper function that takes dictionary of criteria and parses them into position (none if there are no coordinates/object name) and a filter set. Parameters ---------- + resolver : str, optional + The resolver to use when resolving a named target into coordinates. Valid options are "SIMBAD" and "NED". + If not specified, the default resolver order will be used. Please see the + `STScI Archive Name Translation Application (SANTA) `__ + for more information. Default is None. **criteria Criteria to apply. Valid criteria are coordinates, objectname, radius (as in `query_region` and `query_object`), @@ -154,7 +159,9 @@ def _parse_caom_criteria(self, **criteria): mashup_filters = self._portal_api_connection.build_filter_set(self._caom_cone, self._caom_filtered_position, **criteria) - coordinates = utils.parse_input_location(coordinates, objectname) + coordinates = utils.parse_input_location(coordinates=coordinates, + objectname=objectname, + resolver=resolver) else: mashup_filters = self._portal_api_connection.build_filter_set(self._caom_cone, self._caom_filtered, @@ -240,7 +247,7 @@ def query_region_async(self, coordinates, *, radius=0.2*u.deg, pagesize=None, pa return self._portal_api_connection.service_request_async(service, params, pagesize=pagesize, page=page) @class_or_instance - def query_object_async(self, objectname, *, radius=0.2*u.deg, pagesize=None, page=None): + def query_object_async(self, objectname, *, radius=0.2*u.deg, pagesize=None, page=None, resolver=None): """ Given an object name, returns a list of MAST observations. See column documentation `here `__. @@ -262,18 +269,23 @@ def query_object_async(self, objectname, *, radius=0.2*u.deg, pagesize=None, pag Defaulte None. Can be used to override the default behavior of all results being returned to obtain a specific page of results. + resolver : str, optional + The resolver to use when resolving a named target into coordinates. Valid options are "SIMBAD" and "NED". + If not specified, the default resolver order will be used. Please see the + `STScI Archive Name Translation Application (SANTA) `__ + for more information. Default is None. Returns ------- response : list of `~requests.Response` """ - coordinates = utils.resolve_object(objectname) + coordinates = utils.resolve_object(objectname, resolver=resolver) return self.query_region_async(coordinates, radius=radius, pagesize=pagesize, page=page) @class_or_instance - def query_criteria_async(self, *, pagesize=None, page=None, **criteria): + def query_criteria_async(self, *, pagesize=None, page=None, resolver=None, **criteria): """ Given an set of criteria, returns a list of MAST observations. Valid criteria are returned by ``get_metadata("observations")`` @@ -286,6 +298,11 @@ def query_criteria_async(self, *, pagesize=None, page=None, **criteria): page : int, optional Can be used to override the default behavior of all results being returned to obtain one specific page of results. + resolver : str, optional + The resolver to use when resolving a named target into coordinates. Valid options are "SIMBAD" and "NED". + If not specified, the default resolver order will be used. Please see the + `STScI Archive Name Translation Application (SANTA) `__ + for more information. Default is None. **criteria Criteria to apply. At least one non-positional criteria must be supplied. Valid criteria are coordinates, objectname, radius (as in `query_region` and `query_object`), @@ -303,7 +320,7 @@ def query_criteria_async(self, *, pagesize=None, page=None, **criteria): response : list of `~requests.Response` """ - position, mashup_filters = self._parse_caom_criteria(**criteria) + position, mashup_filters = self._parse_caom_criteria(resolver=resolver, **criteria) if not mashup_filters: raise InvalidQueryError("At least one non-positional criterion must be supplied.") @@ -361,7 +378,7 @@ def query_region_count(self, coordinates, *, radius=0.2*u.deg, pagesize=None, pa return int(self._portal_api_connection.service_request(service, params, pagesize, page)[0][0]) - def query_object_count(self, objectname, *, radius=0.2*u.deg, pagesize=None, page=None): + def query_object_count(self, objectname, *, radius=0.2*u.deg, pagesize=None, page=None, resolver=None): """ Given an object name, returns the number of MAST observations. @@ -379,17 +396,22 @@ def query_object_count(self, objectname, *, radius=0.2*u.deg, pagesize=None, pag page : int, optional Can be used to override the default behavior of all results being returned to obtain one specific page of results. + resolver : str, optional + The resolver to use when resolving a named target into coordinates. Valid options are "SIMBAD" and "NED". + If not specified, the default resolver order will be used. Please see the + `STScI Archive Name Translation Application (SANTA) `__ + for more information. Default is None. Returns ------- response : int """ - coordinates = utils.resolve_object(objectname) + coordinates = utils.resolve_object(objectname, resolver=resolver) return self.query_region_count(coordinates, radius=radius, pagesize=pagesize, page=page) - def query_criteria_count(self, *, pagesize=None, page=None, **criteria): + def query_criteria_count(self, *, pagesize=None, page=None, resolver=None, **criteria): """ Given an set of filters, returns the number of MAST observations meeting those criteria. @@ -401,6 +423,11 @@ def query_criteria_count(self, *, pagesize=None, page=None, **criteria): page : int, optional Can be used to override the default behavior of all results being returned to obtain one specific page of results. + resolver : str, optional + The resolver to use when resolving a named target into coordinates. Valid options are "SIMBAD" and "NED". + If not specified, the default resolver order will be used. Please see the + `STScI Archive Name Translation Application (SANTA) `__ + for more information. Default is None. **criteria Criteria to apply. At least one non-positional criterion must be supplied. Valid criteria are coordinates, objectname, radius (as in `query_region` and `query_object`), @@ -418,7 +445,7 @@ def query_criteria_count(self, *, pagesize=None, page=None, **criteria): response : int """ - position, mashup_filters = self._parse_caom_criteria(**criteria) + position, mashup_filters = self._parse_caom_criteria(resolver=resolver, **criteria) # send query if position: diff --git a/astroquery/mast/tests/data/README.rst b/astroquery/mast/tests/data/README.rst index a99f0123e2..6138c3dac3 100644 --- a/astroquery/mast/tests/data/README.rst +++ b/astroquery/mast/tests/data/README.rst @@ -48,3 +48,16 @@ To generate `~astroquery.mast.tests.data.mast_relative_path.json`, use the follo ... {'uri': ['mast:HST/product/u9o40504m_c3m.fits', 'mast:HST/product/does_not_exist.fits']}) >>> with open('mast_relative_path.json', 'w') as file: ... json.dump(resp.json(), file, indent=4) # doctest: +SKIP + + +To generate `~astroquery.mast.tests.data.resolver.json`, use the following: + +.. doctest-remote-data:: + + >>> import json + >>> from astroquery.mast import utils + ... + >>> resp = utils._simple_request('http://mastresolver.stsci.edu/Santa-war/query', + ... {'name': 'TIC 307210830', 'outputFormat': 'json', 'resolveAll': 'true'}) + >>> with open('resolver.json', 'w') as file: + ... json.dump(resp.json(), file, indent=4) # doctest: +SKIP diff --git a/astroquery/mast/tests/data/resolver.json b/astroquery/mast/tests/data/resolver.json index 0133b2eac1..7051c9fc78 100644 --- a/astroquery/mast/tests/data/resolver.json +++ b/astroquery/mast/tests/data/resolver.json @@ -1 +1,39 @@ -{"resolvedCoordinate":[{"searchString":"m103","resolver":"NED","cached":true,"resolverTime":114,"cacheDate":"Apr 19, 2017 2:32:38 PM","searchRadius":-1.0,"canonicalName":"MESSIER 103","ra":23.34086,"decl":60.658,"objectType":"*Cl"}],"status":""} +{ + "resolvedCoordinate": [ + { + "searchString": "tic 307210830", + "resolver": "TIC", + "cached": false, + "resolverTime": 3, + "searchRadius": 0.000333, + "canonicalName": "TIC 307210830", + "ra": 124.531756290083, + "decl": -68.3129998725044 + }, + { + "searchString": "tic 307210830", + "resolver": "SIMBADCFA", + "cached": true, + "resolverTime": 289, + "cacheDate": "Mar 19, 2025, 4:36:27 PM", + "searchRadius": -1.0, + "canonicalName": "L 98-59", + "ra": 124.5317560026638, + "decl": -68.3130014904408, + "objectType": "HighPM*" + }, + { + "searchString": "tic 307210830", + "resolver": "SIMBAD", + "cached": true, + "resolverTime": 299, + "cacheDate": "Apr 17, 2025, 3:47:59 PM", + "searchRadius": -1.0, + "canonicalName": "L 98-59", + "ra": 124.5317560026638, + "decl": -68.3130014904408, + "objectType": "HighPM*" + } + ], + "status": "" +} \ No newline at end of file diff --git a/astroquery/mast/tests/test_mast.py b/astroquery/mast/tests/test_mast.py index 2cc86acfde..8ebdf5af16 100644 --- a/astroquery/mast/tests/test_mast.py +++ b/astroquery/mast/tests/test_mast.py @@ -18,7 +18,7 @@ from astroquery.mast.services import _json_to_table from astroquery.utils.mocks import MockResponse from astroquery.exceptions import (InvalidQueryError, InputWarning, MaxResultsWarning, NoResultsWarning, - RemoteServiceError) + RemoteServiceError, ResolverError) from astroquery import mast @@ -141,7 +141,7 @@ def service_mockreturn(self, method="POST", url=None, data=None, params=None, ti def request_mockreturn(url, params={}): if 'column_list' in url: filename = data_path(DATA_FILES['mission_columns']) - elif 'Mast.Name.Lookup' in params: + elif 'mastresolver' in url: filename = data_path(DATA_FILES["Mast.Name.Lookup"]) elif 'panstarrs' in url: filename = data_path(DATA_FILES['panstarrs_columns']) @@ -492,8 +492,33 @@ def test_mast_query(patch_post): def test_resolve_object(patch_post): - m103_loc = mast.Mast.resolve_object("M103") - assert round(m103_loc.separation(SkyCoord("23.34086 60.658", unit='deg')).value, 10) == 0 + obj = "TIC 307210830" + tic_coord = SkyCoord(124.531756290083, -68.3129998725044, unit="deg") + simbad_coord = SkyCoord(124.5317560026638, -68.3130014904408, unit="deg") + obj_loc = mast.Mast.resolve_object(obj) + assert round(obj_loc.separation(tic_coord).value, 10) == 0 + + # resolve using a specific resolver and an object that belongs to a MAST catalog + obj_loc_simbad = mast.Mast.resolve_object(obj, resolver="SIMBAD") + assert round(obj_loc_simbad.separation(simbad_coord).value, 10) == 0 + + # resolve using a specific resolver and an object that does not belong to a MAST catalog + obj_loc_simbad = mast.Mast.resolve_object("M101", resolver="SIMBAD") + assert round(obj_loc_simbad.separation(simbad_coord).value, 10) == 0 + + # resolve using all resolvers + obj_loc_dict = mast.Mast.resolve_object(obj, resolve_all=True) + assert isinstance(obj_loc_dict, dict) + assert round(obj_loc_dict["SIMBAD"].separation(simbad_coord).value, 10) == 0 + + # error with invalid resolver + with pytest.raises(ResolverError, match="Invalid resolver"): + mast.Mast.resolve_object(obj, resolver="invalid") + + # warn if specifying both resolver and resolve_all + with pytest.warns(InputWarning, match="The resolver parameter is ignored when resolve_all is True"): + loc = mast.Mast.resolve_object(obj, resolver="NED", resolve_all=True) + assert isinstance(loc, dict) def test_login_logout(patch_post): @@ -1162,3 +1187,37 @@ def test_zcut_get_cutouts(patch_post, tmpdir): assert isinstance(cutout_list, list) assert len(cutout_list) == 1 assert isinstance(cutout_list[0], fits.HDUList) + + +################ +# Utils tests # +################ + + +def test_parse_input_location(patch_post): + # Test with coordinates + coord = SkyCoord(23.34086, 60.658, unit="deg") + loc = mast.utils.parse_input_location(coordinates=coord) + assert isinstance(loc, SkyCoord) + assert loc.ra == coord.ra + assert loc.dec == coord.dec + + # Test with object name + obj_coord = SkyCoord(124.531756290083, -68.3129998725044, unit="deg") + loc = mast.utils.parse_input_location(objectname="TIC 307210830") + assert isinstance(loc, SkyCoord) + assert loc.ra == obj_coord.ra + assert loc.dec == obj_coord.dec + + # Error if both coordinates and object name are provided + with pytest.raises(InvalidQueryError, match="Only one of objectname and coordinates may be specified"): + mast.utils.parse_input_location(coordinates=coord, objectname="M101") + + # Error if neither coordinates nor object name is provided + with pytest.raises(InvalidQueryError, match="One of objectname and coordinates must be specified"): + mast.utils.parse_input_location() + + # Warn if resolver is specified without an object name + with pytest.warns(InputWarning, match="Resolver is only used when resolving object names"): + loc = mast.utils.parse_input_location(coordinates=coord, resolver="SIMBAD") + assert isinstance(loc, SkyCoord) diff --git a/astroquery/mast/tests/test_mast_remote.py b/astroquery/mast/tests/test_mast_remote.py index cfd452441b..f6a56a8aa7 100644 --- a/astroquery/mast/tests/test_mast_remote.py +++ b/astroquery/mast/tests/test_mast_remote.py @@ -32,7 +32,7 @@ def msa_product_table(): products = Observations.get_product_list(obs['obsid'][0]) # Filter out everything but the MSA config file - mask = np.char.find(products["dataURI"], "_msa.fits") != -1 + mask = np.char.find(np.char.asarray(products["dataURI"]), "_msa.fits") != -1 products = products[mask] return products @@ -52,6 +52,32 @@ def test_resolve_object(self): ticobj_loc = utils.resolve_object("TIC 141914082") assert round(ticobj_loc.separation(SkyCoord("94.6175354 -72.04484622", unit='deg')).value, 4) == 0 + # Try the same object with different resolvers + # The position of objects can change with different resolvers + ned_loc = utils.resolve_object("jw100", resolver="NED") + assert round(ned_loc.separation(SkyCoord("354.10436 21.15083", unit='deg')).value, 4) == 0 + + simbad_loc = utils.resolve_object("jw100", resolver="simbad") + assert round(simbad_loc.separation(SkyCoord("83.70341477 -5.55918309", unit="deg")).value, 4) == 0 + + # Try an object from a MAST catalog with a resolver + catalog_loc = utils.resolve_object("TIC 307210830", resolver="SIMBAD") + assert round(catalog_loc.separation(SkyCoord("124.5317560 -68.31300149", unit="deg")).value, 4) == 0 + + # Use resolve_all to get all resolvers + loc_dict = utils.resolve_object("jw100", resolve_all=True) + assert isinstance(loc_dict, dict) + assert loc_dict['NED'] == ned_loc + assert loc_dict['SIMBAD'] == simbad_loc + + # Error if coordinates cannot be resolved + with pytest.raises(ResolverError, match="Could not resolve invalid to a sky position."): + utils.resolve_object("invalid") + + # Error if coordinates cannot be resolved with a specific resolver + with pytest.raises(ResolverError, match="Could not resolve invalid to a sky position using NED"): + utils.resolve_object("invalid", resolver="NED") + ########################### # MissionSearchClass Test # ########################### @@ -596,7 +622,7 @@ def test_observations_get_product_list_tess_tica(self, caplog): # Should only return products corresponding to target 429031146 assert len(prods) > 0 - assert (np.char.find(prods['obs_id'], '429031146') != -1).all() + assert (np.char.find(np.char.asarray(prods['obs_id']), '429031146') != -1).all() def test_observations_get_unique_product_list(self, caplog): # Check that no rows are filtered out when all products are unique diff --git a/astroquery/mast/utils.py b/astroquery/mast/utils.py index 8693c7be60..f160635294 100644 --- a/astroquery/mast/utils.py +++ b/astroquery/mast/utils.py @@ -10,20 +10,17 @@ import numpy as np import requests -import json import platform -from urllib import parse -import astropy.coordinates as coord +from astropy.coordinates import SkyCoord from astropy.table import unique, Table +from astropy import units as u from .. import log from ..version import version -from ..exceptions import NoResultsWarning, ResolverError, InvalidQueryError +from ..exceptions import InputWarning, NoResultsWarning, ResolverError, InvalidQueryError from ..utils import commons -from . import conf - __all__ = [] @@ -90,7 +87,7 @@ def _simple_request(url, params=None): return response -def resolve_object(objectname): +def resolve_object(objectname, *, resolver=None, resolve_all=False): """ Resolves an object name to a position on the sky. @@ -98,31 +95,105 @@ def resolve_object(objectname): ---------- objectname : str Name of astronomical object to resolve. + resolver : str, optional + The resolver to use when resolving a named target into coordinates. Valid options are "SIMBAD" and "NED". + If not specified, the default resolver order will be used. Please see the + `STScI Archive Name Translation Application (SANTA) `__ + for more information. If ``resolve_all`` is True, this parameter will be ignored. Default is None. + resolve_all : bool, optional + If True, will try to resolve the object name using all available resolvers ("NED", "SIMBAD"). + Function will return a dictionary where the keys are the resolver names and the values are the + resolved coordinates. Default is False. Returns ------- - response : `~astropy.coordinates.SkyCoord` - The sky position of the given object. + response : `~astropy.coordinates.SkyCoord`, dict + If ``resolve_all`` is False, returns a `~astropy.coordinates.SkyCoord` object with the resolved coordinates. + If ``resolve_all`` is True, returns a dictionary where the keys are the resolver names and the values are + `~astropy.coordinates.SkyCoord` objects with the resolved coordinates. """ - - request_args = {"service": "Mast.Name.Lookup", - "params": {'input': objectname, 'format': 'json'}} - request_string = 'request={}'.format(parse.quote(json.dumps(request_args))) - - response = _simple_request("{}/api/v0/invoke".format(conf.server), request_string) - result = response.json() - - if len(result['resolvedCoordinate']) == 0: - raise ResolverError("Could not resolve {} to a sky position.".format(objectname)) - - ra = result['resolvedCoordinate'][0]['ra'] - dec = result['resolvedCoordinate'][0]['decl'] - coordinates = coord.SkyCoord(ra, dec, unit="deg") - - return coordinates - - -def parse_input_location(coordinates=None, objectname=None): + is_catalog = False # Flag to check if object name belongs to a MAST catalog + catalog = None # Variable to store the catalog name + objectname = objectname.strip() + catalog_prefixes = { + 'TIC ': 'TIC', + 'KIC ': 'KEPLER', + 'EPIC ': 'K2' + } + + if resolver: + # Check that resolver is valid + resolver = resolver.upper() + if resolver not in ('NED', 'SIMBAD'): + raise ResolverError('Invalid resolver. Must be "NED" or "SIMBAD".') + + if resolve_all: + # Warn if user is trying to use a resolver with resolve_all + warnings.warn('The resolver parameter is ignored when resolve_all is True. ' + 'Coordinates will be resolved using all available resolvers.', InputWarning) + + # Check if object belongs to a MAST catalog + for prefix, name in catalog_prefixes.items(): + if objectname.startswith(prefix): + is_catalog = True + catalog = name + break + + # Whether to set resolveAll to True when making the HTTP request + # Should be True when resolve_all = True or when object name belongs to a MAST catalog (TIC, KIC, EPIC, K2) + use_resolve_all = resolve_all or is_catalog + + # Send request to STScI Archive Name Translation Application (SANTA) + params = {'name': objectname, + 'outputFormat': 'json', + 'resolveAll': use_resolve_all} + if resolver and not use_resolve_all: + params['resolver'] = resolver + response = _simple_request('http://mastresolver.stsci.edu/Santa-war/query', params) + response.raise_for_status() # Raise any errors + result = response.json().get('resolvedCoordinate', []) + + # If a resolver is specified and resolve_all is False, find and return the result for that resolver + if resolver and not resolve_all: + resolver_result = next((res for res in result if res.get('resolver') == resolver), None) + if not resolver_result: + raise ResolverError(f'Could not resolve {objectname} to a sky position using {resolver}. ' + 'Please try another resolver or set ``resolver=None`` to use the first ' + 'compatible resolver.') + resolver_coord = SkyCoord(resolver_result['ra'], resolver_result['decl'], unit='deg') + + # If object belongs to a MAST catalog, check the separation between the coordinates from the + # resolver and the catalog + if is_catalog: + catalog_result = next((res for res in result if res.get('resolver') == catalog), None) + if catalog_result: + catalog_coord = SkyCoord(catalog_result['ra'], catalog_result['decl'], unit='deg') + if resolver_coord.separation(catalog_coord) > 1 * u.arcsec: + # Warn user if the coordinates differ by more than 1 arcsec + warnings.warn(f'Resolver {resolver} returned coordinates that differ from MAST {catalog} catalog ' + 'by more than 1 arcsec. ', InputWarning) + + log.debug(f'Coordinates resolved using {resolver}: {resolver_coord}') + return resolver_coord + + if not result: + raise ResolverError('Could not resolve {} to a sky position.'.format(objectname)) + + # Return results for all compatible resolvers + if resolve_all: + return { + res['resolver']: SkyCoord(res['ra'], res['decl'], unit='deg') + for res in result + } + + # Case when resolve_all is False and no resolver is specified + # SANTA returns result from first compatible resolver + coord = SkyCoord(result[0]['ra'], result[0]['decl'], unit='deg') + log.debug(f'Coordinates resolved using {result[0]["resolver"]}: {coord}') + return coord + + +def parse_input_location(*, coordinates=None, objectname=None, resolver=None): """ Convenience function to parse user input of coordinates and objectname. @@ -136,6 +207,11 @@ def parse_input_location(coordinates=None, objectname=None): The target around which to search, by name (objectname="M104") or TIC ID (objectname="TIC 141914082"). One and only one of coordinates and objectname must be supplied. + resolver : str, optional + The resolver to use when resolving a named target into coordinates. Valid options are "SIMBAD" and "NED". + If not specified, the default resolver order will be used. Please see the + `STScI Archive Name Translation Application (SANTA) `__ + for more information. Default is None. Returns ------- @@ -150,8 +226,11 @@ def parse_input_location(coordinates=None, objectname=None): if not (objectname or coordinates): raise InvalidQueryError("One of objectname and coordinates must be specified.") + if not objectname and resolver: + warnings.warn("Resolver is only used when resolving object names and will be ignored.", InputWarning) + if objectname: - obj_coord = resolve_object(objectname) + obj_coord = resolve_object(objectname, resolver=resolver) if coordinates: obj_coord = commons.parse_coordinates(coordinates) diff --git a/docs/mast/mast.rst b/docs/mast/mast.rst index 6844a7f53f..9451e64a34 100644 --- a/docs/mast/mast.rst +++ b/docs/mast/mast.rst @@ -80,6 +80,39 @@ This token can be overwritten using the ``reenter_token`` argument. To logout before a session expires, the `~astroquery.mast.MastClass.logout` method may be used. +Resolving Object Names +======================= + +Each of the MAST query classes has a `~astroquery.mast.MastClass.resolve_object` method that translates named targets into +coordinates. This method uses the `STScI Archive Name Translation Application (SANTA) `_ +service. + +The `~astroquery.mast.MastClass.resolve_object` method accepts an object name to resolve into coordinates. If the ``resolver`` +parameter is specified, then only that resolver will be queried. If the ``resolver`` parameter is not specified, then all available resolvers +will be queried in a default order, and the first one to return a result will be used. +Options for the ``resolver`` parameter are "SIMBAD" and "NED". + +.. doctest-remote-data:: + + >>> from astroquery.mast import Mast + >>> mast = Mast() + >>> coords = mast.resolve_object("M101", resolver="NED") + >>> print(coords) + + +If the ``resolve_all`` parameter is set to ``True``, all resolvers will be queried, and those that give a result will be returned. +The ``resolver`` parameter is ignored in this case. The results are returned as a dictionary, with the resolver name as the key and +the coordinates as the value. + +.. doctest-remote-data:: + + >>> coords = mast.resolve_object("TIC 441662144", resolve_all=True) + >>> print(coords) + {'TIC': } + + Additional Resources ==================== diff --git a/docs/mast/mast_catalog.rst b/docs/mast/mast_catalog.rst index 8bc1110900..6acb3d24a8 100644 --- a/docs/mast/mast_catalog.rst +++ b/docs/mast/mast_catalog.rst @@ -29,7 +29,7 @@ If no catalog is specified, the Hubble Source Catalog will be queried. >>> from astroquery.mast import Catalogs ... - >>> catalog_data = Catalogs.query_object("158.47924 -7.30962", catalog="Galex") + >>> catalog_data = Catalogs.query_region("158.47924 -7.30962", catalog="Galex") >>> print(catalog_data[:10]) # doctest: +IGNORE_OUTPUT distance_arcmin objID survey ... fuv_flux_aper_7 fuv_artifact ------------------ ------------------- ------ ... --------------- ------------