|
| 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