pycontrails 0.41.0__cp311-cp311-macosx_11_0_arm64.whl → 0.42.2__cp311-cp311-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 (40) hide show
  1. pycontrails/_version.py +2 -2
  2. pycontrails/core/airports.py +228 -0
  3. pycontrails/core/cache.py +4 -6
  4. pycontrails/core/datalib.py +13 -6
  5. pycontrails/core/fleet.py +72 -20
  6. pycontrails/core/flight.py +485 -134
  7. pycontrails/core/flightplan.py +238 -0
  8. pycontrails/core/interpolation.py +11 -15
  9. pycontrails/core/met.py +5 -5
  10. pycontrails/core/models.py +4 -0
  11. pycontrails/core/rgi_cython.cpython-311-darwin.so +0 -0
  12. pycontrails/core/vector.py +80 -63
  13. pycontrails/datalib/__init__.py +1 -1
  14. pycontrails/datalib/ecmwf/common.py +14 -19
  15. pycontrails/datalib/spire/__init__.py +19 -0
  16. pycontrails/datalib/spire/spire.py +739 -0
  17. pycontrails/ext/bada/__init__.py +6 -6
  18. pycontrails/ext/cirium/__init__.py +2 -2
  19. pycontrails/models/cocip/cocip.py +37 -39
  20. pycontrails/models/cocip/cocip_params.py +37 -30
  21. pycontrails/models/cocip/cocip_uncertainty.py +47 -58
  22. pycontrails/models/cocip/radiative_forcing.py +220 -193
  23. pycontrails/models/cocip/wake_vortex.py +96 -91
  24. pycontrails/models/cocip/wind_shear.py +2 -2
  25. pycontrails/models/emissions/emissions.py +1 -1
  26. pycontrails/models/humidity_scaling.py +266 -9
  27. pycontrails/models/issr.py +2 -2
  28. pycontrails/models/pcr.py +1 -1
  29. pycontrails/models/quantiles/era5_ensemble_quantiles.npy +0 -0
  30. pycontrails/models/quantiles/iagos_quantiles.npy +0 -0
  31. pycontrails/models/sac.py +7 -5
  32. pycontrails/physics/geo.py +5 -3
  33. pycontrails/physics/jet.py +66 -113
  34. pycontrails/utils/json.py +3 -3
  35. {pycontrails-0.41.0.dist-info → pycontrails-0.42.2.dist-info}/METADATA +4 -7
  36. {pycontrails-0.41.0.dist-info → pycontrails-0.42.2.dist-info}/RECORD +40 -34
  37. {pycontrails-0.41.0.dist-info → pycontrails-0.42.2.dist-info}/LICENSE +0 -0
  38. {pycontrails-0.41.0.dist-info → pycontrails-0.42.2.dist-info}/NOTICE +0 -0
  39. {pycontrails-0.41.0.dist-info → pycontrails-0.42.2.dist-info}/WHEEL +0 -0
  40. {pycontrails-0.41.0.dist-info → pycontrails-0.42.2.dist-info}/top_level.txt +0 -0
