diff --git a/CHANGES.rst b/CHANGES.rst index 6ebc35a201..b73e166f6b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -64,6 +64,10 @@ esa.euclid product_type parameters dynamically. [#3601] - In the method, ``get_scientific_product_list``, the ``dsr_part3`` parameter now supports the additional value ``latest``. [#3601] +- The ``coordinate`` keyword of ``query_object``, ``cone_search``, and ``get_cutout`` is deprecated in + favor of ``coordinates`` for consistency with the rest of astroquery. [#3609] +- ``query_object`` and ``cone_search`` now also accept multiple coordinates (a list, or a non-scalar + ``SkyCoord``); one query is run per position and the results are combined. [#3609] vizier @@ -87,6 +91,85 @@ vo_conesearch PyVO Simple Cone Search interface instead. There is no direct replacement for server validation. [#3548] +alma +^^^^ + +- The ``coordinate`` keyword of ``query_region`` and ``query_region_async`` is deprecated in favor of + ``coordinates`` for consistency with the rest of astroquery. [#3609] +- ``query_region`` now also accepts multiple coordinates (a list, or a non-scalar ``SkyCoord``); + one query is run per position and the results are stacked into a single table with a + ``query_index`` column. [#3609] + +esa.hsa +^^^^^^^ + +- The ``coordinate`` keyword of ``query_observations`` and ``query_region`` is deprecated in favor of + ``coordinates`` for consistency with the rest of astroquery. [#3609] +- ``query_observations`` and ``query_region`` now also accept multiple coordinates (a list, or a + non-scalar ``SkyCoord``); one query is run per position and the results are combined. [#3609] + +esa.jwst +^^^^^^^^ + +- The ``coordinate`` keyword of ``query_region`` and ``cone_search`` is deprecated in favor of + ``coordinates`` for consistency with the rest of astroquery. [#3609] +- ``query_region`` and ``cone_search`` now also accept multiple coordinates (a list, or a non-scalar + ``SkyCoord``); one query is run per position and the results are combined. [#3609] + +esasky +^^^^^^ + +- The ``position`` keyword of ``query_region_maps``, ``query_region_catalogs``, and + ``query_region_spectra`` is deprecated in favor of ``coordinates`` for consistency with the rest of + astroquery. [#3609] +- ``query_region_maps``, ``query_region_catalogs``, and ``query_region_spectra`` now also accept + multiple coordinates (a list, or a non-scalar ``SkyCoord``); one query is run per position and the + per-mission tables are stacked with a ``query_index`` column. [#3609] + +gaia +^^^^ + +- The ``coordinate`` keyword of ``query_object``, ``query_object_async``, ``cone_search``, and + ``cone_search_async`` is deprecated in favor of ``coordinates`` for consistency with the rest of + astroquery. [#3609] +- ``query_object``, ``query_object_async``, ``cone_search``, and ``cone_search_async`` now also + accept multiple coordinates (a list, or a non-scalar ``SkyCoord``); one query is run per position + and the results are combined (stacked tables, or a list of jobs for the cone-search methods). + [#3609] + +heasarc +^^^^^^^ + +- The ``position`` keyword of ``query_region`` is deprecated in favor of ``coordinates`` for + consistency with the rest of astroquery. [#3609] +- ``query_region`` now also accepts multiple coordinates (a list, or a non-scalar ``SkyCoord``); + one query is run per position and the results are stacked into a single table with a + ``query_index`` column. [#3609] + +ipac.irsa.ibe +^^^^^^^^^^^^^ + +- The ``coordinate`` keyword of ``query_region``, ``query_region_sia``, and ``query_region_async`` is + deprecated in favor of ``coordinates`` for consistency with the rest of astroquery. [#3609] +- ``query_region`` and ``query_region_sia`` now also accept multiple coordinates (a list, or a + non-scalar ``SkyCoord``); one query is run per position and the results are stacked into a single + table with a ``query_index`` column. [#3609] + +noirlab +^^^^^^^ + +- The ``coordinate`` keyword of ``query_region`` is deprecated in favor of ``coordinates`` for + consistency with the rest of astroquery. [#3609] +- ``query_region`` now also accepts multiple coordinates (a list, or a non-scalar ``SkyCoord``); + one query is run per position and the results are stacked into a single table with a + ``query_index`` column. [#3609] + +ogle +^^^^ + +- The ``coord`` keyword of ``query_region`` and ``query_region_async`` is deprecated in favor of + ``coordinates`` for consistency with the rest of astroquery. [#3609] + Service fixes and enhancements ------------------------------ @@ -233,6 +316,13 @@ Infrastructure, Utility and Other Changes and Additions - Workaround upstream bug when caching a response using pyvo. [#3586] +- New ``astroquery.utils.multicoord.support_multiple_coordinates`` decorator: single-position + query methods decorated with it accept a list of coordinates or a non-scalar ``SkyCoord`` and + loop over the positions with bounded, rate-limited parallelism. The defaults (3 parallel + requests, at least 0.3 s between submissions) are deliberately conservative — none of the + affected archives publish request-rate limits — and are configurable via + ``astroquery.utils.multicoord.conf``. [#3609] + utils.tap ^^^^^^^^^ diff --git a/astroquery/alma/core.py b/astroquery/alma/core.py index 0a3ee5d1b0..bff365082e 100644 --- a/astroquery/alma/core.py +++ b/astroquery/alma/core.py @@ -20,11 +20,13 @@ from astropy import units as u from astropy.time import Time from astropy.coordinates import SkyCoord +from astropy.utils.decorators import deprecated_renamed_argument from pyvo.dal.sia2 import SIA2_PARAMETERS_DESC, SIA2Service from ..exceptions import LoginError from ..utils import commons +from ..utils.multicoord import support_multiple_coordinates from ..utils.process_asyncs import async_to_sync from ..query import BaseQuery, QueryWithLogin, BaseVOQuery from .tapsql import (_gen_pos_sql, _gen_str_sql, _gen_numeric_sql, @@ -506,7 +508,8 @@ def query_object_async(self, object_name, *, public=True, payload=payload, enhanced_results=enhanced_results, **kwargs) - def query_region_async(self, coordinate, radius, *, public=True, + @deprecated_renamed_argument("coordinate", "coordinates", since="0.4.12") + def query_region_async(self, coordinates, radius, *, public=True, science=True, payload=None, enhanced_results=False, **kwargs): """ @@ -533,7 +536,7 @@ def query_region_async(self, coordinate, radius, *, public=True, rad = radius if not isinstance(radius, u.Quantity): rad = radius*u.deg - obj_coord = commons.parse_coordinates(coordinate).icrs + obj_coord = commons.parse_coordinates(coordinates).icrs ra_dec = '{}, {}'.format(obj_coord.to_string(), rad.to(u.deg).value) if payload is None: payload = {} @@ -546,6 +549,61 @@ def query_region_async(self, coordinate, radius, *, public=True, payload=payload, enhanced_results=enhanced_results, **kwargs) + @deprecated_renamed_argument("coordinate", "coordinates", since="0.4.12") + @support_multiple_coordinates() + def query_region(self, coordinates, radius, *, public=True, + science=True, payload=None, enhanced_results=False, + verbose=False, **kwargs): + """ + Query the ALMA archive with a source name and radius. + + Multiple positions can be queried at once by passing a list of + coordinates (or coordinate strings) or a vector + `~astropy.coordinates.SkyCoord`: one query is run per position and + the results are stacked into a single table with a ``query_index`` + column mapping each row back to the input position. + + Parameters + ---------- + coordinates : str / `astropy.coordinates` + the identifier or coordinates around which to query. A list of + coordinates or a vector `~astropy.coordinates.SkyCoord` triggers + one query per position. + radius : str / `~astropy.units.Quantity`, optional + the radius of the region + public : bool + True to return only public datasets, False to return private only, + None to return both + science : bool + True to return only science datasets, False to return only + calibration, None to return both + payload : dict + Dictionary of additional keywords. See `help`. + enhanced_results : bool + True to return a table with quantities instead of just values. It + also returns the footprints as `regions` objects. + verbose : bool + Whether to show warnings when parsing the result. + + Returns + ------- + table : A `~astropy.table.Table` object. + """ + if payload is not None: + # do not mutate the caller's dict; query_region_async appends the + # position to payload['ra_dec'] in place, which would otherwise + # accumulate positions across the calls of a multi-coordinate query + payload = dict(payload) + response = self.query_region_async(coordinates, radius, public=public, + science=science, payload=payload, + enhanced_results=enhanced_results, + **kwargs) + if kwargs.get('get_query_payload'): + return response + result = self._parse_result(response, verbose=verbose) + self.table = result + return result + def query_async(self, payload, *, public=True, science=True, legacy_columns=False, get_query_payload=False, maxrec=None, enhanced_results=False, **kwargs): diff --git a/astroquery/alma/tests/test_alma.py b/astroquery/alma/tests/test_alma.py index 6bbdc50911..c2759fa0cd 100644 --- a/astroquery/alma/tests/test_alma.py +++ b/astroquery/alma/tests/test_alma.py @@ -12,6 +12,7 @@ from astropy.io import votable from astropy.coordinates import SkyCoord from astropy.time import Time +from astropy.utils.exceptions import AstropyDeprecationWarning import pyvo from astroquery.alma import Alma @@ -364,6 +365,32 @@ def test_query(): language='ADQL', maxrec=None, uploads=None) +def test_query_region_coordinate_deprecated(): + # the old ``coordinate`` keyword should still work but emit a + # deprecation warning (renamed to ``coordinates``, issue #2803) + alma = Alma() + alma._get_dataarchive_url = Mock() + new_payload = alma.query_region(coordinates='1 2', radius=1*u.deg, + get_query_payload=True) + with pytest.warns(AstropyDeprecationWarning): + old_payload = alma.query_region(coordinate='1 2', radius=1*u.deg, + get_query_payload=True) + assert old_payload == new_payload + + +def test_query_region_multiple_coordinates(): + # a list of coordinates runs one query per position; with + # ``get_query_payload`` a list of per-position payloads is returned + alma = Alma() + alma._get_dataarchive_url = Mock() + payloads = alma.query_region(['1 2', '3 4'], radius=1*u.deg, + get_query_payload=True) + assert isinstance(payloads, list) + assert len(payloads) == 2 + assert "CIRCLE('ICRS',1.0,2.0,1.0)" in payloads[0] + assert "CIRCLE('ICRS',3.0,4.0,1.0)" in payloads[1] + + @pytest.mark.filterwarnings("ignore::astropy.utils.exceptions.AstropyUserWarning") def test_enhanced_table(): pytest.importorskip('regions') diff --git a/astroquery/alma/tests/test_alma_remote.py b/astroquery/alma/tests/test_alma_remote.py index 2a1535dc0e..4986a8f8ce 100644 --- a/astroquery/alma/tests/test_alma_remote.py +++ b/astroquery/alma/tests/test_alma_remote.py @@ -337,7 +337,7 @@ def test_query(self, tmp_path, alma): def test_misc(self, alma): # miscellaneous set of common tests # - # alma.query_region(coordinate=orionkl_coords, radius=4 * u.arcmin, + # alma.query_region(coordinates=orionkl_coords, radius=4 * u.arcmin, # public=False, science=False) result = alma.query_object('M83', public=True, science=True) diff --git a/astroquery/esa/euclid/core.py b/astroquery/esa/euclid/core.py index 917e99b020..84df3be1b4 100644 --- a/astroquery/esa/euclid/core.py +++ b/astroquery/esa/euclid/core.py @@ -29,6 +29,7 @@ from astroquery.utils import commons from astroquery.utils.tap import TapPlus from astroquery.utils.tap import taputils +from ...utils.multicoord import support_multiple_coordinates from . import conf @@ -384,15 +385,20 @@ def launch_job_async(self, query, *, name=None, dump_to_file=False, output_file= except Exception as exx: log.error(f'Query failed: {query}, {str(exx)}') - def query_object(self, coordinate, *, radius=None, width=None, height=None, + @deprecated_renamed_argument("coordinate", "coordinates", since="0.4.12") + @support_multiple_coordinates() + def query_object(self, coordinates, *, radius=None, width=None, height=None, async_job=False, verbose=False, columns=None): """ Searches for objects around a given position with the default catalog sascat_pvpr01.mer_final_cat_pvpr01 Parameters ---------- - coordinate : astropy.coordinate, mandatory - coordinates center point + coordinates : astropy.coordinate, mandatory + coordinates center point. A list of coordinates or a non-scalar + `~astropy.coordinates.SkyCoord` runs one query per position and + the resulting tables are stacked, with a ``query_index`` column + mapping each row back to the input position. radius : astropy.units, required if no 'width' nor 'height' are provided radius (deg) width : astropy.units, required if no 'radius' is provided @@ -411,7 +417,7 @@ def query_object(self, coordinate, *, radius=None, width=None, height=None, ------- The job results (astropy.table) """ - coord = commons.parse_coordinates(coordinate) + coord = commons.parse_coordinates(coordinates) if radius is not None: job = self.__cone_search(coord, radius, async_job=async_job, verbose=verbose, columns=columns) @@ -437,14 +443,14 @@ def query_object(self, coordinate, *, radius=None, width=None, height=None, job = super().launch_job(query, verbose=verbose, format_with_results_compressed=('votable_gzip',)) return job.get_results() - def __cone_search(self, coordinate, radius, *, table_name=None, ra_column_name=None, dec_column_name=None, + def __cone_search(self, coordinates, radius, *, table_name=None, ra_column_name=None, dec_column_name=None, async_job=False, verbose=False, columns=None): """Cone search sorted by distance TAP & TAP+ Parameters ---------- - coordinate : astropy.coordinate, mandatory + coordinates : astropy.coordinate, mandatory coordinates center point radius : astropy.units, mandatory radius @@ -478,7 +484,7 @@ def __cone_search(self, coordinate, radius, *, table_name=None, ra_column_name=N raise ValueError(f"Invalid ra or dec column names: ra, {ra_column_name}, dec, {dec_column_name}") radius_deg = None - coord = commons.parse_coordinates(coordinate) + coord = commons.parse_coordinates(coordinates) ra_hours, dec = commons.coord_to_radec(coord) ra = ra_hours * 15.0 # Converts to degrees if radius is not None: @@ -519,7 +525,9 @@ def __cone_search(self, coordinate, radius, *, table_name=None, ra_column_name=N return job - def cone_search(self, coordinate, radius, *, + @deprecated_renamed_argument("coordinate", "coordinates", since="0.4.12") + @support_multiple_coordinates() + def cone_search(self, coordinates, radius, *, table_name=None, ra_column_name=None, dec_column_name=None, @@ -535,8 +543,11 @@ def cone_search(self, coordinate, radius, *, Parameters ---------- - coordinate : astropy.coordinate, mandatory - coordinates center point + coordinates : astropy.coordinate, mandatory + coordinates center point. A list of coordinates or a non-scalar + `~astropy.coordinates.SkyCoord` runs one query per position; since + this method returns a job object, a list of jobs (one per position, + in input order) is returned when multiple positions are given. radius : astropy.units, mandatory radius table_name : str, optional, default the table defined for the selected environment @@ -570,7 +581,7 @@ def cone_search(self, coordinate, radius, *, """ radius_deg = None - coord = commons.parse_coordinates(coordinate) + coord = commons.parse_coordinates(coordinates) ra_hours, dec = commons.coord_to_radec(coord) ra = ra_hours * 15.0 # Converts to degrees if radius is not None: @@ -1453,9 +1464,9 @@ def __is_multiple(self, value): return not isinstance(value, int) and ((isinstance(value, (list, tuple)) and len(value) > 1) or ',' in value) - @deprecated_renamed_argument(('instrument', 'id'), (None, None), since='0.4.12') - def get_cutout(self, *, file_path=None, coordinate, radius, output_file=None, verbose=False, instrument=None, - id=None): + @deprecated_renamed_argument(('coordinate', 'instrument', 'id'), ('coordinates', None, None), since='0.4.12') + def get_cutout(self, *, file_path=None, coordinates=None, radius=None, output_file=None, verbose=False, + instrument=None, id=None): """ Downloads a cutout from a MER mosaic (background-subtracted image) for a given fits file path, centered on a coordinate and with a specified radius. @@ -1466,7 +1477,7 @@ def get_cutout(self, *, file_path=None, coordinate, radius, output_file=None, ve ---------- file_path : str, mandatory, default None file path for the product on the server (MER mosaic) - coordinate : astropy.coordinate or Simbad/VizieR/NED name (str), mandatory + coordinates : astropy.coordinate or Simbad/VizieR/NED name (str), mandatory coordinates center point radius : astropy.units, mandatory the radius of the cutout to generate @@ -1480,14 +1491,14 @@ def get_cutout(self, *, file_path=None, coordinate, radius, output_file=None, ve The fits file is downloaded, and the local path where the cutout is saved is returned """ - if file_path is None or coordinate is None or radius is None: + if file_path is None or coordinates is None or radius is None: raise ValueError(self.__ERROR_MSG_REQUESTED_GENERIC) # Parse POS radius_deg = Angle(self.__get_quantity_input(radius, "radius")).to_value(u.deg) if radius_deg > 0.5: raise ValueError(self.__ERROR_MSG_REQUESTED_RADIUS) - coord = commons.parse_coordinates(coordinate) + coord = commons.parse_coordinates(coordinates) ra_hours, dec = commons.coord_to_radec(coord) ra = ra_hours * 15.0 # Converts to degrees pos = """CIRCLE,{ra},{dec},{radius}""".format(**{'ra': ra, 'dec': dec, 'radius': radius_deg}) diff --git a/astroquery/esa/euclid/tests/test_euclid_remote.py b/astroquery/esa/euclid/tests/test_euclid_remote.py index 68826b37de..7aebbdf167 100644 --- a/astroquery/esa/euclid/tests/test_euclid_remote.py +++ b/astroquery/esa/euclid/tests/test_euclid_remote.py @@ -21,17 +21,17 @@ def test_query_object_row_limit(): coord = SkyCoord(ra=265.8, dec=64.1, unit=(u.degree, u.degree), frame='icrs') width = u.Quantity(0.1, u.deg) height = u.Quantity(0.1, u.deg) - r = euclid.query_object(coordinate=coord, width=width, height=height, async_job=True, verbose=True) + r = euclid.query_object(coordinates=coord, width=width, height=height, async_job=True, verbose=True) assert len(r) == 50 euclid.ROW_LIMIT = 10 - r = euclid.query_object(coordinate=coord, width=width, height=height, async_job=True) + r = euclid.query_object(coordinates=coord, width=width, height=height, async_job=True) assert len(r) == 10 == euclid.ROW_LIMIT euclid.ROW_LIMIT = -1 - r = euclid.query_object(coordinate=coord, width=width, height=height, async_job=True, verbose=True) + r = euclid.query_object(coordinates=coord, width=width, height=height, async_job=True, verbose=True) assert len(r) == 1948 diff --git a/astroquery/esa/euclid/tests/test_euclidtap.py b/astroquery/esa/euclid/tests/test_euclidtap.py index ad543ca057..e31a534b5e 100644 --- a/astroquery/esa/euclid/tests/test_euclidtap.py +++ b/astroquery/esa/euclid/tests/test_euclidtap.py @@ -22,11 +22,13 @@ from astropy.table import Column, Table from astropy.units import Quantity from astropy.utils.data import get_pkg_data_filename +from astropy.utils.exceptions import AstropyDeprecationWarning from requests import HTTPError from astroquery.esa.euclid.core import EuclidClass from astroquery.esa.euclid.core import conf from astroquery.utils.commons import ASTROPY_LT_7_1_1 +from astroquery.utils.multicoord import conf as multicoord_conf from astroquery.utils.tap.conn.tests.DummyConnHandler import DummyConnHandler from astroquery.utils.tap.conn.tests.DummyResponse import DummyResponse from astroquery.utils.tap.core import TapPlus, Tap @@ -198,7 +200,7 @@ def test_load_environments(): def test_query_async_object(column_attrs, mock_querier_async): coord = SkyCoord(ra=60.3372780005097, dec=-49.93184727724773, unit=(u.degree, u.degree), frame='icrs') - table = mock_querier_async.query_object(coordinate=coord, width=u.Quantity(0.1, u.deg), + table = mock_querier_async.query_object(coordinates=coord, width=u.Quantity(0.1, u.deg), height=u.Quantity(0.1, u.deg), async_job=True) assert table is not None @@ -210,7 +212,7 @@ def test_query_async_object(column_attrs, mock_querier_async): def test_query_async_object_columns(column_attrs, mock_querier_async): coord = SkyCoord(ra=60.3372780005097, dec=-49.93184727724773, unit=(u.degree, u.degree), frame='icrs') - table = mock_querier_async.query_object(coordinate=coord, width=u.Quantity(0.1, u.deg), + table = mock_querier_async.query_object(coordinates=coord, width=u.Quantity(0.1, u.deg), height=u.Quantity(0.1, u.deg), columns=("alpha",), async_job=True) assert table is not None @@ -222,7 +224,7 @@ def test_query_async_object_columns(column_attrs, mock_querier_async): def test_query_object(column_attrs, mock_querier): coord = SkyCoord(ra=60.3372780005097, dec=-49.93184727724773, unit=(u.degree, u.degree), frame='icrs') - table = mock_querier.query_object(coordinate=coord, width=u.Quantity(0.1, u.deg), + table = mock_querier.query_object(coordinates=coord, width=u.Quantity(0.1, u.deg), height=u.Quantity(0.1, u.deg)) assert table is not None @@ -234,7 +236,7 @@ def test_query_object(column_attrs, mock_querier): def test_query_object_columns(column_attrs, mock_querier): coord = SkyCoord(ra=60.3372780005097, dec=-49.93184727724773, unit=(u.degree, u.degree), frame='icrs') - table = mock_querier.query_object(coordinate=coord, width=u.Quantity(0.1, u.deg), + table = mock_querier.query_object(coordinates=coord, width=u.Quantity(0.1, u.deg), height=u.Quantity(0.1, u.deg), columns=("alpha",)) assert table is not None @@ -245,7 +247,7 @@ def test_query_object_columns(column_attrs, mock_querier): def test_query_object_async_radius(column_attrs, mock_querier_async): coord = SkyCoord(ra=60.3372780005097, dec=-49.93184727724773, unit=(u.degree, u.degree), frame='icrs') - table = mock_querier_async.query_object(coordinate=coord, radius=RADIUS, async_job=True) + table = mock_querier_async.query_object(coordinates=coord, radius=RADIUS, async_job=True) assert table is not None @@ -256,7 +258,7 @@ def test_query_object_async_radius(column_attrs, mock_querier_async): def test_query_object_radius(column_attrs, mock_querier): coord = SkyCoord(ra=60.3372780005097, dec=-49.93184727724773, unit=(u.degree, u.degree), frame='icrs') - table = mock_querier.query_object(coordinate=coord, radius=RADIUS) + table = mock_querier.query_object(coordinates=coord, radius=RADIUS) assert table is not None @@ -265,9 +267,34 @@ def test_query_object_radius(column_attrs, mock_querier): assert table[colname].attrs_equal(attrs) +def test_query_object_multiple_coordinates(column_attrs, mock_querier): + coords = SkyCoord(ra=[60.3372780005097, 61.0], dec=[-49.93184727724773, -49.0], + unit=(u.degree, u.degree), frame='icrs') + # Serial execution: the dummy connection handler is not thread-safe. + with multicoord_conf.set_temp("max_parallel_queries", 1), \ + multicoord_conf.set_temp("min_request_interval", 0): + table = mock_querier.query_object(coordinates=coords, radius=RADIUS) + + assert table is not None + # One 3-row mock result per input position, stacked into a single table. + assert len(table) == 6 + assert list(table["query_index"]) == [0, 0, 0, 1, 1, 1] + for colname, attrs in column_attrs.items(): + assert table[colname].attrs_equal(attrs) + + +def test_query_object_deprecated_coordinate_keyword(mock_querier): + coord = SkyCoord(ra=60.3372780005097, dec=-49.93184727724773, unit=(u.degree, u.degree), frame='icrs') + with pytest.warns(AstropyDeprecationWarning, match='"coordinate" was deprecated'): + table = mock_querier.query_object(coordinate=coord, radius=RADIUS) + + assert table is not None + assert len(table) == 3 + + def test_query_object_async_radius_columns(column_attrs, mock_querier_async): coord = SkyCoord(ra=60.3372780005097, dec=-49.93184727724773, unit=(u.degree, u.degree), frame='icrs') - table = mock_querier_async.query_object(coordinate=coord, radius=RADIUS, columns=("alpha",), async_job=True) + table = mock_querier_async.query_object(coordinates=coord, radius=RADIUS, columns=("alpha",), async_job=True) assert table is not None @@ -277,7 +304,7 @@ def test_query_object_async_radius_columns(column_attrs, mock_querier_async): def test_query_object_radius_columns(column_attrs, mock_querier): coord = SkyCoord(ra=60.3372780005097, dec=-49.93184727724773, unit=(u.degree, u.degree), frame='icrs') - table = mock_querier.query_object(coordinate=coord, radius=RADIUS, columns=("alpha",)) + table = mock_querier.query_object(coordinates=coord, radius=RADIUS, columns=("alpha",)) assert table is not None @@ -1205,7 +1232,7 @@ def test_get_cutout(capsys): result = tap.get_cutout( file_path='/data/repository/NIR/19704/EUC_NIR_W-STACK_NIR-J-19704_20190718T001858.5Z_00.00.fits', - coordinate=c, radius=r, output_file=None, verbose=True) + coordinates=c, radius=r, output_file=None, verbose=True) assert result is not None @@ -1239,17 +1266,17 @@ def test_get_cutout_exception(): show_server_messages=False) with pytest.raises(ValueError, match="Radius cannot be greater than 30 arcminutes"): - tap.get_cutout(file_path=file_path, coordinate=c, radius=100 * u.arcmin, + tap.get_cutout(file_path=file_path, coordinates=c, radius=100 * u.arcmin, output_file=None) with pytest.raises(ValueError, match="Missing required argument"): - tap.get_cutout(file_path=None, coordinate=c, radius=r, output_file=None) + tap.get_cutout(file_path=None, coordinates=c, radius=r, output_file=None) with pytest.raises(ValueError, match="Missing required argument"): - tap.get_cutout(file_path=file_path, coordinate=None, radius=r, output_file=None) + tap.get_cutout(file_path=file_path, coordinates=None, radius=r, output_file=None) with pytest.raises(ValueError, match="Missing required argument"): - tap.get_cutout(file_path=file_path, coordinate=c, radius=None, output_file=None) + tap.get_cutout(file_path=file_path, coordinates=c, radius=None, output_file=None) @patch.object(TapPlus, 'load_data') @@ -1272,7 +1299,7 @@ def test_get_cutout_exceptions_2(mock_load_data, caplog): mock_load_data.side_effect = HTTPError("launch_job_async HTTPError") - tap.get_cutout(file_path='hola.fits', coordinate=SKYCOORD, radius=1 * u.arcmin, + tap.get_cutout(file_path='hola.fits', coordinates=SKYCOORD, radius=1 * u.arcmin, output_file=None) mssg = ("Cannot retrieve the product for file_path hola.fits. HTTP error: " @@ -1281,7 +1308,7 @@ def test_get_cutout_exceptions_2(mock_load_data, caplog): mock_load_data.side_effect = Exception("launch_job_async Exception") - tap.get_cutout(file_path='hola.fits', coordinate=SKYCOORD, radius=1 * u.arcmin, + tap.get_cutout(file_path='hola.fits', coordinates=SKYCOORD, radius=1 * u.arcmin, output_file=None) mssg = ("Cannot retrieve the product for file_path hola.fits: launch_job_async " diff --git a/astroquery/esa/hsa/core.py b/astroquery/esa/hsa/core.py index c9c655321c..05783c731f 100644 --- a/astroquery/esa/hsa/core.py +++ b/astroquery/esa/hsa/core.py @@ -7,7 +7,9 @@ from pathlib import Path from astropy import units as u +from astropy.utils.decorators import deprecated_renamed_argument from astroquery.utils import commons +from astroquery.utils.multicoord import support_multiple_coordinates from astroquery import log from astroquery.exceptions import LoginError from astroquery.query import BaseQuery @@ -380,14 +382,20 @@ def get_columns(self, table_name, *, only_names=True, verbose=False): else: return columns - def query_observations(self, coordinate, radius, *, n_obs=10, **kwargs): + @deprecated_renamed_argument("coordinate", "coordinates", since="0.4.12") + @support_multiple_coordinates() + def query_observations(self, coordinates, radius, *, n_obs=10, **kwargs): """ Get the observation IDs from a given region Parameters ---------- - coordinate : string / `astropy.coordinates` - the identifier or coordinates around which to query + coordinates : string / `astropy.coordinates` + the identifier or coordinates around which to query. + A list of coordinates or a vector `~astropy.coordinates.SkyCoord` + triggers one query per position; the resulting tables are stacked + into a single table with a ``query_index`` column mapping each row + back to the corresponding input position. radius : int / `~astropy.units.Quantity` the radius of the region n_obs : int, optional @@ -399,16 +407,22 @@ def query_observations(self, coordinate, radius, *, n_obs=10, **kwargs): ------- A table object with the list of observations in the region """ - return self.query_region(coordinate, radius, n_obs=n_obs, columns="observation_id", **kwargs) + return self.query_region(coordinates, radius, n_obs=n_obs, columns="observation_id", **kwargs) - def query_region(self, coordinate, radius, *, n_obs=10, columns='*', **kwargs): + @deprecated_renamed_argument("coordinate", "coordinates", since="0.4.12") + @support_multiple_coordinates() + def query_region(self, coordinates, radius, *, n_obs=10, columns='*', **kwargs): """ Get the observation metadata from a given region Parameters ---------- - coordinate : string / `astropy.coordinates` - the identifier or coordinates around which to query + coordinates : string / `astropy.coordinates` + the identifier or coordinates around which to query. + A list of coordinates or a vector `~astropy.coordinates.SkyCoord` + triggers one query per position; the resulting tables are stacked + into a single table with a ``query_index`` column mapping each row + back to the corresponding input position. radius : int / `~astropy.units.Quantity` the radius of the region n_obs : int, optional @@ -425,7 +439,7 @@ def query_region(self, coordinate, radius, *, n_obs=10, columns='*', **kwargs): r = radius if not isinstance(radius, u.Quantity): r = radius*u.deg - coord = commons.parse_coordinates(coordinate).icrs + coord = commons.parse_coordinates(coordinates).icrs query = (f"select top {n_obs} {columns} from hsa.v_active_observation " f"where contains(" diff --git a/astroquery/esa/hsa/tests/test_hsa.py b/astroquery/esa/hsa/tests/test_hsa.py index 82d35bfc16..5a0bba8c86 100644 --- a/astroquery/esa/hsa/tests/test_hsa.py +++ b/astroquery/esa/hsa/tests/test_hsa.py @@ -1,8 +1,11 @@ # Licensed under a 3-clause BSD style license - see LICENSE.rst import os +import pytest + from astropy import units as u from astropy.coordinates import SkyCoord +from astropy.utils.exceptions import AstropyDeprecationWarning from ..core import HSAClass from ..tests.dummy_tap_handler import DummyHSATapHandler @@ -50,7 +53,7 @@ def test_get_columns(self): def test_query_observations(self): c = SkyCoord(ra=100.2417*u.degree, dec=9.895*u.degree, frame='icrs') - parameters = {'coordinate': c, + parameters = {'coordinates': c, 'radius': 0.5} dummyTapHandler = DummyHSATapHandler("query_observations", parameters) hsa = HSAClass(self.get_dummy_tap_handler()) @@ -59,9 +62,29 @@ def test_query_observations(self): def test_query_region(self): c = SkyCoord(ra=100.2417*u.degree, dec=9.895*u.degree, frame='icrs') - parameters = {'coordinate': c, + parameters = {'coordinates': c, 'radius': 0.5} dummyTapHandler = DummyHSATapHandler("query_region", parameters) hsa = HSAClass(self.get_dummy_tap_handler()) hsa.query_region(**parameters) dummyTapHandler.check_call("query_region", parameters) + + def test_query_region_multiple_coordinates(self): + coords = SkyCoord(ra=[100.2417, 101.2417]*u.degree, + dec=[9.895, 10.895]*u.degree, frame='icrs') + hsa = HSAClass(self.get_dummy_tap_handler()) + result = hsa.query_region(coordinates=coords, radius=0.5) + # The dummy TAP handler's launch_job returns a Job with no results + # and no output file, so each per-position query returns None and + # the combined result is a plain list with one entry per position. + assert isinstance(result, list) + assert len(result) == 2 + assert all(entry is None for entry in result) + + def test_query_region_deprecated_coordinate(self): + c = SkyCoord(ra=100.2417*u.degree, dec=9.895*u.degree, frame='icrs') + hsa = HSAClass(self.get_dummy_tap_handler()) + with pytest.warns(AstropyDeprecationWarning, match="coordinate"): + hsa.query_region(coordinate=c, radius=0.5) + with pytest.warns(AstropyDeprecationWarning, match="coordinate"): + hsa.query_observations(coordinate=c, radius=0.5) diff --git a/astroquery/esa/jwst/core.py b/astroquery/esa/jwst/core.py index b9347e5f4d..8a4dd2b566 100644 --- a/astroquery/esa/jwst/core.py +++ b/astroquery/esa/jwst/core.py @@ -25,6 +25,7 @@ from astropy.coordinates import Angle, SkyCoord from astropy.table import vstack from astropy.units import Quantity +from astropy.utils.decorators import deprecated_renamed_argument from requests.exceptions import ConnectionError from astroquery.exceptions import RemoteServiceError @@ -32,6 +33,7 @@ from astroquery.query import BaseQuery from astroquery.simbad import Simbad from astroquery.utils import commons +from astroquery.utils.multicoord import support_multiple_coordinates from astroquery.utils.tap import TapPlus from astroquery.vizier import Vizier from . import conf @@ -233,7 +235,9 @@ def list_async_jobs(self, *, verbose=False): """ return self.__jwsttap.list_async_jobs(verbose=verbose) - def query_region(self, coordinate, *, + @deprecated_renamed_argument("coordinate", "coordinates", since="0.4.12") + @support_multiple_coordinates() + def query_region(self, coordinates, *, radius=None, width=None, height=None, @@ -251,8 +255,12 @@ def query_region(self, coordinate, *, Parameters ---------- - coordinate : astropy.coordinate, mandatory - coordinates center point + coordinates : astropy.coordinates, mandatory + coordinates center point. A list of coordinates or a vector + `~astropy.coordinates.SkyCoord` may also be provided, in which + case one query is run per position and the resulting tables are + stacked, with a ``query_index`` column mapping each row back to + the corresponding input position radius : astropy.units, required if no 'width' nor 'height' are provided radius (deg) @@ -294,10 +302,10 @@ def query_region(self, coordinate, *, ------- The job results (astropy.table). """ - coord = self.__get_coord_input(value=coordinate, msg="coordinate") + coord = self.__get_coord_input(value=coordinates, msg="coordinates") job = None if radius is not None: - job = self.cone_search(coordinate=coord, + job = self.cone_search(coordinates=coord, radius=radius, only_public=only_public, observation_id=observation_id, @@ -357,7 +365,9 @@ def query_region(self, coordinate, *, job = self.__jwsttap.launch_job(query=query, verbose=verbose) return job.get_results() - def cone_search(self, coordinate, radius, *, + @deprecated_renamed_argument("coordinate", "coordinates", since="0.4.12") + @support_multiple_coordinates() + def cone_search(self, coordinates, radius, *, observation_id=None, cal_level="Top", prod_type=None, @@ -377,8 +387,11 @@ def cone_search(self, coordinate, radius, *, Parameters ---------- - coordinate : astropy.coordinate, mandatory - coordinates center point + coordinates : astropy.coordinates, mandatory + coordinates center point. A list of coordinates or a vector + `~astropy.coordinates.SkyCoord` may also be provided, in which + case one query is run per position; in that case a list of Job + objects is returned, in input order radius : astropy.units, mandatory radius observation_id : str, optional, default None @@ -428,7 +441,7 @@ def cone_search(self, coordinate, radius, *, ------- A Job object """ - coord = self.__get_coord_input(value=coordinate, msg="coordinate") + coord = self.__get_coord_input(value=coordinates, msg="coordinates") ra_hours, dec = commons.coord_to_radec(coord) ra = ra_hours * 15.0 # Converts to degrees @@ -548,7 +561,7 @@ def query_target(self, target_name, *, target_resolver="ALL", """ coordinates = self.resolve_target_coordinates(target_name=target_name, target_resolver=target_resolver) - return self.query_region(coordinate=coordinates, + return self.query_region(coordinates=coordinates, radius=radius, width=width, height=height, diff --git a/astroquery/esa/jwst/tests/test_jwsttap.py b/astroquery/esa/jwst/tests/test_jwsttap.py index d627c7b8e2..c5ed088c82 100644 --- a/astroquery/esa/jwst/tests/test_jwsttap.py +++ b/astroquery/esa/jwst/tests/test_jwsttap.py @@ -24,12 +24,14 @@ from astropy.io.votable import parse_single_table from astropy.table import Table from astropy.units import Quantity +from astropy.utils.exceptions import AstropyDeprecationWarning from astroquery.exceptions import TableParseError from astroquery.esa.jwst import JwstClass from astroquery.esa.jwst.tests.DummyTapHandler import DummyTapHandler from astroquery.ipac.ned import Ned from astroquery.simbad import SimbadClass +from astroquery.utils.multicoord import conf as multicoord_conf from astroquery.utils.tap.conn.tests.DummyConnHandler import DummyConnHandler from astroquery.utils.tap.conn.tests.DummyResponse import DummyResponse from astroquery.utils.tap.core import TapPlus @@ -239,11 +241,11 @@ def test_query_region(self): tap = JwstClass(tap_plus_handler=tapplus, show_messages=False) with pytest.raises(ValueError) as err: - tap.query_region(coordinate=123) - assert "coordinate must be either a string or astropy.coordinates" in err.value.args[0] + tap.query_region(coordinates=123) + assert "coordinates must be either a string or astropy.coordinates" in err.value.args[0] with pytest.raises(NameResolveError) as err: - tap.query_region(coordinate='test') + tap.query_region(coordinates='test') assert ("Unable to find coordinates for name 'test'" in err.value.args[0] or "Unable to retrieve " "coordinates" in err.value.args[0]) @@ -380,6 +382,30 @@ def test_query_region(self): None, np.int32) + def test_query_region_multiple_coordinates(self): + connHandler = DummyConnHandler() + tapplus = TapPlus(url="http://test:1111/tap", connhandler=connHandler) + tap = JwstClass(tap_plus_handler=tapplus, show_messages=False) + + # The query contains decimals: force default response + responseLaunchJob = DummyResponse(200) + responseLaunchJob.set_data(method='POST', body=JOB_DATA) + connHandler.set_default_response(responseLaunchJob) + + sc = SkyCoord(ra=[29.0, 30.0], dec=[15.0, 16.0], + unit=(u.degree, u.degree), frame='icrs') + radius = Quantity(1, u.deg) + with multicoord_conf.set_temp('max_parallel_queries', 1), \ + multicoord_conf.set_temp('min_request_interval', 0): + table = tap.query_region(sc, radius=radius) + assert isinstance(table, Table) + # The dummy connection handler returns the same 3-row job data for + # each of the two per-position queries, so the rows are doubled. + assert len(table) == 6, f"Wrong job results (num rows). Expected: {6}, found {len(table)}" + assert 'query_index' in table.colnames + assert set(table['query_index']) == {0, 1} + assert list(table['query_index']) == [0, 0, 0, 1, 1, 1] + def test_query_region_async(self): connHandler = DummyConnHandler() tapplus = TapPlus(url="http://test:1111/tap", connhandler=connHandler) @@ -537,6 +563,26 @@ def test_cone_search_sync(self): tap.cone_search(sc, radius, proposal_id=123) assert "proposal_id must be string" in err.value.args[0] + def test_coordinate_deprecated_keyword(self): + connHandler = DummyConnHandler() + tapplus = TapPlus(url="http://test:1111/tap", connhandler=connHandler) + tap = JwstClass(tap_plus_handler=tapplus, show_messages=False) + responseLaunchJob = DummyResponse(200) + responseLaunchJob.set_data(method='POST', body=JOB_DATA) + connHandler.set_default_response(responseLaunchJob) + sc = SkyCoord(ra=19.0, dec=20.0, unit=(u.degree, u.degree), frame='icrs') + radius = Quantity(1.0, u.deg) + + with pytest.warns(AstropyDeprecationWarning, + match='"coordinate" was deprecated in version 0.4.12'): + job = tap.cone_search(coordinate=sc, radius=radius) + assert job is not None + + with pytest.warns(AstropyDeprecationWarning, + match='"coordinate" was deprecated in version 0.4.12'): + results = tap.query_region(coordinate=sc, radius=radius) + assert results is not None + def test_cone_search_async(self): connHandler = DummyConnHandler() tapplus = TapPlus(url="http://test:1111/tap", connhandler=connHandler) diff --git a/astroquery/esasky/core.py b/astroquery/esasky/core.py index 34755789b3..afdb30ea86 100644 --- a/astroquery/esasky/core.py +++ b/astroquery/esasky/core.py @@ -14,6 +14,7 @@ from astropy.coordinates import Angle from astropy.io import fits from astropy.utils.console import ProgressBar +from astropy.utils.decorators import deprecated_renamed_argument from astroquery import log from requests import HTTPError from requests import ConnectionError @@ -22,6 +23,7 @@ from ..utils.tap.core import TapPlus from ..utils import commons from ..utils import async_to_sync +from ..utils.multicoord import support_multiple_coordinates from . import conf from .. import version from astropy.coordinates.name_resolve import sesame_database @@ -265,7 +267,7 @@ def query_object_maps(self, position, missions=__ALL_STRING, get_query_payload=F query_object_maps("265.05, 69.0", "Herschel") query_object_maps("265.05, 69.0", ["Herschel", "HST-OPTICAL"]) """ - return self.query_region_maps(position=position, + return self.query_region_maps(coordinates=position, radius=self.__ZERO_ARCMIN_STRING, missions=missions, get_query_payload=get_query_payload, @@ -322,7 +324,7 @@ def query_object_catalogs(self, position, catalogs=__ALL_STRING, row_limit=DEFAU query_object_catalogs("202.469, 47.195", "HSC") query_object_catalogs("202.469, 47.195", ["HSC", "XMM-OM"]) """ - return self.query_region_catalogs(position=position, + return self.query_region_catalogs(coordinates=position, radius=self.__ZERO_ARCMIN_STRING, catalogs=catalogs, row_limit=row_limit, @@ -378,7 +380,7 @@ def query_object_spectra(self, position, missions=__ALL_STRING, get_query_payloa query_object_spectra("202.469, 47.195", "Herschel") query_object_spectra("202.469, 47.195", ["Herschel", "HST-OPTICAL"]) """ - return self.query_region_spectra(position=position, + return self.query_region_spectra(coordinates=position, radius=self.__ZERO_ARCMIN_STRING, missions=missions, get_query_payload=get_query_payload, @@ -643,7 +645,9 @@ def get_images_sso(self, *, sso_name=None, sso_type="ALL", table_list=None, miss log.info("No maps found.") return maps - def query_region_maps(self, position, radius, missions=__ALL_STRING, get_query_payload=False, cache=True, + @deprecated_renamed_argument("position", "coordinates", since="0.4.12") + @support_multiple_coordinates() + def query_region_maps(self, coordinates, radius, missions=__ALL_STRING, get_query_payload=False, cache=True, row_limit=DEFAULT_ROW_LIMIT, verbose=False): """ This method queries a chosen region for all available maps and returns a @@ -652,9 +656,13 @@ def query_region_maps(self, position, radius, missions=__ALL_STRING, get_query_p Parameters ---------- - position : str or `astropy.coordinates` object + coordinates : str or `astropy.coordinates` object Can either be a string of the location, eg 'M51', or the coordinates - of the object. + of the object. A list of positions or a vector + `~astropy.coordinates.SkyCoord` may also be given, in which case one + query is run per position and the per-mission tables are stacked, + with a ``query_index`` column identifying the input position each + row came from. radius : str or `~astropy.units.Quantity` The radius of a region. missions : str or list, optional @@ -700,7 +708,7 @@ def query_region_maps(self, position, radius, missions=__ALL_STRING, get_query_p query_result = {} sesame_database.set('simbad') - coordinates = commons.parse_coordinates(position) + coordinates = commons.parse_coordinates(coordinates) self._store_query_result(query_result=query_result, names=sanitized_missions, descriptors=self._get_observation_info(), coordinates=coordinates, @@ -711,7 +719,9 @@ def query_region_maps(self, position, radius, missions=__ALL_STRING, get_query_p return commons.TableList(query_result) - def query_region_catalogs(self, position, radius, catalogs=__ALL_STRING, row_limit=DEFAULT_ROW_LIMIT, + @deprecated_renamed_argument("position", "coordinates", since="0.4.12") + @support_multiple_coordinates() + def query_region_catalogs(self, coordinates, radius, catalogs=__ALL_STRING, row_limit=DEFAULT_ROW_LIMIT, get_query_payload=False, cache=True, verbose=False): """ This method queries a chosen region for all available catalogs and @@ -720,9 +730,13 @@ def query_region_catalogs(self, position, radius, catalogs=__ALL_STRING, row_lim Parameters ---------- - position : str or `astropy.coordinates` object + coordinates : str or `astropy.coordinates` object Can either be a string of the location, eg 'M51', or the coordinates - of the object. + of the object. A list of positions or a vector + `~astropy.coordinates.SkyCoord` may also be given, in which case one + query is run per position and the per-mission tables are stacked, + with a ``query_index`` column identifying the input position each + row came from. radius : str or `~astropy.units.Quantity` The radius of a region. catalogs : str or list, optional @@ -766,7 +780,7 @@ def query_region_catalogs(self, position, radius, catalogs=__ALL_STRING, row_lim sanitized_row_limit = self._sanitize_input_row_limit(row_limit) sesame_database.set('simbad') - coordinates = commons.parse_coordinates(position) + coordinates = commons.parse_coordinates(coordinates) query_result = {} @@ -780,7 +794,9 @@ def query_region_catalogs(self, position, radius, catalogs=__ALL_STRING, row_lim return commons.TableList(query_result) - def query_region_spectra(self, position, radius, missions=__ALL_STRING, row_limit=DEFAULT_ROW_LIMIT, + @deprecated_renamed_argument("position", "coordinates", since="0.4.12") + @support_multiple_coordinates() + def query_region_spectra(self, coordinates, radius, missions=__ALL_STRING, row_limit=DEFAULT_ROW_LIMIT, get_query_payload=False, cache=True, verbose=False): """ This method queries a chosen region for all available spectra and returns a @@ -789,9 +805,13 @@ def query_region_spectra(self, position, radius, missions=__ALL_STRING, row_limi Parameters ---------- - position : str or `astropy.coordinates` object + coordinates : str or `astropy.coordinates` object Can either be a string of the location, eg 'M51', or the coordinates - of the object. + of the object. A list of positions or a vector + `~astropy.coordinates.SkyCoord` may also be given, in which case one + query is run per position and the per-mission tables are stacked, + with a ``query_index`` column identifying the input position each + row came from. radius : str or `~astropy.units.Quantity` The radius of a region. missions : str or list, optional @@ -837,7 +857,7 @@ def query_region_spectra(self, position, radius, missions=__ALL_STRING, row_limi query_result = {} sesame_database.set('simbad') - coordinates = commons.parse_coordinates(position) + coordinates = commons.parse_coordinates(coordinates) self._store_query_result(query_result=query_result, names=sanitized_missions, descriptors=self._get_spectra_info(), coordinates=coordinates, diff --git a/astroquery/esasky/tests/test_esasky.py b/astroquery/esasky/tests/test_esasky.py new file mode 100644 index 0000000000..a15f3defd3 --- /dev/null +++ b/astroquery/esasky/tests/test_esasky.py @@ -0,0 +1,54 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst +import pytest + +from astropy.coordinates import SkyCoord +from astropy.utils.exceptions import AstropyDeprecationWarning + +from astroquery.esasky import ESASky + +# Fake mission descriptors so that no network access is needed to build the query payload. +FAKE_DESCRIPTORS = {"XMM": {"mission": "XMM", + "table_name": "observations.mv_v_esasky_xmm_om_optical_fdw", + "ra": "ra_deg", "dec": "dec_deg", + "intersect_polygon_query": True}} + + +@pytest.mark.parametrize("method_name, descriptor_getter", + [("query_region_maps", "_get_observation_info"), + ("query_region_catalogs", "_get_catalogs_info"), + ("query_region_spectra", "_get_spectra_info")]) +def test_query_region_deprecated_position(monkeypatch, method_name, descriptor_getter): + # The 'position' keyword was renamed to 'coordinates'; the old keyword + # should still work but emit an AstropyDeprecationWarning. + monkeypatch.setattr(ESASky, descriptor_getter, lambda: FAKE_DESCRIPTORS) + coordinates = SkyCoord(ra=202.469, dec=47.195, unit="deg") + method = getattr(ESASky, method_name) + kwargs = {("catalogs" if method_name == "query_region_catalogs" else "missions"): ["XMM"]} + + expected = method(coordinates=coordinates, radius="5 arcmin", + get_query_payload=True, **kwargs) + + with pytest.warns(AstropyDeprecationWarning): + result = method(position=coordinates, radius="5 arcmin", + get_query_payload=True, **kwargs) + + assert result == expected + assert "QUERY" in result["XMM"] + + +def test_query_region_maps_multiple_coordinates(monkeypatch): + # A list of coordinates should run one query per position and, with + # get_query_payload=True, return a list of payloads in input order. + monkeypatch.setattr(ESASky, "_get_observation_info", lambda: FAKE_DESCRIPTORS) + coordinates = [SkyCoord(ra=202.469, dec=47.195, unit="deg"), + SkyCoord(ra=10.684, dec=41.269, unit="deg")] + + result = ESASky.query_region_maps(coordinates=coordinates, radius="5 arcmin", + missions=["XMM"], get_query_payload=True) + + assert isinstance(result, list) + assert len(result) == 2 + for payload, coord in zip(result, coordinates): + query = payload["XMM"]["QUERY"] + assert str(coord.ra.deg) in query + assert str(coord.dec.deg) in query diff --git a/astroquery/esasky/tests/test_esasky_remote.py b/astroquery/esasky/tests/test_esasky_remote.py index 9f43ed294e..40cec9b905 100755 --- a/astroquery/esasky/tests/test_esasky_remote.py +++ b/astroquery/esasky/tests/test_esasky_remote.py @@ -106,7 +106,7 @@ def test_esasky_get_spectra_obs_id(self, tmp_path, mission, observation_id): result[mission.upper()][0].close() def test_esasky_query_region_maps(self): - result = ESASky.query_region_maps(position="M51", radius="5 arcmin") + result = ESASky.query_region_maps(coordinates="M51", radius="5 arcmin") assert isinstance(result, TableList) def test_esasky_query_object_maps(self): @@ -150,7 +150,7 @@ def test_esasky_get_images_hst(self, tmp_path): assert Path(tmp_path, "HST-UV").exists() def test_esasky_query_region_catalogs(self): - result = ESASky.query_region_catalogs(position="M51", radius="5 arcmin") + result = ESASky.query_region_catalogs(coordinates="M51", radius="5 arcmin") assert isinstance(result, TableList) def test_esasky_query_object_catalogs(self): @@ -177,7 +177,7 @@ def test_esasky_get_maps(self, tmp_path): hdu_list.close() def test_esasky_query_region_spectra(self): - result = ESASky.query_region_spectra(position="M51", radius="5 arcmin") + result = ESASky.query_region_spectra(coordinates="M51", radius="5 arcmin") assert isinstance(result, TableList) def test_esasky_query_object_spectra(self): diff --git a/astroquery/gaia/core.py b/astroquery/gaia/core.py index a0be6d5354..a73c84ecc5 100644 --- a/astroquery/gaia/core.py +++ b/astroquery/gaia/core.py @@ -28,6 +28,7 @@ from astroquery import log from astroquery.utils import commons +from astroquery.utils.multicoord import support_multiple_coordinates from astroquery.utils.tap import TapPlus from astroquery.utils.tap import taputils from . import conf @@ -469,14 +470,14 @@ def get_datalinks(self, ids, *, linking_parameter='SOURCE_ID', verbose=False): return self.__gaiadata.get_datalinks(ids=ids, linking_parameter=final_linking_parameter, verbose=verbose) - def __query_object(self, coordinate, *, radius=None, width=None, height=None, + def __query_object(self, coordinates, *, radius=None, width=None, height=None, async_job=False, verbose=False, columns=(), get_query_payload=False): """Launches a job TAP & TAP+ Parameters ---------- - coordinate : str or astropy.coordinate, mandatory + coordinates : str or astropy.coordinate, mandatory coordinates center point radius : str or astropy.units if no 'width' nor 'height' are provided radius (deg) @@ -499,7 +500,7 @@ def __query_object(self, coordinate, *, radius=None, width=None, height=None, ------- The job results (astropy.table). """ - coord = self.__getCoordInput(coordinate, "coordinate") + coord = self.__getCoordInput(coordinates, "coordinates") if radius is not None: job = self.__cone_search(coord, radius, async_job=async_job, verbose=verbose, columns=columns) @@ -554,7 +555,9 @@ def __query_object(self, coordinate, *, radius=None, width=None, height=None, job = self.launch_job(query, verbose=verbose) return job.get_results() - def query_object(self, coordinate, *, radius=None, width=None, height=None, verbose=False, columns=(), + @deprecated_renamed_argument("coordinate", "coordinates", since="0.4.12") + @support_multiple_coordinates() + def query_object(self, coordinates, *, radius=None, width=None, height=None, verbose=False, columns=(), get_query_payload=False): """Launches a synchronous cone search for the input search radius or the box on the sky, sorted by angular separation @@ -562,8 +565,12 @@ def query_object(self, coordinate, *, radius=None, width=None, height=None, verb Parameters ---------- - coordinate : str or astropy.coordinates, mandatory - coordinates center point + coordinates : str or astropy.coordinates, mandatory + coordinates center point. A list of coordinates or a non-scalar + `~astropy.coordinates.SkyCoord` is also accepted, in which case + one query is run per position and the results are stacked into a + single table with a ``query_index`` column mapping rows back to + the input positions. radius : str or astropy.units if no 'width'/'height' are provided radius (deg) width : str or astropy.units if no 'radius' is provided @@ -582,18 +589,24 @@ def query_object(self, coordinate, *, radius=None, width=None, height=None, verb ------- The job results (astropy.table). """ - return self.__query_object(coordinate, radius=radius, width=width, height=height, async_job=False, + return self.__query_object(coordinates, radius=radius, width=width, height=height, async_job=False, verbose=verbose, columns=columns, get_query_payload=get_query_payload) - def query_object_async(self, coordinate, *, radius=None, width=None, height=None, verbose=False, columns=()): + @deprecated_renamed_argument("coordinate", "coordinates", since="0.4.12") + @support_multiple_coordinates() + def query_object_async(self, coordinates, *, radius=None, width=None, height=None, verbose=False, columns=()): """Launches an asynchronous cone search for the input search radius or the box on the sky, sorted by angular separation TAP & TAP+ Parameters ---------- - coordinate : str or astropy.coordinates, mandatory - coordinates center point + coordinates : str or astropy.coordinates, mandatory + coordinates center point. A list of coordinates or a non-scalar + `~astropy.coordinates.SkyCoord` is also accepted, in which case + one query is run per position and the results are stacked into a + single table with a ``query_index`` column mapping rows back to + the input positions. radius : str or astropy.units if no 'width'/'height' are provided radius width : str or astropy.units if no 'radius' is provided @@ -609,10 +622,10 @@ def query_object_async(self, coordinate, *, radius=None, width=None, height=None ------- The job results (astropy.table). """ - return self.__query_object(coordinate, radius=radius, width=width, height=height, async_job=True, + return self.__query_object(coordinates, radius=radius, width=width, height=height, async_job=True, verbose=verbose, columns=columns) - def __cone_search(self, coordinate, radius, *, table_name=None, + def __cone_search(self, coordinates, radius, *, table_name=None, ra_column_name=MAIN_GAIA_TABLE_RA, dec_column_name=MAIN_GAIA_TABLE_DEC, async_job=False, @@ -626,7 +639,7 @@ def __cone_search(self, coordinate, radius, *, table_name=None, Parameters ---------- - coordinate : astropy.coordinate, mandatory + coordinates : astropy.coordinate, mandatory coordinates center point radius : astropy.units, mandatory radius @@ -662,7 +675,7 @@ def __cone_search(self, coordinate, radius, *, table_name=None, A Job object """ radiusDeg = None - coord = self.__getCoordInput(coordinate, "coordinate") + coord = self.__getCoordInput(coordinates, "coordinates") raHours, dec = commons.coord_to_radec(coord) ra = raHours * 15.0 # Converts to degrees if radius is not None: @@ -706,7 +719,9 @@ def __cone_search(self, coordinate, radius, *, table_name=None, return self.launch_job(query=query, output_file=output_file, output_format=output_format, verbose=verbose, dump_to_file=dump_to_file) - def cone_search(self, coordinate, *, radius=None, + @deprecated_renamed_argument("coordinate", "coordinates", since="0.4.12") + @support_multiple_coordinates() + def cone_search(self, coordinates, *, radius=None, table_name=None, ra_column_name=MAIN_GAIA_TABLE_RA, dec_column_name=MAIN_GAIA_TABLE_DEC, @@ -720,8 +735,11 @@ def cone_search(self, coordinate, *, radius=None, Parameters ---------- - coordinate : str or astropy.coordinate, mandatory - coordinates center point + coordinates : str or astropy.coordinate, mandatory + coordinates center point. A list of coordinates or a non-scalar + `~astropy.coordinates.SkyCoord` is also accepted, in which case + one query is run per position and a list of jobs is returned, in + input order. radius : str or astropy.units, mandatory radius table_name : str, optional, default main gaia table name doing the cone search against @@ -749,7 +767,7 @@ def cone_search(self, coordinate, *, radius=None, ------- A Job object """ - return self.__cone_search(coordinate, + return self.__cone_search(coordinates, radius=radius, table_name=table_name, ra_column_name=ra_column_name, @@ -762,7 +780,9 @@ def cone_search(self, coordinate, *, radius=None, dump_to_file=dump_to_file, columns=columns, get_query_payload=get_query_payload) - def cone_search_async(self, coordinate, *, radius=None, + @deprecated_renamed_argument("coordinate", "coordinates", since="0.4.12") + @support_multiple_coordinates() + def cone_search_async(self, coordinates, *, radius=None, table_name=None, ra_column_name=MAIN_GAIA_TABLE_RA, dec_column_name=MAIN_GAIA_TABLE_DEC, @@ -774,8 +794,11 @@ def cone_search_async(self, coordinate, *, radius=None, Parameters ---------- - coordinate : str or astropy.coordinate, mandatory - coordinates center point + coordinates : str or astropy.coordinate, mandatory + coordinates center point. A list of coordinates or a non-scalar + `~astropy.coordinates.SkyCoord` is also accepted, in which case + one query is run per position and a list of jobs is returned, in + input order. radius : str or astropy.units, mandatory radius table_name : str, optional, default main gaia table name doing the cone search against @@ -804,7 +827,7 @@ def cone_search_async(self, coordinate, *, radius=None, ------- A Job object """ - return self.__cone_search(coordinate, + return self.__cone_search(coordinates, radius=radius, table_name=table_name, ra_column_name=ra_column_name, diff --git a/astroquery/gaia/tests/test_gaia_remote.py b/astroquery/gaia/tests/test_gaia_remote.py index 2fd6e4b8e2..ddb51be938 100644 --- a/astroquery/gaia/tests/test_gaia_remote.py +++ b/astroquery/gaia/tests/test_gaia_remote.py @@ -21,17 +21,17 @@ def test_query_object_row_limit(): coord = SkyCoord(ra=280, dec=-60, unit=(u.degree, u.degree), frame='icrs') width = u.Quantity(0.1, u.deg) height = u.Quantity(0.1, u.deg) - r = gaia.query_object_async(coordinate=coord, width=width, height=height) + r = gaia.query_object_async(coordinates=coord, width=width, height=height) assert len(r) == gaia.ROW_LIMIT gaia.ROW_LIMIT = 10 - r = gaia.query_object_async(coordinate=coord, width=width, height=height) + r = gaia.query_object_async(coordinates=coord, width=width, height=height) assert len(r) == 10 == gaia.ROW_LIMIT gaia.ROW_LIMIT = -1 - r = gaia.query_object_async(coordinate=coord, width=width, height=height) + r = gaia.query_object_async(coordinates=coord, width=width, height=height) assert len(r) == 184 diff --git a/astroquery/gaia/tests/test_gaiatap.py b/astroquery/gaia/tests/test_gaiatap.py index 2fc5b48d72..275edb050e 100644 --- a/astroquery/gaia/tests/test_gaiatap.py +++ b/astroquery/gaia/tests/test_gaiatap.py @@ -518,6 +518,20 @@ def test_query_object_async(column_attrs, mock_querier_async, kwargs): assert table[colname].attrs_equal(attrs) +def test_query_object_deprecated_coordinate_keyword(mock_querier): + with pytest.warns(AstropyDeprecationWarning, match='"coordinate" was deprecated'): + deprecated_query = mock_querier.query_object( + coordinate=SKYCOORD, width=12 * u.deg, height=10 * u.deg, get_query_payload=True) + assert deprecated_query == mock_querier.query_object( + coordinates=SKYCOORD, width=12 * u.deg, height=10 * u.deg, get_query_payload=True) + + with pytest.warns(AstropyDeprecationWarning, match='"coordinate" was deprecated'): + deprecated_query = mock_querier.cone_search( + coordinate=SKYCOORD, radius=RADIUS, get_query_payload=True) + assert deprecated_query == mock_querier.cone_search( + coordinates=SKYCOORD, radius=RADIUS, get_query_payload=True) + + def test_query_object_precision(mock_querier): """ Verifies that query_object() produces a query where RA, DEC, width and height @@ -564,6 +578,27 @@ def test_cone_search_precision(mock_querier): ) +def test_query_object_multiple_coordinates(mock_querier): + coords = SkyCoord(ra=[19, 42] * u.deg, dec=[20, -5] * u.deg, frame="icrs") + queries = mock_querier.query_object(coords, width=12 * u.deg, height=10 * u.deg, get_query_payload=True) + assert isinstance(queries, list) + assert len(queries) == 2 + assert queries[0] != queries[1] + for query, coord in zip(queries, coords): + assert query == mock_querier.query_object(coord, width=12 * u.deg, height=10 * u.deg, + get_query_payload=True) + + +def test_cone_search_multiple_coordinates(mock_querier): + coords = SkyCoord(ra=[19, 42] * u.deg, dec=[20, -5] * u.deg, frame="icrs") + queries = mock_querier.cone_search(coords, radius=RADIUS, get_query_payload=True) + assert isinstance(queries, list) + assert len(queries) == 2 + assert queries[0] != queries[1] + for query, coord in zip(queries, coords): + assert query == mock_querier.cone_search(coord, radius=RADIUS, get_query_payload=True) + + def test_cone_search_sync(column_attrs, mock_querier): assert mock_querier.USE_NAMES_OVER_IDS is True diff --git a/astroquery/heasarc/core.py b/astroquery/heasarc/core.py index 2406e274a9..f04541b81b 100644 --- a/astroquery/heasarc/core.py +++ b/astroquery/heasarc/core.py @@ -5,7 +5,7 @@ import warnings import numpy as np from astropy.table import Table, Row -from astropy import coordinates +from astropy.coordinates import Angle, SkyCoord from astropy import units as u from astropy.utils.decorators import deprecated, deprecated_renamed_argument @@ -14,6 +14,7 @@ from astroquery import log from ..query import BaseQuery, BaseVOQuery from ..utils import commons, parse_coordinates +from ..utils.multicoord import support_multiple_coordinates from ..exceptions import InvalidQueryError, NoResultsWarning from . import conf @@ -422,7 +423,9 @@ def _parse_constraints(self, column_filters): arg_in_kwargs=(False, True, True, True, True, True, True, True, True, False) ) - def query_region(self, position=None, catalog=None, radius=None, *, + @deprecated_renamed_argument("position", "coordinates", since="0.4.12") + @support_multiple_coordinates() + def query_region(self, coordinates=None, catalog=None, radius=None, *, spatial='cone', width=None, polygon=None, column_filters=None, add_offset=False, get_query_payload=False, columns=None, cache=False, verbose=False, maxrec=None, @@ -432,10 +435,15 @@ def query_region(self, position=None, catalog=None, radius=None, *, Parameters ---------- - position : str, `astropy.coordinates` object + coordinates : str, `astropy.coordinates` object Gives the position of the center of the cone or box if performing a cone or box search. Required if spatial is ``'cone'`` or ``'box'``. Ignored if spatial is ``'polygon'`` or ``'all-sky'``. + A list of coordinates or a vector `~astropy.coordinates.SkyCoord` + may also be given, in which case one query is run per position + and the results are stacked into a single table with a + ``query_index`` column mapping each row back to the input + position. catalog : str The catalog to query. To list the available catalogs, use :meth:`~astroquery.heasarc.HeasarcClass.list_catalogs`. @@ -499,8 +507,8 @@ def query_region(self, position=None, catalog=None, radius=None, *, table : A `~astropy.table.Table` object. """ - # if we have column_filters and no position, assume all-sky search - if position is None and column_filters is not None: + # if we have column_filters and no coordinates, assume all-sky search + if coordinates is None and column_filters is not None: spatial = 'all-sky' if spatial.lower() == 'all-sky': @@ -511,14 +519,14 @@ def query_region(self, position=None, catalog=None, radius=None, *, for coord in polygon] except TypeError: try: - coords_list = [coordinates.SkyCoord(*coord).icrs + coords_list = [SkyCoord(*coord).icrs for coord in polygon] except u.UnitTypeError: warnings.warn("Polygon endpoints are being interpreted as " "RA/Dec pairs specified in decimal degree " "units.") coords_list = [ - coordinates.SkyCoord(*coord, unit='deg').icrs + SkyCoord(*coord, unit='deg').icrs for coord in polygon ] @@ -527,19 +535,19 @@ def query_region(self, position=None, catalog=None, radius=None, *, where = ("WHERE CONTAINS(POINT('ICRS',ra,dec)," f"POLYGON('ICRS',{','.join(coords_str)}))=1") else: - if position is None: + if coordinates is None: raise InvalidQueryError( - "position is required to for spatial='cone' (default). " + "coordinates is required for spatial='cone' (default). " "Use spatial='all-sky' For all-sky searches." ) - coords_icrs = parse_coordinates(position).icrs + coords_icrs = parse_coordinates(coordinates).icrs ra, dec = coords_icrs.ra.deg, coords_icrs.dec.deg if spatial.lower() == 'cone': if radius is None: radius = self.get_default_radius(catalog) elif isinstance(radius, str): - radius = coordinates.Angle(radius) + radius = Angle(radius) where = ("WHERE CONTAINS(POINT('ICRS',ra,dec),CIRCLE(" f"'ICRS',{ra},{dec},{radius.to(u.deg).value}))=1") # add search_offset for the case of cone @@ -548,7 +556,7 @@ def query_region(self, position=None, catalog=None, radius=None, *, f"POINT('ICRS',{ra},{dec})) as search_offset") elif spatial.lower() == 'box': if isinstance(width, str): - width = coordinates.Angle(width) + width = Angle(width) where = ("WHERE CONTAINS(POINT('ICRS',ra,dec)," f"BOX('ICRS',{ra},{dec},{width.to(u.deg).value}," f"{width.to(u.deg).value}))=1") @@ -608,7 +616,7 @@ def query_object(self, object_name, mission, *, of additional parameters that can be used to refine search query. """ - pos = coordinates.SkyCoord.from_name(object_name) + pos = SkyCoord.from_name(object_name) return self.query_region(pos, catalog=mission, spatial='cone', get_query_payload=get_query_payload) diff --git a/astroquery/heasarc/tests/test_heasarc.py b/astroquery/heasarc/tests/test_heasarc.py index 3831c9083d..8157977368 100644 --- a/astroquery/heasarc/tests/test_heasarc.py +++ b/astroquery/heasarc/tests/test_heasarc.py @@ -7,6 +7,7 @@ from astropy.coordinates import SkyCoord from astropy.table import Table from astropy.io import votable +from astropy.utils.exceptions import AstropyDeprecationWarning from pyvo.dal import TAPResults import astropy.units as u @@ -124,6 +125,28 @@ def test_query_region_cone(coordinates, radius, offset): assert ",0.0333" in query +def test_query_region_multiple_coordinates(): + coordinates = SkyCoord([182.63, 10.68] * u.deg, [39.40, 41.27] * u.deg) + queries = Heasarc.query_region( + coordinates, + catalog="suzamaster", + spatial="cone", + radius=2 * u.arcmin, + columns="*", + get_query_payload=True, + ) + + assert isinstance(queries, list) + assert len(queries) == 2 + assert "CIRCLE('ICRS',182.63" in queries[0] + assert ",39.4" in queries[0] + assert "CIRCLE('ICRS',10.68" in queries[1] + assert ",41.27" in queries[1] + for query in queries: + assert "SELECT * FROM suzamaster WHERE CONTAINS(POINT('ICRS',ra,dec)," in query + assert ",0.0333" in query + + @pytest.mark.parametrize("coordinates", OBJ_LIST) @pytest.mark.parametrize("width", SIZE_LIST) def test_query_region_box(coordinates, width): @@ -158,7 +181,7 @@ def test_query_region_box(coordinates, width): @pytest.mark.parametrize("polygon", [poly1, poly2]) def test_query_region_polygon(polygon): - # position is not used for polygon + # coordinates is not used for polygon query1 = Heasarc.query_region( catalog="suzamaster", spatial="polygon", @@ -184,7 +207,7 @@ def test_query_region_polygon(polygon): def test_query_region_polygon_no_unit(): - # position is not used for polygon + # coordinates is not used for polygon poly = [ (10.1, 10.1), (10.0, 10.1), @@ -230,11 +253,28 @@ def test_spatial_invalid(spatial): ) -def test_spatial_cone_no_position(): +def test_spatial_cone_no_coordinates(): with pytest.raises(InvalidQueryError): Heasarc.query_region(catalog="xmmmaster", columns="*", spatial="cone") +def test_query_region_position_deprecated(): + # the old `position` keyword still works but emits a deprecation warning + with pytest.warns(AstropyDeprecationWarning): + query = Heasarc.query_region( + position=OBJ_LIST[0], + catalog="suzamaster", + spatial="cone", + radius=2 * u.arcmin, + columns="*", + get_query_payload=True, + ) + assert ( + "FROM suzamaster WHERE CONTAINS(POINT('ICRS',ra,dec)," + "CIRCLE('ICRS',182.63" in query + ) + + def test_no_catalog(): with pytest.raises(InvalidQueryError): # OBJ_LIST[0] and radius added to avoid a remote call diff --git a/astroquery/ipac/irsa/ibe/core.py b/astroquery/ipac/irsa/ibe/core.py index 3d46410cd7..8049f3ddae 100644 --- a/astroquery/ipac/irsa/ibe/core.py +++ b/astroquery/ipac/irsa/ibe/core.py @@ -14,12 +14,15 @@ import astropy.coordinates as coord from astropy.table import Table +from astropy.utils.decorators import deprecated_renamed_argument from astroquery.exceptions import InvalidQueryError from astroquery.query import BaseQuery from astroquery.utils import commons from astroquery.ipac.irsa.ibe import conf +from ....utils.multicoord import support_multiple_coordinates + __all__ = ['Ibe', 'IbeClass'] @@ -30,7 +33,9 @@ class IbeClass(BaseQuery): TABLE = conf.table TIMEOUT = conf.timeout - def query_region(self, *, coordinate=None, where=None, mission=None, + @deprecated_renamed_argument("coordinate", "coordinates", since="0.4.12") + @support_multiple_coordinates() + def query_region(self, *, coordinates=None, where=None, mission=None, dataset=None, table=None, columns=None, width=None, height=None, intersect='OVERLAPS', most_centered=False): """ @@ -47,10 +52,15 @@ def query_region(self, *, coordinate=None, where=None, mission=None, Parameters ---------- - coordinate : str, `astropy.coordinates` object + coordinates : str, `astropy.coordinates` object Gives the position of the center of the box if performing a box search. If it is a string, then it must be a valid argument to `~astropy.coordinates.SkyCoord`. Required if ``where`` is absent. + A list of coordinates (or coordinate strings), or a vector + `~astropy.coordinates.SkyCoord`, may also be given, in which case + one query is run per position and the results are stacked into a + single table with a ``query_index`` column mapping each row back + to the corresponding input position. where : str SQL-like query string. Required if ``coordinates`` is absent. mission : str @@ -94,10 +104,13 @@ def query_region(self, *, coordinate=None, where=None, mission=None, Returns ------- table : `~astropy.table.Table` - A table containing the results of the query + A table containing the results of the query. If multiple + coordinates were given, the per-position tables are stacked and a + ``query_index`` column identifies the input position each row + corresponds to. """ response = self.query_region_async( - coordinate=coordinate, where=where, mission=mission, + coordinates=coordinates, where=where, mission=mission, dataset=dataset, table=table, columns=columns, width=width, height=height, intersect=intersect, most_centered=most_centered) @@ -106,16 +119,24 @@ def query_region(self, *, coordinate=None, where=None, mission=None, return Table.read(response.text, format='ipac', guess=False) - def query_region_sia(self, *, coordinate=None, mission=None, + @deprecated_renamed_argument("coordinate", "coordinates", since="0.4.12") + @support_multiple_coordinates() + def query_region_sia(self, *, coordinates=None, mission=None, dataset=None, table=None, width=None, height=None, intersect='OVERLAPS', most_centered=False): """ Query using simple image access protocol. See ``query_region`` for details. The returned table will include a list of URLs. + + As in ``query_region``, ``coordinates`` may be a list of coordinates + (or coordinate strings) or a vector `~astropy.coordinates.SkyCoord`, + in which case one query is run per position and the results are + stacked into a single table with a ``query_index`` column mapping + each row back to the corresponding input position. """ response = self.query_region_async( - coordinate=coordinate, mission=mission, + coordinates=coordinates, mission=mission, dataset=dataset, table=table, width=width, height=height, intersect=intersect, most_centered=most_centered, action='sia') @@ -126,7 +147,8 @@ def query_region_sia(self, *, coordinate=None, mission=None, return commons.parse_votable( response.text).get_first_table().to_table() - def query_region_async(self, *, coordinate=None, where=None, mission=None, dataset=None, + @deprecated_renamed_argument("coordinate", "coordinates", since="0.4.12") + def query_region_async(self, *, coordinates=None, where=None, mission=None, dataset=None, table=None, columns=None, width=None, height=None, action='search', intersect='OVERLAPS', most_centered=False): """ @@ -143,7 +165,7 @@ def query_region_async(self, *, coordinate=None, where=None, mission=None, datas Parameters ---------- - coordinate : str, `astropy.coordinates` object + coordinates : str, `astropy.coordinates` object Gives the position of the center of the box if performing a box search. If it is a string, then it must be a valid argument to `~astropy.coordinates.SkyCoord`. Required if ``where`` is absent. @@ -198,9 +220,9 @@ def query_region_async(self, *, coordinate=None, where=None, mission=None, datas The HTTP response returned from the service """ - if coordinate is None and where is None: + if coordinates is None and where is None: raise InvalidQueryError( - 'At least one of `coordinate` or `where` is required') + 'At least one of `coordinates` or `where` is required') intersect = intersect.upper() if intersect not in ('COVERS', 'ENCLOSED', 'CENTER', 'OVERLAPS'): @@ -224,8 +246,8 @@ def query_region_async(self, *, coordinate=None, where=None, mission=None, datas if most_centered: args['mcen'] = '1' - if coordinate is not None: - c = commons.parse_coordinates(coordinate).transform_to(coord.ICRS) + if coordinates is not None: + c = commons.parse_coordinates(coordinates).transform_to(coord.ICRS) args['POS'] = '{0},{1}'.format(c.ra.deg, c.dec.deg) if width and height: args['SIZE'] = '{0},{1}'.format( diff --git a/astroquery/ipac/irsa/ibe/tests/test_ibe.py b/astroquery/ipac/irsa/ibe/tests/test_ibe.py index c5051b4a28..a53bf88529 100644 --- a/astroquery/ipac/irsa/ibe/tests/test_ibe.py +++ b/astroquery/ipac/irsa/ibe/tests/test_ibe.py @@ -6,6 +6,7 @@ import pytest from astropy.table import Table from astropy.coordinates import SkyCoord +from astropy.utils.exceptions import AstropyDeprecationWarning import astropy.units as u from astroquery.utils.mocks import MockResponse @@ -82,12 +83,31 @@ def test_get_columns(patch_get): def test_ibe_pos(patch_get): table = Ibe.query_region( - coordinate=SkyCoord(148.969687 * u.deg, 69.679383 * u.deg), + coordinates=SkyCoord(148.969687 * u.deg, 69.679383 * u.deg), where='expid <= 43010') assert isinstance(table, Table) assert len(table) == 21 +def test_ibe_pos_deprecated_coordinate(patch_get): + with pytest.warns(AstropyDeprecationWarning): + table = Ibe.query_region( + coordinate=SkyCoord(148.969687 * u.deg, 69.679383 * u.deg), + where='expid <= 43010') + assert isinstance(table, Table) + assert len(table) == 21 + + +def test_ibe_pos_multiple_coordinates(patch_get): + coords = [SkyCoord(148.969687 * u.deg, 69.679383 * u.deg), + SkyCoord(148.969687 * u.deg, 69.679383 * u.deg)] + table = Ibe.query_region(coordinates=coords, where='expid <= 43010') + assert isinstance(table, Table) + assert 'query_index' in table.colnames + assert set(table['query_index']) == {0, 1} + assert len(table) == 42 + + def test_ibe_field_id(patch_get): table = Ibe.query_region( where="ptffield = 4808 and filter='R'") diff --git a/astroquery/ipac/irsa/ibe/tests/test_ibe_remote.py b/astroquery/ipac/irsa/ibe/tests/test_ibe_remote.py index 8bb919e9f1..fc3a0d28c5 100644 --- a/astroquery/ipac/irsa/ibe/tests/test_ibe_remote.py +++ b/astroquery/ipac/irsa/ibe/tests/test_ibe_remote.py @@ -13,7 +13,7 @@ @pytest.mark.remote_data def test_ibe_pos(): table = ibe.Ibe.query_region( - coordinate=SkyCoord(148.969687 * u.deg, 69.679383 * u.deg), + coordinates=SkyCoord(148.969687 * u.deg, 69.679383 * u.deg), where='expid <= 43010') assert isinstance(table, Table) assert len(table) == 21 diff --git a/astroquery/noirlab/core.py b/astroquery/noirlab/core.py index a52744067b..2a89582d7d 100644 --- a/astroquery/noirlab/core.py +++ b/astroquery/noirlab/core.py @@ -6,8 +6,10 @@ """ import astropy.io.fits as fits import astropy.table +from astropy.utils.decorators import deprecated_renamed_argument from ..query import BaseQuery from ..exceptions import RemoteServiceError +from ..utils.multicoord import support_multiple_coordinates from . import conf @@ -144,7 +146,9 @@ def _service_metadata(self, hdu=False, cache=True): response = self._request('GET', url, timeout=self.TIMEOUT, cache=cache) return response.json() - def query_region(self, coordinate, *, radius=0.1, hdu=False, cache=True, async_=False): + @deprecated_renamed_argument("coordinate", "coordinates", since="0.4.12") + @support_multiple_coordinates() + def query_region(self, coordinates, *, 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` @@ -152,9 +156,14 @@ def query_region(self, coordinate, *, radius=0.1, hdu=False, cache=True, async_= Parameters ---------- - coordinate : :class:`str` or `~astropy.coordinates` object + coordinates : :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. + A list of coordinates or a vector `~astropy.coordinates.SkyCoord` + may also be given, in which case one query is run per position + and the results are stacked into a single table with a + ``query_index`` column identifying the input position each row + came from. 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 @@ -173,7 +182,7 @@ def query_region(self, coordinate, *, radius=0.1, hdu=False, cache=True, async_= A table containing the results. """ self._validate_version() - ra, dec = coordinate.to_string('decimal').split() + ra, dec = coordinates.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_: diff --git a/astroquery/noirlab/tests/test_noirlab.py b/astroquery/noirlab/tests/test_noirlab.py index 05c50ecd4a..5d8fbf85ea 100644 --- a/astroquery/noirlab/tests/test_noirlab.py +++ b/astroquery/noirlab/tests/test_noirlab.py @@ -6,6 +6,7 @@ import pytest from astropy import units as u from astropy.coordinates import SkyCoord +from astropy.utils.exceptions import AstropyDeprecationWarning from ...utils.mocks import MockResponse from ...exceptions import RemoteServiceError from .. import NOIRLab @@ -68,7 +69,7 @@ def test_query_region(patch_request, hdu, radius): It is OK if more files have been added to the remote Archive. """ c = SkyCoord(ra=10.625*u.degree, dec=41.2*u.degree, frame='icrs') - r = NOIRLab.query_region(c, radius=radius, hdu=hdu) + r = NOIRLab.query_region(coordinates=c, radius=radius, hdu=hdu) actual = set(r['md5sum'].tolist()) if hdu: expected = exp.query_region_2 @@ -77,6 +78,28 @@ def test_query_region(patch_request, hdu, radius): assert expected.issubset(actual) +def test_query_region_deprecated_coordinate(patch_request): + """The old ``coordinate`` keyword should still work but emit a deprecation warning. + """ + c = SkyCoord(ra=10.625*u.degree, dec=41.2*u.degree, frame='icrs') + with pytest.warns(AstropyDeprecationWarning): + r = NOIRLab.query_region(coordinate=c, radius='0.1') + actual = set(r['md5sum'].tolist()) + assert exp.query_region_1.issubset(actual) + + +def test_query_region_multiple_coordinates(patch_request): + """A vector SkyCoord should run one query per position and stack the results. + """ + c = SkyCoord(ra=[10.625, 20.5]*u.degree, dec=[41.2, -30.0]*u.degree, frame='icrs') + r = NOIRLab.query_region(coordinates=c, radius='0.1') + assert 'query_index' in r.colnames + assert set(r['query_index'].tolist()) == {0, 1} + assert len(r) == 2 * len(exp.query_region_1) + assert exp.query_region_1.issubset(set(r['md5sum'][r['query_index'] == 0].tolist())) + assert exp.query_region_1.issubset(set(r['md5sum'][r['query_index'] == 1].tolist())) + + @pytest.mark.parametrize('hdu', [(False,), (True,)]) def test_core_fields(patch_request, hdu): """List the available CORE fields. diff --git a/astroquery/noirlab/tests/test_noirlab_remote.py b/astroquery/noirlab/tests/test_noirlab_remote.py index 2bda39bc66..cc1b6e4349 100644 --- a/astroquery/noirlab/tests/test_noirlab_remote.py +++ b/astroquery/noirlab/tests/test_noirlab_remote.py @@ -36,7 +36,7 @@ def test_query_region(hdu, radius): """ conf.timeout = 300 c = SkyCoord(ra=10.625*u.degree, dec=41.2*u.degree, frame='icrs') - r = NOIRLab().query_region(c, radius=radius, hdu=hdu) + r = NOIRLab().query_region(coordinates=c, radius=radius, hdu=hdu) actual = set(r['md5sum'].tolist()) if hdu: expected = exp.query_region_2 diff --git a/astroquery/ogle/core.py b/astroquery/ogle/core.py index 96a22ef04f..9c2db3b70a 100644 --- a/astroquery/ogle/core.py +++ b/astroquery/ogle/core.py @@ -4,6 +4,7 @@ import functools import numpy as np from astropy.table import Table +from astropy.utils.decorators import deprecated_renamed_argument from ..query import BaseQuery from ..utils import commons, async_to_sync @@ -37,7 +38,7 @@ def wrapper(*args, **kwargs): class CoordParseError(ValueError): - def __init__(self, message='Could not parse `coord` argument.', **kwargs): + def __init__(self, message='Could not parse `coordinates` argument.', **kwargs): super().__init__(message, **kwargs) @@ -52,14 +53,15 @@ class OgleClass(BaseQuery): coord_systems = ['RD', 'LB'] @_validate_params - def query_region_async(self, *, coord=None, algorithm='NG', quality='GOOD', + @deprecated_renamed_argument("coord", "coordinates", since="0.4.12") + def query_region_async(self, *, coordinates=None, algorithm='NG', quality='GOOD', coord_sys='RD', get_query_payload=False): """ Query the OGLE-III interstellar extinction calculator. Parameters ---------- - coord : list-like + coordinates : list-like Pointings to evaluate interstellar extinction. Three forms of coordinates may be passed:: @@ -80,7 +82,7 @@ def query_region_async(self, *, coord=None, algorithm='NG', quality='GOOD', * 'GOOD': QF=0 as described in Nataf et al. (2012). coord_sys : string - Coordinate system if using lists of RA/Decs in ``coord``. + Coordinate system if using lists of RA/Decs in ``coordinates``. Valid options:: * 'RD': equatorial coordinates @@ -109,14 +111,14 @@ def query_region_async(self, *, coord=None, algorithm='NG', quality='GOOD', >>> co = SkyCoord(0.0, 3.0, unit=(u.degree, u.degree), ... frame='galactic') >>> from astroquery.ogle import Ogle - >>> t = Ogle.query_region(coord=co) + >>> t = Ogle.query_region(coordinates=co) >>> t.pprint() RA/LON Dec/Lat A_I E(V-I) S_E(V-I) R_JKVI mu S_mu --------- ---------- ----- ------ -------- ------ ------ ----- ... 17.568157 -27.342475 3.126 2.597 0.126 0.3337 14.581 0.212 """ - # Determine the coord object type and generate list of coordinates - lon, lat = self._parse_coords(coord, coord_sys) + # Determine the coordinates object type and generate list of coordinates + lon, lat = self._parse_coords(coordinates, coord_sys) # Generate payload query_header = '# {0} {1} {2}\n'.format(coord_sys, algorithm, quality) sources = '\n'.join(['{0} {1}'.format(lo, la) for lo, la in @@ -138,14 +140,14 @@ def _parse_result(self, response, *, verbose=False): t = Table.read(response.text.split('\n'), format='ascii') return t - def _parse_coords(self, coord, coord_sys): + def _parse_coords(self, coordinates, coord_sys): """ Parse single astropy.coordinates instance, list of astropy.coordinate instances, or 2xN list of coordinate values. Parameters ---------- - coord : list-like + coordinates : list-like coord_sys : string Returns @@ -155,21 +157,21 @@ def _parse_coords(self, coord, coord_sys): lat : list Latitude coordinate values """ - if not isinstance(coord, list): + if not isinstance(coordinates, list): # single astropy coordinate try: - ra, dec = commons.coord_to_radec(coord) + ra, dec = commons.coord_to_radec(coordinates) lon = [ra] lat = [dec] return lon, lat except ValueError: raise CoordParseError() - elif isinstance(coord, list): - shape = np.shape(coord) + elif isinstance(coordinates, list): + shape = np.shape(coordinates) # list of astropy coordinates if len(shape) == 1: try: - radec = [commons.coord_to_radec(co) for co in coord] + radec = [commons.coord_to_radec(co) for co in coordinates] lon, lat = list(zip(*radec)) return lon, lat except ValueError: diff --git a/astroquery/ogle/tests/test_ogle.py b/astroquery/ogle/tests/test_ogle.py index 7c02a7e7c3..8d7ffe9015 100644 --- a/astroquery/ogle/tests/test_ogle.py +++ b/astroquery/ogle/tests/test_ogle.py @@ -5,6 +5,7 @@ import pytest from astropy.coordinates import SkyCoord from astropy import units as u +from astropy.utils.exceptions import AstropyDeprecationWarning from astroquery.utils.mocks import MockResponse @@ -40,7 +41,7 @@ def test_ogle_single(patch_post): Test a single pointing using an astropy coordinate instance """ co = SkyCoord(0, 3, unit=(u.degree, u.degree), frame='galactic') - ogle.core.Ogle.query_region(coord=co) + ogle.core.Ogle.query_region(coordinates=co) def test_ogle_list(patch_post): @@ -49,7 +50,17 @@ def test_ogle_list(patch_post): """ co = SkyCoord(0, 3, unit=(u.degree, u.degree), frame='galactic') co_list = [co, co, co] - ogle.core.Ogle.query_region(coord=co_list) + ogle.core.Ogle.query_region(coordinates=co_list) + + +def test_ogle_deprecated_coord_keyword(patch_post): + """ + Test that the deprecated ``coord`` keyword still works and emits a + deprecation warning + """ + co = SkyCoord(0, 3, unit=(u.degree, u.degree), frame='galactic') + with pytest.warns(AstropyDeprecationWarning): + ogle.core.Ogle.query_region(coord=co) def test_ogle_single_payload(): @@ -57,7 +68,7 @@ def test_ogle_single_payload(): Test single pointing payload """ co = SkyCoord(0*u.deg, 3*u.deg, frame='galactic') - payload = ogle.core.Ogle.query_region(coord=co, get_query_payload=True) + payload = ogle.core.Ogle.query_region(coordinates=co, get_query_payload=True) fk5 = co.transform_to('fk5') ra = fk5.ra.hour dec = fk5.dec.degree @@ -74,7 +85,7 @@ def test_ogle_multipointing_payload(): co2 = SkyCoord(4*u.deg, 5*u.deg, frame='galactic') pointings = [co1, co2] payload = ogle.core.Ogle.query_region( - coord=pointings, + coordinates=pointings, get_query_payload=True ) conversions = [] diff --git a/astroquery/ogle/tests/test_ogle_remote.py b/astroquery/ogle/tests/test_ogle_remote.py index 23365a7af9..ebee61cad3 100644 --- a/astroquery/ogle/tests/test_ogle_remote.py +++ b/astroquery/ogle/tests/test_ogle_remote.py @@ -9,7 +9,7 @@ @pytest.mark.remote_data def test_ogle_single(): co = SkyCoord(0, 3, unit=(u.degree, u.degree), frame='galactic') - response = Ogle.query_region(coord=co) + response = Ogle.query_region(coordinates=co) assert len(response) == 1 @@ -17,6 +17,6 @@ def test_ogle_single(): def test_ogle_list(): co = SkyCoord(0, 3, unit=(u.degree, u.degree), frame='galactic') co_list = [co, co, co] - response = Ogle.query_region(coord=co_list) + response = Ogle.query_region(coordinates=co_list) assert len(response) == 3 assert response['RA[hr]'][0] == response['RA[hr]'][1] == response['RA[hr]'][2] diff --git a/astroquery/utils/multicoord.py b/astroquery/utils/multicoord.py new file mode 100644 index 0000000000..2edda5b45e --- /dev/null +++ b/astroquery/utils/multicoord.py @@ -0,0 +1,186 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst +""" +Support for looping single-position query methods over multiple coordinates. + +Most archive query methods accept a single sky position per request. The +`support_multiple_coordinates` decorator lets such a method transparently +accept a vector `~astropy.coordinates.SkyCoord` or a list of coordinates +(or coordinate strings): the method is called once per position, a few +requests at a time in parallel, and the per-position results are combined. + +The parallelism is deliberately conservative so that scripted use does not +overload archive servers; it can be tuned through +``astroquery.utils.multicoord.conf``. +""" +import functools +import inspect +import threading +import time +from concurrent.futures import ThreadPoolExecutor + +from astropy import config as _config +from astropy.coordinates import SkyCoord, BaseCoordinateFrame +from astropy.table import Table, vstack + +from .commons import TableList + +__all__ = ['support_multiple_coordinates', 'conf', 'Conf'] + + +class Conf(_config.ConfigNamespace): + """Configuration for queries looped over multiple coordinates.""" + + max_parallel_queries = _config.ConfigItem( + 3, + 'Maximum number of requests sent in parallel when a query method is ' + 'called with multiple coordinates. Keep this small: archive servers ' + 'are a shared resource, and aggressive parallelism can degrade the ' + 'service or get a client blocked. Do not raise this above what the ' + 'archive documentation explicitly allows.', + cfgtype='integer') + + min_request_interval = _config.ConfigItem( + 0.3, + 'Minimum delay (seconds) between submitting consecutive requests of ' + 'a multi-coordinate query, across all parallel workers. This bounds ' + 'the aggregate request rate at 1/min_request_interval regardless of ' + 'how many workers run in parallel. Set to 0 to disable.', + cfgtype='float') + + +conf = Conf() + + +class _Throttle: + """Enforce a minimum interval between request submissions (thread-safe).""" + + def __init__(self, interval): + self._interval = interval + self._lock = threading.Lock() + self._next_time = None + + def wait(self): + if self._interval <= 0: + return + with self._lock: + now = time.monotonic() + if self._next_time is None or now >= self._next_time: + delay = 0. + self._next_time = now + self._interval + else: + delay = self._next_time - now + self._next_time += self._interval + if delay > 0: + time.sleep(delay) + + +def _coordinate_list(value): + """Return a list of single positions if ``value`` holds several, else None. + + Lists and tuples are interpreted as one entry per position; a + non-scalar (vector) `~astropy.coordinates.SkyCoord` or coordinate frame + is split into its scalar elements. Anything else (a string, a scalar + coordinate, None) is treated as a single position. + """ + if isinstance(value, (list, tuple)): + if len(value) == 0: + raise ValueError("An empty list of coordinates was provided.") + return list(value) + if (isinstance(value, (SkyCoord, BaseCoordinateFrame)) + and not value.isscalar): + return list(value) + return None + + +def _stack_tables(tables): + if all('query_index' not in tbl.colnames for tbl in tables): + for index, tbl in enumerate(tables): + if len(tbl.colnames) > 0: + tbl['query_index'] = index + return vstack(tables, metadata_conflicts='silent') + + +def _combine_results(results): + """Combine per-coordinate results into a single object where possible. + + Tables are stacked (gaining a ``query_index`` column identifying which + input position each row came from, unless a column of that name already + exists). `~astroquery.utils.TableList` results are merged key by key. + Any other result type is returned as a plain list, in input order. + """ + if all(isinstance(result, Table) for result in results): + return _stack_tables(results) + if all(isinstance(result, TableList) for result in results): + keys = [] + for result in results: + keys.extend(key for key in result.keys() if key not in keys) + merged = {} + for key in keys: + for index, result in enumerate(results): + if (key in result.keys() + and 'query_index' not in result[key].colnames + and len(result[key].colnames) > 0): + result[key]['query_index'] = index + merged[key] = vstack([result[key] for result in results + if key in result.keys()], + metadata_conflicts='silent') + return TableList(merged) + return list(results) + + +def support_multiple_coordinates(coord_arg='coordinates', max_workers=None): + """Allow a single-position query method to accept multiple coordinates. + + When the decorated method receives a vector + `~astropy.coordinates.SkyCoord` or a list of coordinates (or coordinate + strings) in its ``coord_arg`` argument, it is called once per position + and the results are combined: tables are stacked into one table with an + added ``query_index`` column mapping rows back to the input positions; + other return types come back as a list in input order. + + A bounded worker pool (``conf.max_parallel_queries``, default 3) issues + the requests, and consecutive request submissions are spaced by at least + ``conf.min_request_interval`` seconds in aggregate, to stay well below + archive rate limits. An exception in any single request propagates and + aborts the combined result. + + Parameters + ---------- + coord_arg : str + Name of the coordinate parameter of the wrapped method. + max_workers : int, optional + Per-method cap on parallel requests, overriding + ``conf.max_parallel_queries`` (use for services with stricter + documented limits). + """ + def decorator(func): + signature = inspect.signature(func) + if coord_arg not in signature.parameters: + raise TypeError(f"'{func.__name__}' has no '{coord_arg}' parameter") + + @functools.wraps(func) + def wrapper(*args, **kwargs): + bound = signature.bind(*args, **kwargs) + coords = _coordinate_list(bound.arguments.get(coord_arg)) + if coords is None: + return func(*args, **kwargs) + + nworkers = max_workers if max_workers is not None else conf.max_parallel_queries + nworkers = max(1, min(int(nworkers), len(coords))) + throttle = _Throttle(conf.min_request_interval) + + def run_one(coord): + rebound = signature.bind(*args, **kwargs) + rebound.arguments[coord_arg] = coord + throttle.wait() + return func(*rebound.args, **rebound.kwargs) + + if nworkers == 1: + results = [run_one(coord) for coord in coords] + else: + with ThreadPoolExecutor(max_workers=nworkers) as executor: + results = list(executor.map(run_one, coords)) + return _combine_results(results) + + return wrapper + return decorator diff --git a/astroquery/utils/tests/test_multicoord.py b/astroquery/utils/tests/test_multicoord.py new file mode 100644 index 0000000000..2bc3438fb9 --- /dev/null +++ b/astroquery/utils/tests/test_multicoord.py @@ -0,0 +1,151 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst +import threading + +import pytest + +import astropy.units as u +from astropy.coordinates import SkyCoord +from astropy.table import Table +from astropy.utils.decorators import deprecated_renamed_argument +from astropy.utils.exceptions import AstropyDeprecationWarning + +from astroquery.utils.commons import TableList +from astroquery.utils.multicoord import support_multiple_coordinates, conf + + +COORDS = SkyCoord([10, 20, 30] * u.deg, [-10, 0, 10] * u.deg) + + +class DummyQuery: + """Single-position query class for exercising the decorator.""" + + def __init__(self): + self.lock = threading.Lock() + self.active = 0 + self.max_active = 0 + + def _track(self): + with self.lock: + self.active += 1 + self.max_active = max(self.max_active, self.active) + + def _untrack(self): + with self.lock: + self.active -= 1 + + @support_multiple_coordinates() + def query_region(self, coordinates, radius=1 * u.deg): + if not (isinstance(coordinates, str) or coordinates.isscalar): + raise TypeError("scalar input required") + self._track() + try: + ra = (coordinates if isinstance(coordinates, str) + else str(coordinates.ra.deg)) + result = Table({'ra': [ra], 'radius': [str(radius)]}) + finally: + self._untrack() + return result + + @support_multiple_coordinates(coord_arg='coordinates', max_workers=1) + def query_serial(self, coordinates): + self._track() + try: + with self.lock: + concurrent = self.active + finally: + self._untrack() + return Table({'concurrent': [concurrent]}) + + @support_multiple_coordinates() + def query_payload(self, coordinates, get_query_payload=False): + return {'POS': str(coordinates)} + + @support_multiple_coordinates() + def query_tablelist(self, coordinates): + return TableList({'mission': Table({'ra': [str(coordinates)]})}) + + +def test_scalar_passthrough(): + result = DummyQuery().query_region(COORDS[0]) + assert isinstance(result, Table) + assert len(result) == 1 + assert 'query_index' not in result.colnames + + +def test_string_passthrough(): + result = DummyQuery().query_region("10 -10") + assert len(result) == 1 + + +def test_vector_skycoord_stacks_in_order(): + result = DummyQuery().query_region(COORDS, radius=2 * u.deg) + assert isinstance(result, Table) + assert len(result) == 3 + assert list(result['query_index']) == [0, 1, 2] + assert list(result['ra']) == [str(c.ra.deg) for c in COORDS] + assert all(result['radius'] == str(2 * u.deg)) + + +def test_list_of_coordinates(): + result = DummyQuery().query_region([COORDS[0], COORDS[1]]) + assert len(result) == 2 + assert list(result['query_index']) == [0, 1] + + +def test_list_of_strings(): + result = DummyQuery().query_region(["10 -10", "20 0"]) + assert list(result['ra']) == ["10 -10", "20 0"] + + +def test_empty_list_raises(): + with pytest.raises(ValueError, match="empty list"): + DummyQuery().query_region([]) + + +def test_max_workers_one_is_serial(): + dummy = DummyQuery() + result = dummy.query_serial(list(COORDS)) + assert dummy.max_active == 1 + assert len(result) == 3 + + +def test_parallelism_is_bounded(): + dummy = DummyQuery() + coords = SkyCoord(range(1, 21) * u.deg, range(1, 21) * u.deg) + with conf.set_temp('max_parallel_queries', 2), \ + conf.set_temp('min_request_interval', 0): + dummy.query_region(coords) + assert dummy.max_active <= 2 + + +def test_non_table_results_return_list(): + result = DummyQuery().query_payload(list(COORDS[:2]), get_query_payload=True) + assert isinstance(result, list) + assert len(result) == 2 + assert all('POS' in payload for payload in result) + + +def test_tablelist_results_merge(): + result = DummyQuery().query_tablelist(list(COORDS[:2])) + assert isinstance(result, TableList) + assert len(result['mission']) == 2 + assert list(result['mission']['query_index']) == [0, 1] + + +def test_missing_coord_arg_raises_at_definition(): + with pytest.raises(TypeError, match="no 'coordinates' parameter"): + @support_multiple_coordinates() + def query(self, position): + pass + + +def test_composes_with_deprecated_renamed_argument(): + class Legacy: + @deprecated_renamed_argument("coordinate", "coordinates", since="0.4.12") + @support_multiple_coordinates() + def query_region(self, coordinates=None): + return Table({'ra': [str(coordinates)]}) + + with pytest.warns(AstropyDeprecationWarning): + result = Legacy().query_region(coordinate=["10 -10", "20 0"]) + assert len(result) == 2 diff --git a/docs/esa/euclid/euclid.rst b/docs/esa/euclid/euclid.rst index cad80ee9c4..a52f11304f 100644 --- a/docs/esa/euclid/euclid.rst +++ b/docs/esa/euclid/euclid.rst @@ -143,7 +143,7 @@ the "mer_catalogue" and its outcome is restricted to 50 rows. >>> import astropy.units as u >>> coord = SkyCoord("17h51m07.4s +65d31m50.8s", frame='icrs') >>> radius = u.Quantity(0.5, u.deg) - >>> job = Euclid.cone_search(coordinate=coord, radius=radius, columns="*", async_job=True) # doctest: +IGNORE_WARNINGS + >>> job = Euclid.cone_search(coordinates=coord, radius=radius, columns="*", async_job=True) # doctest: +IGNORE_WARNINGS INFO: Query finished. [astroquery.utils.tap.core] >>> cone_results = job.get_results() >>> print(f"Found {len(cone_results)} results") @@ -163,7 +163,7 @@ that the name is recognised by the Simbad, VizieR, or NED services. >>> radius = u.Quantity(0.2, u.deg) >>> Euclid.ROW_LIMIT = -1 - >>> job = Euclid.cone_search(coordinate='NGC 6505', radius=radius, table_name="sedm.calibrated_frame", ra_column_name="ra", dec_column_name="dec", async_job=True, columns = ['ra', 'dec', 'datalabs_path', 'file_path', 'file_name', 'observation_id', 'instrument_name']) + >>> job = Euclid.cone_search(coordinates='NGC 6505', radius=radius, table_name="sedm.calibrated_frame", ra_column_name="ra", dec_column_name="dec", async_job=True, columns = ['ra', 'dec', 'datalabs_path', 'file_path', 'file_name', 'observation_id', 'instrument_name']) INFO: Query finished. [astroquery.utils.tap.core] >>> res = job.get_results() >>> print(f"* Found {len(res)} results") @@ -375,7 +375,7 @@ Download the cutout... .. doctest-skip:: >>> file_path = f"{res['file_path'][0]}/{res['file_name'][0]}" - >>> cutout_out = Euclid.get_cutout(file_path=file_path, coordinate='NGC 6505', radius= 0.1 * u.arcmin, output_file='ngc6505_cutout_mer.fits') + >>> cutout_out = Euclid.get_cutout(file_path=file_path, coordinates='NGC 6505', radius= 0.1 * u.arcmin, output_file='ngc6505_cutout_mer.fits') >>> cutout_out = cutout_out[0] ... and inspect its content: diff --git a/docs/esa/jwst/jwst.rst b/docs/esa/jwst/jwst.rst index 8e6535aa15..377b678a28 100644 --- a/docs/esa/jwst/jwst.rst +++ b/docs/esa/jwst/jwst.rst @@ -101,7 +101,7 @@ service degradation. >>> coord = SkyCoord(ra=53, dec=-27, unit=(u.degree, u.degree), frame='icrs') >>> width = u.Quantity(5, u.deg) >>> height = u.Quantity(5, u.deg) - >>> result = Jwst.query_region(coordinate=coord, width=width, height=height) + >>> result = Jwst.query_region(coordinates=coord, width=width, height=height) >>> result # doctest: +IGNORE_OUTPUT dist observationid ... ------------------ ------------------------------ ... @@ -129,7 +129,7 @@ service degradation. >>> >>> coord = SkyCoord(ra=53, dec=-27, unit=(u.degree, u.degree), frame='icrs') >>> radius = u.Quantity(5.0, u.deg) - >>> j = Jwst.cone_search(coordinate=coord, radius=radius, async_job=True) + >>> j = Jwst.cone_search(coordinates=coord, radius=radius, async_job=True) INFO: Query finished. [astroquery.utils.tap.core] >>> result = j.get_results() >>> result # doctest: +IGNORE_OUTPUT diff --git a/docs/esasky/esasky.rst b/docs/esasky/esasky.rst index 13d2cfa021..37a49e5717 100644 --- a/docs/esasky/esasky.rst +++ b/docs/esasky/esasky.rst @@ -182,7 +182,7 @@ value for the radius of the region. For instance to query region around M51 in t >>> from astroquery.esasky import ESASky >>> import astropy.units as u - >>> result = ESASky.query_region_catalogs(position="M51", radius=10 * u.arcmin, catalogs=["HSC", "XMM-OM"]) + >>> result = ESASky.query_region_catalogs(coordinates="M51", radius=10 * u.arcmin, catalogs=["HSC", "XMM-OM"]) To search in all available catalogs you can write ``"all"`` instead of a catalog name. The same thing will happen if you don't write any catalog name. @@ -190,8 +190,8 @@ In the same manner, the radius can be specified with either a string or any `~as .. doctest-remote-data:: - >>> result = ESASky.query_region_catalogs(position="M51", radius=10 * u.arcmin, catalogs="all") - >>> result = ESASky.query_region_catalogs(position="M51", radius="10 arcmin") + >>> result = ESASky.query_region_catalogs(coordinates="M51", radius=10 * u.arcmin, catalogs="all") + >>> result = ESASky.query_region_catalogs(coordinates="M51", radius="10 arcmin") To see the result: @@ -228,8 +228,8 @@ you write this: .. doctest-remote-data:: - >>> result = ESASky.query_region_maps(position="M51", radius=10 * u.arcmin, missions="all") - >>> result = ESASky.query_region_spectra(position="M51", radius=10 * u.arcmin, missions="all") + >>> result = ESASky.query_region_maps(coordinates="M51", radius=10 * u.arcmin, missions="all") + >>> result = ESASky.query_region_spectra(coordinates="M51", radius=10 * u.arcmin, missions="all") The parameters are interchangeable in the same way as in :meth:`~astroquery.esasky.ESASkyClass.query_region_catalogs`. @@ -306,7 +306,7 @@ position, radius and missions. .. Skip testing examples with with hard-wired download dir values .. doctest-remote-data:: - >>> table_list = ESASky.query_region_maps(position="V* HT Aqr", radius="15 arcmin", missions=['Herschel', 'ISO-IR']) + >>> table_list = ESASky.query_region_maps(coordinates="V* HT Aqr", radius="15 arcmin", missions=['Herschel', 'ISO-IR']) >>> images = ESASky.get_maps(query_table_list=table_list, download_dir="/home/user/esasky") # doctest: +SKIP This example is equivalent to: @@ -341,7 +341,7 @@ or .. doctest-remote-data:: - >>> table_list = ESASky.query_region_spectra(position="Gaia DR3 4512810408088819712", radius="6.52 arcmin", + >>> table_list = ESASky.query_region_spectra(coordinates="Gaia DR3 4512810408088819712", radius="6.52 arcmin", ... missions=['Herschel', 'XMM-NEWTON']) >>> spectra = ESASky.get_spectra_from_table(query_table_list=table_list, download_dir="/home/user/esasky") # doctest: +SKIP dict: { @@ -357,7 +357,7 @@ Here is another example for Herschel, since it is a bit special: .. doctest-remote-data:: >>> from astroquery.esasky import ESASky - >>> result = ESASky.query_region_spectra(position='[SMB88] 6327', radius='1 arcmin', missions=['HERSCHEL']) + >>> result = ESASky.query_region_spectra(coordinates='[SMB88] 6327', radius='1 arcmin', missions=['HERSCHEL']) >>> herschel_result = result['HERSCHEL'] >>> herschel_result['observation_id', 'target_name', 'instrument', 'observing_mode_name', 'band', 'duration'].pprint() observation_id target_name instrument ... band duration diff --git a/docs/gaia/gaia.rst b/docs/gaia/gaia.rst index 6066b6aefd..f2bbd37be4 100644 --- a/docs/gaia/gaia.rst +++ b/docs/gaia/gaia.rst @@ -102,7 +102,7 @@ degrees around an specific point in RA/Dec coordinates. The results are sorted b >>> coord = SkyCoord(ra=280, dec=-60, unit=(u.degree, u.degree), frame='icrs') >>> width = u.Quantity(0.1, u.deg) >>> height = u.Quantity(0.1, u.deg) - >>> r = Gaia.query_object_async(coordinate=coord, width=width, height=height) + >>> r = Gaia.query_object_async(coordinates=coord, width=width, height=height) INFO: Query finished. [astroquery.utils.tap.core] >>> r.pprint(max_lines=12, max_width=130) dist solution_id designation ... ebpminrp_gspphot_upper libname_gspphot @@ -123,7 +123,7 @@ Queries return a limited number of rows controlled by ``Gaia.ROW_LIMIT``. To cha .. doctest-remote-data:: >>> Gaia.ROW_LIMIT = 8 - >>> r = Gaia.query_object_async(coordinate=coord, width=width, height=height) + >>> r = Gaia.query_object_async(coordinates=coord, width=width, height=height) INFO: Query finished. [astroquery.utils.tap.core] >>> r.pprint(max_width=140) dist solution_id designation ... ebpminrp_gspphot_lower ebpminrp_gspphot_upper libname_gspphot @@ -143,7 +143,7 @@ To return an unlimited number of rows set ``Gaia.ROW_LIMIT`` to -1. .. doctest-remote-data:: >>> Gaia.ROW_LIMIT = -1 - >>> r = Gaia.query_object_async(coordinate=coord, width=width, height=height) + >>> r = Gaia.query_object_async(coordinates=coord, width=width, height=height) INFO: Query finished. [astroquery.utils.tap.core] >>> r.pprint(max_lines=12, max_width=140) dist solution_id designation ... ebpminrp_gspphot_lower ebpminrp_gspphot_upper libname_gspphot diff --git a/docs/ogle/ogle.rst b/docs/ogle/ogle.rst index e099f163f3..d63970173b 100644 --- a/docs/ogle/ogle.rst +++ b/docs/ogle/ogle.rst @@ -19,7 +19,7 @@ using an `astropy.coordinates` instance use: >>> from astropy import units as u >>> from astroquery.ogle import Ogle >>> co = SkyCoord(0*u.deg, 3*u.deg, frame='galactic') - >>> t = Ogle.query_region(coord=co) + >>> t = Ogle.query_region(coordinates=co) Arguments can be passed to choose the interpolation algorithm, quality factor, and coordinate system. Multiple coordinates may be queried simultaneously by @@ -30,10 +30,10 @@ to FK5. >>> # list of coordinate instances >>> co_list = [co, co, co] - >>> t1 = Ogle.query_region(coord=co_list) + >>> t1 = Ogle.query_region(coordinates=co_list) >>> # (2 x N) list of values >>> co_list_values = SkyCoord([0, 1, 2], [3, 3, 3], unit=u.deg, frame='galactic') - >>> t2 = Ogle.query_region(coord=co_list_values, coord_sys='LB') + >>> t2 = Ogle.query_region(coordinates=co_list_values, coord_sys='LB') Note that non-Astropy coordinates may not be supported in a future version. diff --git a/docs/utils.rst b/docs/utils.rst index 83e82eb69d..8fbdcabd35 100644 --- a/docs/utils.rst +++ b/docs/utils.rst @@ -6,12 +6,55 @@ Astroquery utils (`astroquery.utils`) ************************************* +Querying multiple coordinates +============================= + +Several query methods that accept a single sky position per request (for +example ``Gaia.query_object``, ``Heasarc.query_region``, +``Alma.query_region``) also accept a vector +`~astropy.coordinates.SkyCoord` or a list of coordinates (or coordinate +strings). In that case astroquery submits one request per position and +combines the results: methods returning a `~astropy.table.Table` return a +single stacked table with an added ``query_index`` column mapping each row +back to the input position, while other return types come back as a list +in input order. + +.. code-block:: python + + from astropy.coordinates import SkyCoord + import astropy.units as u + from astroquery.heasarc import Heasarc + + coords = SkyCoord([182.6, 10.7] * u.deg, [39.4, 41.3] * u.deg) + result = Heasarc.query_region(coords, catalog='suzamaster', radius=2 * u.arcmin) + +The requests are issued through a small worker pool with a minimum spacing +between submissions, so that looped queries stay well below request rates +that could degrade the archive services. None of the affected archives +publish a hard rate limit, so the defaults are deliberately conservative; +they can be tuned through ``astroquery.utils.multicoord.conf``: + +.. code-block:: python + + from astroquery.utils.multicoord import conf + + conf.max_parallel_queries = 2 # parallel requests (default 3) + conf.min_request_interval = 1. # seconds between submissions (default 0.3) + +Please be considerate: archive servers are a shared resource, raising these +values can get a client blocked, and where a service offers a native +multi-position interface (for example a TAP table upload), that is always +preferable to a long client-side loop. + Reference/API ============= .. automodapi:: astroquery.utils :no-inheritance-diagram: +.. automodapi:: astroquery.utils.multicoord + :no-inheritance-diagram: + .. automodapi:: astroquery.utils.timer :no-inheritance-diagram: