pycontrails 0.50.2__cp312-cp312-macosx_11_0_arm64.whl → 0.51.1__cp312-cp312-macosx_11_0_arm64.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 (32) hide show
  1. pycontrails/_version.py +2 -2
  2. pycontrails/core/datalib.py +22 -0
  3. pycontrails/core/flight.py +87 -7
  4. pycontrails/core/met.py +33 -5
  5. pycontrails/core/polygon.py +10 -3
  6. pycontrails/core/rgi_cython.cpython-312-darwin.so +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/humidity_scaling/humidity_scaling.py +55 -8
  17. pycontrails/models/humidity_scaling/quantiles/era5-model-level-quantiles.pq +0 -0
  18. pycontrails/models/ps_model/ps_aircraft_params.py +19 -3
  19. pycontrails/models/ps_model/ps_grid.py +21 -21
  20. pycontrails/models/ps_model/ps_model.py +41 -6
  21. pycontrails/models/ps_model/ps_operational_limits.py +15 -6
  22. pycontrails/models/ps_model/static/{ps-aircraft-params-20240417.csv → ps-aircraft-params-20240524.csv} +16 -12
  23. pycontrails/models/ps_model/static/ps-synonym-list-20240524.csv +103 -0
  24. pycontrails/physics/thermo.py +1 -1
  25. pycontrails/utils/types.py +3 -2
  26. {pycontrails-0.50.2.dist-info → pycontrails-0.51.1.dist-info}/METADATA +4 -4
  27. {pycontrails-0.50.2.dist-info → pycontrails-0.51.1.dist-info}/RECORD +32 -26
  28. /pycontrails/models/humidity_scaling/quantiles/{era5-quantiles.pq → era5-pressure-level-quantiles.pq} +0 -0
  29. {pycontrails-0.50.2.dist-info → pycontrails-0.51.1.dist-info}/LICENSE +0 -0
  30. {pycontrails-0.50.2.dist-info → pycontrails-0.51.1.dist-info}/NOTICE +0 -0
  31. {pycontrails-0.50.2.dist-info → pycontrails-0.51.1.dist-info}/WHEEL +0 -0
  32. {pycontrails-0.50.2.dist-info → pycontrails-0.51.1.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.2'
16
- __version_tuple__ = version_tuple = (0, 50, 2)
15
+ __version__ = version = '0.51.1'
16
+ __version_tuple__ = version_tuple = (0, 51, 1)
@@ -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]:
@@ -447,6 +447,48 @@ class Flight(GeoVectorDataset):
447
447
 
448
448
  return segment_duration(self.data["time"], dtype=dtype)
449
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
+
450
492
  def segment_length(self) -> npt.NDArray[np.float64]:
451
493
  """Compute spherical distance between flight waypoints.
452
494
 
@@ -686,9 +728,17 @@ class Flight(GeoVectorDataset):
686
728
  """
687
729
  return units.tas_to_mach_number(true_airspeed, air_temperature)
688
730
 
689
- def segment_rocd(self) -> npt.NDArray[np.float64]:
731
+ def segment_rocd(
732
+ self,
733
+ air_temperature: None | npt.NDArray[np.float64] = None,
734
+ ) -> npt.NDArray[np.float64]:
690
735
  """Calculate the rate of climb and descent (ROCD).
691
736
 
737
+ Parameters
738
+ ----------
739
+ air_temperature: None | npt.NDArray[np.float64]
740
+ Air temperature of each flight waypoint, [:math:`K`]
741
+
692
742
  Returns
693
743
  -------
694
744
  npt.NDArray[np.float64]
@@ -698,12 +748,13 @@ class Flight(GeoVectorDataset):
698
748
  --------
699
749
  :func:`segment_rocd`
700
750
  """
701
- return segment_rocd(self.segment_duration(), self.altitude_ft)
751
+ return segment_rocd(self.segment_duration(), self.altitude_ft, air_temperature)
702
752
 
703
753
  def segment_phase(
704
754
  self,
705
755
  threshold_rocd: float = 250.0,
706
756
  min_cruise_altitude_ft: float = 20000.0,
757
+ air_temperature: None | npt.NDArray[np.float64] = None,
707
758
  ) -> npt.NDArray[np.uint8]:
708
759
  """Identify the phase of flight (climb, cruise, descent) for each segment.
709
760
 
@@ -717,6 +768,8 @@ class Flight(GeoVectorDataset):
717
768
  This is specific for each aircraft type,
718
769
  and can be approximated as 50% of the altitude ceiling.
719
770
  Defaults to 20000 ft.
771
+ air_temperature: None | npt.NDArray[np.float64]
772
+ Air temperature of each flight waypoint, [:math:`K`]
720
773
 
721
774
  Returns
722
775
  -------
@@ -731,7 +784,7 @@ class Flight(GeoVectorDataset):
731
784
  :func:`segment_rocd`
732
785
  """
