pycontrails 0.59.0__cp314-cp314-macosx_10_15_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 (123) 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 +2936 -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.cpython-314-darwin.so +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 +764 -0
  37. pycontrails/datalib/gruan.py +343 -0
  38. pycontrails/datalib/himawari/__init__.py +27 -0
  39. pycontrails/datalib/himawari/header_struct.py +266 -0
  40. pycontrails/datalib/himawari/himawari.py +671 -0
  41. pycontrails/datalib/landsat.py +589 -0
  42. pycontrails/datalib/leo_utils/__init__.py +5 -0
  43. pycontrails/datalib/leo_utils/correction.py +266 -0
  44. pycontrails/datalib/leo_utils/landsat_metadata.py +300 -0
  45. pycontrails/datalib/leo_utils/search.py +250 -0
  46. pycontrails/datalib/leo_utils/sentinel_metadata.py +748 -0
  47. pycontrails/datalib/leo_utils/static/bq_roi_query.sql +6 -0
  48. pycontrails/datalib/leo_utils/vis.py +59 -0
  49. pycontrails/datalib/sentinel.py +650 -0
  50. pycontrails/datalib/spire/__init__.py +5 -0
  51. pycontrails/datalib/spire/exceptions.py +62 -0
  52. pycontrails/datalib/spire/spire.py +604 -0
  53. pycontrails/ext/bada.py +42 -0
  54. pycontrails/ext/cirium.py +14 -0
  55. pycontrails/ext/empirical_grid.py +140 -0
  56. pycontrails/ext/synthetic_flight.py +431 -0
  57. pycontrails/models/__init__.py +1 -0
  58. pycontrails/models/accf.py +425 -0
  59. pycontrails/models/apcemm/__init__.py +8 -0
  60. pycontrails/models/apcemm/apcemm.py +983 -0
  61. pycontrails/models/apcemm/inputs.py +226 -0
  62. pycontrails/models/apcemm/static/apcemm_yaml_template.yaml +183 -0
  63. pycontrails/models/apcemm/utils.py +437 -0
  64. pycontrails/models/cocip/__init__.py +29 -0
  65. pycontrails/models/cocip/cocip.py +2742 -0
  66. pycontrails/models/cocip/cocip_params.py +305 -0
  67. pycontrails/models/cocip/cocip_uncertainty.py +291 -0
  68. pycontrails/models/cocip/contrail_properties.py +1530 -0
  69. pycontrails/models/cocip/output_formats.py +2270 -0
  70. pycontrails/models/cocip/radiative_forcing.py +1260 -0
  71. pycontrails/models/cocip/radiative_heating.py +520 -0
  72. pycontrails/models/cocip/unterstrasser_wake_vortex.py +508 -0
  73. pycontrails/models/cocip/wake_vortex.py +396 -0
  74. pycontrails/models/cocip/wind_shear.py +120 -0
  75. pycontrails/models/cocipgrid/__init__.py +9 -0
  76. pycontrails/models/cocipgrid/cocip_grid.py +2552 -0
  77. pycontrails/models/cocipgrid/cocip_grid_params.py +138 -0
  78. pycontrails/models/dry_advection.py +602 -0
  79. pycontrails/models/emissions/__init__.py +21 -0
  80. pycontrails/models/emissions/black_carbon.py +599 -0
  81. pycontrails/models/emissions/emissions.py +1353 -0
  82. pycontrails/models/emissions/ffm2.py +336 -0
  83. pycontrails/models/emissions/static/default-engine-uids.csv +239 -0
  84. pycontrails/models/emissions/static/edb-gaseous-v29b-engines.csv +596 -0
  85. pycontrails/models/emissions/static/edb-nvpm-v29b-engines.csv +215 -0
  86. pycontrails/models/extended_k15.py +1327 -0
  87. pycontrails/models/humidity_scaling/__init__.py +37 -0
  88. pycontrails/models/humidity_scaling/humidity_scaling.py +1075 -0
  89. pycontrails/models/humidity_scaling/quantiles/era5-model-level-quantiles.pq +0 -0
  90. pycontrails/models/humidity_scaling/quantiles/era5-pressure-level-quantiles.pq +0 -0
  91. pycontrails/models/issr.py +210 -0
  92. pycontrails/models/pcc.py +326 -0
  93. pycontrails/models/pcr.py +154 -0
  94. pycontrails/models/ps_model/__init__.py +18 -0
  95. pycontrails/models/ps_model/ps_aircraft_params.py +381 -0
  96. pycontrails/models/ps_model/ps_grid.py +701 -0
  97. pycontrails/models/ps_model/ps_model.py +1000 -0
  98. pycontrails/models/ps_model/ps_operational_limits.py +525 -0
  99. pycontrails/models/ps_model/static/ps-aircraft-params-20250328.csv +69 -0
  100. pycontrails/models/ps_model/static/ps-synonym-list-20250328.csv +104 -0
  101. pycontrails/models/sac.py +442 -0
  102. pycontrails/models/tau_cirrus.py +183 -0
  103. pycontrails/physics/__init__.py +1 -0
  104. pycontrails/physics/constants.py +117 -0
  105. pycontrails/physics/geo.py +1138 -0
  106. pycontrails/physics/jet.py +968 -0
  107. pycontrails/physics/static/iata-cargo-load-factors-20250221.csv +74 -0
  108. pycontrails/physics/static/iata-passenger-load-factors-20250221.csv +74 -0
  109. pycontrails/physics/thermo.py +551 -0
  110. pycontrails/physics/units.py +472 -0
  111. pycontrails/py.typed +0 -0
  112. pycontrails/utils/__init__.py +1 -0
  113. pycontrails/utils/dependencies.py +66 -0
  114. pycontrails/utils/iteration.py +13 -0
  115. pycontrails/utils/json.py +187 -0
  116. pycontrails/utils/temp.py +50 -0
  117. pycontrails/utils/types.py +163 -0
  118. pycontrails-0.59.0.dist-info/METADATA +179 -0
  119. pycontrails-0.59.0.dist-info/RECORD +123 -0
  120. pycontrails-0.59.0.dist-info/WHEEL +6 -0
  121. pycontrails-0.59.0.dist-info/licenses/LICENSE +178 -0
  122. pycontrails-0.59.0.dist-info/licenses/NOTICE +43 -0
  123. pycontrails-0.59.0.dist-info/top_level.txt +3 -0
