pycontrails 0.50.1__cp39-cp39-win_amd64.whl → 0.51.0__cp39-cp39-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 (37) hide show
  1. pycontrails/_version.py +2 -2
  2. pycontrails/core/datalib.py +22 -0
  3. pycontrails/core/flight.py +140 -1
  4. pycontrails/core/met.py +33 -5
  5. pycontrails/core/polygon.py +10 -3
  6. pycontrails/core/rgi_cython.cp39-win_amd64.pyd +0 -0
  7. pycontrails/datalib/ecmwf/__init__.py +6 -0
  8. pycontrails/datalib/ecmwf/arco_era5.py +2 -53
  9. pycontrails/datalib/ecmwf/common.py +4 -0
  10. pycontrails/datalib/ecmwf/era5.py +2 -6
  11. pycontrails/datalib/ecmwf/era5_model_level.py +481 -0
  12. pycontrails/datalib/ecmwf/hres_model_level.py +494 -0
  13. pycontrails/datalib/ecmwf/model_levels.py +79 -0
  14. pycontrails/datalib/ecmwf/static/model_level_dataframe_v20240418.csv +139 -0
  15. pycontrails/datalib/ecmwf/variables.py +12 -0
  16. pycontrails/models/emissions/emissions.py +2 -2
  17. pycontrails/models/emissions/static/default-engine-uids.csv +1 -1
  18. pycontrails/models/emissions/static/{edb-gaseous-v28c-engines.csv → edb-gaseous-v29b-engines.csv} +49 -11
  19. pycontrails/models/emissions/static/{edb-nvpm-v28c-engines.csv → edb-nvpm-v29b-engines.csv} +90 -54
  20. pycontrails/models/humidity_scaling/humidity_scaling.py +55 -8
  21. pycontrails/models/humidity_scaling/quantiles/era5-model-level-quantiles.pq +0 -0
  22. pycontrails/models/ps_model/ps_aircraft_params.py +13 -1
  23. pycontrails/models/ps_model/ps_grid.py +20 -20
  24. pycontrails/models/ps_model/ps_model.py +1 -1
  25. pycontrails/models/ps_model/ps_operational_limits.py +202 -1
  26. pycontrails/models/ps_model/static/ps-aircraft-params-20240417.csv +64 -0
  27. pycontrails/physics/thermo.py +1 -1
  28. pycontrails/physics/units.py +2 -2
  29. pycontrails/utils/types.py +6 -3
  30. {pycontrails-0.50.1.dist-info → pycontrails-0.51.0.dist-info}/METADATA +4 -4
  31. {pycontrails-0.50.1.dist-info → pycontrails-0.51.0.dist-info}/RECORD +36 -31
  32. pycontrails/models/ps_model/static/ps-aircraft-params-20240209.csv +0 -63
  33. /pycontrails/models/humidity_scaling/quantiles/{era5-quantiles.pq → era5-pressure-level-quantiles.pq} +0 -0
  34. {pycontrails-0.50.1.dist-info → pycontrails-0.51.0.dist-info}/LICENSE +0 -0
  35. {pycontrails-0.50.1.dist-info → pycontrails-0.51.0.dist-info}/NOTICE +0 -0
  36. {pycontrails-0.50.1.dist-info → pycontrails-0.51.0.dist-info}/WHEEL +0 -0
  37. {pycontrails-0.50.1.dist-info → pycontrails-0.51.0.dist-info}/top_level.txt +0 -0
