pycontrails 0.53.0__cp313-cp313-macosx_10_13_x86_64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of pycontrails might be problematic. Click here for more details.

Files changed (109) hide show
  1. pycontrails/__init__.py +70 -0
  2. pycontrails/_version.py +16 -0
  3. pycontrails/core/__init__.py +30 -0
  4. pycontrails/core/aircraft_performance.py +641 -0
  5. pycontrails/core/airports.py +226 -0
  6. pycontrails/core/cache.py +881 -0
  7. pycontrails/core/coordinates.py +174 -0
  8. pycontrails/core/fleet.py +470 -0
  9. pycontrails/core/flight.py +2312 -0
  10. pycontrails/core/flightplan.py +220 -0
  11. pycontrails/core/fuel.py +140 -0
  12. pycontrails/core/interpolation.py +721 -0
  13. pycontrails/core/met.py +2833 -0
  14. pycontrails/core/met_var.py +307 -0
  15. pycontrails/core/models.py +1181 -0
  16. pycontrails/core/polygon.py +549 -0
  17. pycontrails/core/rgi_cython.cpython-313-darwin.so +0 -0
  18. pycontrails/core/vector.py +2191 -0
  19. pycontrails/datalib/__init__.py +12 -0
  20. pycontrails/datalib/_leo_utils/search.py +250 -0
  21. pycontrails/datalib/_leo_utils/static/bq_roi_query.sql +6 -0
  22. pycontrails/datalib/_leo_utils/vis.py +59 -0
  23. pycontrails/datalib/_met_utils/metsource.py +743 -0
  24. pycontrails/datalib/ecmwf/__init__.py +53 -0
  25. pycontrails/datalib/ecmwf/arco_era5.py +527 -0
  26. pycontrails/datalib/ecmwf/common.py +109 -0
  27. pycontrails/datalib/ecmwf/era5.py +538 -0
  28. pycontrails/datalib/ecmwf/era5_model_level.py +482 -0
  29. pycontrails/datalib/ecmwf/hres.py +782 -0
  30. pycontrails/datalib/ecmwf/hres_model_level.py +495 -0
  31. pycontrails/datalib/ecmwf/ifs.py +284 -0
  32. pycontrails/datalib/ecmwf/model_levels.py +79 -0
  33. pycontrails/datalib/ecmwf/static/model_level_dataframe_v20240418.csv +139 -0
  34. pycontrails/datalib/ecmwf/variables.py +256 -0
  35. pycontrails/datalib/gfs/__init__.py +28 -0
  36. pycontrails/datalib/gfs/gfs.py +646 -0
  37. pycontrails/datalib/gfs/variables.py +100 -0
  38. pycontrails/datalib/goes.py +772 -0
  39. pycontrails/datalib/landsat.py +568 -0
  40. pycontrails/datalib/sentinel.py +512 -0
  41. pycontrails/datalib/spire.py +739 -0
  42. pycontrails/ext/bada.py +41 -0
  43. pycontrails/ext/cirium.py +14 -0
  44. pycontrails/ext/empirical_grid.py +140 -0
  45. pycontrails/ext/synthetic_flight.py +426 -0
  46. pycontrails/models/__init__.py +1 -0
  47. pycontrails/models/accf.py +406 -0
  48. pycontrails/models/apcemm/__init__.py +8 -0
  49. pycontrails/models/apcemm/apcemm.py +983 -0
  50. pycontrails/models/apcemm/inputs.py +226 -0
  51. pycontrails/models/apcemm/static/apcemm_yaml_template.yaml +183 -0
  52. pycontrails/models/apcemm/utils.py +437 -0
  53. pycontrails/models/cocip/__init__.py +29 -0
  54. pycontrails/models/cocip/cocip.py +2617 -0
  55. pycontrails/models/cocip/cocip_params.py +299 -0
  56. pycontrails/models/cocip/cocip_uncertainty.py +285 -0
  57. pycontrails/models/cocip/contrail_properties.py +1517 -0
  58. pycontrails/models/cocip/output_formats.py +2261 -0
  59. pycontrails/models/cocip/radiative_forcing.py +1262 -0
  60. pycontrails/models/cocip/radiative_heating.py +520 -0
  61. pycontrails/models/cocip/unterstrasser_wake_vortex.py +403 -0
  62. pycontrails/models/cocip/wake_vortex.py +396 -0
  63. pycontrails/models/cocip/wind_shear.py +120 -0
  64. pycontrails/models/cocipgrid/__init__.py +9 -0
  65. pycontrails/models/cocipgrid/cocip_grid.py +2573 -0
  66. pycontrails/models/cocipgrid/cocip_grid_params.py +138 -0
  67. pycontrails/models/dry_advection.py +486 -0
  68. pycontrails/models/emissions/__init__.py +21 -0
  69. pycontrails/models/emissions/black_carbon.py +594 -0
  70. pycontrails/models/emissions/emissions.py +1353 -0
  71. pycontrails/models/emissions/ffm2.py +336 -0
  72. pycontrails/models/emissions/static/default-engine-uids.csv +239 -0
  73. pycontrails/models/emissions/static/edb-gaseous-v29b-engines.csv +596 -0
  74. pycontrails/models/emissions/static/edb-nvpm-v29b-engines.csv +215 -0
  75. pycontrails/models/humidity_scaling/__init__.py +37 -0
  76. pycontrails/models/humidity_scaling/humidity_scaling.py +1025 -0
  77. pycontrails/models/humidity_scaling/quantiles/era5-model-level-quantiles.pq +0 -0
  78. pycontrails/models/humidity_scaling/quantiles/era5-pressure-level-quantiles.pq +0 -0
  79. pycontrails/models/issr.py +210 -0
  80. pycontrails/models/pcc.py +327 -0
  81. pycontrails/models/pcr.py +154 -0
  82. pycontrails/models/ps_model/__init__.py +17 -0
  83. pycontrails/models/ps_model/ps_aircraft_params.py +376 -0
  84. pycontrails/models/ps_model/ps_grid.py +505 -0
  85. pycontrails/models/ps_model/ps_model.py +1017 -0
  86. pycontrails/models/ps_model/ps_operational_limits.py +540 -0
  87. pycontrails/models/ps_model/static/ps-aircraft-params-20240524.csv +68 -0
  88. pycontrails/models/ps_model/static/ps-synonym-list-20240524.csv +103 -0
  89. pycontrails/models/sac.py +459 -0
  90. pycontrails/models/tau_cirrus.py +168 -0
  91. pycontrails/physics/__init__.py +1 -0
  92. pycontrails/physics/constants.py +116 -0
  93. pycontrails/physics/geo.py +989 -0
  94. pycontrails/physics/jet.py +837 -0
  95. pycontrails/physics/thermo.py +451 -0
  96. pycontrails/physics/units.py +472 -0
  97. pycontrails/py.typed +0 -0
  98. pycontrails/utils/__init__.py +1 -0
  99. pycontrails/utils/dependencies.py +66 -0
  100. pycontrails/utils/iteration.py +13 -0
  101. pycontrails/utils/json.py +188 -0
  102. pycontrails/utils/temp.py +50 -0
  103. pycontrails/utils/types.py +165 -0
  104. pycontrails-0.53.0.dist-info/LICENSE +178 -0
  105. pycontrails-0.53.0.dist-info/METADATA +181 -0
  106. pycontrails-0.53.0.dist-info/NOTICE +43 -0
  107. pycontrails-0.53.0.dist-info/RECORD +109 -0
  108. pycontrails-0.53.0.dist-info/WHEEL +5 -0
  109. pycontrails-0.53.0.dist-info/top_level.txt +3 -0