@@ -0,0 +1,466 @@
1
+ """Model-level HRES data access from the ECMWF operational archive.
2
+
3
+ This module supports
4
+
5
+ - Retrieving model-level HRES data by submitting MARS requests through the ECMWF API.
6
+ - Processing retrieved model-level files to produce netCDF files on target pressure levels.
7
+ - Local caching of processed netCDF files.
8
+ - Opening processed and cached files as a :class:`pycontrails.MetDataset` object.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import contextlib
14
+ import hashlib
15
+ import logging
16
+ import sys
17
+ import warnings
18
+ from datetime import datetime, timedelta
19
+ from typing import Any
20
+
21
+ if sys.version_info >= (3, 12):
22
+ from typing import override
23
+ else:
24
+ from typing_extensions import override
25
+
26
+ LOG = logging.getLogger(__name__)
27
+
28
+ import pandas as pd
29
+ import xarray as xr
30
+
31
+ import pycontrails
32
+ from pycontrails.core import cache
33
+ from pycontrails.core.met import MetDataset, MetVariable
34
+ from pycontrails.datalib._met_utils import metsource
35
+ from pycontrails.datalib.ecmwf import model_levels as mlmod
36
+ from pycontrails.datalib.ecmwf.common import ECMWFAPI
37
+ from pycontrails.datalib.ecmwf.variables import MODEL_LEVEL_VARIABLES
38
+ from pycontrails.utils import dependencies, temp
39
+ from pycontrails.utils.types import DatetimeLike
40
+
41
+ LAST_STEP_1H = 96 # latest forecast step with 1 hour frequency
42
+ LAST_STEP_3H = 144 # latest forecast step with 3 hour frequency
43
+ LAST_STEP_6H = 240 # latest forecast step with 6 hour frequency
44
+
45
+
46
+ class HRESModelLevel(ECMWFAPI):
47
+ """Class to support model-level HRES data access, download, and organization.
48
+
49
+ The interface is similar to :class:`pycontrails.datalib.ecmwf.HRES`,
50
+ which downloads pressure-level data with much lower vertical resolution and single-level data.
51
+ Note, however, that only a subset of the pressure-level data available through the operational
52
+ archive is available as model-level data. As a consequence, this interface only
53
+ supports access to nominal HRES forecasts (corresponding to ``stream = "oper"`` and
54
+ ``field_type = "fc"`` in :class:`pycontrails.datalib.ecmwf.HRES`) initialized at 00z and 12z.
55
+
56
+ Requires account with ECMWF and API key.
57
+
58
+ API credentials can be set in local ``~/.ecmwfapirc`` file:
59
+
60
+ .. code:: json
61
+
62
+ {
63
+ "url": "https://api.ecmwf.int/v1",
64
+ "email": "<email>",
65
+ "key": "<key>"
66
+ }
67
+
68
+ Credentials can also be provided directly in ``url``, ``key``, and ``email`` keyword args.
69
+ A third option is to set the environment variables ``ECMWF_API_URL``, ``ECMWF_API_KEY``,
70
+ and ``ECMWF_API_EMAIL``.
71
+
72
+ See `ecmwf-api-client <https://github.com/ecmwf/ecmwf-api-client>`_ documentation
73
+ for more information.
74
+
75
+ Parameters
76
+ ----------
77
+ time : metsource.TimeInput
78
+ The time range for data retrieval, either a single datetime or (start, end) datetime range.
79
+ Input must be datetime-like or tuple of datetime-like
80
+ (:py:class:`datetime.datetime`, :class:`pandas.Timestamp`, :class:`numpy.datetime64`)
81
+ specifying the (start, end) of the date range, inclusive.
82
+ All times will be downloaded in a single NetCDF file, which
83
+ ensures that exactly one request is submitted per file on tape accessed.
84
+ If ``forecast_time`` is unspecified, the forecast time will
85
+ be assumed to be the nearest synoptic hour available in the operational archive (00 or 12).
86
+ All subsequent times will be downloaded for relative to :attr:`forecast_time`.
87
+ variables : metsource.VariableInput
88
+ Variable name (i.e. "t", "air_temperature", ["air_temperature, specific_humidity"])
89
+ pressure_levels : metsource.PressureLevelInput, optional
90
+ Pressure levels for data, in hPa (mbar).
91
+ To download surface-level parameters, use :class:`pycontrails.datalib.ecmwf.HRES`.
92
+ Defaults to pressure levels that match model levels at a nominal surface pressure.
93
+ timestep_freq : str, optional
94
+ Manually set the timestep interval within the bounds defined by :attr:`time`.
95
+ Supports any string that can be passed to ``pandas.date_range(freq=...)``.
96
+ By default, this is set to the highest frequency that can supported the requested
97
+ time range ("1h" out to 96 hours, "3h" out to 144 hours, and "6h" out to 240 hours)
98
+ grid : float, optional
99
+ Specify latitude/longitude grid spacing in data.
100
+ By default, this is set to 0.1.
101
+ forecast_time : DatetimeLike, optional
102
+ Specify forecast by initialization time.
103
+ By default, set to the most recent forecast that includes the requested time range.
104
+ levels : list[int], optional
105
+ Specify ECMWF model levels to include in MARS requests.
106
+ By default, this is set to include all model levels.
107
+ cachestore : CacheStore | None, optional
108
+ Cache data store for staging processed netCDF files.
109
+ Defaults to :class:`pycontrails.core.cache.DiskCacheStore`.
110
+ If None, cache is turned off.
111
+ cache_download: bool, optional
112
+ If True, cache downloaded NetCDF files rather than storing them in a temporary file.
113
+ By default, False.
114
+ url : str
115
+ Override `ecmwf-api-client <https://github.com/ecmwf/ecmwf-api-client>`_ url
116
+ key : str
117
+ Override `ecmwf-api-client <https://github.com/ecmwf/ecmwf-api-client>`_ key
118
+ email : str
119
+ Override `ecmwf-api-client <https://github.com/ecmwf/ecmwf-api-client>`_ email
120
+ """
121
+
122
+ __marker = object()
123
+
124
+ def __init__(
125
+ self,
126
+ time: metsource.TimeInput,
127
+ variables: metsource.VariableInput,
128
+ pressure_levels: metsource.PressureLevelInput | None = None,
129
+ timestep_freq: str | None = None,
130
+ grid: float | None = None,
131
+ forecast_time: DatetimeLike | None = None,
132
+ model_levels: list[int] | None = None,
133
+ cachestore: cache.CacheStore = __marker, # type: ignore[assignment]
134
+ cache_download: bool = False,
135
+ url: str | None = None,
136
+ key: str | None = None,
137
+ email: str | None = None,
138
+ ) -> None:
139
+ # Parse and set each parameter to the instance
140
+
141
+ self.cachestore = cache.DiskCacheStore() if cachestore is self.__marker else cachestore
142
+ self.cache_download = cache_download
143
+
144
+ self.paths = None
145
+
146
+ self.url = url
147
+ self.key = key
148
+ self.email = email
149
+
150
+ if grid is None:
151
+ grid = 0.1
152
+ else:
153
+ grid_min = 0.1
154
+ if grid < grid_min:
155
+ msg = (
156
+ f"The highest resolution available is {grid_min} degrees. "
157
+ f"Your downloaded data will have resolution {grid}, but it is a "
158
+ f"reinterpolation of the {grid_min} degree data. The same interpolation can be "
159
+ "achieved directly with xarray."
160
+ )
161
+ warnings.warn(msg)
162
+ self.grid = grid
163
+
164
+ if model_levels is None:
165
+ model_levels = list(range(1, 138))
166
+ elif min(model_levels) < 1 or max(model_levels) > 137:
167
+ msg = "Retrieval model_levels must be between 1 and 137, inclusive."
168
+ raise ValueError(msg)
169
+ self.model_levels = model_levels
170
+
171
+ forecast_hours = metsource.parse_timesteps(time, freq="1h")
172
+ if forecast_time is None:
173
+ self.forecast_time = metsource.round_hour(forecast_hours[0], 12)
174
+ else:
175
+ forecast_time_pd = pd.to_datetime(forecast_time)
176
+ if (hour := forecast_time_pd.hour) % 12:
177
+ msg = f"Forecast hour must be one of 00 or 12 but is {hour:02d}."
178
+ raise ValueError(msg)
179
+ self.forecast_time = metsource.round_hour(forecast_time_pd.to_pydatetime(), 12)
180
+
181
+ last_step = (forecast_hours[-1] - self.forecast_time) / timedelta(hours=1)
182
+ if last_step > LAST_STEP_6H:
183
+ msg = (
184
+ f"Requested times requires forecast steps out to {last_step}, "
185
+ f"which is beyond latest available step of {LAST_STEP_6H}"
186
+ )
187
+ raise ValueError(msg)
188
+
189
+ datasource_timestep_freq = (
190
+ "1h" if last_step <= LAST_STEP_1H else "3h" if last_step <= LAST_STEP_3H else "6h"
191
+ )
192
+ if timestep_freq is None:
193
+ timestep_freq = datasource_timestep_freq
194
+ if not metsource.validate_timestep_freq(timestep_freq, datasource_timestep_freq):
195
+ msg = (
196
+ f"Forecast out to step {last_step} "
197
+ f"has timestep frequency of {datasource_timestep_freq} "
198
+ f"and cannot support requested timestep frequency of {timestep_freq}."
199
+ )
200
+ raise ValueError(msg)
201
+
202
+ self.timesteps = metsource.parse_timesteps(time, freq=timestep_freq)
203
+ if self.step_offset < 0:
204
+ msg = f"Selected forecast time {self.forecast_time} is after first timestep."
205
+ raise ValueError(msg)
206
+
207
+ if pressure_levels is None:
208
+ pressure_levels = mlmod.model_level_reference_pressure(20_000.0, 50_000.0)
209
+ self.pressure_levels = metsource.parse_pressure_levels(pressure_levels)
210
+ self.variables = metsource.parse_variables(variables, self.pressure_level_variables)
211
+
212
+ def __repr__(self) -> str:
213
+ base = super().__repr__()
214
+ return "\n\t".join(
215
+ [
216
+ base,
217
+ f"Forecast time: {getattr(self, 'forecast_time', '')}",
218
+ f"Steps: {getattr(self, 'steps', '')}",
219
+ ]
220
+ )
221
+
222
+ def get_forecast_steps(self, times: list[datetime]) -> list[int]:
223
+ """Convert list of times to list of forecast steps.
224
+
225
+ Parameters
226
+ ----------
227
+ times : list[datetime]
228
+ Times to convert to forecast steps
229
+
230
+ Returns
231
+ -------
232
+ list[int]
233
+ Forecast step at each time
234
+ """
235
+
236
+ def time_to_step(time: datetime) -> int:
237
+ step = (time - self.forecast_time) / timedelta(hours=1)
238
+ if not step.is_integer():
239
+ msg = (
240
+ f"Time-to-step conversion returned fractional forecast step {step} "
241
+ f"for timestep {time.strftime('%Y-%m-%d %H:%M:%S')}"
242
+ )
243
+ raise ValueError(msg)
244
+ return int(step)
245
+
246
+ return [time_to_step(t) for t in times]
247
+
248
+ @property
249
+ def step_offset(self) -> int:
250
+ """Difference between :attr:`forecast_time` and first timestep.
251
+
252
+ Returns
253
+ -------
254
+ int
255
+ Number of steps to offset in order to retrieve data starting from input time.
256
+ """
257
+ return self.get_forecast_steps([self.timesteps[0]])[0]
258
+
259
+ @property
260
+ def steps(self) -> list[int]:
261
+ """Forecast steps from :attr:`forecast_time` corresponding within input :attr:`time`.
262
+
263
+ Returns
264
+ -------
265
+ list[int]
266
+ List of forecast steps relative to :attr:`forecast_time`
267
+ """
268
+ return self.get_forecast_steps(self.timesteps)
269
+
270
+ @property
271
+ def pressure_level_variables(self) -> list[MetVariable]:
272
+ """ECMWF pressure level parameters available on model levels.
273
+
274
+ Returns
275
+ -------
276
+ list[MetVariable]
277
+ List of MetVariable available in datasource
278
+ """
279
+ return MODEL_LEVEL_VARIABLES
280
+
281
+ @property
282
+ def single_level_variables(self) -> list[MetVariable]:
283
+ """ECMWF single-level parameters available on model levels.
284
+
285
+ Returns
286
+ -------
287
+ list[MetVariable]
288
+ Always returns an empty list.
289
+ To access single-level variables, use :class:`pycontrails.datalib.ecmwf.HRES`.
290
+ """
291
+ return []
292
+
293
+ @override
294
+ def create_cachepath(self, t: datetime | pd.Timestamp) -> str:
295
+ """Return cachepath to local HRES data file based on datetime.
296
+
297
+ This uniquely defines a cached data file with class parameters.
298
+
299
+ Parameters
300
+ ----------
301
+ t : datetime | pd.Timestamp
302
+ Datetime of datafile
303
+
304
+ Returns
305
+ -------
306
+ str
307
+ Path to local HRES data file
308
+ """
309
+ if self.cachestore is None:
310
+ msg = "Cachestore is required to create cache path"
311
+ raise ValueError(msg)
312
+
313
+ string = (
314
+ f"{t:%Y%m%d%H}-"
315
+ f"{self.forecast_time:%Y%m%d%H}-"
316
+ f"{'.'.join(str(p) for p in self.pressure_levels)}-"
317
+ f"{'.'.join(sorted(self.variable_shortnames))}-"
318
+ f"{self.grid}"
319
+ )
320
+
321
+ name = hashlib.md5(string.encode()).hexdigest()
322
+ cache_path = f"hresml-{name}.nc"
323
+
324
+ return self.cachestore.path(cache_path)
325
+
326
+ @override
327
+ def download_dataset(self, times: list[datetime]) -> None:
328
+ # will always submit a single MARS request since each forecast is a separate file on tape
329
+ LOG.debug(f"Retrieving ERA5 data for times {times} from forecast {self.forecast_time}")
330
+ self._download_convert_cache_handler(times)
331
+
332
+ @override
333
+ def open_metdataset(
334
+ self,
335
+ dataset: xr.Dataset | None = None,
336
+ xr_kwargs: dict[str, Any] | None = None,
337
+ **kwargs: Any,
338
+ ) -> MetDataset:
339
+ if dataset:
340
+ msg = "Parameter 'dataset' is not supported for Model-level ERA5 data"
341
+ raise ValueError(msg)
342
+
343
+ if self.cachestore is None:
344
+ msg = "Cachestore is required to download data"
345
+ raise ValueError(msg)
346
+
347
+ xr_kwargs = xr_kwargs or {}
348
+ self.download(**xr_kwargs)
349
+
350
+ disk_cachepaths = [self.cachestore.get(f) for f in self._cachepaths]
351
+ ds = self.open_dataset(disk_cachepaths, **xr_kwargs)
352
+
353
+ mds = self._process_dataset(ds, **kwargs)
354
+
355
+ self.set_metadata(mds)
356
+ return mds
357
+
358
+ @override
359
+ def set_metadata(self, ds: xr.Dataset | MetDataset) -> None:
360
+ ds.attrs.update(
361
+ provider="ECMWF", dataset="HRES", product="forecast", radiation_accumulated=True
362
+ )
363
+
364
+ def mars_request(self, times: list[datetime]) -> str:
365
+ """Generate MARS request for specific list of times.
366
+
367
+ Parameters
368
+ ----------
369
+ times : list[datetime]
370
+ Times included in MARS request.
371
+
372
+ Returns
373
+ -------
374
+ str
375
+ MARS request for submission to ECMWF API.
376
+ """
377
+ date = self.forecast_time.strftime("%Y-%m-%d")
378
+ time = self.forecast_time.strftime("%H:%M:%S")
379
+ steps = self.get_forecast_steps(times)
380
+ # param 152 = log surface pressure, needed for model level conversion
381
+ grib_params = {*self.variable_ecmwfids, 152}
382
+ return (
383
+ f"retrieve,\n"
384
+ f"class=od,\n"
385
+ f"date={date},\n"
386
+ f"expver=1,\n"
387
+ f"levelist={'/'.join(str(lev) for lev in sorted(self.model_levels))},\n"
388
+ f"levtype=ml,\n"
389
+ f"param={'/'.join(str(p) for p in sorted(grib_params))},\n"
390
+ f"step={'/'.join(str(s) for s in sorted(steps))},\n"
391
+ f"stream=oper,\n"
392
+ f"time={time},\n"
393
+ f"type=fc,\n"
394
+ f"grid={self.grid}/{self.grid},\n"
395
+ "format=netcdf"
396
+ )
397
+
398
+ def _set_server(self) -> None:
399
+ """Set the ecmwfapi.ECMWFService instance."""
400
+ try:
401
+ from ecmwfapi import ECMWFService
402
+ except ModuleNotFoundError as e:
403
+ dependencies.raise_module_not_found_error(
404
+ name="HRESModelLevel._set_server method",
405
+ package_name="ecmwf-api-client",
406
+ module_not_found_error=e,
407
+ pycontrails_optional_package="ecmwf",
408
+ )
409
+
410
+ self.server = ECMWFService("mars", url=self.url, key=self.key, email=self.email)
411
+
412
+ def _download_convert_cache_handler(
413
+ self,
414
+ times: list[datetime],
415
+ ) -> None:
416
+ """Download, convert, and cache HRES model level data.
417
+
418
+ This function builds a MARS request and retrieves a single NetCDF file.
419
+ The calling function should ensure that all times will be contained
420
+ in a single file on tape in the MARS archive.
421
+
422
+ Because MARS requests treat dates and times as separate dimensions,
423
+ retrieved data will include the Cartesian product of all unique
424
+ dates and times in the list of specified times.
425
+
426
+ After retrieval, this function processes the NetCDF file
427
+ to produce the dataset specified by class attributes.
428
+
429
+ Parameters
430
+ ----------
431
+ times : list[datetime]
432
+ Times to download in a single MARS request.
433
+
434
+ """
435
+ if self.cachestore is None:
436
+ msg = "Cachestore is required to download and cache data"
437
+ raise ValueError(msg)
438
+
439
+ request = self.mars_request(times)
440
+
441
+ stack = contextlib.ExitStack()
442
+ if not self.cache_download:
443
+ target = stack.enter_context(temp.temp_file())
444
+ else:
445
+ name = hashlib.md5(request.encode()).hexdigest()
446
+ target = self.cachestore.path(f"hresml-{name}.nc")
447
+
448
+ with stack:
449
+ if not self.cache_download or not self.cachestore.exists(target):
450
+ if not hasattr(self, "server"):
451
+ self._set_server()
452
+ self.server.execute(request, target)
453
+
454
+ LOG.debug("Opening model level data file")
455
+
456
+ # Use a chunking scheme harmonious with self.cache_dataset, which groups by time
457
+ # Because ds_ml is dask-backed, nothing gets computed until cache_dataset is called
458
+ ds_ml = xr.open_dataset(target).chunk(time=1)
459
+
460
+ ds_ml = ds_ml.rename(level="model_level")
461
+ lnsp = ds_ml["lnsp"].sel(model_level=1)
462
+ ds_ml = ds_ml.drop_vars("lnsp")
463
+
464
+ ds = mlmod.ml_to_pl(ds_ml, target_pl=self.pressure_levels, lnsp=lnsp)
465
+ ds.attrs["pycontrails_version"] = pycontrails.__version__
466
+ self.cache_dataset(ds)