pycontrails 0.54.6__cp311-cp311-macosx_11_0_arm64.whl → 0.54.8__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 (43) hide show
  1. pycontrails/__init__.py +1 -1
  2. pycontrails/_version.py +9 -4
  3. pycontrails/core/aircraft_performance.py +12 -30
  4. pycontrails/core/airports.py +4 -1
  5. pycontrails/core/cache.py +4 -0
  6. pycontrails/core/flight.py +4 -4
  7. pycontrails/core/flightplan.py +10 -2
  8. pycontrails/core/met.py +53 -40
  9. pycontrails/core/met_var.py +18 -0
  10. pycontrails/core/models.py +79 -3
  11. pycontrails/core/rgi_cython.cpython-311-darwin.so +0 -0
  12. pycontrails/core/vector.py +74 -0
  13. pycontrails/datalib/spire/__init__.py +5 -0
  14. pycontrails/datalib/spire/exceptions.py +62 -0
  15. pycontrails/datalib/spire/spire.py +604 -0
  16. pycontrails/models/accf.py +4 -4
  17. pycontrails/models/cocip/cocip.py +52 -6
  18. pycontrails/models/cocip/cocip_params.py +10 -1
  19. pycontrails/models/cocip/contrail_properties.py +4 -6
  20. pycontrails/models/cocip/output_formats.py +12 -4
  21. pycontrails/models/cocip/radiative_forcing.py +2 -8
  22. pycontrails/models/cocip/unterstrasser_wake_vortex.py +132 -30
  23. pycontrails/models/cocipgrid/cocip_grid.py +14 -11
  24. pycontrails/models/emissions/black_carbon.py +19 -14
  25. pycontrails/models/emissions/emissions.py +8 -8
  26. pycontrails/models/humidity_scaling/humidity_scaling.py +49 -4
  27. pycontrails/models/ps_model/ps_aircraft_params.py +1 -1
  28. pycontrails/models/ps_model/ps_grid.py +22 -22
  29. pycontrails/models/ps_model/ps_model.py +4 -7
  30. pycontrails/models/ps_model/static/{ps-aircraft-params-20240524.csv → ps-aircraft-params-20250328.csv} +58 -57
  31. pycontrails/models/ps_model/static/{ps-synonym-list-20240524.csv → ps-synonym-list-20250328.csv} +1 -0
  32. pycontrails/models/tau_cirrus.py +1 -0
  33. pycontrails/physics/constants.py +2 -1
  34. pycontrails/physics/jet.py +5 -4
  35. pycontrails/physics/static/{iata-cargo-load-factors-20241115.csv → iata-cargo-load-factors-20250221.csv} +3 -0
  36. pycontrails/physics/static/{iata-passenger-load-factors-20241115.csv → iata-passenger-load-factors-20250221.csv} +3 -0
  37. {pycontrails-0.54.6.dist-info → pycontrails-0.54.8.dist-info}/METADATA +5 -4
  38. {pycontrails-0.54.6.dist-info → pycontrails-0.54.8.dist-info}/RECORD +42 -40
  39. {pycontrails-0.54.6.dist-info → pycontrails-0.54.8.dist-info}/WHEEL +2 -1
  40. {pycontrails-0.54.6.dist-info → pycontrails-0.54.8.dist-info/licenses}/NOTICE +1 -1
  41. pycontrails/datalib/spire.py +0 -739
  42. {pycontrails-0.54.6.dist-info → pycontrails-0.54.8.dist-info/licenses}/LICENSE +0 -0
  43. {pycontrails-0.54.6.dist-info → pycontrails-0.54.8.dist-info}/top_level.txt +0 -0
@@ -18,11 +18,12 @@ import numpy.typing as npt
18
18
  import pandas as pd
19
19
  import xarray as xr
20
20
 
21
- from pycontrails.core import met_var
21
+ from pycontrails.core import met_var, models
22
22
  from pycontrails.core.aircraft_performance import AircraftPerformance
23
23
  from pycontrails.core.fleet import Fleet
24
24
  from pycontrails.core.flight import Flight
25
25
  from pycontrails.core.met import MetDataset
26
+ from pycontrails.core.met_var import MetVariable
26
27
  from pycontrails.core.models import Model, interpolate_met
27
28
  from pycontrails.core.vector import GeoVectorDataset, VectorDataDict
28
29
  from pycontrails.datalib import ecmwf, gfs
@@ -482,6 +483,42 @@ class Cocip(Model):
482
483
 
483
484
  return self.source
484
485
 