@@ -0,0 +1,538 @@
1
+ """ECMWF ERA5 data access."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import collections
6
+ import hashlib
7
+ import logging
8
+ import os
9
+ import pathlib
10
+ import warnings
11
+ from contextlib import ExitStack
12
+ from datetime import datetime
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ LOG = logging.getLogger(__name__)
16
+
17
+ import pandas as pd
18
+ import xarray as xr
19
+ from overrides import overrides
20
+
21
+ import pycontrails
22
+ from pycontrails.core import cache
23
+ from pycontrails.core.met import MetDataset, MetVariable
24
+ from pycontrails.datalib._met_utils import metsource
25
+ from pycontrails.datalib.ecmwf.common import ECMWFAPI, CDSCredentialsNotFound
26
+ from pycontrails.datalib.ecmwf.variables import PRESSURE_LEVEL_VARIABLES, SURFACE_VARIABLES
27
+ from pycontrails.utils import dependencies, temp
28
+
29
+ if TYPE_CHECKING:
30
+ import cdsapi
31
+
32
+
33
+ class ERA5(ECMWFAPI):
34
+ """Class to support ERA5 data access, download, and organization.
35
+
36
+ Requires account with
37
+ `Copernicus Data Portal <https://cds.climate.copernicus.eu/cdsapp#!/home>`_
38
+ and local credentials.
39
+
40
+ API credentials can be stored in a ``~/.cdsapirc`` file
41
+ or as ``CDSAPI_URL`` and ``CDSAPI_KEY`` environment variables.
42
+
43
+ export CDSAPI_URL=...
44
+ export CDSAPI_KEY=...
45
+
46
+ Credentials can also be provided directly ``url`` and ``key`` keyword args.
47
+
48
+ See `cdsapi <https://github.com/ecmwf/cdsapi>`_ documentation
49
+ for more information.
50
+
51
+ Parameters
52
+ ----------
53
+ time : metsource.TimeInput | None
54
+ The time range for data retrieval, either a single datetime or (start, end) datetime range.
55
+ Input must be datetime-like or tuple of datetime-like
56
+ (`datetime`, :class:`pd.Timestamp`, :class:`np.datetime64`)
57
+ specifying the (start, end) of the date range, inclusive.
58
+ Datafiles will be downloaded from CDS for each day to reduce requests.
59
+ If None, ``paths`` must be defined and all time coordinates will be loaded from files.
60
+ variables : metsource.VariableInput
61
+ Variable name (i.e. "t", "air_temperature", ["air_temperature, relative_humidity"])
62
+ pressure_levels : metsource.PressureLevelInput, optional
63
+ Pressure levels for data, in hPa (mbar)
64
+ Set to -1 for to download surface level parameters.
65
+ Defaults to -1.
66
+ paths : str | list[str] | pathlib.Path | list[pathlib.Path] | None, optional
67
+ Path to CDS NetCDF files to load manually.
68
+ Can include glob patterns to load specific files.
69
+ Defaults to None, which looks for files in the :attr:`cachestore` or CDS.
70
+ timestep_freq : str, optional
71
+ Manually set the timestep interval within the bounds defined by :attr:`time`.
72
+ Supports any string that can be passed to `pd.date_range(freq=...)`.
73
+ By default, this is set to "1h" for reanalysis products and "3h" for ensemble products.
74
+ product_type : str, optional
75
+ Product type, one of "reanalysis", "ensemble_mean", "ensemble_members", "ensemble_spread"
76
+ grid : float, optional
77
+ Specify latitude/longitude grid spacing in data.
78
+ By default, this is set to 0.25 for reanalysis products and 0.5 for ensemble products.
79
+ cachestore : cache.CacheStore | None, optional
80
+ Cache data store for staging ECMWF ERA5 files.
81
+ Defaults to :class:`cache.DiskCacheStore`.
82
+ If None, cache is turned off.
83
+ url : str
84
+ Override `cdsapi <https://github.com/ecmwf/cdsapi>`_ url
85
+ key : str
86
+ Override `cdsapi <https://github.com/ecmwf/cdsapi>`_ key
87
+
88
+ Notes
89
+ -----
90
+ ERA5 parameter list:
91
+ https://confluence.ecmwf.int/pages/viewpage.action?pageId=82870405#ERA5:datadocumentation-Parameterlistings
92
+
93
+ All radiative quantities are accumulated.
94
+ See https://www.ecmwf.int/sites/default/files/elibrary/2015/18490-radiation-quantities-ecmwf-model-and-mars.pdf
95
+ for more information.
96
+
97
+ Local ``paths`` are loaded using :func:`xarray.open_mfdataset`.
98
+ Pass ``xr_kwargs`` inputs to :meth:`open_metdataset` to customize file loading.
99
+
100
+ Examples
101
+ --------
102
+ >>> from datetime import datetime
103
+ >>> from pycontrails.datalib.ecmwf import ERA5
104
+ >>> from pycontrails import GCPCacheStore
105
+
106
+ >>> # Store data files from CDS to local disk (default behavior)
107
+ >>> era5 = ERA5(
108
+ ... "2020-06-01 12:00:00",
109
+ ... variables=["air_temperature", "relative_humidity"],
110
+ ... pressure_levels=[350, 300]
111
+ ... )
112
+
113
+ >>> # cache files to google cloud storage
114
+ >>> gcp_cache = GCPCacheStore(
115
+ ... bucket="contrails-301217-unit-test",
116
+ ... cache_dir="ecmwf",
117
+ ... )
118
+ >>> era5 = ERA5(
119
+ ... "2020-06-01 12:00:00",
120
+ ... variables=["air_temperature", "relative_humidity"],
121
+ ... pressure_levels=[350, 300],
122
+ ... cachestore=gcp_cache
123
+ ... )
124
+ """
125
+
126
+ __slots__ = (
127
+ "product_type",
128
+ "cds",
129
+ "url",
130
+ "key",
131
+ )
132
+
133
+ #: Product type, one of "reanalysis", "ensemble_mean", "ensemble_members", "ensemble_spread"
134
+ product_type: str
135
+
136
+ #: Handle to ``cdsapi.Client``
137
+ cds: cdsapi.Client
138
+
139
+ #: User provided ``cdsapi.Client`` url
140
+ url: str | None
141
+
142
+ #: User provided ``cdsapi.Client`` url
143
+ key: str | None
144
+
145
+ __marker = object()
146
+
147
+ def __init__(
148
+ self,
149
+ time: metsource.TimeInput | None,
150
+ variables: metsource.VariableInput,
151
+ pressure_levels: metsource.PressureLevelInput = -1,
152
+ paths: str | list[str] | pathlib.Path | list[pathlib.Path] | None = None,
153
+ timestep_freq: str | None = None,
154
+ product_type: str = "reanalysis",
155
+ grid: float | None = None,
156
+ cachestore: cache.CacheStore | None = __marker, # type: ignore[assignment]
157
+ url: str | None = None,
158
+ key: str | None = None,
159
+ ) -> None:
160
+ # Parse and set each parameter to the instance
161
+
162
+ self.product_type = product_type
163
+
164
+ self.cachestore = cache.DiskCacheStore() if cachestore is self.__marker else cachestore
165
+
166
+ self.paths = paths
167
+
168
+ self.url = url or os.getenv("CDSAPI_URL")
169
+ self.key = key or os.getenv("CDSAPI_KEY")
170
+
171
+ if time is None and paths is None:
172
+ raise ValueError("The parameter 'time' must be defined if 'paths' is None")
173
+
174
+ supported = ("reanalysis", "ensemble_mean", "ensemble_members", "ensemble_spread")
175
+ if product_type not in supported:
176
+ raise ValueError(
177
+ f"Unknown product_type {product_type}. "
178
+ f"Currently support product types: {', '.join(supported)}"
179
+ )
180
+
181
+ if grid is None:
182
+ grid = 0.25 if product_type == "reanalysis" else 0.5
183
+ else:
184
+ grid_min = 0.25 if product_type == "reanalysis" else 0.5
185
+ if grid < grid_min:
186
+ warnings.warn(
187
+ f"The highest resolution available through the CDS API is {grid_min} degrees. "
188
+ f"Your downloaded data will have resolution {grid}, but it is a "
189
+ f"reinterpolation of the {grid_min} degree data. The same interpolation can be "
190
+ "achieved directly with xarray."
191
+ )
192
+ self.grid = grid
193
+
194
+ if timestep_freq is None:
195
+ timestep_freq = "1h" if product_type == "reanalysis" else "3h"
196
+
197
+ self.timesteps = metsource.parse_timesteps(time, freq=timestep_freq)
198
+ self.pressure_levels = metsource.parse_pressure_levels(
199
+ pressure_levels, self.supported_pressure_levels
200
+ )
201
+ self.variables = metsource.parse_variables(variables, self.supported_variables)
202
+
203
+ # ensemble_mean, etc - time is only available on the 0, 3, 6, etc
204
+ if product_type.startswith("ensemble") and any(t.hour % 3 for t in self.timesteps):
205
+ raise NotImplementedError("Ensemble products only support every three hours")
206
+
207
+ def __repr__(self) -> str:
208
+ base = super().__repr__()
209
+ return f"{base}\n\tDataset: {self.dataset}\n\tProduct type: {self.product_type}"
210
+
211
+ @property
212
+ def hash(self) -> str:
213
+ """Generate a unique hash for this datasource.
214
+
215
+ Returns
216
+ -------
217
+ str
218
+ Unique hash for met instance (sha1)
219
+ """
220
+ hashstr = (
221
+ f"{self.__class__.__name__}{self.timesteps}{self.variable_shortnames}"
222
+ f"{self.pressure_levels}{self.grid}{self.product_type}"
223
+ )
224
+ return hashlib.sha1(bytes(hashstr, "utf-8")).hexdigest()
225
+
226
+ @property
227
+ def pressure_level_variables(self) -> list[MetVariable]:
228
+ """ECMWF pressure level parameters.
229
+
230
+ Returns
231
+ -------
232
+ list[MetVariable] | None
233
+ List of MetVariable available in datasource
234
+ """
235
+ return PRESSURE_LEVEL_VARIABLES
236
+
237
+ @property
238
+ def single_level_variables(self) -> list[MetVariable]:
239
+ """ECMWF surface level parameters.
240
+
241
+ Returns
242
+ -------
243
+ list[MetVariable] | None
244
+ List of MetVariable available in datasource
245
+ """
246
+ return SURFACE_VARIABLES
247
+
248
+ @property
249
+ def supported_pressure_levels(self) -> list[int]:
250
+ """Get pressure levels available from ERA5 pressure level dataset.
251
+
252
+ Returns
253
+ -------
254
+ list[int]
255
+ List of integer pressure level values
256
+ """
257
+ return [
258
+ 1000,
259
+ 975,
260
+ 950,
261
+ 925,
262
+ 900,
263
+ 875,
264
+ 850,
265
+ 825,
266
+ 800,
267
+ 775,
268
+ 750,
269
+ 700,
270
+ 650,
271
+ 600,
272
+ 550,
273
+ 500,
274
+ 450,
275
+ 400,
276
+ 350,
277
+ 300,
278
+ 250,
279
+ 225,
280
+ 200,
281
+ 175,
282
+ 150,
283
+ 125,
284
+ 100,
285
+ 70,
286
+ 50,
287
+ 30,
288
+ 20,
289
+ 10,
290
+ 7,
291
+ 5,
292
+ 3,
293
+ 2,
294
+ 1,
295
+ -1,
296
+ ]
297
+
298
+ @property
299
+ def dataset(self) -> str:
300
+ """Select dataset for download based on :attr:`pressure_levels`.
301
+
302
+ One of "reanalysis-era5-pressure-levels" or "reanalysis-era5-single-levels"
303
+
304
+ Returns
305
+ -------
306
+ str
307
+ ERA5 dataset name in CDS
308
+ """
309
+ if self.pressure_levels != [-1]:
310
+ return "reanalysis-era5-pressure-levels"
311
+ return "reanalysis-era5-single-levels"
312
+
313
+ def create_cachepath(self, t: datetime | pd.Timestamp) -> str:
314
+ """Return cachepath to local ERA5 data file based on datetime.
315
+
316
+ This uniquely defines a cached data file ith class parameters.
317
+
318
+ Parameters
319
+ ----------
320
+ t : datetime | pd.Timestamp
321
+ Datetime of datafile
322
+
323
+ Returns
324
+ -------
325
+ str
326
+ Path to local ERA5 data file
327
+ """
328
+ if self.cachestore is None:
329
+ raise ValueError("self.cachestore attribute must be defined to create cache path")
330
+
331
+ datestr = t.strftime("%Y%m%d-%H")
332
+
333
+ # set date/time for file
334
+ if self.pressure_levels == [-1]:
335
+ suffix = f"era5sl{self.grid}{self.product_type}"
336
+ else:
337
+ suffix = f"era5pl{self.grid}{self.product_type}"
338
+
339
+ # return cache path
340
+ return self.cachestore.path(f"{datestr}-{suffix}.nc")
341
+
342
+ @overrides
343
+ def download_dataset(self, times: list[datetime]) -> None:
344
+ download_times: dict[datetime, list[datetime]] = collections.defaultdict(list)
345
+ for t in times:
346
+ unique_day = datetime(t.year, t.month, t.day)
347
+ download_times[unique_day].append(t)
348
+
349
+ # download data file for each unique day
350
+ LOG.debug(f"Downloading ERA5 dataset for times {times}")
351
+ for times_for_day in download_times.values():
352
+ self._download_file(times_for_day)
353
+
354
+ @overrides
355
+ def open_metdataset(
356
+ self,
357
+ dataset: xr.Dataset | None = None,
358
+ xr_kwargs: dict[str, Any] | None = None,
359
+ **kwargs: Any,
360
+ ) -> MetDataset:
361
+ xr_kwargs = xr_kwargs or {}
362
+
363
+ # short-circuit dataset or file paths if provided
364
+ if dataset is not None:
365
+ ds = self._preprocess_era5_dataset(dataset)
366
+
367
+ # load from local paths
368
+ elif self.paths is not None:
369
+ ds = self._open_and_cache(xr_kwargs)
370
+
371
+ # load from cache or download
372
+ else:
373
+ if self.cachestore is None:
374
+ raise ValueError("Cachestore is required to download data")
375
+
376
+ # confirm files are downloaded from CDS or MARS
377
+ self.download(**xr_kwargs)
378
+
379
+ # ensure all files are guaranteed to be available locally here
380
+ # this would download a file from a remote (e.g. GCP) cache
381
+ disk_cachepaths = [self.cachestore.get(f) for f in self._cachepaths]
382
+
383
+ ds = self.open_dataset(disk_cachepaths, **xr_kwargs)
384
+
385
+ # If any files are already cached, they will not have the version attached
386
+ ds.attrs.setdefault("pycontrails_version", pycontrails.__version__)
387
+
388
+ # run the same ECMWF-specific processing on the dataset
389
+ mds = self._process_dataset(ds, **kwargs)
390
+
391
+ self.set_metadata(mds)
392
+ return mds
393
+
394
+ @overrides
395
+ def set_metadata(self, ds: xr.Dataset | MetDataset) -> None:
396
+ if self.product_type == "reanalysis":
397
+ product = "reanalysis"
398
+ elif self.product_type.startswith("ensemble"):
399
+ product = "ensemble"
400
+ else:
401
+ msg = f"Unknown product type {self.product_type}"
402
+ raise ValueError(msg)
403
+
404
+ ds.attrs.update(
405
+ provider="ECMWF",
406
+ dataset="ERA5",
407
+ product=product,
408
+ )
409
+
410
+ def _open_and_cache(self, xr_kwargs: dict[str, Any]) -> xr.Dataset:
411
+ """Open and cache :class:`xr.Dataset` from :attr:`self.paths`.
412
+
413
+ Parameters
414
+ ----------
415
+ xr_kwargs : dict[str, Any]
416
+ Additional kwargs passed directly to :func:`xarray.open_mfdataset`.
417
+ See :meth:`open_metdataset`.
418
+
419
+ Returns
420
+ -------
421
+ xr.Dataset
422
+ Dataset opened from local paths.
423
+ """
424
+
425
+ if self.paths is None:
426
+ raise ValueError("Attribute `self.paths` must be defined to open and cache")
427
+
428
+ # if timesteps are defined and all timesteps are cached already
429
+ # then we can skip loading
430
+ if self.timesteps and self.cachestore and not self.list_timesteps_not_cached(**xr_kwargs):
431
+ LOG.debug("All timesteps already in cache store")
432
+ disk_cachepaths = [self.cachestore.get(f) for f in self._cachepaths]
433
+ return self.open_dataset(disk_cachepaths, **xr_kwargs)
434
+
435
+ ds = self.open_dataset(self.paths, **xr_kwargs)
436
+ ds = self._preprocess_era5_dataset(ds)
437
+ self.cache_dataset(ds)
438
+
439
+ return ds
440
+
441
+ def _download_file(self, times: list[datetime]) -> None:
442
+ """Download data file for specific sets of times for *unique date* from CDS API.
443
+
444
+ Splits datafiles by the hour and saves each hour in the cache datastore.
445
+ Overwrites files if they already exists.
446
+
447
+ Parameters
448
+ ----------
449
+ times : list[datetime]
450
+ Times to download from single day
451
+ """
452
+
453
+ # set date/time for file
454
+ date_str = times[0].strftime("%Y-%m-%d")
455
+
456
+ # check to make sure times are all on the same day
457
+ if any(dt.strftime("%Y-%m-%d") != date_str for dt in times):
458
+ raise ValueError("All times must be on the same date when downloading from CDS")
459
+
460
+ time_strs = [t.strftime("%H:%M") for t in times]
461
+
462
+ # make request of cdsapi
463
+ request: dict[str, Any] = {
464
+ "product_type": self.product_type,
465
+ "variable": self.variable_shortnames,
466
+ "date": date_str,
467
+ "time": time_strs,
468
+ "grid": [self.grid, self.grid],
469
+ "format": "netcdf",
470
+ }
471
+ if self.dataset == "reanalysis-era5-pressure-levels":
472
+ request["pressure_level"] = self.pressure_levels
473
+
474
+ # Open ExitStack to control temp_file context manager
475
+ with ExitStack() as stack:
476
+ # hold downloaded file in named temp file
477
+ cds_temp_filename = stack.enter_context(temp.temp_file())
478
+ LOG.debug(f"Performing CDS request: {request} to dataset {self.dataset}")
479
+ if not hasattr(self, "cds"):
480
+ self._set_cds()
481
+
482
+ self.cds.retrieve(self.dataset, request, cds_temp_filename)
483
+
484
+ # open file, edit, and save for each hourly time step
485
+ ds = stack.enter_context(
486
+ xr.open_dataset(cds_temp_filename, engine=metsource.NETCDF_ENGINE)
487
+ )
488
+
489
+ # run preprocessing before cache
490
+ ds = self._preprocess_era5_dataset(ds)
491
+
492
+ self.cache_dataset(ds)
493
+
494
+ def _set_cds(self) -> None:
495
+ """Set the cdsapi.Client instance."""
496
+ try:
497
+ import cdsapi
498
+ except ModuleNotFoundError as e:
499
+ dependencies.raise_module_not_found_error(
500
+ name="ERA5._set_cds method",
501
+ package_name="cdsapi",
502
+ module_not_found_error=e,
503
+ pycontrails_optional_package="ecmwf",
504
+ )
505
+
506
+ try:
507
+ self.cds = cdsapi.Client(url=self.url, key=self.key)
508
+ # cdsapi throws base-level Exception
509
+ except Exception as err:
510
+ raise CDSCredentialsNotFound from err
511
+
512
+ def _preprocess_era5_dataset(self, ds: xr.Dataset) -> xr.Dataset:
513
+ """Process ERA5 data before caching.
514
+
515
+ Parameters
516
+ ----------
517
+ ds : xr.Dataset
518
+ Loaded :class:`xr.Dataset`
519
+
520
+ Returns
521
+ -------
522
+ xr.Dataset
523
+ Processed :class:`xr.Dataset`
524
+ """
525
+
526
+ if "pycontrails_version" in ds.attrs:
527
+ LOG.debug("Input dataset processed with pycontrails > 0.29")
528
+ return ds
529
+
530
+ # not pre-processed source file from `download` or `paths`
531
+
532
+ # for "reanalysis-era5-single-levels" or if self.pressure_levels length == 1,
533
+ # then the netcdf file does not contain the dimension "level"
534
+ if len(self.pressure_levels) == 1:
535
+ ds = ds.expand_dims({"level": self.pressure_levels})
536
+
537
+ ds.attrs["pycontrails_version"] = pycontrails.__version__
538
+ return ds