pycontrails 0.53.0__cp313-cp313-manylinux_2_17_x86_64.manylinux2014_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-x86_64-linux-gnu.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 +6 -0
  109. pycontrails-0.53.0.dist-info/top_level.txt +3 -0
@@ -0,0 +1,782 @@
1
+ """ECWMF HRES forecast data access."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import logging
7
+ import pathlib
8
+ from contextlib import ExitStack
9
+ from datetime import datetime
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ LOG = logging.getLogger(__name__)
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+ import xarray as xr
17
+ from overrides import overrides
18
+
19
+ import pycontrails
20
+ from pycontrails.core import cache
21
+ from pycontrails.core.met import MetDataset, MetVariable
22
+ from pycontrails.datalib._met_utils import metsource
23
+ from pycontrails.datalib.ecmwf.common import ECMWFAPI
24
+ from pycontrails.datalib.ecmwf.variables import PRESSURE_LEVEL_VARIABLES, SURFACE_VARIABLES
25
+ from pycontrails.utils import dependencies, iteration, temp
26
+ from pycontrails.utils.types import DatetimeLike
27
+
28
+ if TYPE_CHECKING:
29
+ from ecmwfapi import ECMWFService
30
+
31
+
32
+ def get_forecast_filename(
33
+ forecast_time: datetime,
34
+ timestep: datetime,
35
+ cc: str = "A1",
36
+ S: str | None = None,
37
+ E: str = "1",
38
+ ) -> str:
39
+ """Create forecast filename from ECMWF dissemination products.
40
+
41
+ See `ECMWF Dissemination <https://confluence.ecmwf.int/x/0EykDQ>`_ for more information::
42
+
43
+ The following dissemination filename convention is used for the
44
+ transmission of ECMWF dissemination products:
45
+
46
+ ccSMMDDHHIImmddhhiiE where:
47
+
48
+ cc is Dissemination stream name
49
+ S is dissemination data stream indicator
50
+ MMDDHHII is month, day, hour and minute on which the products are based
51
+ mmddhhii is month, day, hour and minute on which the products are valid at
52
+ ddhhii is set to “______” for Seasonal Forecasting System products
53
+ ii is set to 01 for high resolution forecast time step zero, type=fc, step=0
54
+ E is the Experiment Version Number' (as EXPVER in MARS, normally 1)
55
+
56
+ Parameters
57
+ ----------
58
+ forecast_time : datetime
59
+ Forecast time to stage
60
+ timestep : datetime
61
+ Time within forecast
62
+ cc : str, optional
63
+ Dissemination stream name.
64
+ Defaults to "A1"
65
+ S : str, optional
66
+ Dissemination data stream indicator. If None, S is set to "S" for 6 or 18 hour forecast
67
+ and "D" for 0 or 12 hour forecast.
68
+ E : str, optional
69
+ Experiment Version Number.
70
+ Defaults to "1"
71
+
72
+ Returns
73
+ -------
74
+ str
75
+ Filename to forecast file
76
+
77
+ Raises
78
+ ------
79
+ ValueError
80
+ If ``forecast_time`` is not on a synoptic hour (00, 06, 12, 18)
81
+ If ``timestep`` is before ``forecast_time``
82
+ """
83
+
84
+ if forecast_time.hour % 6 != 0:
85
+ raise ValueError("Forecast time must have hour 0, 6, 12, or 18")
86
+
87
+ if timestep < forecast_time:
88
+ raise ValueError("Forecast timestep must be on or after forecast time")
89
+
90
+ if S is None:
91
+ S = "D" if forecast_time.hour in (0, 12) else "S"
92
+
93
+ forecast_time_str = forecast_time.strftime("%m%d%H%M")
94
+ timestep_str = timestep.strftime("%m%d%H")
95
+
96
+ # for some reason "ii" is set to 01 for the first forecast timestep
97
+ ii = "01" if forecast_time == timestep else "00"
98
+
99
+ return f"{cc}{S}{forecast_time_str}{timestep_str}{ii}{E}"
100
+
101
+
102
+ class HRES(ECMWFAPI):
103
+ """Class to support HRES data access, download, and organization.
104
+
105
+ Requires account with ECMWF and API key.
106
+
107
+ API credentials set in local ``~/.ecmwfapirc`` file:
108
+
109
+ .. code:: json
110
+
111
+ {
112
+ "url": "https://api.ecmwf.int/v1",
113
+ "email": "<email>",
114
+ "key": "<key>"
115
+ }
116
+
117
+ Credentials can also be provided directly ``url`` ``key``, and ``email`` keyword args.
118
+
119
+ See `ecmwf-api-client <https://github.com/ecmwf/ecmwf-api-client>`_ documentation
120
+ for more information.
121
+
122
+ Parameters
123
+ ----------
124
+ time : metsource.TimeInput | None
125
+ The time range for data retrieval, either a single datetime or (start, end) datetime range.
126
+ Input must be a datetime-like or tuple of datetime-like
127
+ (datetime, :class:`pandas.Timestamp`, :class:`numpy.datetime64`)
128
+ specifying the (start, end) of the date range, inclusive.
129
+ If ``forecast_time`` is unspecified, the forecast time will
130
+ be assumed to be the nearest synoptic hour: 00, 06, 12, 18.
131
+ All subsequent times will be downloaded for relative to :attr:`forecast_time`.
132
+ If None, ``paths`` must be defined and all time coordinates will be loaded from files.
133
+ variables : metsource.VariableInput
134
+ Variable name (i.e. "air_temperature", ["air_temperature, relative_humidity"])
135
+ See :attr:`pressure_level_variables` for the list of available variables.
136
+ pressure_levels : metsource.PressureLevelInput, optional
137
+ Pressure levels for data, in hPa (mbar)
138
+ Set to -1 for to download surface level parameters.
139
+ Defaults to -1.
140
+ paths : str | list[str] | pathlib.Path | list[pathlib.Path] | None, optional
141
+ Path to CDS NetCDF files to load manually.
142
+ Can include glob patterns to load specific files.
143
+ Defaults to None, which looks for files in the :attr:`cachestore` or CDS.
144
+ grid : float, optional
145
+ Specify latitude/longitude grid spacing in data.
146
+ Defaults to 0.25.
147
+ stream : str, optional
148
+ "oper" = atmospheric model/HRES, "enfo" = ensemble forecast.
149
+ Defaults to "oper" (HRES),
150
+ field_type : str, optional
151
+ Field type can be e.g. forecast (fc), perturbed forecast (pf),
152
+ control forecast (cf), analysis (an).
153
+ Defaults to "fc".
154
+ forecast_time : DatetimeLike, optional
155
+ Specify forecast run by runtime.
156
+ Defaults to None.
157
+ cachestore : cache.CacheStore | None, optional
158
+ Cache data store for staging data files.
159
+ Defaults to :class:`cache.DiskCacheStore`.
160
+ If None, cache is turned off.
161
+ url : str
162
+ Override `ecmwf-api-client <https://github.com/ecmwf/ecmwf-api-client>`_ url
163
+ key : str
164
+ Override `ecmwf-api-client <https://github.com/ecmwf/ecmwf-api-client>`_ key
165
+ email : str
166
+ Override `ecmwf-api-client <https://github.com/ecmwf/ecmwf-api-client>`_ email
167
+
168
+ Notes
169
+ -----
170
+ `MARS key word definitions <https://confluence.ecmwf.int/display/UDOC/Identification+keywords>`_
171
+
172
+ - `class <https://apps.ecmwf.int/codes/grib/format/mars/class/>`_:
173
+ in most cases this will be operational data, or "od"
174
+ - `stream <https://apps.ecmwf.int/codes/grib/format/mars/stream/>`_:
175
+ "enfo" = ensemble forecast, "oper" = atmospheric model/HRES
176
+ - `expver <https://confluence.ecmwf.int/pages/viewpage.action?pageId=124752178>`_:
177
+ experimental version, production data is 1 or 2
178
+ - `date <https://confluence.ecmwf.int/pages/viewpage.action?pageId=118817289>`_:
179
+ there are numerous acceptible date formats
180
+ - `time <https://confluence.ecmwf.int/pages/viewpage.action?pageId=118817378>`_:
181
+ forecast base time, always in synoptic time (0,6,12,18 UTC)
182
+ - `type <https://confluence.ecmwf.int/pages/viewpage.action?pageId=127315300>`_:
183
+ forecast (oper), perturbed or control forecast (enfo only), or analysis
184
+ - `levtype <https://confluence.ecmwf.int/pages/viewpage.action?pageId=149335319>`_:
185
+ options include surface, pressure levels, or model levels
186
+ - `levelist <https://confluence.ecmwf.int/pages/viewpage.action?pageId=149335403>`_:
187
+ list of levels in format specified by **levtype** `levelist`_
188
+ - `param <https://confluence.ecmwf.int/pages/viewpage.action?pageId=149335858>`_:
189
+ list of variables in catalog number, long name or short name
190
+ - `step <https://confluence.ecmwf.int/pages/viewpage.action?pageId=118820050>`_:
191
+ hourly time steps from base forecast time
192
+ - `number <https://confluence.ecmwf.int/pages/viewpage.action?pageId=149335478>`_:
193
+ for ensemble forecasts, ensemble numbers
194
+ - `format <https://confluence.ecmwf.int/pages/viewpage.action?pageId=116970058>`_:
195
+ specify netcdf instead of default grib, DEPRECATED `format`_
196
+ - `grid <https://confluence.ecmwf.int/pages/viewpage.action?pageId=123799065>`_:
197
+ specify model return grid spacing
198
+
199
+ Local ``paths`` are loaded using :func:`xarray.open_mfdataset`.
200
+ Pass ``xr_kwargs`` inputs to :meth:`open_metdataset` to customize file loading.
201
+
202
+
203
+ Examples
204
+ --------
205
+ >>> from datetime import datetime
206
+ >>> from pycontrails import GCPCacheStore
207
+ >>> from pycontrails.datalib.ecmwf import HRES
208
+
209
+ >>> # Store data files to local disk (default behavior)
210
+ >>> times = (datetime(2021, 5, 1, 2), datetime(2021, 5, 1, 3))
211
+ >>> hres = HRES(times, variables="air_temperature", pressure_levels=[300, 250])
212
+
213
+ >>> # Cache files to google cloud storage
214
+ >>> gcp_cache = GCPCacheStore(
215
+ ... bucket="contrails-301217-unit-test",
216
+ ... cache_dir="ecmwf",
217
+ ... )
218
+ >>> hres = HRES(
219
+ ... times,
220
+ ... variables="air_temperature",
221
+ ... pressure_levels=[300, 250],
222
+ ... cachestore=gcp_cache
223
+ ... )
224
+ """
225
+
226
+ __slots__ = ("server", "stream", "field_type", "forecast_time", "url", "key", "email")
227
+
228
+ #: stream type, "oper" = atmospheric model/HRES, "enfo" = ensemble forecast.
229
+ stream: str
230
+
231
+ #: Field type, forecast ("fc"), perturbed forecast ("pf"),
232
+ #: control forecast ("cf"), analysis ("an").
233
+ field_type: str
234
+
235
+ #: Handle to ECMWFService client
236
+ server: ECMWFService
237
+
238
+ #: Forecast run time, either specified or assigned by the closest previous forecast run
239
+ forecast_time: datetime
240
+
241
+ __marker = object()
242
+
243
+ def __init__(
244
+ self,
245
+ time: metsource.TimeInput | None,
246
+ variables: metsource.VariableInput,
247
+ pressure_levels: metsource.PressureLevelInput = -1,
248
+ paths: str | list[str] | pathlib.Path | list[pathlib.Path] | None = None,
249
+ cachepath: str | list[str] | pathlib.Path | list[pathlib.Path] | None = None,
250
+ grid: float = 0.25,
251
+ stream: str = "oper",
252
+ field_type: str = "fc",
253
+ forecast_time: DatetimeLike | None = None,
254
+ cachestore: cache.CacheStore | None = __marker, # type: ignore[assignment]
255
+ url: str | None = None,
256
+ key: str | None = None,
257
+ email: str | None = None,
258
+ ) -> None:
259
+ try:
260
+ from ecmwfapi import ECMWFService
261
+ except ModuleNotFoundError as e:
262
+ dependencies.raise_module_not_found_error(
263
+ name="HRES class",
264
+ package_name="ecmwf-api-client",
265
+ module_not_found_error=e,
266
+ pycontrails_optional_package="ecmwf",
267
+ )
268
+
269
+ # ERA5 now delays creating the server attribute until it is needed to download
270
+ # from CDS. We could do the same here.
271
+ self.server = ECMWFService("mars", url=url, key=key, email=email)
272
+ self.paths = paths
273
+
274
+ if cachestore is self.__marker:
275
+ cachestore = cache.DiskCacheStore()
276
+ self.cachestore = cachestore
277
+
278
+ if time is None and paths is None:
279
+ raise ValueError("Time input is required when paths is None")
280
+
281
+ self.timesteps = metsource.parse_timesteps(time, freq="1h")
282
+ self.pressure_levels = metsource.parse_pressure_levels(
283
+ pressure_levels, self.supported_pressure_levels
284
+ )
285
+ self.variables = metsource.parse_variables(variables, self.supported_variables)
286
+
287
+ self.grid = metsource.parse_grid(grid, [0.1, 0.25, 0.5, 1]) # lat/lon degree resolution
288
+
289
+ # "enfo" = ensemble forecast
290
+ # "oper" = atmospheric model/HRES
291
+ if stream not in ("oper", "enfo"):
292
+ msg = "Parameter stream must be 'oper' or 'enfo'"
293
+ raise ValueError(msg)
294
+
295
+ self.stream = stream
296
+
297
+ # "fc" = forecast
298
+ # "pf" = perturbed forecast
299
+ # "cf" = control forecast
300
+ # "an" = analysis
301
+ if field_type not in ("fc", "pf", "cf", "an"):
302
+ msg = "Parameter field_type must be 'fc', 'pf', 'cf', or 'an'"
303
+ raise ValueError(msg)
304
+
305
+ self.field_type = field_type
306
+
307
+ # set specific forecast time is requested
308
+ if forecast_time is not None:
309
+ forecast_time_pd = pd.to_datetime(forecast_time)
310
+ if forecast_time_pd.hour % 6:
311
+ raise ValueError("Forecast hour must be on one of 00, 06, 12, 18")
312
+
313
+ self.forecast_time = metsource.round_hour(forecast_time_pd.to_pydatetime(), 6)
314
+
315
+ # if no specific forecast is requested, set the forecast time using timesteps
316
+ elif self.timesteps:
317
+ # round first element to the nearest 6 hour time (00, 06, 12, 18 UTC) for forecast_time
318
+ self.forecast_time = metsource.round_hour(self.timesteps[0], 6)
319
+
320
+ # when no forecast_time or time input, forecast_time is defined in _open_and_cache
321
+
322
+ def __repr__(self) -> str:
323
+ base = super().__repr__()
324
+ return (
325
+ f"{base}\n\tForecast time: {getattr(self, 'forecast_time', '')}\n\tSteps:"
326
+ f" {getattr(self, 'steps', '')}"
327
+ )
328
+
329
+ @classmethod
330
+ def create_synoptic_time_ranges(
331
+ self, timesteps: list[pd.Timestamp]
332
+ ) -> list[tuple[pd.Timestamp, pd.Timestamp]]:
333
+ """Create synoptic time bounds encompassing date range.
334
+
335
+ Extracts time bounds for synoptic time range ([00:00, 11:59], [12:00, 23:59])
336
+ for a list of input timesteps.
337
+
338
+ Parameters
339
+ ----------
340
+ timesteps : list[pd.Timestamp]
341
+ List of timesteps formatted as :class:`pd.Timestamps`.
342
+ Often this it the output from `pd.date_range()`
343
+
344
+ Returns
345
+ -------
346
+ list[tuple[pd.Timestamp, pd.Timestamp]]
347
+ List of tuple time bounds that can be used as inputs to :class:`HRES(time=...)`
348
+ """
349
+ time_ranges = np.unique(
350
+ [pd.Timestamp(t.year, t.month, t.day, 12 * (t.hour // 12)) for t in timesteps]
351
+ )
352
+
353
+ if len(time_ranges) == 1:
354
+ time_ranges = [(timesteps[0], timesteps[-1])]
355
+ else:
356
+ time_ranges[0] = (timesteps[0], time_ranges[1] - pd.Timedelta(hours=1))
357
+ time_ranges[1:-1] = [(t, t + pd.Timedelta(hours=11)) for t in time_ranges[1:-1]]
358
+ time_ranges[-1] = (time_ranges[-1], timesteps[-1])
359
+
360
+ return time_ranges
361
+
362
+ @property
363
+ def hash(self) -> str:
364
+ """Generate a unique hash for this datasource.
365
+
366
+ Returns
367
+ -------
368
+ str
369
+ Unique hash for met instance (sha1)
370
+ """
371
+ hashstr = (
372
+ f"{self.__class__.__name__}{self.timesteps}{self.variable_shortnames}"
373
+ f"{self.pressure_levels}{self.grid}{self.forecast_time}{self.field_type}{self.stream}"
374
+ )
375
+ return hashlib.sha1(bytes(hashstr, "utf-8")).hexdigest()
376
+
377
+ @property
378
+ def pressure_level_variables(self) -> list[MetVariable]:
379
+ """ECMWF pressure level parameters.
380
+
381
+ Returns
382
+ -------
383
+ list[MetVariable] | None
384
+ List of MetVariable available in datasource
385
+ """
386
+ return PRESSURE_LEVEL_VARIABLES
387
+
388
+ @property
389
+ def single_level_variables(self) -> list[MetVariable]:
390
+ """ECMWF surface level parameters.
391
+
392
+ Returns
393
+ -------
394
+ list[MetVariable] | None
395
+ List of MetVariable available in datasource
396
+ """
397
+ return SURFACE_VARIABLES
398
+
399
+ @property
400
+ def supported_pressure_levels(self) -> list[int]:
401
+ """Get pressure levels available from MARS.
402
+
403
+ Returns
404
+ -------
405
+ list[int]
406
+ List of integer pressure level values
407
+ """
408
+ return [
409
+ 1000,
410
+ 950,
411
+ 925,
412
+ 900,
413
+ 850,
414
+ 800,
415
+ 700,
416
+ 600,
417
+ 500,
418
+ 400,
419
+ 300,
420
+ 250,
421
+ 200,
422
+ 150,
423
+ 100,
424
+ 70,
425
+ 50,
426
+ 30,
427
+ 20,
428
+ 10,
429
+ 7,
430
+ 5,
431
+ 3,
432
+ 2,
433
+ 1,
434
+ -1,
435
+ ]
436
+
437
+ @property
438
+ def step_offset(self) -> int:
439
+ """Difference between :attr:`forecast_time` and first timestep.
440
+
441
+ Returns
442
+ -------
443
+ int
444
+ Number of steps to offset in order to retrieve data starting from input time.
445
+ Returns 0 if :attr:`timesteps` is empty when loading from :attr:`paths`.
446
+ """
447
+ if self.timesteps:
448
+ return int((self.timesteps[0] - self.forecast_time).total_seconds() // 3600)
449
+
450
+ return 0
451
+
452
+ @property
453
+ def steps(self) -> list[int]:
454
+ """Forecast steps from :attr:`forecast_time` corresponding within input :attr:`time`.
455
+
456
+ Returns
457
+ -------
458
+ list[int]
459
+ List of forecast steps relative to :attr:`forecast_time`
460
+ """
461
+ return [self.step_offset + i for i in range(len(self.timesteps))]
462
+
463
+ def list_from_mars(self) -> str:
464
+ """List metadata on query from MARS.
465
+
466
+ Returns
467
+ -------
468
+ str
469
+ Metadata for MARS request.
470
+ Note this is queued the same as data requests.
471
+ """
472
+ request = self.generate_mars_request(self.forecast_time, self.steps, request_type="list")
473
+
474
+ # hold downloaded file in named temp file
475
+ with temp.temp_file() as mars_temp_filename:
476
+ LOG.debug("Performing MARS request: %s", request)
477
+ self.server.execute(request, target=mars_temp_filename)
478
+
479
+ with open(mars_temp_filename) as f:
480
+ return f.read()
481
+
482
+ def generate_mars_request(
483
+ self,
484
+ forecast_time: datetime | None = None,
485
+ steps: list[int] | None = None,
486
+ request_type: str = "retrieve",
487
+ request_format: str = "mars",
488
+ ) -> str | dict[str, Any]:
489
+ """Generate MARS request in MARS request syntax.
490
+
491
+ Parameters
492
+ ----------
493
+ forecast_time : :class:`datetime`, optional
494
+ Base datetime for the forecast.
495
+ Defaults to :attr:`forecast_time`.
496
+ steps : list[int], optional
497
+ list of steps.
498
+ Defaults to :attr:`steps`.
499
+ request_type : str, optional
500
+ "retrieve" for download request or "list" for metadata request.
501
+ Defaults to "retrieve".
502
+ request_format : str, optional
503
+ "mars" for MARS string format, or "dict" for dict version.
504
+ Defaults to "mars".
505
+
506
+ Returns
507
+ -------
508
+ str | dict[str, Any]
509
+ Returns MARS query string if ``request_format`` is "mars".
510
+ Returns dict query if ``request_format`` is "dict"
511
+
512
+ Notes
513
+ -----
514
+ Brief overview of `MARS request syntax
515
+ <https://confluence.ecmwf.int/display/WEBAPI/Brief+MARS+request+syntax>`_
516
+ """
517
+
518
+ if forecast_time is None:
519
+ forecast_time = self.forecast_time
520
+
521
+ if steps is None:
522
+ steps = self.steps
523
+
524
+ # set date/time for file
525
+ date = forecast_time.strftime("%Y%m%d")
526
+ time = forecast_time.strftime("%H")
527
+
528
+ # make request of mars
529
+ request: dict[str, Any] = {
530
+ "class": "od", # operational data
531
+ "stream": self.stream,
532
+ "expver": "1", # production data only
533
+ "date": date,
534
+ "time": time,
535
+ "type": self.field_type,
536
+ "param": f"{'/'.join(self.variable_shortnames)}",
537
+ "step": f"{'/'.join([str(s) for s in steps])}",
538
+ "grid": f"{self.grid}/{self.grid}",
539
+ }
540
+
541
+ if self.pressure_levels != [-1]:
542
+ request["levtype"] = "pl"
543
+ request["levelist"] = f"{'/'.join([str(pl) for pl in self.pressure_levels])}"
544
+ else:
545
+ request["levtype"] = "sfc"
546
+
547
+ if request_format == "dict":
548
+ return request
549
+
550
+ levelist = f",\n\tlevelist={request['levelist']}" if self.pressure_levels != [-1] else ""
551
+ return (
552
+ f"{request_type},\n\tclass={request['class']},\n\tstream={request['stream']},"
553
+ f"\n\texpver={request['expver']},\n\tdate={request['date']},"
554
+ f"\n\ttime={request['time']},\n\ttype={request['type']},"
555
+ f"\n\tparam={request['param']},\n\tstep={request['step']},"
556
+ f"\n\tgrid={request['grid']},\n\tlevtype={request['levtype']}{levelist}"
557
+ )
558
+
559
+ @overrides
560
+ def create_cachepath(self, t: datetime) -> str:
561
+ if self.cachestore is None:
562
+ raise ValueError("self.cachestore attribute must be defined to create cache path")
563
+
564
+ # get forecast_time and step for specific file
565
+ datestr = self.forecast_time.strftime("%Y%m%d-%H")
566
+
567
+ # get step relative to forecast forecast_time
568
+ step = self.step_offset + self.timesteps.index(t)
569
+
570
+ # single level or pressure level
571
+ levtype = "sl" if self.pressure_levels == [-1] else "pl"
572
+ suffix = f"hres{levtype}{self.grid}{self.stream}{self.field_type}"
573
+
574
+ # return cache path
575
+ return self.cachestore.path(f"{datestr}-{step}-{suffix}.nc")
576
+
577
+ @overrides
578
+ def download_dataset(self, times: list[datetime]) -> None:
579
+ """Download data from data source for input times.
580
+
581
+ Parameters
582
+ ----------
583
+ times : list[:class:`datetime`]
584
+ List of datetimes to download and store in cache datastore
585
+ """
586
+
587
+ # get step relative to forecast forecast_time
588
+ steps = [self.step_offset + self.timesteps.index(t) for t in times]
589
+ LOG.debug(f"Downloading HRES dataset for base time {self.forecast_time} and steps {steps}")
590
+
591
+ # download in sets of 24
592
+ if len(steps) > 24:
593
+ for _steps in iteration.chunk_list(steps, 24):
594
+ self._download_file(_steps)
595
+ elif len(steps) > 0:
596
+ self._download_file(steps)
597
+
598
+ @overrides
599
+ def open_metdataset(
600
+ self,
601
+ dataset: xr.Dataset | None = None,
602
+ xr_kwargs: dict[str, Any] | None = None,
603
+ **kwargs: Any,
604
+ ) -> MetDataset:
605
+ xr_kwargs = xr_kwargs or {}
606
+
607
+ # short-circuit dataset or file paths if provided
608
+ if dataset is not None:
609
+ ds = self._preprocess_hres_dataset(dataset)
610
+
611
+ # load from local paths
612
+ elif self.paths is not None:
613
+ ds = self._open_and_cache(xr_kwargs)
614
+
615
+ # download from MARS
616
+ else:
617
+ if self.cachestore is None:
618
+ raise ValueError("Cachestore is required to download data")
619
+
620
+ # confirm files are downloaded from CDS or MARS
621
+ self.download(**xr_kwargs)
622
+
623
+ # ensure all files are guaranteed to be available locally here
624
+ # this would download a file from a remote (e.g. GCP) cache
625
+ disk_cachepaths = [self.cachestore.get(f) for f in self._cachepaths]
626
+
627
+ # open cache files as xr.Dataset
628
+ ds = self.open_dataset(disk_cachepaths, **xr_kwargs)
629
+
630
+ ds.attrs.setdefault("pycontrails_version", pycontrails.__version__)
631
+
632
+ # run the same ECMWF-specific processing on the dataset
633
+ mds = self._process_dataset(ds, **kwargs)
634
+
635
+ self.set_metadata(mds)
636
+ return mds
637
+
638
+ @overrides
639
+ def set_metadata(self, ds: xr.Dataset | MetDataset) -> None:
640
+ if self.stream == "oper":
641
+ product = "forecast"
642
+ elif self.stream == "enfo":
643
+ product = "ensemble"
644
+ else:
645
+ msg = f"Unknown stream type {self.stream}"
646
+ raise ValueError(msg)
647
+
648
+ ds.attrs.update(
649
+ provider="ECMWF",
650
+ dataset="HRES",
651
+ product=product,
652
+ radiation_accumulated=True,
653
+ )
654
+
655
+ def _open_and_cache(self, xr_kwargs: dict[str, Any]) -> xr.Dataset:
656
+ """Open and cache :class:`xr.Dataset` from :attr:`self.paths`.
657
+
658
+ Parameters
659
+ ----------
660
+ xr_kwargs : dict[str, Any]
661
+ Additional kwargs passed directly to :func:`xarray.open_mfdataset`.
662
+ See :meth:`open_metdataset`.
663
+
664
+ Returns
665
+ -------
666
+ xr.Dataset
667
+ Dataset opened from local paths.
668
+ """
669
+
670
+ if self.paths is None:
671
+ raise ValueError("Attribute `self.paths` must be defined to open and cache")
672
+
673
+ # if timesteps are defined and all timesteps are cached already
674
+ # then we can skip loading
675
+ if self.timesteps and self.cachestore and not self.list_timesteps_not_cached(**xr_kwargs):
676
+ LOG.debug("All timesteps already in cache store")
677
+ disk_cachepaths = [self.cachestore.get(f) for f in self._cachepaths]
678
+ return self.open_dataset(disk_cachepaths, **xr_kwargs)
679
+
680
+ # set default parameters for loading grib files
681
+ xr_kwargs.setdefault("engine", "cfgrib")
682
+ xr_kwargs.setdefault("combine", "nested")
683
+ xr_kwargs.setdefault("concat_dim", "step")
684
+ xr_kwargs.setdefault("parallel", False)
685
+ ds = self.open_dataset(self.paths, **xr_kwargs)
686
+
687
+ # set forecast time if its not already defined
688
+ if not getattr(self, "forecast_time", None):
689
+ self.forecast_time = ds["time"].values.astype("datetime64[s]").tolist()
690
+
691
+ # check that forecast_time is correct if defined
692
+ # note the "time" coordinate here is the HRES forecast_time
693
+ elif self.forecast_time != ds["time"].values.astype("datetime64[s]").tolist():
694
+ raise ValueError(
695
+ f"HRES.forecast_time {self.forecast_time} is not the same forecast time listed"
696
+ " in file"
697
+ )
698
+
699
+ ds = self._preprocess_hres_dataset(ds)
700
+
701
+ # set timesteps if not defined
702
+ # note that "time" is now the actual timestep coordinates
703
+ if not self.timesteps:
704
+ self.timesteps = ds["time"].values.astype("datetime64[s]").tolist()
705
+
706
+ self.cache_dataset(ds)
707
+
708
+ return ds
709
+
710
+ def _download_file(self, steps: list[int]) -> None:
711
+ """Download data file for base datetime and timesteps.
712
+
713
+ Overwrites files if they already exists.
714
+
715
+ Parameters
716
+ ----------
717
+ steps : list[int]
718
+ Steps to download relative to base date
719
+ """
720
+ request = self.generate_mars_request(self.forecast_time, steps)
721
+
722
+ # Open ExitStack to control temp_file context manager
723
+ with ExitStack() as stack:
724
+ # hold downloaded file in named temp file
725
+ mars_temp_grib_filename = stack.enter_context(temp.temp_file())
726
+
727
+ # retrieve data from MARS
728
+ LOG.debug(f"Performing MARS request: {request}")
729
+ self.server.execute(request, mars_temp_grib_filename)
730
+
731
+ # translate into netcdf from grib
732
+ LOG.debug("Translating file into netcdf")
733
+ ds = stack.enter_context(xr.open_dataset(mars_temp_grib_filename, engine="cfgrib"))
734
+
735
+ # run preprocessing before cache
736
+ ds = self._preprocess_hres_dataset(ds)
737
+
738
+ self.cache_dataset(ds)
739
+
740
+ def _preprocess_hres_dataset(self, ds: xr.Dataset) -> xr.Dataset:
741
+ """Process HRES data before caching.
742
+
743
+ Parameters
744
+ ----------
745
+ ds : xr.Dataset
746
+ Loaded :class:`xr.Dataset`
747
+
748
+ Returns
749
+ -------
750
+ xr.Dataset
751
+ Processed :class:`xr.Dataset`
752
+ """
753
+
754
+ if "pycontrails_version" in ds.attrs:
755
+ LOG.debug("Input dataset processed with pycontrails > 0.29")
756
+ return ds
757
+
758
+ # for pressure levels, need to rename "level" field
759
+ if self.pressure_levels != [-1]:
760
+ ds = ds.rename({"isobaricInhPa": "level"})
761
+
762
+ # for single level, and singular pressure levels, add the level dimension
763
+ if len(self.pressure_levels) == 1:
764
+ ds = ds.expand_dims({"level": self.pressure_levels})
765
+
766
+ # for single time, add the step dimension and assign time coords to step
767
+ if ds["step"].size == 1:
768
+ if "step" not in ds.dims:
769
+ ds = ds.expand_dims({"step": [ds["step"].values]})
770
+
771
+ ds = ds.assign_coords({"valid_time": ("step", [ds["valid_time"].values])})
772
+
773
+ # rename fields and swap time dimension for step
774
+ ds = ds.rename({"time": "forecast_time"})
775
+ ds = ds.rename({"valid_time": "time"})
776
+ ds = ds.swap_dims({"step": "time"})
777
+
778
+ # drop step/number
779
+ ds = ds.drop_vars(["step", "number"], errors="ignore")
780
+
781
+ ds.attrs["pycontrails_version"] = pycontrails.__version__
782
+ return ds