486
+ @classmethod
487
+ def generic_rad_variables(cls) -> tuple[MetVariable, ...]:
488
+ """Return a model-agnostic list of required radiation variables.
489
+
490
+ Returns
491
+ -------
492
+ tuple[MetVariable]
493
+ List of model-agnostic variants of required variables
494
+ """
495
+ available = set(met_var.MET_VARIABLES)
496
+ return tuple(models._find_match(required, available) for required in cls.rad_variables)
497
+
498
+ @classmethod
499
+ def ecmwf_rad_variables(cls) -> tuple[MetVariable, ...]:
500
+ """Return an ECMWF-specific list of required radiation variables.
501
+
502
+ Returns
503
+ -------
504
+ tuple[MetVariable]
505
+ List of ECMWF-specific variants of required variables
506
+ """
507
+ available = set(ecmwf.ECMWF_VARIABLES)
508
+ return tuple(models._find_match(required, available) for required in cls.rad_variables)
509
+
510
+ @classmethod
511
+ def gfs_rad_variables(cls) -> tuple[MetVariable, ...]:
512
+ """Return a GFS-specific list of required radiation variables.
513
+
514
+ Returns
515
+ -------
516
+ tuple[MetVariable]
517
+ List of GFS-specific variants of required variables
518
+ """
519
+ available = set(gfs.GFS_VARIABLES)
520
+ return tuple(models._find_match(required, available) for required in cls.rad_variables)
521
+
485
522
  def _set_timesteps(self) -> None:
486
523
  """Set the :attr:`timesteps` based on the ``source`` time range.
487
524
 
@@ -960,14 +997,14 @@ class Cocip(Model):
960
997
  else:
961
998
  f_surv = contrail_properties.ice_particle_survival_fraction(iwc, iwc_1)
962
999
 
963
- n_ice_per_m_1 = contrail_properties.ice_particle_number(
1000
+ n_ice_per_m_0 = contrail_properties.initial_ice_particle_number(
964
1001
  nvpm_ei_n=nvpm_ei_n,
965
1002
  fuel_dist=fuel_dist,
966
- f_surv=f_surv,
967
1003
  air_temperature=air_temperature,
968
1004
  T_crit_sac=T_critical_sac,
969
1005
  min_ice_particle_number_nvpm_ei_n=self.params["min_ice_particle_number_nvpm_ei_n"],
970
1006
  )
1007
+ n_ice_per_m_1 = n_ice_per_m_0 * f_surv
971
1008
 
972
1009
  # Check for persistent initial_contrails
973
1010
  persistent_1 = contrail_properties.initial_persistent(iwc_1, rhi_1)
@@ -981,6 +1018,8 @@ class Cocip(Model):
981
1018
  self._sac_flight["rho_air_1"] = rho_air_1
982
1019
  self._sac_flight["rhi_1"] = rhi_1
983
1020
  self._sac_flight["iwc_1"] = iwc_1
1021
+ self._sac_flight["f_surv"] = f_surv
1022
+ self._sac_flight["n_ice_per_m_0"] = n_ice_per_m_0
984
1023
  self._sac_flight["n_ice_per_m_1"] = n_ice_per_m_1
985
1024
  self._sac_flight["persistent_1"] = persistent_1
986
1025
 
@@ -1347,7 +1386,13 @@ class Cocip(Model):
1347
1386
  if verbose_outputs:
1348
1387
  sac_cols += ["dT_dz", "ds_dz", "dz_max"]
1349
1388
 
1350
- downwash_cols = ["rho_air_1", "iwc_1", "n_ice_per_m_1"]
1389
+ downwash_cols = [
1390
+ "rho_air_1",
1391
+ "iwc_1",
1392
+ "f_surv",
1393
+ "n_ice_per_m_0",
1394
+ "n_ice_per_m_1",
1395
+ ]
1351
1396
  df = pd.concat(
1352
1397
  [
1353
1398
  self.source.dataframe.set_index(col_idx),
@@ -2174,8 +2219,6 @@ def calc_contrail_properties(
2174
2219
  air_temperature += contrail["cumul_heat"]
2175
2220
 
2176
2221
  # get required radiation
2177
- theta_rad = geo.orbital_position(time)
2178
- sd0 = geo.solar_constant(theta_rad)
2179
2222
  sdr = contrail["sdr"]
2180
2223
  rsr = contrail["rsr"]
2181
2224
  olr = contrail["olr"]
@@ -2210,6 +2253,9 @@ def calc_contrail_properties(
2210
2253
  diffuse_h = contrail_properties.horizontal_diffusivity(ds_dz, depth)
2211
2254
 
2212
2255
  if radiative_heating_effects:
2256
+ # theta_rad has float64 dtype, convert back to float32 if needed
2257
+ theta_rad = geo.orbital_position(time).astype(sdr.dtype, copy=False)
2258
+ sd0 = geo.solar_constant(theta_rad)
2213
2259
  heat_rate = radiative_heating.heating_rate(
2214
2260
  air_temperature=air_temperature,
2215
2261
  rhi=rhi,
@@ -103,6 +103,8 @@ class CocipParams(AdvectionBuffers):
103
103
  #: Cocip output with ``preprocess_lowmem=True`` is only guaranteed to match output
104
104
  #: with ``preprocess_lowmem=False`` when run with ``interpolation_bounds_error=True``
105
105
  #: to ensure no out-of-bounds interpolation occurs.
106
+ #:
107
+ #: .. versionadded:: 0.52.3
106
108
  preprocess_lowmem: bool = False
107
109
 
108
110
  # --------------
@@ -114,6 +116,8 @@ class CocipParams(AdvectionBuffers):
114
116
  #: ``"auto"``, ``"tau_cirrus"`` will be computed during model initialization
115
117
  #: iff the met data is dask-backed. Otherwise, it will be computed during model
116
118
  #: evaluation after the met data is downselected.
119
+ #:
120
+ #: .. versionadded:: 0.47.1
117
121
  compute_tau_cirrus_in_model_init: bool | str = "auto"
118
122
 
119
123
  # ---------
@@ -152,10 +156,14 @@ class CocipParams(AdvectionBuffers):
152
156
  #: These are not standard CoCiP outputs but based on the derivation used
153
157
  #: in the first supplement to :cite:`yinPredictingClimateImpact2023`. ATR20 is defined
154
158
  #: as the average temperature response over a 20 year horizon.
159
+ #:
160
+ #: .. versionadded:: 0.50.0
155
161
  compute_atr20: bool = False
156
162
 
157
163
  #: Constant factor used to convert global- and year-mean RF, [:math:`W m^{-2}`],
158
164
  #: to ATR20, [:math:`K`], given by :cite:`yinPredictingClimateImpact2023`.
165
+ #:
166
+ #: .. versionadded:: 0.50.0
159
167
  global_rf_to_atr20_factor: float = 0.0151
160
168
 
161
169
  # ----------------
@@ -197,12 +205,13 @@ class CocipParams(AdvectionBuffers):
197
205
  max_depth: float = 1500.0
198
206
 
199
207
  #: Experimental. Improved ice crystal number survival fraction in the wake vortex phase.
200
- #: Implement :cite:`unterstrasserPropertiesYoungContrails2016`, who developed a
208
+ #: Implement :cite:`lottermoserHighResolutionEarlyContrails2025`, who developed a
201
209
  #: parametric model that estimates the survival fraction of the contrail ice crystal
202
210
  #: number after the wake vortex phase based on the results from large eddy simulations.
203
211
  #: This replicates Fig. 4 of :cite:`karcherFormationRadiativeForcing2018`.
204
212
  #:
205
213
  #: .. versionadded:: 0.50.1
214
+ #: .. versionchanged:: 0.54.7
206
215
  unterstrasser_ice_survival_fraction: bool = False
207
216
 
208
217
  #: Experimental. Radiative heating effects on contrail cirrus properties.
@@ -201,10 +201,9 @@ def iwc_post_wake_vortex(
201
201
  return np.maximum(iwc - iwc_ad, 0.0)
202
202
 
203
203
 
204
- def ice_particle_number(
204
+ def initial_ice_particle_number(
205
205
  nvpm_ei_n: npt.NDArray[np.floating],
206
206
  fuel_dist: npt.NDArray[np.floating],
207
- f_surv: npt.NDArray[np.floating],
208
207
  air_temperature: npt.NDArray[np.floating],
209
208
  T_crit_sac: npt.NDArray[np.floating],
210
209
  min_ice_particle_number_nvpm_ei_n: float,
@@ -222,8 +221,6 @@ def ice_particle_number(
222
221
  black carbon number emissions index, [:math:`kg^{-1}`]
223
222
  fuel_dist : npt.NDArray[np.floating]
224
223
  fuel consumption of the flight segment per distance traveled, [:math:`kg m^{-1}`]
225
- f_surv : npt.NDArray[np.floating]
226
- Fraction of contrail ice particle number that survive the wake vortex phase.
227
224
  air_temperature : npt.NDArray[np.floating]
228
225
  ambient temperature for each waypoint, [:math:`K`]
229
226
  T_crit_sac : npt.NDArray[np.floating]
@@ -235,11 +232,12 @@ def ice_particle_number(
235
232
  Returns
236
233
  -------
237
234
  npt.NDArray[np.floating]
238
- initial number of ice particles per distance after the wake vortex phase, [:math:`# m^{-1}`]
235
+ The initial number of ice particles per distance before the wake vortex
236
+ phase, [:math:`# m^{-1}`]
239
237
  """
240
238
  f_activation = ice_particle_activation_rate(air_temperature, T_crit_sac)
241
239
  nvpm_ei_n_activated = nvpm_ei_n * f_activation
242
- return fuel_dist * np.maximum(nvpm_ei_n_activated, min_ice_particle_number_nvpm_ei_n) * f_surv
240
+ return fuel_dist * np.maximum(nvpm_ei_n_activated, min_ice_particle_number_nvpm_ei_n)
243
241
 
244
242
 