733
786
  return segment_phase(
734
- self.segment_rocd(),
787
+ self.segment_rocd(air_temperature),
735
788
  self.altitude_ft,
736
789
  threshold_rocd=threshold_rocd,
737
790
  min_cruise_altitude_ft=min_cruise_altitude_ft,
@@ -1232,7 +1285,7 @@ class Flight(GeoVectorDataset):
1232
1285
  raise NotImplementedError("Only implemented for EPSG:4326 CRS.")
1233
1286
 
1234
1287
  # Omit the final nan and ensure index + 1 (below) is well defined
1235
- segs = self.segment_length()[:-1]
1288
+ segs = self.segment_haversine()[:-1]
1236
1289
 
1237
1290
  # For default geodesic_threshold, we expect gap_indices to be very
1238
1291
  # sparse (so the for loop below is cheap)
@@ -1910,6 +1963,7 @@ def filter_altitude(
1910
1963
  altitude_ft: npt.NDArray[np.float64],
1911
1964
  kernel_size: int = 17,
1912
1965
  cruise_threshold: float = 120,
1966
+ air_temperature: None | npt.NDArray[np.float64] = None,
1913
1967
  ) -> npt.NDArray[np.float64]:
1914
1968
  """
1915
1969
  Filter noisy altitude on a single flight.
@@ -1929,6 +1983,8 @@ def filter_altitude(
1929
1983
  Passed also to :func:`scipy.signal.medfilt`
1930
1984
  cruise_theshold : int, optional
1931
1985
  Minimal length of time, in seconds, for a flight to be in cruise to apply median filter
1986
+ air_temperature: None | npt.NDArray[np.float64]
1987
+ Air temperature of each flight waypoint, [:math:`K`]
1932
1988
 
1933
1989
  Returns
1934
1990
  -------
@@ -1975,7 +2031,7 @@ def filter_altitude(
1975
2031
 
1976
2032
  # Find cruise phase in filtered profile
1977
2033
  seg_duration = segment_duration(time)
1978
- seg_rocd = segment_rocd(seg_duration, altitude_filt)
2034
+ seg_rocd = segment_rocd(seg_duration, altitude_filt, air_temperature)
1979
2035
  seg_phase = segment_phase(seg_rocd, altitude_filt)
1980
2036
  is_cruise = seg_phase == FlightPhase.CRUISE
1981
2037
 
@@ -2111,7 +2167,9 @@ def segment_phase(
2111
2167
 
2112
2168
 
2113
2169
  def segment_rocd(
2114
- segment_duration: npt.NDArray[np.float64], altitude_ft: npt.NDArray[np.float64]
2170
+ segment_duration: npt.NDArray[np.float64],
2171
+ altitude_ft: npt.NDArray[np.float64],
2172
+ air_temperature: None | npt.NDArray[np.float64] = None,
2115
2173
  ) -> npt.NDArray[np.float64]:
2116
2174
  """Calculate the rate of climb and descent (ROCD).
2117
2175
 
@@ -2123,12 +2181,22 @@ def segment_rocd(
2123
2181
  See output from :func:`segment_duration`.
2124
2182
  altitude_ft: npt.NDArray[np.float64]
2125
2183
  Altitude of each waypoint, [:math:`ft`]
2184
+ air_temperature: None | npt.NDArray[np.float64]
2185
+ Air temperature of each flight waypoint, [:math:`K`]
2126
2186
 
2127
2187
  Returns
2128
2188
  -------
2129
2189
  npt.NDArray[np.float64]
2130
2190
  Rate of climb and descent over segment, [:math:`ft min^{-1}`]
2131
2191
 
2192
+ Notes
2193
+ -----
2194
+ The hydrostatic equation will be used to estimate the ROCD if `air_temperature` is provided.
2195
+ This will improve the accuracy of the estimated ROCD with a temperature correction. The
2196
+ estimated ROCD with the temperature correction are expected to differ by up to +-5% compared to
2197
+ those without the correction. These differences are important when the ROCD estimates are used
2198
+ as inputs to aircraft performance models.
2199
+
2132
2200
  See Also
2133
2201
  --------
2134
2202
  :func:`segment_duration`
@@ -2139,7 +2207,19 @@ def segment_rocd(
2139
2207
  out[:-1] = np.diff(altitude_ft) / dt_min[:-1]
2140
2208
  out[-1] = np.nan
2141
2209
 
2142
- return out
2210
+ if air_temperature is None:
2211
+ return out
2212
+
2213
+ else:
2214
+ altitude_m = units.ft_to_m(altitude_ft)
2215
+ T_isa = units.m_to_T_isa(altitude_m)
2216
+
2217
+ T_correction = np.empty_like(altitude_ft)
2218
+ T_correction[:-1] = (0.5 * (air_temperature[:-1] + air_temperature[1:])) / (
2219
+ 0.5 * (T_isa[:-1] + T_isa[1:])
2220
+ )
2221
+ T_correction[-1] = np.nan
2222
+ return T_correction * out
2143
2223
 
2144
2224
 
2145
2225
  def _resample_to_freq(df: pd.DataFrame, freq: str) -> tuple[pd.DataFrame, pd.DatetimeIndex]:
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."""