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