245
243
  def ice_particle_activation_rate(
@@ -1188,10 +1188,18 @@ def meteorological_time_slice_statistics(
1188
1188
  )
1189
1189
 
1190
1190
  # ISSR: Volume of airspace with RHi > 100% between FL300 and FL450
1191
- met = humidity_scaling.eval(met)
1192
- rhi = met["rhi"].data.sel(level=slice(150, 300))
1193
- rhi = rhi.interp(time=time)
1194
- is_issr = rhi > 1
1191
+ met_cruise = MetDataset(met.data.sel(level=slice(150, 300)))
1192
+ rhi = humidity_scaling.eval(met_cruise)["rhi"].data
1193
+
1194
+ try:
1195
+ # If the given time is already in the dataset, select the time slice
1196
+ i = rhi.get_index("time").get_loc(time)
1197
+ except KeyError:
1198
+ rhi = rhi.interp(time=time)
1199
+ else:
1200
+ rhi = rhi.isel(time=i)
1201
+
1202
+ is_issr = rhi > 1.0
1195
1203
 
1196
1204
  # Cirrus in a longitude-latitude grid
1197
1205
  if cirrus_coverage is None:
@@ -1104,10 +1104,7 @@ def _contrail_optical_depth_above_contrail_layer(
1104
1104
 
1105
1105
  da_surface_area = geo.grid_surface_area(da["longitude"].values, da["latitude"].values)
1106
1106
  da = da / da_surface_area
1107
-
1108
- lon_coords = da["longitude"].values
1109
- wrap_longitude = (lon_coords[-1] + lon_coords[0] + np.diff(lon_coords)[0]) == 0.0
1110
- mda = MetDataArray(da, wrap_longitude=wrap_longitude, copy=False)
1107
+ mda = MetDataArray(da)
1111
1108
 
1112
1109
  # Interpolate to contrails_level
1113
1110
  contrails_level["tau_contrails_above"] = contrails_level.intersect_met(mda)
@@ -1133,10 +1130,7 @@ def _rsr_and_olr_with_contrail_overlap(
1133
1130
  Contrail waypoints at the current altitude layer with `rsr_overlap` and
1134
1131
  `olr_overlap` attached.
1135
1132
  """
1136
- # Change in radiation fields
1137
- lon_coords = delta_rad_t["longitude"].values
1138
- wrap_longitude = (lon_coords[-1] + lon_coords[0] + np.diff(lon_coords)[0]) == 0.0
1139
- mds = MetDataset(delta_rad_t, wrap_longitude=wrap_longitude, copy=False)
1133
+ mds = MetDataset(delta_rad_t)
1140
1134
 
1141
1135
  # Interpolate radiation fields to obtain `rsr_overlap` and `olr_overlap`
1142
1136
  delta_rsr = contrails_level.intersect_met(mds["rsr"])
@@ -1,11 +1,16 @@
1
- """Wave-vortex downwash functions from Unterstrasser (2016).
1
+ """Wave-vortex downwash functions from Lottermoser & Unterstrasser (2025).
2
2
 
3
3
  Notes
4
4
  -----
5
5
  :cite:`unterstrasserPropertiesYoungContrails2016` provides a parameterized model of the
6
6
  survival fraction of the contrail ice crystal number ``f_surv`` during the wake-vortex phase.
7
- The model was developed based on output from large eddy simulations, and improves agreement with
8
- LES outputs relative to the default survival fraction parameterization used in CoCiP.
7
+ The model has since been updated in :cite:`lottermoserHighResolutionEarlyContrails2025`. This update
8
+ improves the goodness-of-fit between the parameterised model and LES, and expands the parameter
9
+ space and can now be used for very low and very high soot inputs, different fuel types (where the
10
+ EI H2Os are different), and higher ambient temperatures (up to 235 K) to accomodate for contrails
11
+ formed by liquid hydrogen aircraft. The model was developed based on output from large eddy
12
+ simulations, and improves agreement with LES outputs relative to the default survival fraction
13
+ parameterization used in CoCiP.
9
14
 
10
15
  For comparison, CoCiP assumes that ``f_surv`` is equal to the change in the contrail ice water
11
16
  content (by mass) before and after the wake vortex phase. However, for larger (smaller) ice
@@ -14,6 +19,9 @@ by mass. This is particularly important in the "soot-poor" scenario, for example
14
19
  lean-burn engines where their soot emissions can be 3-4 orders of magnitude lower than conventional
15
20
  RQL engines.
16
21
 
22
+ ADD CITATION TO BIBTEX: :cite:`lottermoserHighResolutionEarlyContrails2025`
23
+ Lottermoser, A. and Unterstraßer, S.: High-resolution modelling of early contrail evolution from
24
+ hydrogen-powered aircraft, EGUsphere [preprint], https://doi.org/10.5194/egusphere-2024-3859, 2025.
17
25
  """
18
26
 
19
27
  from __future__ import annotations
@@ -34,6 +42,8 @@ def ice_particle_number_survival_fraction(
34
42
  fuel_flow: npt.NDArray[np.floating],
35
43
  aei_n: npt.NDArray[np.floating],
36
44
  z_desc: npt.NDArray[np.floating],
45
+ *,
46
+ analytical_solution: bool = True,
37
47
  ) -> npt.NDArray[np.floating]:
38
48
  r"""
39
49
  Calculate fraction of ice particle number surviving the wake vortex phase and required inputs.
@@ -61,6 +71,8 @@ def ice_particle_number_survival_fraction(
61
71
  z_desc : npt.NDArray[np.floating]
62
72
  Final vertical displacement of the wake vortex, ``dz_max`` in :mod:`wake_vortex.py`,
63
73
  [:math:`m`].
74
+ analytical_solution : bool
75
+ Use analytical solution to calculate ``z_atm`` and ``z_emit`` instead of numerical solution.
64
76
 
65
77
  Returns
66
78
  -------
@@ -70,57 +82,114 @@ def ice_particle_number_survival_fraction(
70
82
  References
71
83
  ----------
72
84
  - :cite:`unterstrasserPropertiesYoungContrails2016`
85
+ - :cite:`lottermoserHighResolutionEarlyContrails2025`
73
86
 
74
87
  Notes
75
88
  -----
76
- - See eq. (3), (9), and (10) in :cite:`unterstrasserPropertiesYoungContrails2016`.
77
89
  - For consistency in CoCiP, ``z_desc`` should be calculated using :func:`dz_max` instead of
78
90
  using :func:`z_desc_length_scale`.
79
91
  """
80
- # Length scales
81
- z_atm = z_atm_length_scale(air_temperature, rhi_0)
82
92
  rho_emit = emitted_water_vapour_concentration(ei_h2o, wingspan, true_airspeed, fuel_flow)
83
- z_emit = z_emit_length_scale(rho_emit, air_temperature)
84
- z_total = z_total_length_scale(aei_n, z_atm, z_emit, z_desc)
93
+
94
+ # Length scales
95
+ if analytical_solution:
96
+ z_atm = z_atm_length_scale_analytical(air_temperature, rhi_0)
97
+ z_emit = z_emit_length_scale_analytical(rho_emit, air_temperature)
98
+
99
+ else:
100
+ z_atm = z_atm_length_scale_numerical(air_temperature, rhi_0)
101
+ z_emit = z_emit_length_scale_numerical(rho_emit, air_temperature)
102
+
103
+ z_total = z_total_length_scale(z_atm, z_emit, z_desc, true_airspeed, fuel_flow, aei_n, wingspan)
85
104
  return _survival_fraction_from_length_scale(z_total)
86
105
 
87
106
 
88
107
  def z_total_length_scale(
89
- aei_n: npt.NDArray[np.floating],
90
108
  z_atm: npt.NDArray[np.floating],
91
109
  z_emit: npt.NDArray[np.floating],
92
110
  z_desc: npt.NDArray[np.floating],
111
+ true_airspeed: npt.NDArray[np.floating],
112
+ fuel_flow: npt.NDArray[np.floating],
113
+ aei_n: npt.NDArray[np.floating],
114
+ wingspan: npt.NDArray[np.floating] | float,
93
115
  ) -> npt.NDArray[np.floating]:
94
116
  """
95
117
  Calculate the total length-scale effect of the wake vortex downwash.
96
118
 
97
119
  Parameters
98
120
  ----------
99
- aei_n : npt.NDArray[np.floating]
100
- Apparent ice crystal number emissions index at contrail formation, [:math:`kg^{-1}`]
101
121
  z_atm : npt.NDArray[np.floating]
102
122
  Length-scale effect of ambient supersaturation on the ice crystal mass budget, [:math:`m`]
103
123
  z_emit : npt.NDArray[np.floating]
104
124
  Length-scale effect of water vapour emissions on the ice crystal mass budget, [:math:`m`]
105
125
  z_desc : npt.NDArray[np.floating]
106
126
  Final vertical displacement of the wake vortex, `dz_max` in `wake_vortex.py`, [:math:`m`]
127
+ true_airspeed : npt.NDArray[np.floating]
128
+ true airspeed for each waypoint, [:math:`m s^{-1}`]
129
+ fuel_flow : npt.NDArray[np.floating]
130
+ Fuel mass flow rate, [:math:`kg s^{-1}`]
131
+ aei_n : npt.NDArray[np.floating]
132
+ Apparent ice crystal number emissions index at contrail formation, [:math:`kg^{-1}`]
133
+ wingspan : npt.NDArray[np.floating] | float
134
+ aircraft wingspan, [:math:`m`]
107
135
 
108
136
  Returns
109
137
  -------
110
138
  npt.NDArray[np.floating]
111
139
  Total length-scale effect of the wake vortex downwash, [:math:`m`]
140
+
141
+ Notes
142
+ -----
143
+ - For `psi`, see Appendix A1 in :cite:`lottermoserHighResolutionEarlyContrails2025`.
144
+ - For `z_total`, see Eq. (9) and (10) in :cite:`lottermoserHighResolutionEarlyContrails2025`.
145
+ """
146
+ # Calculate psi term
147
+ fuel_dist = fuel_flow / true_airspeed # Units: [:math:`kg m^{-1}`]
148
+ n_ice_dist = fuel_dist * aei_n # Units: [:math:`m^{-1}`]
149
+
150
+ n_ice_per_vol = n_ice_dist / plume_area(wingspan) # Units: [:math:`m^{-3}`]
151
+ n_ice_per_vol_ref = 3.38e12 / plume_area(60.3)
152
+
153
+ psi = (n_ice_per_vol_ref / n_ice_per_vol) ** 0.16
154
+
155
+ # Calculate total length-scale effect
156
+ return psi * (1.27 * z_atm + 0.42 * z_emit) - 0.49 * z_desc
157
+
158
+
159
+ def z_atm_length_scale_analytical(
160
+ air_temperature: npt.NDArray[np.floating],
161
+ rhi_0: npt.NDArray[np.floating],
162
+ ) -> npt.NDArray[np.floating]:
163
+ """Calculate the length-scale effect of ambient supersaturation on the ice crystal mass budget.
164
+
165
+ Parameters
166
+ ----------
167
+ air_temperature : npt.NDArray[np.floating]
168
+ Ambient temperature for each waypoint, [:math:`K`].
169
+ rhi_0 : npt.NDArray[np.floating]
170
+ Relative humidity with respect to ice at the flight waypoint.
171
+
172
+ Returns
173
+ -------
174
+ npt.NDArray[np.floating]
175
+ The effect of the ambient supersaturation on the ice crystal mass budget,
176
+ provided as a length scale equivalent, estimated with analytical fit [:math:`m`].
177
+
178
+ Notes
179
+ -----
180
+ - See Eq. (A2) in :cite:`lottermoserHighResolutionEarlyContrails2025`.
112
181
  """
113
- alpha_base = (aei_n / 2.8e14) ** (-0.18)
114
- alpha_atm = 1.7 * alpha_base
115
- alpha_emit = 1.15 * alpha_base
182
+ z_atm = np.zeros_like(rhi_0)
116
183
 
117
- z_total = alpha_atm * z_atm + alpha_emit * z_emit - 0.6 * z_desc
184
+ # Only perform operation when the ambient condition is supersaturated w.r.t. ice
185
+ issr = rhi_0 > 1.0
118
186
 
119
- z_total.clip(min=0.0, out=z_total)
120
- return z_total
187
+ s_i = rhi_0 - 1.0
188
+ z_atm[issr] = 607.46 * s_i[issr] ** 0.897 * (air_temperature[issr] / 205.0) ** 2.225
189
+ return z_atm
121
190
 
122
191
 
123
- def z_atm_length_scale(
192
+ def z_atm_length_scale_numerical(
124
193
  air_temperature: npt.NDArray[np.floating],
125
194
  rhi_0: npt.NDArray[np.floating],
126
195
  *,
@@ -141,11 +210,11 @@ def z_atm_length_scale(
141
210
  -------
142
211
  npt.NDArray[np.floating]
143
212
  The effect of the ambient supersaturation on the ice crystal mass budget,
144
- provided as a length scale equivalent, [:math:`m`].
213
+ provided as a length scale equivalent, estimated with numerical methods [:math:`m`].
145
214
 
146
215
  Notes
147
216
  -----
148
- - See eq. (5) in :cite:`unterstrasserPropertiesYoungContrails2016`.
217
+ - See Eq. (6) in :cite:`lottermoserHighResolutionEarlyContrails2025`.
149
218
  """
150
219
  # Only perform operation when the ambient condition is supersaturated w.r.t. ice
151
220
  issr = rhi_0 > 1.0
@@ -157,14 +226,14 @@ def z_atm_length_scale(
157
226
  # Did not use scipy functions because it is unstable when dealing with np.arrays
158
227
  z_1 = np.zeros_like(rhi_issr)
159
228
  z_2 = np.full_like(rhi_issr, 1000.0)
160
- lhs = rhi_issr * thermo.e_sat_ice(air_temperature_issr) / air_temperature_issr
229
+ lhs = rhi_issr * thermo.e_sat_ice(air_temperature_issr) / (air_temperature_issr**3.5)
161
230
 
162
231
  dry_adiabatic_lapse_rate = constants.g / constants.c_pd
163
232
  for _ in range(n_iter):
164
233
  z_est = 0.5 * (z_1 + z_2)
165
234
  rhs = (thermo.e_sat_ice(air_temperature_issr + dry_adiabatic_lapse_rate * z_est)) / (
166
235
  air_temperature_issr + dry_adiabatic_lapse_rate * z_est
167
- )
236
+ ) ** 3.5
168
237
  z_1[lhs > rhs] = z_est[lhs > rhs]
169
238
  z_2[lhs < rhs] = z_est[lhs < rhs]
170
239
 
@@ -207,7 +276,38 @@ def emitted_water_vapour_concentration(
207
276
  return h2o_per_dist / area_p
208
277
 
209
278
 
210
- def z_emit_length_scale(
279
+ def z_emit_length_scale_analytical(
280
+ rho_emit: npt.NDArray[np.floating],
281
+ air_temperature: npt.NDArray[np.floating],
282
+ ) -> npt.NDArray[np.floating]:
283
+ """Calculate the length-scale effect of water vapour emissions on the ice crystal mass budget.
284
+
285
+ Parameters
286
+ ----------
287
+ rho_emit : npt.NDArray[np.floating] | float
288
+ Aircraft-emitted water vapour concentration in the plume, [:math:`kg m^{-3}`]
289
+ air_temperature : npt.NDArray[np.floating]
290
+ ambient temperature for each waypoint, [:math:`K`]
291
+
292
+ Returns
293
+ -------
294
+ npt.NDArray[np.floating]
295
+ The effect of the aircraft water vapour emission on the ice crystal mass budget,
296
+ provided as a length scale equivalent, estimated with analytical fit [:math:`m`]
297
+
298
+ Notes
299
+ -----
300
+ - See Eq. (A3) in :cite:`lottermoserHighResolutionEarlyContrails2025`.
301
+ """
302
+ t_205 = air_temperature - 205.0
303
+ return (
304
+ 1106.6
305
+ * ((rho_emit * 1e5) ** (0.678 + 0.0116 * t_205))
306
+ * np.exp(-(0.0807 + 0.000428 * t_205) * t_205)
307
+ )
308
+
309
+
310
+ def z_emit_length_scale_numerical(
211
311
  rho_emit: npt.NDArray[np.floating],
212
312
  air_temperature: npt.NDArray[np.floating],
213
313
  *,
@@ -228,24 +328,26 @@ def z_emit_length_scale(
228
328
  -------
229
329
  npt.NDArray[np.floating]
230
330
  The effect of the aircraft water vapour emission on the ice crystal mass budget,
231
- provided as a length scale equivalent, [:math:`m`]
331
+ provided as a length scale equivalent, estimated with numerical methods [:math:`m`]
232
332
 
233
333
  Notes
234
334
  -----
235
- - See eq. (7) in :cite:`unterstrasserPropertiesYoungContrails2016`.
335
+ - See Eq. (7) in :cite:`lottermoserHighResolutionEarlyContrails2025`.
236
336
  """
237
337
  # Solve non-linear equation numerically using the bisection method
238
338
  # Did not use scipy functions because it is unstable when dealing with np.arrays
239
339
  z_1 = np.zeros_like(rho_emit)
240
340
  z_2 = np.full_like(rho_emit, 1000.0)
241
341
 
242
- lhs = (thermo.e_sat_ice(air_temperature) / (constants.R_v * air_temperature)) + rho_emit
342
+ lhs = (thermo.e_sat_ice(air_temperature) / (constants.R_v * air_temperature**3.5)) + (
343
+ rho_emit / (air_temperature**2.5)
344
+ )
243
345
 
244
346
  dry_adiabatic_lapse_rate = constants.g / constants.c_pd
245
347
  for _ in range(n_iter):
246
348
  z_est = 0.5 * (z_1 + z_2)
247
349
  rhs = thermo.e_sat_ice(air_temperature + dry_adiabatic_lapse_rate * z_est) / (
248
- constants.R_v * (air_temperature + dry_adiabatic_lapse_rate * z_est)
350
+ constants.R_v * (air_temperature + dry_adiabatic_lapse_rate * z_est) ** 3.5
249
351
  )
250
352
  z_1[lhs > rhs] = z_est[lhs > rhs]
251
353
  z_2[lhs < rhs] = z_est[lhs < rhs]
@@ -268,10 +370,10 @@ def plume_area(wingspan: npt.NDArray[np.floating] | float) -> npt.NDArray[np.flo
268
370
 
269
371
  Notes
270
372
  -----
271
- - See eq. (A6) and (A7) in :cite:`unterstrasserPropertiesYoungContrails2016`.
373
+ - See Appendix A2 in eq. (A6) and (A7) in :cite:`lottermoserHighResolutionEarlyContrails2025`.
272
374
  """
273
375
  r_plume = 1.5 + 0.314 * wingspan
274
- return 2.0 * 2.0 * np.pi * r_plume**2
376
+ return 2.0 * np.pi * r_plume**2
275
377
 
276
378
 
277
379
  def z_desc_length_scale(
@@ -368,7 +470,7 @@ def _survival_fraction_from_length_scale(
368
470
  npt.NDArray[np.floating]
369
471
  Fraction of ice particle number surviving the wake vortex phase
370
472
  """
371
- f_surv = 0.45 + (1.19 / np.pi) * np.arctan(-1.35 + (z_total / 100.0))
473
+ f_surv = 0.42 + (1.31 / np.pi) * np.arctan(-1.00 + (z_total / 100.0))
372
474
  np.clip(f_surv, 0.0, 1.0, out=f_surv)
373
475
  return f_surv
374
476
 
@@ -88,6 +88,9 @@ class CocipGrid(models.Model):
88
88
  met_variables = cocip.Cocip.met_variables
89
89
  rad_variables = cocip.Cocip.rad_variables
90
90
  processed_met_variables = cocip.Cocip.processed_met_variables
91
+ generic_rad_variables = cocip.Cocip.generic_rad_variables
92
+ ecmwf_rad_variables = cocip.Cocip.ecmwf_rad_variables
93
+ gfs_rad_variables = cocip.Cocip.gfs_rad_variables
91
94
 
92
95
  #: Met data is not optional
93
96
  met: MetDataset
@@ -1485,18 +1488,18 @@ def find_initial_persistent_contrails(
1485
1488
  )
1486
1489
  iwc_1 = contrail_properties.iwc_post_wake_vortex(iwc, iwc_ad)
1487
1490
  f_surv = contrail_properties.ice_particle_survival_fraction(iwc, iwc_1)
1488
- n_ice_per_m = contrail_properties.ice_particle_number(
1491
+ n_ice_per_m_0 = contrail_properties.initial_ice_particle_number(
1489
1492
  nvpm_ei_n=nvpm_ei_n,
1490
1493
  fuel_dist=fuel_dist,
1491
- f_surv=f_surv,
1492
1494
  air_temperature=air_temperature,
1493
1495
  T_crit_sac=T_crit_sac,
1494
1496
  min_ice_particle_number_nvpm_ei_n=params["min_ice_particle_number_nvpm_ei_n"],
1495
1497
  )
1498
+ n_ice_per_m_1 = n_ice_per_m_0 * f_surv
1496
1499
 
1497
1500
  # The logic below corresponds to Cocip._create_downwash_contrail (roughly)
1498
1501
  contrail["iwc"] = iwc_1
1499
- contrail["n_ice_per_m"] = n_ice_per_m
1502
+ contrail["n_ice_per_m"] = n_ice_per_m_1
1500
1503
 
1501
1504
  # Check for persistent initial_contrails
1502
1505
  rhi_1 = contrail["rhi"]
@@ -2149,18 +2152,18 @@ def result_to_metdataset(
2149
2152
  size = np.prod(shape)
2150
2153
 
2151
2154
  dtype = result["ef"].dtype if result else np.float32
2152
- contrail_age = np.zeros(size, dtype=np.float32)
2153
- ef_per_m = np.zeros(size, dtype=dtype)
2155
+ contrail_age_1d = np.zeros(size, dtype=np.float32)
2156
+ ef_per_m_1d = np.zeros(size, dtype=dtype)
2154
2157
 
2155
2158
  if result:
2156
2159
  contrail_idx = result["index"]
2157
2160
  # Step 1: Contrail age. Convert from timedelta to float
2158
- contrail_age[contrail_idx] = result["age"] / np.timedelta64(1, "h")
2161
+ contrail_age_1d[contrail_idx] = result["age"] / np.timedelta64(1, "h")
2159
2162
  # Step 2: EF
2160
- ef_per_m[contrail_idx] = result["ef"] / nominal_segment_length
2163
+ ef_per_m_1d[contrail_idx] = result["ef"] / nominal_segment_length
2161
2164
 
2162
- contrail_age = contrail_age.reshape(shape)
2163
- ef_per_m = ef_per_m.reshape(shape)
2165
+ contrail_age_4d = contrail_age_1d.reshape(shape)
2166
+ ef_per_m_4d = ef_per_m_1d.reshape(shape)
2164
2167
 
2165
2168
  # Step 3: Dataset dims and attrs
2166
2169
  dims = tuple(source.coords)
@@ -2168,8 +2171,8 @@ def result_to_metdataset(
2168
2171
 
2169
2172
  # Step 4: Dataset core variables
2170
2173
  data_vars = {
2171
- "contrail_age": (dims, contrail_age, local_attrs["contrail_age"]),
2172
- "ef_per_m": (dims, ef_per_m, local_attrs["ef_per_m"]),
2174
+ "contrail_age": (dims, contrail_age_4d, local_attrs["contrail_age"]),
2175
+ "ef_per_m": (dims, ef_per_m_4d, local_attrs["ef_per_m"]),
2173
2176
  }
2174
2177
 
2175
2178
  # Step 5: Dataset variables from verbose_dicts