Skip to content

Commit 2a06262

Browse files
authored
Merge pull request #3511 from esdc-esac-esa-int/ESA_isla-emds-epsa_improvements
ESA: abstract EsaTap using PyVO, HST and ISLA refactored, EMDS module and EinsteinProbe module
2 parents 234c624 + dbf41fd commit 2a06262

File tree

25 files changed

+3602
-407
lines changed

25 files changed

+3602
-407
lines changed

CHANGES.rst

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,26 @@ noirlab
1010
- Restore access to the `NSF NOIRLab <https://noirlab.edu>`_
1111
`Astro Data Archive <https://astroarchive.noirlab.edu>`_ [#3359].
1212

13+
esa.emds
14+
^^^^^^^^
15+
16+
- New module to access the ESA ESDC Multi-Mission Data Services (EMDS). [#3511]
17+
18+
esa.emds.einsteinprobe
19+
^^^^^^^^^^^^^^^^^^^^^^
20+
21+
- New module to access the ESA Einstein Probe Science Archive. [#3511]
22+
23+
24+
1325
API changes
1426
-----------
1527

28+
esa.utils
29+
^^^^^^^^^^
30+
31+
- Class EsaTap created as abstract class to extend all ESA modules based on PyVO. [#3511]
32+
1633
esa.euclid
1734
^^^^^^^^^^
1835

astroquery/esa/emds/__init__.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""
2+
=========
3+
EMDS Init
4+
=========
5+
6+
European Space Astronomy Centre (ESAC)
7+
European Space Agency (ESA)
8+
9+
"""
10+
11+
from astropy import config as _config
12+
13+
EMDS_DOMAIN = 'https://emds.esac.esa.int/service/'
14+
EMDS_TAP_URL = EMDS_DOMAIN + 'tap'
15+
16+
17+
class Conf(_config.ConfigNamespace):
18+
"""
19+
Configuration parameters for `astroquery.esa.emds`.
20+
"""
21+
EMDS_TAP_SERVER = _config.ConfigItem(EMDS_TAP_URL, "EMDS TAP Server")
22+
EMDS_DATA_SERVER = _config.ConfigItem(EMDS_DOMAIN + 'data?', "EMDS Data Server")
23+
EMDS_LOGIN_SERVER = _config.ConfigItem(EMDS_DOMAIN + 'login', "EMDS Login Server")
24+
EMDS_LOGOUT_SERVER = _config.ConfigItem(EMDS_DOMAIN + 'logout', "EMDS Logout Server")
25+
EMDS_SERVLET = _config.ConfigItem(EMDS_TAP_URL + "/sync/?PHASE=RUN",
26+
"EMDS Sync Request")
27+
EMDS_TARGET_RESOLVER = _config.ConfigItem(EMDS_DOMAIN + "servlet/target-resolver?TARGET_NAME={}"
28+
"&RESOLVER_TYPE={}&FORMAT=json",
29+
"EMDS Target Resolver Request")
30+
DEFAULT_SCHEMAS = _config.ConfigItem("",
31+
"Default TAP schema(s) used to filter available tables. "
32+
"If empty, no schema-based filtering is applied and all tables are returned. "
33+
"Use a comma-separated list if multiple schemas are required."
34+
"e.g. \"schema1, schema2, schema3\".")
35+
OBSCORE_TABLE = _config.ConfigItem("ivoa.ObsCore",
36+
"Fully qualified ObsCore table or view name (including schema)")
37+
38+
TIMEOUT = 60
39+
40+
41+
conf = Conf()
42+
43+
from .core import Emds, EmdsClass
44+
45+
__all__ = ['Emds', 'EmdsClass', 'Conf', 'conf']

astroquery/esa/emds/core.py

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
# Licensed under a 3-clause BSD style license - see LICENSE.rst
2+
"""
3+
==========================================
4+
Multi-Mission Data Services (EMDS)
5+
==========================================
6+
7+
European Space Astronomy Centre (ESAC)
8+
European Space Agency (ESA)
9+
10+
"""
11+
12+
from . import conf
13+
from astroquery.utils import commons
14+
from html import unescape
15+
import os
16+
import warnings
17+
import astroquery.esa.utils.utils as esautils
18+
from astroquery.esa.utils import EsaTap, download_file
19+
20+
__all__ = ['Emds', 'EmdsClass']
21+
22+
from ...exceptions import NoResultsWarning
23+
24+
25+
class EmdsClass(EsaTap):
26+
27+
"""
28+
EMDS TAP client (multi-mission / multi-schema).
29+
30+
EMDS provides access to multiple missions through a single TAP service. Mission data are
31+
organised under different TAP schemas. This client offers a small convenience layer to work
32+
with mission-specific tables while reusing the standard TAP utilities.
33+
"""
34+
35+
ESA_ARCHIVE_NAME = "ESA Multi-Mission Data Services (EMDS)"
36+
TAP_URL = conf.EMDS_TAP_SERVER
37+
LOGIN_URL = conf.EMDS_LOGIN_SERVER
38+
LOGOUT_URL = conf.EMDS_LOGOUT_SERVER
39+
40+
def __init__(self, auth_session=None, tap_url=None):
41+
super().__init__(auth_session=auth_session, tap_url=tap_url)
42+
# IMPORTANT: ensure every instance has a config namespace.
43+
# Subclasses (missions) can overwrite this with their own module conf.
44+
self.conf = conf
45+
46+
def _get_obscore_table(self) -> str:
47+
"""
48+
Return the fully-qualified ObsCore table/view used by this client.
49+
Sub-clients override this by providing `conf.OBSCORE_TABLE`.
50+
"""
51+
52+
table = getattr(self.conf, "OBSCORE_TABLE", None)
53+
if not (isinstance(table, str) and table.strip()):
54+
raise ValueError(
55+
"OBSCORE_TABLE is not configured for this client. "
56+
"Please set conf.OBSCORE_TABLE to a fully-qualified name like 'schema.table'."
57+
)
58+
return table
59+
60+
def get_tables(self, *, only_names: bool = False):
61+
"""
62+
Return the tables available for this mission.
63+
64+
By default, only tables belonging to the mission-specific schema(s) are returned.
65+
Set ``only_names=True`` to return table names instead of table objects.
66+
67+
Parameters
68+
----------
69+
only_names : bool, optional
70+
If True, return table names as strings. If False, return table objects.
71+
72+
Returns
73+
-------
74+
list
75+
Table names (str) if ``only_names=True``, otherwise table objects.
76+
77+
"""
78+
79+
tables = super().get_tables(only_names=only_names)
80+
81+
schemas = getattr(self.conf, "DEFAULT_SCHEMAS", "")
82+
if not isinstance(schemas, str) or not schemas.strip():
83+
# No schema filtering configured: return all tables
84+
return tables
85+
86+
# Split and normalize schema names
87+
schemas_list = [s.strip() for s in schemas.split(",") if s.strip()]
88+
89+
# Build lowercase schema prefixes
90+
schema_prefixes = tuple(s.lower() + "." for s in schemas_list)
91+
92+
# Check whether a table belongs to one of the schemas
93+
def belongs(name: str) -> bool:
94+
n = (name or "").lower()
95+
return n.startswith(schema_prefixes)
96+
97+
if only_names:
98+
# Filter table names (strings)
99+
return [t for t in tables if belongs(t)]
100+
else:
101+
# Filter table objects using their 'name' attribute
102+
return [
103+
t for t in tables
104+
if belongs(getattr(t, "name", ""))
105+
]
106+
107+
def list_missions(self):
108+
"""
109+
Retrieve the list of missions available in the EMDS ObsCore view.
110+
111+
This method returns the distinct values of the ``obs_collection`` field
112+
from the ``ivoa.ObsCore`` view, where ``obs_collection`` typically
113+
identifies the mission or data collection associated with each observation.
114+
115+
Returns
116+
-------
117+
astropy.table that contains the distinct mission identifiers present in ObsCore.
118+
"""
119+
120+
query = "SELECT DISTINCT obs_collection FROM ivoa.ObsCore WHERE obs_collection IS NOT NULL"
121+
return self.query_tap(query=query)
122+
123+
def get_observations(self, *, target_name=None, coordinates=None, radius=1.0, columns=None, get_metadata=False,
124+
output_file=None, **filters):
125+
"""
126+
Query the observation catalogue for this mission.
127+
128+
This method queries the mission-specific observation catalogue configured for
129+
this client and returns observation-level metadata as an Astropy table.
130+
Queries can be restricted using a cone search (by target name or coordinates)
131+
and additional column-based filters.
132+
133+
Parameters
134+
----------
135+
target_name: str, optional
136+
Name of the target to be resolved against SIMBAD/NED/VIZIER
137+
coordinates: str or SkyCoord, optional
138+
coordinates of the center in the cone search
139+
radius: float or quantity, optional, default value 1 degree
140+
radius in degrees (int, float) or quantity of the cone_search
141+
columns : str or list of str, optional, default None
142+
Columns from the table to be retrieved. They can be checked using
143+
get_metadata=True
144+
get_metadata : bool, optional, default False
145+
Get the table metadata to verify the columns that can be filtered
146+
output_file : str, optional, default None
147+
file name where the results are saved.
148+
If this parameter is not provided, the jobid is used instead
149+
**filters : str, optional, default None
150+
Filters to be applied to the search. The column name is the keyword and the value is any
151+
value accepted by the column datatype. They will be
152+
used to generate the SQL filters for the query. Some examples are described below,
153+
where the left side is the parameter defined for this method and the right side the
154+
SQL filter generated:
155+
obs_collection="EPSA" -> obs_collection = 'EPSA'
156+
target_name="AT 2023%" -> target_name ILIKE 'AT 2023%'
157+
dataproduct_type=["img", "pha"] -> dataproduct_type = 'img' OR dataproduct_type = 'pha'
158+
dataproduct_type=["img", "pha"] -> dataproduct_type IN ('img', 'pha')
159+
t_min=(">", 60000) -> t_min > 60000
160+
s_ra=(80, 82) -> s_ra >= 80 AND s_ra <= 82
161+
162+
Returns
163+
-------
164+
An astropy.table containing the query results, or the metadata table when ``get_metadata=True``
165+
166+
"""
167+
168+
cone_search_filter = None
169+
if radius is not None:
170+
radius = esautils.get_degree_radius(radius)
171+
172+
if target_name and coordinates:
173+
raise TypeError("Please use only target or coordinates as "
174+
"parameter.")
175+
elif target_name:
176+
coordinates = esautils.resolve_target(conf.EMDS_TARGET_RESOLVER,
177+
self.tap._session, target_name,
178+
'ALL')
179+
cone_search_filter = self.create_cone_search_query(coordinates.ra.deg, coordinates.dec.deg,
180+
"s_ra", "s_dec", radius)
181+
elif coordinates:
182+
coord = commons.parse_coordinates(coordinates=coordinates)
183+
ra = coord.ra.degree
184+
dec = coord.dec.degree
185+
cone_search_filter = self.create_cone_search_query(ra, dec, "s_ra", "s_dec", radius)
186+
187+
obscore_table = self._get_obscore_table()
188+
return self.query_table(table_name=obscore_table, columns=columns, custom_filters=cone_search_filter,
189+
get_metadata=get_metadata, async_job=True, output_file=output_file, **filters)
190+
191+
def get_products(self, *, target_name=None, coordinates=None, radius=1.0, get_metadata=False, **filters):
192+
193+
"""
194+
Retrieve data products given a Taget Name, coordinates and/or some filters
195+
196+
This method queries the mission product catalogue and returns product-level
197+
information. It ensures that the ``obs_publisher_did`` and ``access_url`` columns required
198+
for downloading products are included in the results.
199+
200+
Parameters
201+
----------
202+
target_name: str, optional
203+
Name of the target to be resolved against SIMBAD/NED/VIZIER
204+
coordinates: str or SkyCoord, optional
205+
coordinates of the center in the cone search
206+
radius: float or quantity, optional, default value 1 degree
207+
radius in degrees (int, float) or quantity of the cone_search
208+
get_metadata : bool, optional, default False
209+
Get the table metadata to verify the columns that can be filtered
210+
**filters : str, optional, default None
211+
Filters to be applied to the search. The column name is the keyword and the value is any
212+
value accepted by the column datatype. They will be
213+
used to generate the SQL filters for the query. Some examples are described below,
214+
where the left side is the parameter defined for this method and the right side the
215+
SQL filter generated:
216+
obs_collection="EPSA" -> obs_collection = 'EPSA'
217+
target_name="AT 2023%" -> target_name ILIKE 'AT 2023%'
218+
dataproduct_type=["img", "pha"] -> dataproduct_type = 'img' OR dataproduct_type = 'pha'
219+
dataproduct_type=["img", "pha"] -> dataproduct_type IN ('img', 'pha')
220+
t_min=(">", 60000) -> t_min > 60000
221+
s_ra=(80, 82) -> s_ra >= 80 AND s_ra <= 82
222+
223+
Returns
224+
-------
225+
astropy.table.Table
226+
"""
227+
return self.get_observations(target_name=target_name, coordinates=coordinates, radius=radius,
228+
columns=['obs_id', 'obs_publisher_did', 'access_url'],
229+
get_metadata=get_metadata, **filters)
230+
231+
def download_products(self, products, *, path="", cache=False, cache_folder=None,
232+
verbose=False, params=None):
233+
"""
234+
Download all products from a table returned by `get_products()`.
235+
236+
237+
Parameters
238+
----------
239+
products : `~astropy.table.Table`
240+
Table returned by `get_products()`. The table must contain the
241+
``access_url`` and ``obs_publisher_did`` columns.
242+
path : str, optional
243+
Local directory where the downloaded files will be stored.
244+
If not provided, files are downloaded to the current working directory.
245+
Ignored if ``cache=True``.
246+
cache : bool, optional
247+
If True, store the downloaded files in the Astroquery cache.
248+
Default is False.
249+
cache_folder : str, optional
250+
Subdirectory within the Astroquery cache where files will be stored.
251+
Only used if ``cache=True``.
252+
verbose : bool, optional
253+
If True, print progress messages during download.
254+
Default is False.
255+
params : dict, optional
256+
Additional parameters passed to the HTTP request.
257+
258+
Returns
259+
-------
260+
list of str
261+
List of local file paths for the downloaded products.
262+
"""
263+
if products is None or len(products) == 0:
264+
warnings.warn('There are no products available', NoResultsWarning)
265+
return []
266+
267+
if "access_url" not in products.colnames:
268+
raise ValueError("Products table must contain an 'access_url' column.")
269+
270+
if "obs_publisher_did" not in products.colnames:
271+
raise ValueError("Products table must contain an 'obs_publisher_did' column.")
272+
273+
if path and not cache:
274+
os.makedirs(path, exist_ok=True)
275+
276+
downloaded = []
277+
278+
for row in products:
279+
url = unescape(row["access_url"])
280+
session = self.tap._session
281+
282+
file_path = download_file(
283+
url,
284+
session,
285+
params=params,
286+
path=path,
287+
cache=cache,
288+
cache_folder=cache_folder,
289+
verbose=verbose,
290+
)
291+
downloaded.append(file_path)
292+
293+
return downloaded
294+
295+
296+
Emds = EmdsClass()

0 commit comments

Comments
 (0)