@@ -1,17 +1,20 @@
1
- """Support for humidity scaling methodologies on ERA5 data."""
1
+ """Support for humidity scaling methodologies."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  import abc
6
6
  import dataclasses
7
+ import functools
8
+ import pathlib
7
9
  import warnings
8
10
  from typing import Any, NoReturn, overload
9
11
 
10
12
  import numpy as np
13
+ import numpy.typing as npt
11
14
  import xarray as xr
12
15
  from overrides import overrides
13
16
 
14
- from pycontrails.core.met import MetDataset
17
+ from pycontrails.core.met import MetDataArray, MetDataset
15
18
  from pycontrails.core.models import Model, ModelParams
16
19
  from pycontrails.core.vector import GeoVectorDataset
17
20
  from pycontrails.physics import constants, thermo, units
@@ -19,7 +22,7 @@ from pycontrails.utils.types import ArrayLike
19
22
 
20
23
 
21
24
  def _rhi_over_q(air_temperature: ArrayLike, air_pressure: ArrayLike) -> ArrayLike:
22
- """Compute the quotient RHi / q."""
25
+ """Compute the quotient ``RHi / q``."""
23
26
  return air_pressure * (constants.R_v / constants.R_d) / thermo.e_sat_ice(air_temperature)
24
27
 
25
28
 
@@ -140,16 +143,18 @@ class HumidityScaling(Model):
140
143
  "being applied more than once."
141
144
  )
142
145
 
146
+ p: np.ndarray | xr.DataArray
143
147
  if isinstance(self.source, GeoVectorDataset):
144
- self.source.setdefault("air_pressure", self.source.air_pressure)
148
+ p = self.source.setdefault("air_pressure", self.source.air_pressure)
149
+ else:
150
+ p = self.source.data["air_pressure"]
145
151
 
146
152
  q = self.source.data["specific_humidity"]
147
153
  T = self.source.data["air_temperature"]
148
- p = self.source.data["air_pressure"]
149
154
  kwargs = {k: self.get_source_param(k) for k in self.scaler_specific_keys}
150
155
 
151
156
  q, rhi = self.scale(q, T, p, **kwargs)
152
- self.source.update(specific_humidity=q, rhi=rhi) # type: ignore[arg-type]
157
+ self.source.update(specific_humidity=q, rhi=rhi)
153
158
 
154
159
  return self.source
155
160
 
@@ -264,7 +269,7 @@ class ExponentialBoostHumidityScaling(HumidityScaling):
264
269
  rhi /= rhi_adj
265
270
 
266
271
  # Find ISSRs
267
- is_issr = rhi >= 1
272
+ is_issr = rhi >= 1.0
268
273
 
269
274
  # Apply boosting to ISSRs
270
275
  if isinstance(rhi, xr.DataArray):
@@ -440,7 +445,7 @@ def _calc_rhi_max(air_temperature: ArrayLike) -> ArrayLike:
440
445
  p_ice = thermo.e_sat_ice(air_temperature)
441
446
  p_liq = thermo.e_sat_liquid(air_temperature)
442
447
  return xr.where(
443
- air_temperature < 235,
448
+ air_temperature < 235.0,
444
449
  1.67 + (1.45 - 1.67) * (air_temperature - 190.0) / (235.0 - 190.0),
445
450
  p_liq / p_ice,
446
451
  )
@@ -553,10 +558,262 @@ class HumidityScalingByLevel(HumidityScaling):
553
558
  "attribute 'mid_troposphere_threshold'."
554
559
  )
555
560
 
556
- level = air_pressure / 100
561
+ level = air_pressure / 100.0
557
562
  fp = [rhi_adj_stratosphere, rhi_adj_mid_troposphere]
558
563
  rhi_adj = np.interp(level, xp=xp, fp=fp)
559
564
 
560
565
  q = specific_humidity / rhi_adj
561
566
  rhi = thermo.rhi(q, air_temperature, air_pressure)
562
567
  return q, rhi
568
+
569
+
570
+ @functools.cache
571
+ def _load_iagos_quantiles() -> npt.NDArray[np.float64]:
572
+ path = pathlib.Path(__file__).parent / "quantiles" / "iagos_quantiles.npy"
573
+ # FIXME: Recompute to avoid the divide by 100.0 here
574
+ return np.load(path, allow_pickle=False) / 100.0
575
+
576
+
577
+ @functools.cache
578
+ def _load_era5_ensemble_quantiles() -> npt.NDArray[np.float64]:
579
+ path = pathlib.Path(__file__).parent / "quantiles" / "era5_ensemble_quantiles.npy"
580
+ # FIXME: Recompute to avoid the divide by 100.0 here
581
+ return np.load(path, allow_pickle=False) / 100.0
582
+
583
+
584
+ def quantile_rhi_map(era5_rhi: npt.NDArray[np.float_], member: int) -> npt.NDArray[np.float64]:
585
+ """Map ERA5-derived RHi to it's corresponding IAGOS quantile via histogram matching.
586
+
587
+ This matching is performed on a **single** ERA5 ensemble member.
588
+
589
+ Parameters
590
+ ----------
591
+ era5_rhi : npt.NDArray[np.float_]
592
+ ERA5-derived RHi values for the given ensemble member.
593
+ member : int
594
+ The ERA5 ensemble member to use. Must be in the range ``[0, 10)``.
595
+
596
+ Returns
597
+ -------
598
+ npt.NDArray[np.float64]
599
+ The IAGOS quantiles corresponding to the ERA5-derived RHi values.
600
+ """
601
+
602
+ era5_quantiles = _load_era5_ensemble_quantiles() # shape (801, 10)
603
+ era5_quantiles = era5_quantiles[:, member] # shape (801,)
604
+ iagos_quantiles = _load_iagos_quantiles() # shape (801,)
605
+
606
+ return np.interp(era5_rhi, era5_quantiles, iagos_quantiles)
607
+
608
+
609
+ def recalibrate_rhi(
610
+ era5_rhi_all_members: npt.NDArray[np.float_], member: int
611
+ ) -> npt.NDArray[np.float_]:
612
+ """Recalibrate ERA5-derived RHi values to IAGOS quantiles.
613
+
614
+ This recalibration requires values for **all** ERA5 ensemble members.
615
+
616
+ Parameters
617
+ ----------
618
+ era5_rhi_all_members : npt.NDArray[np.float_]
619
+ ERA5-derived RHi values for all ensemble members. This array should have shape ``(n, 10)``.
620
+ member : int
621
+ The ERA5 ensemble member to use. Must be in the range ``[0, 10)``.
622
+
623
+ Returns
624
+ -------
625
+ npt.NDArray[np.float_]
626
+ The recalibrated RHi values. This is an array of shape ``(n,)``.
627
+
628
+ References
629
+ ----------
630
+ :cite:`eckelCalibratedProbabilisticQuantitative1998`
631
+ """
632
+
633
+ n_members = 10
634
+ assert era5_rhi_all_members.shape[1] == n_members
635
+
636
+ # Perform histogram matching on the given ensemble member
637
+ recalibrated_rhi = quantile_rhi_map(era5_rhi_all_members[:, member], member)
638
+
639
+ # Perform histogram matching on all other ensemble members
640
+ # Add up the results into a single 'ensemble_mean_rhi' array
641
+ ensemble_mean_rhi: npt.NDArray[np.float_] = 0.0 # type: ignore[assignment]
642
+ for r in range(n_members):
643
+ if r == member:
644
+ ensemble_mean_rhi += recalibrated_rhi
645
+ else:
646
+ ensemble_mean_rhi += quantile_rhi_map(era5_rhi_all_members[:, r], r)
647
+
648
+ # Divide by the number of ensemble members to get the mean
649
+ ensemble_mean_rhi /= n_members
650
+
651
+ eckel_a = -0.005213832567192828
652
+ eckel_c = 2.7859172756970354
653
+
654
+ out = (ensemble_mean_rhi - eckel_a) + eckel_c * (recalibrated_rhi - ensemble_mean_rhi)
655
+ return out.astype(era5_rhi_all_members.dtype)
656
+
657
+
658
+ @dataclasses.dataclass
659
+ class HistogramMatchingWithEckelParams(ModelParams):
660
+ """Parameters for :class:`HistogramMatchingWithEckel`.
661
+
662
+ .. warning::
663
+ Experimental. This may change or be removed in a future release.
664
+ """
665
+
666
+ #: A length-10 list of ERA5 ensemble members.
667
+ #: Each element is a :class:`MetDataArray` holding specific humidity
668
+ #: values for a single ensemble member. If None, a ValueError will be
669
+ #: raised at model instantiation time. The order of the list must be
670
+ #: consistent with the order of the ERA5 ensemble members.
671
+ ensemble_specific_humidity: list[MetDataArray] | None = None
672
+
673
+ #: The specific member used. Must be in the range [0, 10). If None,
674
+ #: a ValueError will be raised at model instantiation time.
675
+ member: int | None = None
676
+
677
+
678
+ class HistogramMatchingWithEckel(HumidityScaling):
679
+ """Scale humidity by histogram matching to IAGOS RHi quantiles.
680
+
681
+ This method also applies the Eckel scaling to the recalibrated RHi values.
682
+
683
+ Unlike other specific humidity scaling methods, this method requires met data
684
+ and performs interpolation at evaluation time.
685
+
686
+ .. warning::
687
+ Experimental. This may change or be removed in a future release.
688
+
689
+ References
690
+ ----------
691
+ :cite:`eckelCalibratedProbabilisticQuantitative1998`
692
+ """
693
+
694
+ name = "histogram_matching_with_eckel"
695
+ long_name = "IAGOS RHi histogram matching with Eckel scaling"
696
+ formula = "era5_quantiles -> iagos_quantiles -> recalibrated_rhi"
697
+ default_params = HistogramMatchingWithEckelParams
698
+
699
+ n_members = 10 # hard-coded elsewhere
700
+
701
+ def __init__(
702
+ self,
703
+ met: MetDataset | None = None,
704
+ params: dict[str, Any] | None = None,
705
+ **params_kwargs: Any,
706
+ ) -> None:
707
+ super().__init__(met, params, **params_kwargs)
708
+
709
+ # Some very crude validation
710
+ member = self.params["member"]
711
+ assert member in range(self.n_members)
712
+ self.member: int = member
713
+
714
+ ensemble_specific_humidity = self.params["ensemble_specific_humidity"]
715
+ assert len(ensemble_specific_humidity) == self.n_members
716
+ for member, mda in enumerate(ensemble_specific_humidity):
717
+ try:
718
+ assert mda.data["number"] == member
719
+ except KeyError:
720
+ pass
721
+
722
+ self.ensemble_specific_humidity: list[MetDataArray] = ensemble_specific_humidity
723
+
724
+ @overload
725
+ def eval(self, source: GeoVectorDataset, **params: Any) -> GeoVectorDataset:
726
+ ...
727
+
728
+ @overload
729
+ def eval(self, source: MetDataset, **params: Any) -> NoReturn:
730
+ ...
731
+
732
+ @overload
733
+ def eval(self, source: None = ..., **params: Any) -> NoReturn:
734
+ ...
735
+
736
+ def eval(
737
+ self, source: GeoVectorDataset | MetDataset | None = None, **params: Any
738
+ ) -> GeoVectorDataset | MetDataset:
739
+ """Scale specific humidity by histogram matching to IAGOS RHi quantiles.
740
+
741
+ This method assumes ``source`` is equipped with the following variables:
742
+
743
+ - air_temperature
744
+ - specific_humidity: Humidity values for the :attr:`member` ERA5 ensemble member.
745
+ """
746
+
747
+ self.update_params(params)
748
+ self.set_source(source)
749
+ self.source = self.require_source_type(GeoVectorDataset)
750
+
751
+ if "rhi" in self.source:
752
+ warnings.warn(
753
+ "Variable 'rhi' already found on source to be scaled. This "
754
+ "is unexpected and may be the result of humidity scaling "
755
+ "being applied more than once."
756
+ )
757
+
758
+ # Create a 2D array of specific humidity values for all ensemble members
759
+ # The specific humidity values for the current member are taken from the source
760
+ # This matches patterns used in other humidity scaling methods
761
+ # The remaining values are interpolated from the ERA5 ensemble members
762
+ q = self.source.data["specific_humidity"]
763
+ q2d = np.empty((len(self.source), self.n_members), dtype=q.dtype)
764
+
765
+ for member, mda in enumerate(self.ensemble_specific_humidity):
766
+ if member == self.member:
767
+ q2d[:, member] = q
768
+ else:
769
+ q2d[:, member] = self.source.intersect_met(mda, **self.interp_kwargs)
770
+
771
+ p = self.source.setdefault("air_pressure", self.source.air_pressure)
772
+ T = self.source.data["air_temperature"]
773
+
774
+ q, rhi = self.scale(q2d, T, p)
775
+ self.source.update(specific_humidity=q, rhi=rhi)
776
+
777
+ return self.source
778
+
779
+ @overrides
780
+ def scale( # type: ignore[override]
781
+ self,
782
+ specific_humidity: npt.NDArray[np.float_],
783
+ air_temperature: npt.NDArray[np.float_],
784
+ air_pressure: npt.NDArray[np.float_],
785
+ **kwargs: Any,
786
+ ) -> tuple[npt.NDArray[np.float_], npt.NDArray[np.float_]]:
787
+ """Scale specific humidity values via histogram matching and Eckel scaling.
788
+
789
+ Unlike the method on the base class, the method assumes each of the input
790
+ arrays are :class:`np.ndarray` and not :class:`xr.DataArray` objects.
791
+
792
+ Parameters
793
+ ----------
794
+ specific_humidity : npt.NDArray[np.float_]
795
+ A 2D array of specific humidity values for all ERA5 ensemble members.
796
+ The shape of this array must be ``(n, 10)``, where ``n`` is the number
797
+ of observations and ``10`` is the number of ERA5 ensemble members.
798
+ air_temperature : npt.NDArray[np.float_]
799
+ A 1D array of air temperature values with shape ``(n,)``.
800
+ air_pressure : npt.NDArray[np.float_]
801
+ A 1D array of air pressure values with shape ``(n,)``.
802
+ kwargs: Any
803
+ Unused, kept for compatibility with the base class.
804
+
805
+ Returns
806
+ -------
807
+ specific_humidity : npt.NDArray[np.float_]
808
+ The recalibrated specific humidity values. A 1D array with shape ``(n,)``.
809
+ rhi : npt.NDArray[np.float_]
810
+ The recalibrated RHi values. A 1D array with shape ``(n,)``.
811
+ """
812
+
813
+ rhi_over_q = _rhi_over_q(air_temperature, air_pressure)
814
+ rhi = rhi_over_q[:, np.newaxis] * specific_humidity
815
+
816
+ recalibrated_rhi = recalibrate_rhi(rhi, self.member)
817
+ recalibrated_q = recalibrated_rhi / rhi_over_q
818
+
819
+ return recalibrated_q, recalibrated_rhi
@@ -51,7 +51,7 @@ class ISSR(Model):
51
51
  >>> variables = ["air_temperature", "specific_humidity"]
52
52
  >>> pressure_levels = [200, 250, 300]
53
53
  >>> era5 = ERA5(time, variables, pressure_levels)
54
- >>> met = era5.open_metdataset(xr_kwargs=dict(parallel=False))
54
+ >>> met = era5.open_metdataset()
55
55
 
56
56
  >>> # Instantiate and run model
57
57
  >>> scaling = ConstantHumidityScaling(rhi_adj=0.98)
@@ -136,7 +136,7 @@ class ISSR(Model):
136
136
  rhi=self.source.data.get("rhi", None), # if rhi already known, pass it in
137
137
  rhi_threshold=self.params["rhi_threshold"],
138
138
  )
139
- self.source["issr"] = issr_ # type: ignore[assignment]
139
+ self.source["issr"] = issr_
140
140
 
141
141
  # Tag output with additional attrs when source is MetDataset
142
142
  if isinstance(self.source, MetDataset):
pycontrails/models/pcr.py CHANGED
@@ -97,7 +97,7 @@ class PCR(Model):
97
97
  sac_model.eval(self.source)
98
98
 
99
99
  pcr_ = _pcr_from_issr_and_sac(self.source.data["issr"], self.source.data["sac"])
100
- self.source["pcr"] = pcr_ # type: ignore[assignment]
100
+ self.source["pcr"] = pcr_
101
101
  # Tag output with additional attrs when source is MetDataset
102
102
  if isinstance(self.source, MetDataset):
103
103
  attrs = {**self.source["issr"].attrs, **self.source["sac"].attrs}
pycontrails/models/sac.py CHANGED
@@ -139,11 +139,11 @@ class SAC(Model):
139
139
  sac_ = sac(rh, rh_crit_sac)
140
140
 
141
141
  # Attaching some intermediate artifacts onto the source
142
- self.source["G"] = G # type: ignore[assignment]
143
- self.source["T_sat_liquid"] = T_sat_liquid_ # type: ignore[assignment]
144
- self.source["rh"] = rh # type: ignore[assignment]
145
- self.source["rh_critical_sac"] = rh_crit_sac # type: ignore[assignment]
146
- self.source["sac"] = sac_ # type: ignore[assignment]
142
+ self.source["G"] = G
143
+ self.source["T_sat_liquid"] = T_sat_liquid_
144
+ self.source["rh"] = rh
145
+ self.source["rh_critical_sac"] = rh_crit_sac
146
+ self.source["sac"] = sac_
147
147
 
148
148
  # Tag output with additional attrs when source is MetDataset
149
149
  if isinstance(self.source, MetDataset):
@@ -438,6 +438,8 @@ def T_critical_sac(
438
438
  # We only apply Newton's method at points with rh bounded below 1 (scipy will
439
439
  # raise an error if Newton's method is not converging well).
440
440
  filt = relative_humidity < 0.999
441
+ if not np.any(filt):
442
+ return T_LM
441
443
 
442
444
  U_filt = relative_humidity[filt]
443
445
  T_LM_filt = T_LM[filt]
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import numpy as np
6
+ import numpy.typing as npt
6
7
  import xarray as xr
7
8
 
8
9
  from pycontrails.physics import constants, units
@@ -32,11 +33,12 @@ def haversine(lons0: ArrayLike, lats0: ArrayLike, lons1: ArrayLike, lats1: Array
32
33
 
33
34
  Notes
34
35
  -----
35
- This formula does not take into account the non-spheroidal (ellipsoidal) shape of the Earth
36
+ This formula does not take into account the non-spheroidal (ellipsoidal) shape of the Earth.
37
+ Originally referenced from https://andrew.hedges.name/experiments/haversine/.
36
38
 
37
39
  References
38
40
  ----------
39
- Source: https://andrew.hedges.name/experiments/haversine/
41
+ - :cite:`CalculateDistanceBearing`
40
42
 
41
43
  See Also
42
44
  --------
@@ -833,7 +835,7 @@ def advect_level(
833
835
  return (level * 100.0 + (dt_s * velocity)) / 100.0
834
836
 
835
837
 
836
- def _dt_to_float_seconds(dt: np.ndarray | np.timedelta64, dtype: np.dtype) -> np.ndarray:
838
+ def _dt_to_float_seconds(dt: np.ndarray | np.timedelta64, dtype: npt.DTypeLike) -> np.ndarray:
837
839
  """Convert a time delta to seconds as a float with specified ``dtype`` precision.
838
840
 
839
841
  Parameters