pycontrails/_version.py CHANGED
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '0.50.1'
16
- __version_tuple__ = version_tuple = (0, 50, 1)
15
+ __version__ = version = '0.51.0'
16
+ __version_tuple__ = version_tuple = (0, 51, 0)
@@ -103,6 +103,28 @@ def parse_timesteps(time: TimeInput | None, freq: str | None = "1h") -> list[dat
103
103
  return daterange.to_pydatetime().tolist()
104
104
 
105
105
 
106
+ def validate_timestep_freq(freq: str, datasource_freq: str) -> bool:
107
+ """Check that input timestep frequency is compatible with the data source timestep frequency.
108
+
109
+ A data source timestep frequency of 1 hour allows input timestep frequencies of
110
+ 1 hour, 2 hours, 3 hours, etc., but not 1.5 hours or 30 minutes.
111
+
112
+ Parameters
113
+ ----------
114
+ freq : str
115
+ Input timestep frequency
116
+ datasource_freq : str
117
+ Datasource timestep frequency
118
+
119
+ Returns
120
+ -------
121
+ bool
122
+ True if the input timestep frequency is an even multiple
123
+ of the data source timestep frequency.
124
+ """
125
+ return pd.Timedelta(freq) % pd.Timedelta(datasource_freq) == pd.Timedelta(0)
126
+
127
+
106
128
  def parse_pressure_levels(
107
129
  pressure_levels: PressureLevelInput, supported: list[int] | None = None
108
130
  ) -> list[int]:
@@ -17,6 +17,7 @@ from pycontrails.core.fuel import Fuel, JetA
17
17
  from pycontrails.core.vector import AttrDict, GeoVectorDataset, VectorDataDict, VectorDataset
18
18
  from pycontrails.physics import constants, geo, units
19
19
  from pycontrails.utils import dependencies
20
+ from pycontrails.utils.types import ArrayOrFloat
20
21
 
21
22
  logger = logging.getLogger(__name__)
22
23
 
@@ -446,6 +447,48 @@ class Flight(GeoVectorDataset):
446
447
 
447
448
  return segment_duration(self.data["time"], dtype=dtype)
448
449
 
450
+ def segment_haversine(self) -> npt.NDArray[np.float64]:
451
+ """Compute Haversine (great circle) distance between flight waypoints.
452
+
453
+ Helper function used in :meth:`resample_and_fill`.
454
+ `np.nan` appended so the length of the output is the same as number of waypoints.
455
+
456
+ To account for vertical displacements when computing segment lengths,
457
+ use :meth:`segment_length`.
458
+
459
+ Returns
460
+ -------
461
+ npt.NDArray[np.float64]
462
+ Array of great circle distances in [:math:`m`] between waypoints
463
+
464
+ Raises
465
+ ------
466
+ NotImplementedError
467
+ Raises when attr:`attrs["crs"]` is not EPSG:4326
468
+
469
+ Examples
470
+ --------
471
+ >>> from pycontrails import Flight
472
+ >>> fl = Flight(
473
+ ... longitude=np.array([1, 2, 3, 5, 8]),
474
+ ... latitude=np.arange(5),
475
+ ... altitude=np.full(shape=(5,), fill_value=11000),
476
+ ... time=pd.date_range('2021-01-01T12', '2021-01-01T14', periods=5),
477
+ ... )
478
+ >>> fl.segment_haversine()
479
+ array([157255.03346286, 157231.08336815, 248456.48781503, 351047.44358851,
480
+ nan])
481
+
482
+ See Also
483
+ --------
484
+ :func:`segment_haversine`
485
+ :meth:`segment_length`
486
+ """
487
+ if self.attrs["crs"] != "EPSG:4326":
488
+ raise NotImplementedError("Only implemented for EPSG:4326 CRS.")
489
+
490
+ return geo.segment_haversine(self["longitude"], self["latitude"])
491
+
449
492
  def segment_length(self) -> npt.NDArray[np.float64]:
450
493
  """Compute spherical distance between flight waypoints.
451
494
 
@@ -1112,6 +1155,102 @@ class Flight(GeoVectorDataset):
1112
1155
  out.data.pop("level", None) # avoid any ambiguity
1113
1156
  return out
1114
1157
 
1158
+ def distance_to_coords(self: Flight, distance: ArrayOrFloat) -> tuple[
1159
+ ArrayOrFloat,
1160
+ ArrayOrFloat,
1161
+ np.intp | npt.NDArray[np.intp],
1162
+ ]:
1163
+ """
1164
+ Convert distance along flight path to geodesic coordinates.
1165
+
1166
+ Will return a tuple containing `(lat, lon, index)`, where index indicates which flight
1167
+ segment contains the returned coordinate.
1168
+
1169
+ Parameters
1170
+ ----------
1171
+ distance : ArrayOrFloat
1172
+ Distance along flight path, [:math:`m`]
1173
+
1174
+ Returns
1175
+ -------
1176
+ (ArrayOrFloat, ArrayOrFloat, int | npt.NDArray[int])
1177
+ latitude, longitude, and segment index cooresponding to distance.
1178
+ """
1179
+
1180
+ # Check if flight crosses antimeridian line
1181
+ lon_ = self["longitude"]
1182
+ lat_ = self["latitude"]
1183
+ sign_ = np.sign(lon_)
1184
+ min_pos = np.min(lon_[sign_ == 1.0], initial=np.inf)
1185
+ max_neg = np.max(lon_[sign_ == -1.0], initial=-np.inf)
1186
+
1187
+ if (180.0 - min_pos) + (180.0 + max_neg) < 180.0 and min_pos < np.inf and max_neg > -np.inf:
1188
+ # In this case, we believe the flight crosses the antimeridian
1189
+ shift = min_pos
1190
+ # So we shift the longitude "chart"
1191
+ lon_ = (lon_ - shift) % 360.0
1192
+ else:
1193
+ shift = None
1194
+
1195
+ # Make a fake flight that flies at constant height so distance is just
1196
+ # distance traveled across groud
1197
+ flat_dataset = Flight(
1198
+ longitude=self.coords["longitude"],
1199
+ latitude=self.coords["latitude"],
1200
+ time=self.coords["time"],
1201
+ level=[self.coords["level"][0] for _ in range(self.size)],
1202
+ )
1203
+
1204
+ lengths = flat_dataset.segment_length()
1205
+ cumulative_lengths = np.nancumsum(lengths)
1206
+ cumulative_lengths = np.insert(cumulative_lengths[:-1], 0, 0)
1207
+ seg_idx: np.intp | npt.NDArray[np.intp]
1208
+
1209
+ if isinstance(distance, float):
1210
+ seg_idx = np.argmax(cumulative_lengths > distance)
1211
+ else:
1212
+ seg_idx = np.argmax(cumulative_lengths > distance.reshape((distance.size, 1)), axis=1)
1213
+
1214
+ # If in the last segment (which has length 0), then just return the last waypoint
1215
+ seg_idx -= 1
1216
+
1217
+ # linear interpolation in lat/lon - assuming the way points are within 100-200km so this
1218
+ # should be accurate enough without needed to reproject or use spherical distance
1219
+ lat1: ArrayOrFloat = lat_[seg_idx]
1220
+ lon1: ArrayOrFloat = lon_[seg_idx]
1221
+ lat2: ArrayOrFloat = lat_[seg_idx + 1]
1222
+ lon2: ArrayOrFloat = lon_[seg_idx + 1]
1223
+
1224
+ dx = distance - cumulative_lengths[seg_idx]
1225
+ fx = dx / lengths[seg_idx]
1226
+ lat = (1 - fx) * lat1 + fx * lat2
1227
+ lon = (1 - fx) * lon1 + fx * lon2
1228
+
1229
+ if isinstance(distance, float):
1230
+ if distance < 0:
1231
+ lat = np.nan
1232
+ lon = np.nan
1233
+ seg_idx = np.intp(0)
1234
+ elif distance >= cumulative_lengths[-1]:
1235
+ lat = lat_[-1]
1236
+ lon = lon_[-1]
1237
+ seg_idx = np.intp(self.size - 1)
1238
+ else:
1239
+ lat[distance < 0] = np.nan
1240
+ lon[distance < 0] = np.nan
1241
+ seg_idx[distance < 0] = 0 # type: ignore
1242
+
1243
+ lat[distance >= cumulative_lengths[-1]] = lat_[-1]
1244
+ lon[distance >= cumulative_lengths[-1]] = lon_[-1]
1245
+ seg_idx[distance >= cumulative_lengths[-1]] = self.size - 1 # type: ignore
1246
+
1247
+ if shift is not None:
1248
+ # We need to translate back to the original chart here
1249
+ lon += shift
1250
+ lon = ((lon + 180.0) % 360.0) - 180.0
1251
+
1252
+ return lat, lon, seg_idx
1253
+
1115
1254
  def _geodesic_interpolation(self, geodesic_threshold: float) -> pd.DataFrame | None:
1116
1255
  """Geodesic interpolate between large gaps between waypoints.
1117
1256
 
@@ -1135,7 +1274,7 @@ class Flight(GeoVectorDataset):
1135
1274
  raise NotImplementedError("Only implemented for EPSG:4326 CRS.")
1136
1275
 
1137
1276
  # Omit the final nan and ensure index + 1 (below) is well defined
1138
- segs = self.segment_length()[:-1]
1277
+ segs = self.segment_haversine()[:-1]
1139
1278
 
1140
1279
  # For default geodesic_threshold, we expect gap_indices to be very
1141
1280
  # sparse (so the for loop below is cheap)
pycontrails/core/met.py CHANGED
@@ -1793,10 +1793,11 @@ class MetDataArray(MetBase):
1793
1793
  self,
1794
1794
  level: float | int | None = None,
1795
1795
  time: np.datetime64 | datetime | None = None,
1796
- fill_value: float = 0.0,
1796
+ fill_value: float = np.nan,
1797
1797
  iso_value: float | None = None,
1798
1798
  min_area: float = 0.0,
1799
1799
  epsilon: float = 0.0,
1800
+ lower_bound: bool = True,
1800
1801
  precision: int | None = None,
1801
1802
  interiors: bool = True,
1802
1803
  convex_hull: bool = False,
@@ -1848,7 +1849,8 @@ class MetDataArray(MetBase):
1848
1849
  automatically.
1849
1850
  fill_value : float, optional
1850
1851
  Value used for filling missing data and for padding the underlying data array.
1851
- Expected to be less than the ``iso_value`` parameter. By default, 0.0.
1852
+ Set to ``np.nan`` by default, which ensures that regions with missing data are
1853
+ never included in polygons.
1852
1854
  iso_value : float, optional
1853
1855
  Value in field to create iso-surface.
1854
1856
  Defaults to the average of the min and max value of the array. (This is the
@@ -1862,6 +1864,9 @@ class MetDataArray(MetBase):
1862
1864
  Control the extent to which the polygon is simplified. A value of 0 does not alter
1863
1865
  the geometry of the polygon. The unit of this parameter is in longitude/latitude
1864
1866
  degrees. By default, 0.0.
1867
+ lower_bound : bool, optional
1868
+ Whether to use ``iso_value`` as a lower or upper bound on values in polygon interiors.
1869
+ By default, True.
1865
1870
  precision : int, optional
1866
1871
  Number of decimal places to round coordinates to. If None, no rounding is performed.
1867
1872
  interiors : bool, optional
@@ -1882,6 +1887,14 @@ class MetDataArray(MetBase):
1882
1887
  dict[str, Any]
1883
1888
  Python representation of GeoJSON Feature with MultiPolygon geometry.
1884
1889
 
1890
+ Notes
1891
+ -----
1892
+ :class:`Cocip` and :class:`CocipGrid` set some quantities to 0 and other quantities
1893
+ to ``np.nan`` in regions where no contrails form. When computing polygons from
1894
+ :class:`Cocip` or :class:`CocipGrid` output, take care that the choice of
1895
+ ``fill_value`` correctly includes or excludes contrail-free regions. See the
1896
+ :class:`Cocip` documentation for details about ``np.nan`` in model output.
1897
+
1885
1898
  See Also
1886
1899
  --------
1887
1900
  :meth:`to_polyhedra`
@@ -1925,11 +1938,12 @@ class MetDataArray(MetBase):
1925
1938
  "include_altitude=False."
1926
1939
  )
1927
1940
 
1928
- np.nan_to_num(arr, copy=False, nan=fill_value)
1941
+ if not np.isnan(fill_value):
1942
+ np.nan_to_num(arr, copy=False, nan=fill_value)
1929
1943
 
1930
1944
  # default iso_value
1931
1945
  if iso_value is None:
1932
- iso_value = (np.max(arr) + np.min(arr)) / 2
1946
+ iso_value = (np.nanmax(arr) + np.nanmin(arr)) / 2
1933
1947
  warnings.warn(f"The 'iso_value' parameter was not specified. Using value: {iso_value}")
1934
1948
 
1935
1949
  # We'll get a nice error message if dependencies are not installed
@@ -1945,6 +1959,7 @@ class MetDataArray(MetBase):
1945
1959
  threshold=iso_value,
1946
1960
  min_area=min_area,
1947
1961
  epsilon=epsilon,
1962
+ lower_bound=lower_bound,
1948
1963
  interiors=interiors,
1949
1964
  convex_hull=convex_hull,
1950
1965
  longitude=longitude,
@@ -1957,10 +1972,11 @@ class MetDataArray(MetBase):
1957
1972
  def to_polygon_feature_collection(
1958
1973
  self,
1959
1974
  time: np.datetime64 | datetime | None = None,
1960
- fill_value: float = 0.0,
1975
+ fill_value: float = np.nan,
1961
1976
  iso_value: float | None = None,
1962
1977
  min_area: float = 0.0,
1963
1978
  epsilon: float = 0.0,
1979
+ lower_bound: bool = True,
1964
1980
  precision: int | None = None,
1965
1981
  interiors: bool = True,
1966
1982
  convex_hull: bool = False,
@@ -1991,6 +2007,7 @@ class MetDataArray(MetBase):
1991
2007
  iso_value=iso_value,
1992
2008
  min_area=min_area,
1993
2009
  epsilon=epsilon,
2010
+ lower_bound=lower_bound,
1994
2011
  precision=precision,
1995
2012
  interiors=interiors,
1996
2013
  convex_hull=convex_hull,
@@ -2011,6 +2028,7 @@ class MetDataArray(MetBase):
2011
2028
  time: datetime | None = ...,
2012
2029
  iso_value: float = ...,
2013
2030
  simplify_fraction: float = ...,
2031
+ lower_bound: bool = ...,
2014
2032
  return_type: Literal["geojson"],
2015
2033
  path: str | None = ...,
2016
2034
  altitude_scale: float = ...,
@@ -2025,6 +2043,7 @@ class MetDataArray(MetBase):
2025
2043
  time: datetime | None = ...,
2026
2044
  iso_value: float = ...,
2027
2045
  simplify_fraction: float = ...,
2046
+ lower_bound: bool = ...,
2028
2047
  return_type: Literal["mesh"],
2029
2048
  path: str | None = ...,
2030
2049
  altitude_scale: float = ...,
@@ -2038,6 +2057,7 @@ class MetDataArray(MetBase):
2038
2057
  time: datetime | None = None,
2039
2058
  iso_value: float = 0.0,
2040
2059
  simplify_fraction: float = 1.0,
2060
+ lower_bound: bool = True,
2041
2061
  return_type: str = "geojson",
2042
2062
  path: str | None = None,
2043
2063
  altitude_scale: float = 1.0,
@@ -2058,6 +2078,9 @@ class MetDataArray(MetBase):
2058
2078
  Apply `open3d` `simplify_quadric_decimation` method to simplify the polyhedra geometry.
2059
2079
  This parameter must be in the half-open interval (0.0, 1.0].
2060
2080
  Defaults to 1.0, corresponding to no reduction.
2081
+ lower_bound : bool, optional
2082
+ Whether to use ``iso_value`` as a lower or upper bound on values in polyhedra interiors.
2083
+ By default, True.
2061
2084
  return_type : str, optional
2062
2085
  Must be one of "geojson" or "mesh". Defaults to "geojson".
2063
2086
  If "geojson", this method returns a dictionary representation of a geojson MultiPolygon
@@ -2145,6 +2168,11 @@ class MetDataArray(MetBase):
2145
2168
  # 3d array of longitude, latitude, altitude values
2146
2169
  volume = self.data.sel(time=time).values
2147
2170
 
2171
+ # invert if iso_value is an upper bound on interior values
2172
+ if not lower_bound:
2173
+ volume = -volume
2174
+ iso_value = -iso_value
2175
+
2148
2176
  # convert from array index back to coordinates
2149
2177
  longitude = self.indexes["longitude"].values
2150
2178
  latitude = self.indexes["latitude"].values
@@ -283,6 +283,7 @@ def find_multipolygon(
283
283
  threshold: float,
284
284
  min_area: float,
285
285
  epsilon: float,
286
+ lower_bound: bool = True,
286
287
  interiors: bool = True,
287
288
  convex_hull: bool = False,
288
289
  longitude: npt.NDArray[np.float64] | None = None,
@@ -304,10 +305,13 @@ def find_multipolygon(
304
305
  epsilon : float
305
306
  Epsilon value to use when simplifying the polygons. Passed into shapely's
306
307
  :meth:`shapely.geometry.Polygon.simplify` method.
308
+ lower_bound : bool, optional
309
+ Whether to treat ``threshold`` as a lower or upper bound on values in polygon interiors.
310
+ By default, True.
307
311
  interiors : bool, optional
308
- Whether to include interior polygons.
312
+ Whether to include interior polygons. By default, True.
309
313
  convex_hull : bool, optional
310
- Experimental. Whether to take the convex hull of each polygon.
314
+ Experimental. Whether to take the convex hull of each polygon. By default, False.
311
315
  longitude : npt.NDArray[np.float64] | None, optional
312
316
  If provided, the coordinates values corresponding to the longitude dimensions of ``arr``.
313
317
  The contour coordinates will be converted to longitude-latitude values by indexing
@@ -339,7 +343,10 @@ def find_multipolygon(
339
343
  buffer = 0.5
340
344
 
341
345
  arr_bin = np.empty(arr.shape, dtype=np.uint8)
342
- np.greater_equal(arr, threshold, out=arr_bin)
346
+ if lower_bound:
347
+ np.greater_equal(arr, threshold, out=arr_bin)
348
+ else:
349
+ np.less_equal(arr, threshold, out=arr_bin)
343
350
 
344
351
  mode = cv2.RETR_CCOMP if interiors else cv2.RETR_EXTERNAL
345
352
  contours, hierarchy = cv2.findContours(arr_bin, mode, cv2.CHAIN_APPROX_SIMPLE)
@@ -4,10 +4,13 @@ from __future__ import annotations
4
4
 
5
5
  from pycontrails.datalib.ecmwf.arco_era5 import ARCOERA5
6
6
  from pycontrails.datalib.ecmwf.era5 import ERA5
7
+ from pycontrails.datalib.ecmwf.era5_model_level import ERA5ModelLevel
7
8
  from pycontrails.datalib.ecmwf.hres import HRES
9
+ from pycontrails.datalib.ecmwf.hres_model_level import HRESModelLevel
8
10
  from pycontrails.datalib.ecmwf.ifs import IFS
9
11
  from pycontrails.datalib.ecmwf.variables import (
10
12
  ECMWF_VARIABLES,
13
+ MODEL_LEVEL_VARIABLES,
11
14
  PRESSURE_LEVEL_VARIABLES,
12
15
  SURFACE_VARIABLES,
13
16
  CloudAreaFraction,
@@ -27,7 +30,9 @@ from pycontrails.datalib.ecmwf.variables import (
27
30
  __all__ = [
28
31
  "ARCOERA5",
29
32
  "ERA5",
33
+ "ERA5ModelLevel",
30
34
  "HRES",
35
+ "HRESModelLevel",
31
36
  "IFS",
32
37
  "CloudAreaFraction",
33
38
  "CloudAreaFractionInLayer",
@@ -44,4 +49,5 @@ __all__ = [
44
49
  "ECMWF_VARIABLES",
45
50
  "PRESSURE_LEVEL_VARIABLES",
46
51
  "SURFACE_VARIABLES",
52
+ "MODEL_LEVEL_VARIABLES",
47
53
  ]
@@ -11,7 +11,6 @@ This module supports:
11
11
  This module requires the following additional dependencies:
12
12
 
13
13
  - `metview (binaries and python bindings) <https://metview.readthedocs.io/en/latest/python.html>`_
14
- - `lxml <https://lxml.de/>`_
15
14
  - `gcsfs <https://gcsfs.readthedocs.io/en/latest/>`_
16
15
  - `zarr <https://zarr.readthedocs.io/en/stable/>`_
17
16
 
@@ -22,7 +21,6 @@ from __future__ import annotations
22
21
  import contextlib
23
22
  import dataclasses
24
23
  import datetime
25
- import functools
26
24
  import hashlib
27
25
  import multiprocessing
28
26
  import pathlib
@@ -31,7 +29,6 @@ import warnings
31
29
  from collections.abc import Iterable
32
30
  from typing import Any
33
31
 
34
- import pandas as pd
35
32
  import xarray as xr
36
33
  from overrides import overrides
37
34
 
@@ -39,7 +36,7 @@ from pycontrails.core import cache, datalib, met_var
39
36
  from pycontrails.core.met import MetDataset
40
37
  from pycontrails.datalib.ecmwf import common as ecmwf_common
41
38
  from pycontrails.datalib.ecmwf import variables as ecmwf_variables
42
- from pycontrails.physics import units
39
+ from pycontrails.datalib.ecmwf.model_levels import pressure_levels_at_model_levels
43
40
  from pycontrails.utils import dependencies
44
41
 
45
42
  try:
@@ -76,54 +73,6 @@ MOISTURE_STORE_VARIABLES = [
76
73
  PRESSURE_LEVEL_VARIABLES = [*WIND_STORE_VARIABLES, *MOISTURE_STORE_VARIABLES, met_var.Geopotential]
77
74
 
78
75
 
79
- @functools.cache
80
- def _read_model_level_dataframe() -> pd.DataFrame:
81
- """Read the ERA5 model level definitions published by ECMWF.
82
-
83
- This requires the lxml package to be installed.
84
- """
85
- url = "https://confluence.ecmwf.int/display/UDOC/L137+model+level+definitions"
86
- try:
87
- return pd.read_html(url, na_values="-", index_col="n")[0]
88
- except ImportError as exc:
89
- if "lxml" in exc.msg:
90
- dependencies.raise_module_not_found_error(
91
- "arco_era5._read_model_level_dataframe function",
92
- package_name="lxml",
93
- module_not_found_error=exc,
94
- extra=(
95
- "Alternatively, if instantiating an 'ARCOERA5' object, you can provide "
96
- "the 'pressure_levels' parameter directly to avoid the need to read the "
97
- "ECMWF model level definitions."
98
- ),
99
- )
100
- raise
101
-
102
-
103
- def pressure_levels_at_model_levels(alt_ft_min: float, alt_ft_max: float) -> list[int]:
104
- """Return the pressure levels at each model level assuming a constant surface pressure.
105
-
106
- The pressure levels are rounded to the nearest hPa.
107
-
108
- Parameters
109
- ----------
110
- alt_ft_min : float
111
- Minimum altitude, [:math:`ft`].
112
- alt_ft_max : float
113
- Maximum altitude, [:math:`ft`].
114
-
115
- Returns
116
- -------
117
- list[int]
118
- List of pressure levels, [:math:`hPa`].
119
- """
120
- df = _read_model_level_dataframe()
121
- alt_m_min = units.ft_to_m(alt_ft_min)
122
- alt_m_max = units.ft_to_m(alt_ft_max)
123
- filt = df["Geometric Altitude [m]"].between(alt_m_min, alt_m_max)
124
- return df.loc[filt, "pf [hPa]"].round().astype(int).tolist()
125
-
126
-
127
76
  def _attribute_fix(ds: xr.Dataset | None) -> None:
128
77
  """Fix GRIB attributes.
129
78
 
@@ -444,7 +393,7 @@ class ARCOERA5(ecmwf_common.ECMWFAPI):
444
393
  self.variables = datalib.parse_variables(variables, self.supported_variables)
445
394
  self.grid = grid
446
395
  self.cachestore = cache.DiskCacheStore() if cachestore is self.__marker else cachestore
447
- self.n_jobs = n_jobs
396
+ self.n_jobs = max(1, n_jobs)
448
397
  self.cleanup_metview_tempfiles = cleanup_metview_tempfiles
449
398
 
450
399
  @property
@@ -102,3 +102,7 @@ class ECMWFAPI(datalib.MetDataSource):
102
102
  os.remove(cache_path)
103
103
 
104
104
  ds_t.to_netcdf(cache_path)
105
+
106
+
107
+ class CDSCredentialsNotFound(Exception):
108
+ """Raise when CDS credentials are not found by :class:`cdsapi.Client` instance."""
@@ -21,7 +21,7 @@ from overrides import overrides
21
21
  import pycontrails
22
22
  from pycontrails.core import cache, datalib
23
23
  from pycontrails.core.met import MetDataset, MetVariable
24
- from pycontrails.datalib.ecmwf.common import ECMWFAPI
24
+ from pycontrails.datalib.ecmwf.common import ECMWFAPI, CDSCredentialsNotFound
25
25
  from pycontrails.datalib.ecmwf.variables import PRESSURE_LEVEL_VARIABLES, SURFACE_VARIABLES
26
26
  from pycontrails.utils import dependencies, temp
27
27
 
@@ -183,7 +183,7 @@ class ERA5(ECMWFAPI):
183
183
  grid_min = 0.25 if product_type == "reanalysis" else 0.5
184
184
  if grid < grid_min:
185
185
  warnings.warn(
186
- f"The smallest resolution available through the CDS API is {grid_min} degrees. "
186
+ f"The highest resolution available through the CDS API is {grid_min} degrees. "
187
187
  f"Your downloaded data will have resolution {grid}, but it is a "
188
188
  f"reinterpolation of the {grid_min} degree data. The same interpolation can be "
189
189
  "achieved directly with xarray."
@@ -535,7 +535,3 @@ class ERA5(ECMWFAPI):
535
535
 
536
536
  ds.attrs["pycontrails_version"] = pycontrails.__version__
537
537
  return ds
538
-
539
-
540
- class CDSCredentialsNotFound(Exception):
541
- """Raise when CDS credentials are not found by :class:`cdsapi.Client` instance."""