pycontrails 0.59.0__cp314-cp314-macosx_10_15_x86_64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of pycontrails might be problematic. Click here for more details.

Files changed (123) hide show
  1. pycontrails/__init__.py +70 -0
  2. pycontrails/_version.py +34 -0
  3. pycontrails/core/__init__.py +30 -0
  4. pycontrails/core/aircraft_performance.py +679 -0
  5. pycontrails/core/airports.py +228 -0
  6. pycontrails/core/cache.py +889 -0
  7. pycontrails/core/coordinates.py +174 -0
  8. pycontrails/core/fleet.py +483 -0
  9. pycontrails/core/flight.py +2185 -0
  10. pycontrails/core/flightplan.py +228 -0
  11. pycontrails/core/fuel.py +140 -0
  12. pycontrails/core/interpolation.py +702 -0
  13. pycontrails/core/met.py +2936 -0
  14. pycontrails/core/met_var.py +387 -0
  15. pycontrails/core/models.py +1321 -0
  16. pycontrails/core/polygon.py +549 -0
  17. pycontrails/core/rgi_cython.cpython-314-darwin.so +0 -0
  18. pycontrails/core/vector.py +2249 -0
  19. pycontrails/datalib/__init__.py +12 -0
  20. pycontrails/datalib/_met_utils/metsource.py +746 -0
  21. pycontrails/datalib/ecmwf/__init__.py +73 -0
  22. pycontrails/datalib/ecmwf/arco_era5.py +345 -0
  23. pycontrails/datalib/ecmwf/common.py +114 -0
  24. pycontrails/datalib/ecmwf/era5.py +554 -0
  25. pycontrails/datalib/ecmwf/era5_model_level.py +490 -0
  26. pycontrails/datalib/ecmwf/hres.py +804 -0
  27. pycontrails/datalib/ecmwf/hres_model_level.py +466 -0
  28. pycontrails/datalib/ecmwf/ifs.py +287 -0
  29. pycontrails/datalib/ecmwf/model_levels.py +435 -0
  30. pycontrails/datalib/ecmwf/static/model_level_dataframe_v20240418.csv +139 -0
  31. pycontrails/datalib/ecmwf/variables.py +268 -0
  32. pycontrails/datalib/geo_utils.py +261 -0
  33. pycontrails/datalib/gfs/__init__.py +28 -0
  34. pycontrails/datalib/gfs/gfs.py +656 -0
  35. pycontrails/datalib/gfs/variables.py +104 -0
  36. pycontrails/datalib/goes.py +764 -0
  37. pycontrails/datalib/gruan.py +343 -0
  38. pycontrails/datalib/himawari/__init__.py +27 -0
  39. pycontrails/datalib/himawari/header_struct.py +266 -0
  40. pycontrails/datalib/himawari/himawari.py +671 -0
  41. pycontrails/datalib/landsat.py +589 -0
  42. pycontrails/datalib/leo_utils/__init__.py +5 -0
  43. pycontrails/datalib/leo_utils/correction.py +266 -0
  44. pycontrails/datalib/leo_utils/landsat_metadata.py +300 -0
  45. pycontrails/datalib/leo_utils/search.py +250 -0
  46. pycontrails/datalib/leo_utils/sentinel_metadata.py +748 -0
  47. pycontrails/datalib/leo_utils/static/bq_roi_query.sql +6 -0
  48. pycontrails/datalib/leo_utils/vis.py +59 -0
  49. pycontrails/datalib/sentinel.py +650 -0
  50. pycontrails/datalib/spire/__init__.py +5 -0
  51. pycontrails/datalib/spire/exceptions.py +62 -0
  52. pycontrails/datalib/spire/spire.py +604 -0
  53. pycontrails/ext/bada.py +42 -0
  54. pycontrails/ext/cirium.py +14 -0
  55. pycontrails/ext/empirical_grid.py +140 -0
  56. pycontrails/ext/synthetic_flight.py +431 -0
  57. pycontrails/models/__init__.py +1 -0
  58. pycontrails/models/accf.py +425 -0
  59. pycontrails/models/apcemm/__init__.py +8 -0
  60. pycontrails/models/apcemm/apcemm.py +983 -0
  61. pycontrails/models/apcemm/inputs.py +226 -0
  62. pycontrails/models/apcemm/static/apcemm_yaml_template.yaml +183 -0
  63. pycontrails/models/apcemm/utils.py +437 -0
  64. pycontrails/models/cocip/__init__.py +29 -0
  65. pycontrails/models/cocip/cocip.py +2742 -0
  66. pycontrails/models/cocip/cocip_params.py +305 -0
  67. pycontrails/models/cocip/cocip_uncertainty.py +291 -0
  68. pycontrails/models/cocip/contrail_properties.py +1530 -0
  69. pycontrails/models/cocip/output_formats.py +2270 -0
  70. pycontrails/models/cocip/radiative_forcing.py +1260 -0
  71. pycontrails/models/cocip/radiative_heating.py +520 -0
  72. pycontrails/models/cocip/unterstrasser_wake_vortex.py +508 -0
  73. pycontrails/models/cocip/wake_vortex.py +396 -0
  74. pycontrails/models/cocip/wind_shear.py +120 -0
  75. pycontrails/models/cocipgrid/__init__.py +9 -0
  76. pycontrails/models/cocipgrid/cocip_grid.py +2552 -0
  77. pycontrails/models/cocipgrid/cocip_grid_params.py +138 -0
  78. pycontrails/models/dry_advection.py +602 -0
  79. pycontrails/models/emissions/__init__.py +21 -0
  80. pycontrails/models/emissions/black_carbon.py +599 -0
  81. pycontrails/models/emissions/emissions.py +1353 -0
  82. pycontrails/models/emissions/ffm2.py +336 -0
  83. pycontrails/models/emissions/static/default-engine-uids.csv +239 -0
  84. pycontrails/models/emissions/static/edb-gaseous-v29b-engines.csv +596 -0
  85. pycontrails/models/emissions/static/edb-nvpm-v29b-engines.csv +215 -0
  86. pycontrails/models/extended_k15.py +1327 -0
  87. pycontrails/models/humidity_scaling/__init__.py +37 -0
  88. pycontrails/models/humidity_scaling/humidity_scaling.py +1075 -0
  89. pycontrails/models/humidity_scaling/quantiles/era5-model-level-quantiles.pq +0 -0
  90. pycontrails/models/humidity_scaling/quantiles/era5-pressure-level-quantiles.pq +0 -0
  91. pycontrails/models/issr.py +210 -0
  92. pycontrails/models/pcc.py +326 -0
  93. pycontrails/models/pcr.py +154 -0
  94. pycontrails/models/ps_model/__init__.py +18 -0
  95. pycontrails/models/ps_model/ps_aircraft_params.py +381 -0
  96. pycontrails/models/ps_model/ps_grid.py +701 -0
  97. pycontrails/models/ps_model/ps_model.py +1000 -0
  98. pycontrails/models/ps_model/ps_operational_limits.py +525 -0
  99. pycontrails/models/ps_model/static/ps-aircraft-params-20250328.csv +69 -0
  100. pycontrails/models/ps_model/static/ps-synonym-list-20250328.csv +104 -0
  101. pycontrails/models/sac.py +442 -0
  102. pycontrails/models/tau_cirrus.py +183 -0
  103. pycontrails/physics/__init__.py +1 -0
  104. pycontrails/physics/constants.py +117 -0
  105. pycontrails/physics/geo.py +1138 -0
  106. pycontrails/physics/jet.py +968 -0
  107. pycontrails/physics/static/iata-cargo-load-factors-20250221.csv +74 -0
  108. pycontrails/physics/static/iata-passenger-load-factors-20250221.csv +74 -0
  109. pycontrails/physics/thermo.py +551 -0
  110. pycontrails/physics/units.py +472 -0
  111. pycontrails/py.typed +0 -0
  112. pycontrails/utils/__init__.py +1 -0
  113. pycontrails/utils/dependencies.py +66 -0
  114. pycontrails/utils/iteration.py +13 -0
  115. pycontrails/utils/json.py +187 -0
  116. pycontrails/utils/temp.py +50 -0
  117. pycontrails/utils/types.py +163 -0
  118. pycontrails-0.59.0.dist-info/METADATA +179 -0
  119. pycontrails-0.59.0.dist-info/RECORD +123 -0
  120. pycontrails-0.59.0.dist-info/WHEEL +6 -0
  121. pycontrails-0.59.0.dist-info/licenses/LICENSE +178 -0
  122. pycontrails-0.59.0.dist-info/licenses/NOTICE +43 -0
  123. pycontrails-0.59.0.dist-info/top_level.txt +3 -0
@@ -0,0 +1,2742 @@
1
+ """Top level CoCiP classes and methods."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ import warnings
8
+ from collections.abc import Sequence
9
+ from typing import Any, Literal, NoReturn, overload
10
+
11
+ if sys.version_info >= (3, 12):
12
+ from typing import override
13
+ else:
14
+ from typing_extensions import override
15
+
16
+ import numpy as np
17
+ import numpy.typing as npt
18
+ import pandas as pd
19
+ import xarray as xr
20
+
21
+ from pycontrails.core import met_var, models
22
+ from pycontrails.core.aircraft_performance import AircraftPerformance
23
+ from pycontrails.core.fleet import Fleet
24
+ from pycontrails.core.flight import Flight
25
+ from pycontrails.core.met import MetDataset
26
+ from pycontrails.core.met_var import MetVariable
27
+ from pycontrails.core.models import Model, interpolate_met
28
+ from pycontrails.core.vector import GeoVectorDataset, VectorDataDict
29
+ from pycontrails.datalib import ecmwf, gfs
30
+ from pycontrails.models import extended_k15, sac, tau_cirrus
31
+ from pycontrails.models.cocip import (
32
+ contrail_properties,
33
+ radiative_forcing,
34
+ radiative_heating,
35
+ unterstrasser_wake_vortex,
36
+ wake_vortex,
37
+ wind_shear,
38
+ )
39
+ from pycontrails.models.cocip.cocip_params import CocipFlightParams
40
+ from pycontrails.models.emissions.emissions import Emissions
41
+ from pycontrails.physics import constants, geo, thermo, units
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+
46
+ class Cocip(Model):
47
+ r"""Contrail Cirrus Prediction Model (CoCiP).
48
+
49
+ Published by Ulrich Schumann *et. al.*
50
+ (`DLR Institute of Atmospheric Physics <https://www.dlr.de/pa/en/>`_)
51
+ in :cite:`schumannContrailCirrusPrediction2012`, :cite:`schumannParametricRadiativeForcing2012`.
52
+
53
+ Parameters
54
+ ----------
55
+ met : MetDataset
56
+ Pressure level dataset containing :attr:`met_variables` variables.
57
+ See *Notes* for variable names by data source.
58
+ rad : MetDataset
59
+ Single level dataset containing top of atmosphere radiation fluxes.
60
+ See *Notes* for variable names by data source.
61
+ params : dict[str, Any] | None, optional
62
+ Override Cocip model parameters with dictionary.
63
+ See :class:`CocipFlightParams` for model parameters.
64
+ **params_kwargs : Any
65
+ Override Cocip model parameters with keyword arguments.
66
+ See :class:`CocipFlightParams` for model parameters.
67
+
68
+ Notes
69
+ -----
70
+ **Inputs**
71
+
72
+ The required meteorology variables depend on the data source. :class:`Cocip`
73
+ supports data-source-specific variables from ECMWF models (HRES, ERA5) and the NCEP GFS, plus
74
+ a generic set of model-agnostic variables.
75
+
76
+ See :attr:`met_variables` and :attr:`rad_variables` for the list of required variables
77
+ to the ``met`` and ``rad`` parameters, respectively.
78
+ When an item in one of these arrays is a :class:`tuple`, variable keys depend on data source.
79
+
80
+ A warning will be raised if meteorology data is from a source not currently supported by
81
+ a pycontrails datalib. In this case it is the responsibility of the user to ensure that
82
+ meteorology data is formatted correctly. The warning can be suppressed with a context manager:
83
+
84
+ .. code-block:: python
85
+ :emphasize-lines: 2,3
86
+
87
+ import warnings
88
+ with warnings.catch_warnings():
89
+ warnings.simplefilter("ignore", category=UserWarning, message="Unknown provider")
90
+ cocip = Cocip(met, rad, ...)
91
+
92
+ The current list of required variables (labelled by ``"standard_name"``):
93
+
94
+ .. list-table:: Variable keys for pressure level data
95
+ :header-rows: 1
96
+
97
+ * - Parameter
98
+ - ECMWF
99
+ - GFS
100
+ - Generic
101
+ * - Air Temperature
102
+ - ``air_temperature``
103
+ - ``air_temperature``
104
+ - ``air_temperature``
105
+ * - Specific Humidity
106
+ - ``specific_humidity``
107
+ - ``specific_humidity``
108
+ - ``specific_humidity``
109
+ * - Eastward wind
110
+ - ``eastward_wind``
111
+ - ``eastward_wind``
112
+ - ``eastward_wind``
113
+ * - Northward wind
114
+ - ``northward_wind``
115
+ - ``northward_wind``
116
+ - ``northward_wind``
117
+ * - Vertical velocity
118
+ - ``lagrangian_tendency_of_air_pressure``
119
+ - ``lagrangian_tendency_of_air_pressure``
120
+ - ``lagrangian_tendency_of_air_pressure``
121
+ * - Ice water content
122
+ - ``specific_cloud_ice_water_content``
123
+ - ``ice_water_mixing_ratio``
124
+ - ``mass_fraction_of_cloud_ice_in_air``
125
+
126
+ .. list-table:: Variable keys for single-level radiation data
127
+ :header-rows: 1
128
+
129
+ * - Parameter
130
+ - ECMWF
131
+ - GFS
132
+ - Generic
133
+ * - Top solar radiation
134
+ - ``top_net_solar_radiation``
135
+ - ``toa_upward_shortwave_flux``
136
+ - ``toa_net_downward_shortwave_flux``
137
+ * - Top thermal radiation
138
+ - ``top_net_thermal_radiation``
139
+ - ``toa_upward_longwave_flux``
140
+ - ``toa_outgoing_longwave_flux``
141
+
142
+ **Modifications**
143
+
144
+ This implementation differs from original CoCiP (Fortran) implementation in a few places:
145
+
146
+ - This model uses aircraft performance and emissions models to calculate nvPM, fuel flow,
147
+ and overall propulsion efficiency, if not already provided.
148
+ - As described in :cite:`teohAviationContrailClimate2022`, this implementation sets
149
+ the initial ice particle activation rate to be a function of
150
+ the difference between the ambient temperature and the critical SAC threshold temperature.
151
+ See :func:`pycontrails.models.sac.T_critical_sac`.
152
+ - Isobaric heat capacity calculation.
153
+ The original model uses a constant value of 1004 :math:`J \ kg^{-1} \ K^{-1}`,
154
+ whereas this model calculates isobaric heat capacity as a function of specific humidity.
155
+ See :func:`pycontrails.physics.thermo.c_pm`.
156
+ - Solar direct radiation.
157
+ The original algorithm uses ECMWF radiation variable `tisr` (top incident solar radiation)
158
+ as solar direct radiation value.
159
+ This implementation calculates the theoretical solar direct radiation at
160
+ any arbitrary point in the atmosphere.
161
+ See :func:`pycontrails.physics.geo.solar_direct_radiation`.
162
+ - Segment angle.
163
+ The segment angle calculations for flights and contrail segments have been updated
164
+ to use more precise spherical geometry instead of a triangular approximation.
165
+ As the triangle approaches zero, the two calculations agree.
166
+ See :func:`pycontrails.physics.geo.segment_angle`.
167
+ - Integration.
168
+ This implementation consistently uses left-Riemann sums
169
+ in the time integration of contrail segments.
170
+ - Segment length ratio.
171
+ Instead of taking a geometric mean between contrail segments before/after advection,
172
+ a simple ratio is computed.
173
+ See :func:`contrail_properties.segment_length_ratio`.
174
+ - Segment energy flux.
175
+ This implementation does not average spatially contiguous contrail segments when calculating
176
+ the mean energy flux for the segment of interest.
177
+ See :func:`contrail_properties.mean_energy_flux_per_m`.
178
+
179
+ This implementation is regression tested against
180
+ results from :cite:`teohAviationContrailClimate2022`.
181
+
182
+ **Outputs**
183
+
184
+ NaN values may appear in model output. Specifically, ``np.nan`` values are used to indicate:
185
+
186
+ - Flight waypoint or contrail waypoint is not contained with the :attr:`met` domain.
187
+ - The variable was NOT computed during the model evaluation. For example, at flight waypoints
188
+ not producing any persistent contrails, "radiative" variables (``rsr``, ``olr``, ``rf_sw``,
189
+ ``rf_lw``, ``rf_net``) are not computed. Consequently, the corresponding values in the output
190
+ of :meth:`eval` are NaN. One exception to this rule is found on ``ef`` (energy forcing)
191
+ `contrail_age` predictions. For these two "cumulative" variables, waypoints not producing
192
+ any persistent contrails are assigned 0 values.
193
+
194
+ References
195
+ ----------
196
+ - :cite:`schumannDeterminationContrailsSatellite1990`
197
+ - :cite:`schumannContrailCirrusPrediction2010`
198
+ - :cite:`voigtInsituObservationsYoung2010`
199
+ - :cite:`schumannPotentialReduceClimate2011`
200
+ - :cite:`schumannContrailCirrusPrediction2012`
201
+ - :cite:`schumannParametricRadiativeForcing2012`
202
+ - :cite:`schumannContrailsVisibleAviation2012`
203
+ - :cite:`schumannEffectiveRadiusIce2011`
204
+ - :cite:`schumannDehydrationEffectsContrails2015`
205
+ - :cite:`teohMitigatingClimateForcing2020`
206
+ - :cite:`schumannAviationContrailCirrus2021`
207
+ - :cite:`schumannAirTrafficContrail2021`
208
+ - :cite:`teohAviationContrailClimate2022`
209
+
210
+ See Also
211
+ --------
212
+ :class:`CocipFlightParams`
213
+ :mod:`wake_vortex`
214
+ :mod:`contrail_properties`
215
+ :mod:`radiative_forcing`
216
+ :mod:`humidity_scaling`
217
+ :class:`Emissions`
218
+ :mod:`sac`
219
+ :mod:`tau_cirrus`
220
+ """
221
+
222
+ __slots__ = (
223
+ "_downwash_contrail",
224
+ "_downwash_flight",
225
+ "_sac_flight",
226
+ "contrail",
227
+ "contrail_dataset",
228
+ "contrail_list",
229
+ "rad",
230
+ "timesteps",
231
+ )
232
+
233
+ name = "cocip"
234
+ long_name = "Contrail Cirrus Prediction Model"
235
+ default_params = CocipFlightParams
236
+ met_variables = (
237
+ met_var.AirTemperature,
238
+ met_var.SpecificHumidity,
239
+ met_var.EastwardWind,
240
+ met_var.NorthwardWind,
241
+ met_var.VerticalVelocity,
242
+ (
243
+ met_var.MassFractionOfCloudIceInAir,
244
+ ecmwf.SpecificCloudIceWaterContent,
245
+ gfs.CloudIceWaterMixingRatio,
246
+ ),
247
+ )
248
+
249
+ #: Required single-level top of atmosphere radiation variables.
250
+ #: Variable keys depend on data source (e.g. ECMWF, GFS).
251
+ rad_variables = (
252
+ (
253
+ met_var.TOANetDownwardShortwaveFlux,
254
+ ecmwf.TopNetSolarRadiation,
255
+ gfs.TOAUpwardShortwaveRadiation,
256
+ ),
257
+ (
258
+ met_var.TOAOutgoingLongwaveFlux,
259
+ ecmwf.TopNetThermalRadiation,
260
+ gfs.TOAUpwardLongwaveRadiation,
261
+ ),
262
+ )
263
+
264
+ #: Minimal set of met variables needed to run the model after pre-processing.
265
+ #: The intention here is that ``ciwc`` is unnecessary after
266
+ #: ``tau_cirrus`` has already been calculated.
267
+ processed_met_variables = (
268
+ met_var.AirTemperature,
269
+ met_var.SpecificHumidity,
270
+ met_var.EastwardWind,
271
+ met_var.NorthwardWind,
272
+ met_var.VerticalVelocity,
273
+ tau_cirrus.TauCirrus,
274
+ )
275
+
276
+ #: Additional met variables used to support outputs
277
+ #:
278
+ #: .. versionchanged:: 0.48.0
279
+ #: Moved Geopotential from :attr:`met_variables` to :attr:`optional_met_variables`
280
+ optional_met_variables = (
281
+ (met_var.Geopotential, met_var.GeopotentialHeight),
282
+ (
283
+ met_var.CloudAreaFractionInAtmosphereLayer,
284
+ ecmwf.CloudAreaFractionInLayer,
285
+ gfs.TotalCloudCoverIsobaric,
286
+ ),
287
+ )
288
+
289
+ #: Met data is not optional
290
+ met: MetDataset
291
+ met_required = True
292
+
293
+ #: Radiation data formatted as a :class:`MetDataset` at a single pressure level [-1]
294
+ rad: MetDataset
295
+
296
+ #: Last Flight modeled in :meth:`eval`
297
+ source: Flight | Fleet
298
+
299
+ #: List of :class:`GeoVectorDataset` contrail objects - one for each timestep
300
+ contrail_list: list[GeoVectorDataset]
301
+
302
+ #: Contrail evolution output from model.
303
+ #:
304
+ #: Set to None when no contrails are formed.
305
+ #: Otherwise, this is a :class:`pandas.DataFrame` describing the evolution of the contrail.
306
+ #: Columns include:
307
+ #:
308
+ #: - ``waypoint``: The index of the waypoint in the original flight creating
309
+ #: the contrail. This can be used to join the contrail DataFrame to the :attr:`source`.
310
+ #: - ``formation_time``: Time of contrail formation. Agrees with the ``time`` column
311
+ #: in :attr:`source`.
312
+ #: - ``continuous``: Boolean indicating whether the contrail is continuous or not.
313
+ #: - ``persistent``: Boolean indicating whether the contrail is persistent or not.
314
+ #: A contrail segment is considered continuous if both the current and the next
315
+ #: contrail waypoint at the same time step persist.
316
+ #: - ``segment_length``: Length of the contrail segment, [:math:`m`].
317
+ #: - ``sin_a``, ``cos_a``: Sine and cosine of the segment angle.
318
+ #: - ``width``, ``depth``: Contrail width and depth, [:math:`m`].
319
+ #: - ``sigma_yz``: The ``yz`` component of the covariance matrix, [:math:`m^{2}`].
320
+ #: See :func:`contrail_properties.plume_temporal_evolution`.
321
+ #: - ``q_sat``: Saturation specific humidity over ice, [:math:`kg \ kg^{-1}`].
322
+ #: - ``n_ice_per_m``: Number of ice particles per distance, [:math:`m^{-1}`].
323
+ #: - ``iwc``: Ice water content, [:math:`kg_{ice} kg_{air}^{-1}`].
324
+ #: - ``tau_contrail``: Optical depth of the contrail. See
325
+ #: :func:`contrail_properties.contrail_optical_depth`.
326
+ #: - ``rf_sw``, ``rf_lw``, ``rf_net``: Shortwave, longwave, and net instantaneous
327
+ #: radiative forcing, [:math:`W \ m^{-2}`] at the contrail waypoint.
328
+ #: - ``ef``: Energy forcing, [:math:`J`] at the contrail waypoint. See
329
+ #: :func:`contrail_properties.energy_forcing`.
330
+ contrail: pd.DataFrame | None
331
+
332
+ #: :class:`xr.Dataset` representation of contrail evolution.
333
+ contrail_dataset: xr.Dataset | None
334
+
335
+ #: Array of :class:`numpy.datetime64` time steps for contrail evolution
336
+ timesteps: npt.NDArray[np.datetime64]
337
+
338
+ #: Parallel copy of flight waypoints after SAC filter applied
339
+ _sac_flight: Flight
340
+
341
+ #: Parallel copy of flight waypoints after wake vortex downwash applied
342
+ _downwash_flight: Flight
343
+
344
+ #: GeoVectorDataset representation of :attr:`downwash_flight`
345
+ _downwash_contrail: GeoVectorDataset
346
+
347
+ def __init__(
348
+ self,
349
+ met: MetDataset,
350
+ rad: MetDataset,
351
+ params: dict[str, Any] | None = None,
352
+ **params_kwargs: Any,
353
+ ) -> None:
354
+ # call Model init
355
+ super().__init__(met, params=params, **params_kwargs)
356
+
357
+ compute_tau_cirrus = self.params["compute_tau_cirrus_in_model_init"]
358
+ self.met, self.rad = process_met_datasets(met, rad, compute_tau_cirrus)
359
+
360
+ # initialize outputs to None
361
+ self.contrail = None
362
+ self.contrail_dataset = None
363
+
364
+ # ----------
365
+ # Public API
366
+ # ----------
367
+
368
+ @overload
369
+ def eval(self, source: Fleet, **params: Any) -> Fleet: ...
370
+
371
+ @overload
372
+ def eval(self, source: Flight, **params: Any) -> Flight: ...
373
+
374
+ @overload
375
+ def eval(self, source: Sequence[Flight], **params: Any) -> list[Flight]: ...
376
+
377
+ @overload
378
+ def eval(self, source: None = ..., **params: Any) -> NoReturn: ...
379
+
380
+ def eval(
381
+ self,
382
+ source: Flight | Sequence[Flight] | None = None,
383
+ **params: Any,
384
+ ) -> Flight | list[Flight]:
385
+ """Run CoCiP simulation on flight.
386
+
387
+ Simulates the formation and evolution of contrails from a Flight
388
+ using the contrail cirrus prediction model (CoCiP) from Schumann (2012)
389
+ :cite:`schumannContrailCirrusPrediction2012`.
390
+
391
+ .. versionchanged:: 0.25.11
392
+
393
+ Previously, any waypoint not surviving the wake vortex downwash phase of CoCiP
394
+ was assigned a nan-value in the ``ef`` array within the model
395
+ output. This is no longer the case. Instead, energy forcing is set to 0.0
396
+ for all waypoints which fail to produce persistent contrails. In particular,
397
+ nan values in the ``ef`` array are only used to indicate an
398
+ out-of-met-domain waypoint. The same convention is now used for output variables
399
+ ``contrail_age`` and ``cocip`` as well.
400
+
401
+ .. versionchanged::0.26.0
402
+
403
+ :attr:`met` and :attr:`rad` down-selection is now handled automatically.
404
+
405
+ Parameters
406
+ ----------
407
+ source : Flight | Sequence[Flight] | None
408
+ Input Flight(s) to model.
409
+ **params : Any
410
+ Overwrite model parameters before eval.
411
+
412
+ Returns
413
+ -------
414
+ Flight | list[Flight]
415
+ Flight(s) with updated Contrail data. The model parameter "verbose_outputs"
416
+ determines the variables on the return flight object.
417
+
418
+ References
419
+ ----------
420
+ - :cite:`schumannContrailCirrusPrediction2012`
421
+ """
422
+
423
+ self.update_params(params)
424
+ self.set_source(source)
425
+ self.source = self.require_source_type(Flight)
426
+ return_flight_list = isinstance(self.source, Fleet) and isinstance(source, Sequence)
427
+
428
+ self._set_timesteps()
429
+
430
+ # Downselect met for CoCiP initialization
431
+ # We only need to buffer in the negative vertical direction,
432
+ # which is the positive direction for level
433
+ logger.debug("Downselect met for Cocip initialization")
434
+ level_buffer = 0, self.params["met_level_buffer"][1]
435
+ met = self.source.downselect_met(self.met, level_buffer=level_buffer)
436
+ met = add_tau_cirrus(met)
437
+
438
+ # Prepare flight for model
439
+ self._process_flight(met)
440
+
441
+ # Save humidity scaling type to output attrs
442
+ # NOTE: Do this after _process_flight because that method automatically
443
+ # broadcasts all numeric source params.
444
+ humidity_scaling = self.params["humidity_scaling"]
445
+ if humidity_scaling is not None:
446
+ for k, v in humidity_scaling.description.items():
447
+ self.source.attrs[f"humidity_scaling_{k}"] = v
448
+
449
+ if isinstance(self.source, Fleet):
450
+ label = f"fleet of {self.source.n_flights} flights"
451
+ else:
452
+ label = f"flight {self.source['flight_id'][0]}"
453
+
454
+ logger.debug("Finding initial linear contrails with SAC")
455
+ self._find_initial_contrail_regions()
456
+
457
+ if not self._sac_flight:
458
+ logger.debug("No linear contrails formed by %s", label)
459
+ return self._fill_empty_flight_results(return_flight_list)
460
+
461
+ self._simulate_wake_vortex_downwash(met)
462
+
463
+ self._find_initial_persistent_contrails(met)
464
+
465
+ if not self._downwash_flight:
466
+ logger.debug("No persistent contrails formed by flight %s", label)
467
+ return self._fill_empty_flight_results(return_flight_list)
468
+
469
+ self.contrail_list = []
470
+ self._simulate_contrail_evolution()
471
+
472
+ if not self.contrail_list:
473
+ logger.debug("No contrails formed by %s", label)
474
+ return self._fill_empty_flight_results(return_flight_list)
475
+
476
+ logger.debug("Complete contrail simulation for %s", label)
477
+
478
+ self._cleanup_indices()
479
+ self._bundle_results()
480
+
481
+ if return_flight_list:
482
+ return self.source.to_flight_list() # type: ignore[attr-defined]
483
+
484
+ return self.source
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
+
522
+ def _set_timesteps(self) -> None:
523
+ """Set the :attr:`timesteps` based on the ``source`` time range.
524
+
525
+ This method is called in :meth:`eval` before the flight is processed.
526
+ """
527
+ if isinstance(self.source, Fleet):
528
+ # time not sorted in Fleet instance
529
+ tmin = self.source["time"].min()
530
+ tmax = self.source["time"].max()
531
+ else:
532
+ tmin = self.source["time"][0]
533
+ tmax = self.source["time"][-1]
534
+
535
+ tmin = pd.to_datetime(tmin)
536
+ tmax = pd.to_datetime(tmax)
537
+ dt = pd.to_timedelta(self.params["dt_integration"])
538
+
539
+ t_start = tmin.ceil(dt)
540
+ t_end = tmax.floor(dt) + self.params["max_age"] + dt
541
+ self.timesteps = np.arange(t_start, t_end, dt)
542
+
543
+ # -------------
544
+ # Model Methods
545
+ # -------------
546
+
547
+ def _process_flight(self, met: MetDataset) -> None:
548
+ """Prepare :attr:`self.source` for use in model eval.
549
+
550
+ Missing flight values should be prefilled before calling this method.
551
+ This method modifies :attr:`self.source`.
552
+
553
+ .. versionchanged:: 0.35.2
554
+
555
+ No longer broadcast all numeric source params. Instead, numeric
556
+ source params can be accessed with :meth:`Flight.get_data_or_attr`.
557
+
558
+ Parameters
559
+ ----------
560
+ met : MetDataset
561
+ Meteorology data
562
+
563
+ Raises
564
+ ------
565
+ ValueError
566
+ If non-sequential waypoints are found in ``self.source["waypoint"]``
567
+ If there is no intersection between met domain and :attr:`source`.
568
+
569
+ See Also
570
+ --------
571
+ :method:`Flight.resample_and_fill`
572
+ """
573
+ logger.debug("Pre-processing flight parameters")
574
+
575
+ # STEP 1: Check for core columns
576
+ # Attach level and altitude to avoid some redundancy
577
+ self.source.setdefault("altitude", self.source.altitude)
578
+ self.source.setdefault("level", self.source.level)
579
+ self.source.setdefault("air_pressure", self.source.air_pressure)
580
+
581
+ core_columns = ("longitude", "latitude", "altitude", "time")
582
+ for col in core_columns:
583
+ if np.isnan(self.source[col]).any():
584
+ raise ValueError(
585
+ f"Parameter `flight` must not contain NaN values in {col} field."
586
+ "Call method `resample_and_fill` to clean up flight trajectory."
587
+ )
588
+
589
+ # STEP 2: Check for waypoints
590
+ # Note: Fleet instances always have a waypoint column
591
+ if "waypoint" not in self.source:
592
+ # Give flight a range index
593
+ self.source["waypoint"] = np.arange(self.source.size)
594
+ elif not isinstance(self.source, Fleet) and not np.all(
595
+ np.diff(self.source["waypoint"]) == 1
596
+ ):
597
+ # If self.source is a usual Flight (not the subclass Fleet)
598
+ # and has "waypoint" data, we want to give a warning if the waypoint
599
+ # data has an usual index. CoCiP uses the waypoint for its continuity
600
+ # convention, so the waypoint data is critical.
601
+ msg = (
602
+ "Found non-sequential waypoints in flight key 'waypoint'. "
603
+ "The CoCiP algorithm requires flight data key 'waypoint' "
604
+ "to contain sequential waypoints if defined."
605
+ )
606
+ raise ValueError(msg)
607
+
608
+ # STEP 3: Test met domain for some overlap
609
+ # We attach the intersection to the source.
610
+ # This is used in the function _fill_empty_flight_results
611
+ intersection = self.source.coords_intersect_met(met)
612
+ self.source["_met_intersection"] = intersection
613
+ logger.debug(
614
+ "Fraction of flight waypoints intersecting met domain: %s / %s",
615
+ intersection.sum(),
616
+ self.source.size,
617
+ )
618
+ if not intersection.any():
619
+ msg = (
620
+ "No intersection between flight waypoints and met domain. "
621
+ "Rerun Cocip with met overlapping flight."
622
+ )
623
+ raise ValueError(msg)
624
+
625
+ # STEP 4: Begin met interpolation
626
+ # Unfortunately we use both "u_wind" and "eastward_wind" to refer to the
627
+ # same variable, so the logic gets a bit more complex.
628
+ humidity_scaling = self.params["humidity_scaling"]
629
+ scale_humidity = humidity_scaling is not None and "specific_humidity" not in self.source
630
+ verbose_outputs = self.params["verbose_outputs"]
631
+
632
+ interp_kwargs = self.interp_kwargs
633
+ if self.params["preprocess_lowmem"]:
634
+ interp_kwargs["lowmem"] = True
635
+ interpolate_met(met, self.source, "air_temperature", **interp_kwargs)
636
+ interpolate_met(met, self.source, "specific_humidity", **interp_kwargs)
637
+ interpolate_met(met, self.source, "eastward_wind", "u_wind", **interp_kwargs)
638
+ interpolate_met(met, self.source, "northward_wind", "v_wind", **interp_kwargs)
639
+
640
+ if scale_humidity:
641
+ humidity_scaling.eval(self.source, copy_source=False)
642
+
643
+ # if humidity_scaling isn't defined, add rhi to source for verbose_outputs
644
+ elif verbose_outputs:
645
+ self.source["rhi"] = thermo.rhi(
646
+ self.source["specific_humidity"],
647
+ self.source["air_temperature"],
648
+ self.source.air_pressure,
649
+ )
650
+
651
+ # Cache extra met properties for post-analysis
652
+ if verbose_outputs:
653
+ interpolate_met(met, self.source, "tau_cirrus", **interp_kwargs)
654
+
655
+ # handle ECMWF/GFS/generic ciwc variables
656
+ if (key := "specific_cloud_ice_water_content") in met: # noqa: SIM114
657
+ interpolate_met(met, self.source, key, **interp_kwargs)
658
+ elif (key := "ice_water_mixing_ratio") in met: # noqa: SIM114
659
+ interpolate_met(met, self.source, key, **interp_kwargs)
660
+ elif (key := "mass_fraction_of_cloud_ice_in_air") in met:
661
+ interpolate_met(met, self.source, key, **interp_kwargs)
662
+
663
+ self.source["rho_air"] = thermo.rho_d(
664
+ self.source["air_temperature"], self.source.air_pressure
665
+ )
666
+ self.source["sdr"] = geo.solar_direct_radiation(
667
+ self.source["longitude"], self.source["latitude"], self.source["time"]
668
+ )
669
+
670
+ # STEP 5: Calculate segment-specific properties if they are not already attached
671
+ if "true_airspeed" not in self.source:
672
+ self.source["true_airspeed"] = self.source.segment_true_airspeed(
673
+ u_wind=self.source["u_wind"],
674
+ v_wind=self.source["v_wind"],
675
+ smooth=self.params["smooth_true_airspeed"],
676
+ window_length=self.params["smooth_true_airspeed_window_length"],
677
+ )
678
+ if "segment_length" not in self.source:
679
+ self.source["segment_length"] = self.source.segment_length()
680
+
681
+ max_ = self.source.max_distance_gap
682
+ lim_ = self.params["max_seg_length_m"]
683
+ if max_ > 0.9 * lim_:
684
+ warnings.warn(
685
+ "Flight trajectory has segment lengths close to or exceeding the "
686
+ "'max_seg_length_m' parameter. Evolved contrail segments may reach "
687
+ "their end of life artificially early. Either resample the flight "
688
+ "with the 'resample_and_fill' method (recommended), or use a larger "
689
+ "'max_seg_length_m' parameter. Current values: "
690
+ f"max_seg_length_m={lim_}, max_seg_length_on_trajectory={max_}"
691
+ )
692
+
693
+ # if either angle is not provided, re-calculate both
694
+ # this is the one case where we will overwrite a flight data key
695
+ if "sin_a" not in self.source or "cos_a" not in self.source:
696
+ self.source["sin_a"], self.source["cos_a"] = self.source.segment_angle()
697
+
698
+ # STEP 6: Calculate emissions if requested, or if keys don't exist in Flight
699
+ if self.params["process_emissions"]:
700
+ self._process_emissions()
701
+
702
+ # STEP 7: Ensure that flight has the required variables defined as attrs or columns
703
+ self.source.ensure_vars(_emissions_variables())
704
+
705
+ def _process_emissions(self) -> None:
706
+ """Process flight emissions.
707
+
708
+ See :class:`Emissions`.
709
+
710
+ We should consider supporting OpenAP (https://github.com/TUDelft-CNS-ATM/openap)
711
+ and alternate performance models in the future.
712
+ """
713
+ logger.debug("Processing flight emissions")
714
+
715
+ # Call aircraft performance and Emissions models as needed
716
+ # NOTE: None of these sub-models actually do any met interpolation -- self.source already
717
+ # has all of the required met variables attached. Therefore, we don't need to worry about
718
+ # being consistent with passing in Cocip's interp_kwargs and humidity_scaling into
719
+ # the sub-models.
720
+ emissions = Emissions()
721
+ ap_model = self.params["aircraft_performance"]
722
+
723
+ # Run against a list of flights (Fleet)
724
+ if isinstance(self.source, Fleet):
725
+ # Rip the Fleet apart, run BADA on each, then reassemble
726
+ logger.debug("Separately running aircraft performance on each flight in fleet")
727
+ fls = self.source.to_flight_list(copy=False)
728
+ fls = [_eval_aircraft_performance(ap_model, fl) for fl in fls]
729
+
730
+ # In Fleet-mode, always call emissions
731
+ logger.debug("Separately running emissions on each flight in fleet")
732
+ fls = [_eval_emissions(emissions, fl) for fl in fls]
733
+
734
+ # Broadcast numeric AP and emissions variables back to Fleet.data
735
+ for fl in fls:
736
+ fl.broadcast_attrs(_emissions_variables(), raise_error=False)
737
+
738
+ # Convert back to fleet
739
+ attrs = self.source.attrs
740
+ attrs.pop("fl_attrs", None)
741
+ attrs.pop("data_keys", None)
742
+ self.source = Fleet.from_seq(fls, broadcast_numeric=False, attrs=attrs)
743
+
744
+ # Single flight
745
+ else:
746
+ self.source = _eval_aircraft_performance(ap_model, self.source)
747
+ self.source = _eval_emissions(emissions, self.source)
748
+
749
+ # Scale nvPM with parameter (fleet / flight)
750
+ factor = self.params["nvpm_ei_n_enhancement_factor"]
751
+ try:
752
+ self.source["nvpm_ei_n"] *= factor
753
+ except KeyError:
754
+ self.source.attrs.update({"nvpm_ei_n": self.source.attrs["nvpm_ei_n"] * factor})
755
+
756
+ def _find_initial_contrail_regions(self) -> None:
757
+ """Apply Schmidt-Appleman criteria to determine regions of persistent contrail formation.
758
+
759
+ This method:
760
+ - Modifies :attr:`flight` in place by assigning additional columns arising from SAC
761
+ - Creates :attr:`_sac_flight` needed for :meth:`_simulate_wave_vortex_downwash`.
762
+ """
763
+ # calculate the SAC for each waypoint
764
+ sac_model = sac.SAC(
765
+ met=None, # self.source is already interpolated against met, so we don't need it
766
+ copy_source=False,
767
+ )
768
+ self.source = sac_model.eval(source=self.source)
769
+
770
+ # Estimate SAC threshold temperature along initial contrail
771
+ # This variable is not involved in calculating the SAC,
772
+ # but it is required by `contrail_properties.ice_particle_number`.
773
+ # The three variables used below are all computed in sac_model.eval
774
+ self.source["T_critical_sac"] = sac.T_critical_sac(
775
+ self.source["T_sat_liquid"],
776
+ self.source["rh"],
777
+ self.source["G"],
778
+ )
779
+
780
+ # create a new Flight only at points where "sac" == 1
781
+ filt = self.source["sac"] == 1.0
782
+ if self.params["filter_sac"]:
783
+ self._sac_flight = self.source.filter(filt)
784
+ logger.debug(
785
+ "Fraction of waypoints satisfying the SAC: %s / %s",
786
+ len(self._sac_flight),
787
+ len(self.source),
788
+ )
789
+ return
790
+
791
+ warnings.warn("Manually overriding SAC filter")
792
+ logger.info("Manually overriding SAC filter")
793
+ self._sac_flight = self.source.copy()
794
+ logger.debug(
795
+ "Fraction of waypoints satisfying the SAC: %s / %s",
796
+ np.sum(filt),
797
+ self._sac_flight.size,
798
+ )
799
+ logger.debug("None are filtered out!")
800
+
801
+ def _simulate_wake_vortex_downwash(self, met: MetDataset) -> None:
802
+ """Apply wake vortex model to calculate initial contrail geometry.
803
+
804
+ The calculation uses a parametric wake vortex transport and decay model to simulate the
805
+ wake vortex phase and obtain the initial contrail width, depth and downward displacement.
806
+
807
+ This method assigns additional columns "width" and "depth" to :attr:`_sac_flight`
808
+
809
+ Parameters
810
+ ----------
811
+ met : MetDataset
812
+ Meteorology data
813
+
814
+ References
815
+ ----------
816
+ - :cite:`holzapfelProbabilisticTwoPhaseWake2003`
817
+ """
818
+ air_temperature = self._sac_flight["air_temperature"]
819
+ u_wind = self._sac_flight["u_wind"]
820
+ v_wind = self._sac_flight["v_wind"]
821
+ air_pressure = self._sac_flight.air_pressure
822
+
823
+ # flight parameters
824
+ aircraft_mass = self._sac_flight.get_data_or_attr("aircraft_mass")
825
+ true_airspeed = self._sac_flight["true_airspeed"]
826
+
827
+ # In Fleet-mode, wingspan resides on `data`, and in Flight-mode,
828
+ # wingspan resides on `attrs`.
829
+ wingspan = self._sac_flight.get_data_or_attr("wingspan")
830
+
831
+ # get the pressure level `dz_m` lower than element pressure
832
+ dz_m = self.params["dz_m"]
833
+ air_pressure_lower = thermo.pressure_dz(air_temperature, air_pressure, dz_m)
834
+ level_lower = air_pressure_lower / 100.0
835
+
836
+ # get full met grid or flight data interpolated to the pressure level `p_dz`
837
+ interp_kwargs = self.interp_kwargs
838
+ if self.params["preprocess_lowmem"]:
839
+ interp_kwargs["lowmem"] = True
840
+ air_temperature_lower = interpolate_met(
841
+ met,
842
+ self._sac_flight,
843
+ "air_temperature",
844
+ "air_temperature_lower",
845
+ level=level_lower,
846
+ **interp_kwargs,
847
+ )
848
+ u_wind_lower = interpolate_met(
849
+ met,
850
+ self._sac_flight,
851
+ "eastward_wind",
852
+ "u_wind_lower",
853
+ level=level_lower,
854
+ **interp_kwargs,
855
+ )
856
+ v_wind_lower = interpolate_met(
857
+ met,
858
+ self._sac_flight,
859
+ "northward_wind",
860
+ "v_wind_lower",
861
+ level=level_lower,
862
+ **interp_kwargs,
863
+ )
864
+
865
+ # Temperature gradient
866
+ dT_dz = self._sac_flight["dT_dz"] = thermo.T_potential_gradient(
867
+ air_temperature,
868
+ air_pressure,
869
+ air_temperature_lower,
870
+ air_pressure_lower,
871
+ dz_m,
872
+ )
873
+
874
+ # wind shear
875
+ ds_dz = self._sac_flight["ds_dz"] = wind_shear.wind_shear(
876
+ u_wind, u_wind_lower, v_wind, v_wind_lower, dz_m
877
+ )
878
+
879
+ # Initial contrail width, depth and downward displacement
880
+ dz_max = self._sac_flight["dz_max"] = wake_vortex.max_downward_displacement(
881
+ wingspan,
882
+ true_airspeed,
883
+ aircraft_mass,
884
+ air_temperature,
885
+ dT_dz,
886
+ ds_dz,
887
+ air_pressure,
888
+ effective_vertical_resolution=self.params["effective_vertical_resolution"],
889
+ wind_shear_enhancement_exponent=self.params["wind_shear_enhancement_exponent"],
890
+ )
891
+
892
+ # derive downwash values and save to data model
893
+ self._sac_flight["width"] = wake_vortex.initial_contrail_width(wingspan, dz_max)
894
+ initial_wake_vortex_depth = self.params["initial_wake_vortex_depth"]
895
+ self._sac_flight["depth"] = wake_vortex.initial_contrail_depth(
896
+ dz_max, initial_wake_vortex_depth
897
+ )
898
+ # Initially, sigma_yz is set to 0
899
+ # See bottom left paragraph p. 552 Schumann 2012 beginning with:
900
+ # >>> "The contrail model starts from initial values ..."
901
+ self._sac_flight["sigma_yz"] = np.zeros_like(dz_max)
902
+
903
+ def _find_initial_persistent_contrails(self, met: MetDataset) -> None:
904
+ """Calculate the initial contrail properties after the wake vortex phase.
905
+
906
+ Determine points with initial persistent contrails.
907
+
908
+ This method first calculates ice water content (``iwc``) and number of ice particles per
909
+ distance (``n_ice_per_m_1``).
910
+
911
+ It then tests each contrail waypoint for initial persistence and calculates
912
+ parameters for the first iteration of the time integration.
913
+
914
+ Note that the subscript "_1" represents the conditions after the wake vortex phase.
915
+
916
+ Parameters
917
+ ----------
918
+ met : MetDataset
919
+ Meteorology data
920
+ """
921
+
922
+ # met parameters along Flight path
923
+ air_pressure = self._sac_flight.air_pressure
924
+ air_temperature = self._sac_flight["air_temperature"]
925
+ specific_humidity = self._sac_flight["specific_humidity"]
926
+ T_critical_sac = self._sac_flight["T_critical_sac"]
927
+
928
+ # Flight performance parameters
929
+ fuel_flow = self._sac_flight.get_data_or_attr("fuel_flow")
930
+ true_airspeed = self._sac_flight["true_airspeed"]
931
+ fuel_dist = fuel_flow / true_airspeed
932
+
933
+ nvpm_ei_n = self._sac_flight.get_data_or_attr("nvpm_ei_n")
934
+ ei_h2o = self._sac_flight.fuel.ei_h2o
935
+
936
+ # get initial contrail parameters from wake vortex simulation
937
+ width = self._sac_flight["width"]
938
+ depth = self._sac_flight["depth"]
939
+
940
+ # initial contrail altitude set to 0.5 * depth
941
+ contrail_1 = GeoVectorDataset(
942
+ longitude=self._sac_flight["longitude"],
943
+ latitude=self._sac_flight["latitude"],
944
+ altitude=self._sac_flight.altitude - 0.5 * depth,
945
+ time=self._sac_flight["time"],
946
+ copy=False,
947
+ )
948
+
949
+ # get met post wake vortex along initial contrail
950
+ interp_kwargs = self.interp_kwargs
951
+ if self.params["preprocess_lowmem"]:
952
+ interp_kwargs["lowmem"] = True
953
+ air_temperature_1 = interpolate_met(met, contrail_1, "air_temperature", **interp_kwargs)
954
+ interpolate_met(met, contrail_1, "specific_humidity", **interp_kwargs)
955
+
956
+ humidity_scaling = self.params["humidity_scaling"]
957
+ if humidity_scaling is not None:
958
+ humidity_scaling.eval(contrail_1, copy_source=False)
959
+ else:
960
+ contrail_1["air_pressure"] = contrail_1.air_pressure
961
+ contrail_1["rhi"] = thermo.rhi(
962
+ contrail_1["specific_humidity"], air_temperature_1, contrail_1["air_pressure"]
963
+ )
964
+
965
+ air_pressure_1 = contrail_1["air_pressure"]
966
+ specific_humidity_1 = contrail_1["specific_humidity"]
967
+ rhi_1 = contrail_1["rhi"]
968
+ level_1 = contrail_1.level
969
+ altitude_1 = contrail_1.altitude
970
+
971
+ # calculate thermo properties post wake vortex
972
+ q_sat_1 = thermo.q_sat_ice(air_temperature_1, air_pressure_1)
973
+ rho_air_1 = thermo.rho_d(air_temperature_1, air_pressure_1)
974
+
975
+ # Initialize initial contrail properties
976
+ iwc = contrail_properties.initial_iwc(
977
+ air_temperature, specific_humidity, air_pressure, fuel_dist, width, depth, ei_h2o
978
+ )
979
+ iwc_ad = contrail_properties.iwc_adiabatic_heating(
980
+ air_temperature, air_pressure, air_pressure_1
981
+ )
982
+ iwc_1 = contrail_properties.iwc_post_wake_vortex(iwc, iwc_ad)
983
+
984
+ if self.params["vpm_activation"]:
985
+ # We can add a Cocip parameter for T_exhaust, vpm_ei_n, and particles
986
+ aei = extended_k15.droplet_apparent_emission_index(
987
+ specific_humidity=specific_humidity,
988
+ T_ambient=air_temperature,
989
+ T_exhaust=self.source.attrs.get("T_exhaust", extended_k15.DEFAULT_EXHAUST_T),
990
+ air_pressure=air_pressure,
991
+ nvpm_ei_n=nvpm_ei_n,
992
+ vpm_ei_n=self.source.attrs.get("vpm_ei_n", extended_k15.DEFAULT_VPM_EI_N),
993
+ G=self._sac_flight["G"],
994
+ )
995
+ min_aei = None # don't clip
996
+
997
+ else:
998
+ f_activation = contrail_properties.ice_particle_activation_rate(
999
+ air_temperature, T_critical_sac
1000
+ )
1001
+ aei = nvpm_ei_n * f_activation
1002
+ min_aei = self.params["min_ice_particle_number_nvpm_ei_n"]
1003
+
1004
+ n_ice_per_m_0 = contrail_properties.initial_ice_particle_number(
1005
+ aei=aei, fuel_dist=fuel_dist, min_aei=min_aei
1006
+ )
1007
+
1008
+ if self.params["unterstrasser_ice_survival_fraction"]:
1009
+ wingspan = self._sac_flight.get_data_or_attr("wingspan")
1010
+ rhi_0 = thermo.rhi(specific_humidity, air_temperature, air_pressure)
1011
+ f_surv = unterstrasser_wake_vortex.ice_particle_number_survival_fraction(
1012
+ air_temperature,
1013
+ rhi_0,
1014
+ ei_h2o,
1015
+ wingspan,
1016
+ true_airspeed,
1017
+ fuel_flow,
1018
+ nvpm_ei_n,
1019
+ 0.5 * depth, # Taking the mid-point of the contrail plume
1020
+ )
1021
+ else:
1022
+ f_surv = contrail_properties.ice_particle_survival_fraction(iwc, iwc_1)
1023
+
1024
+ n_ice_per_m_1 = n_ice_per_m_0 * f_surv
1025
+
1026
+ # Check for persistent initial_contrails
1027
+ persistent_1 = contrail_properties.initial_persistent(iwc_1, rhi_1)
1028
+
1029
+ self._sac_flight["altitude_1"] = altitude_1
1030
+ self._sac_flight["level_1"] = level_1
1031
+ self._sac_flight["air_temperature_1"] = air_temperature_1
1032
+ self._sac_flight["specific_humidity_1"] = specific_humidity_1
1033
+ self._sac_flight["q_sat_1"] = q_sat_1
1034
+ self._sac_flight["air_pressure_1"] = air_pressure_1
1035
+ self._sac_flight["rho_air_1"] = rho_air_1
1036
+ self._sac_flight["rhi_1"] = rhi_1
1037
+ self._sac_flight["iwc_1"] = iwc_1
1038
+ self._sac_flight["f_surv"] = f_surv
1039
+ self._sac_flight["n_ice_per_m_0"] = n_ice_per_m_0
1040
+ self._sac_flight["n_ice_per_m_1"] = n_ice_per_m_1
1041
+ self._sac_flight["persistent_1"] = persistent_1
1042
+
1043
+ # Create new Flight only at persistent points
1044
+ if self.params["filter_initially_persistent"]:
1045
+ self._downwash_flight = self._sac_flight.filter(persistent_1.astype(bool))
1046
+ logger.debug(
1047
+ "Fraction of waypoints with initially persistent contrails: %s / %s",
1048
+ len(self._downwash_flight),
1049
+ len(self._sac_flight),
1050
+ )
1051
+
1052
+ else:
1053
+ warnings.warn("Manually overriding initially persistent filter")
1054
+ logger.info("Manually overriding initially persistent filter")
1055
+ self._downwash_flight = self._sac_flight.copy()
1056
+ logger.debug(
1057
+ "Fraction of waypoints with initially persistent contrails: %s / %s",
1058
+ persistent_1.sum(),
1059
+ persistent_1.size,
1060
+ )
1061
+ logger.debug("None are filtered out!")
1062
+
1063
+ def _process_downwash_flight(self) -> tuple[MetDataset | None, MetDataset | None]:
1064
+ """Create and calculate properties of contrails created by downwash vortex.
1065
+
1066
+ ``_downwash_contrail`` is a contrail representation of the waypoints of
1067
+ ``_downwash_flight``, which has already been filtered for initial persistent waypoints.
1068
+
1069
+ Returns MetDatasets for subsequent use if ``preprocess_lowmem=False``.
1070
+ """
1071
+ self._downwash_contrail = self._create_downwash_contrail()
1072
+ buffers = {
1073
+ f"{coord}_buffer": self.params[f"met_{coord}_buffer"]
1074
+ for coord in ("longitude", "latitude", "level")
1075
+ }
1076
+ logger.debug("Downselect met for start of Cocip evolution")
1077
+ met = self._downwash_contrail.downselect_met(self.met, **buffers)
1078
+ met = add_tau_cirrus(met)
1079
+ rad = self._downwash_contrail.downselect_met(self.rad, **buffers)
1080
+
1081
+ calc_continuous(self._downwash_contrail)
1082
+ calc_timestep_geometry(self._downwash_contrail)
1083
+
1084
+ interp_kwargs = self.interp_kwargs
1085
+ if self.params["preprocess_lowmem"]:
1086
+ interp_kwargs["lowmem"] = True
1087
+ calc_timestep_meteorology(self._downwash_contrail, met, self.params, **interp_kwargs)
1088
+ calc_shortwave_radiation(rad, self._downwash_contrail, **interp_kwargs)
1089
+ calc_outgoing_longwave_radiation(rad, self._downwash_contrail, **interp_kwargs)
1090
+ calc_contrail_properties(
1091
+ self._downwash_contrail,
1092
+ self.params["effective_vertical_resolution"],
1093
+ self.params["wind_shear_enhancement_exponent"],
1094
+ self.params["sedimentation_impact_factor"],
1095
+ self.params["radiative_heating_effects"],
1096
+ )
1097
+
1098
+ # Intersect with rad dataset
1099
+ calc_radiative_properties(self._downwash_contrail, self.params)
1100
+
1101
+ if self.params["preprocess_lowmem"]:
1102
+ return None, None
1103
+ return met, rad
1104
+
1105
+ def _simulate_contrail_evolution(self) -> None:
1106
+ """Simulate contrail evolution."""
1107
+
1108
+ met, rad = self._process_downwash_flight()
1109
+ interp_kwargs = self.interp_kwargs
1110
+
1111
+ contrail_contrail_overlapping = self.params["contrail_contrail_overlapping"]
1112
+ if contrail_contrail_overlapping and not isinstance(self.source, Fleet):
1113
+ warnings.warn("Contrail-Contrail Overlapping is only valid for Fleet mode.")
1114
+
1115
+ # Complete iteration at time_idx - 2
1116
+ for time_idx, time_end in enumerate(self.timesteps[:-1]):
1117
+ logger.debug("Start time integration step %s ending at time %s", time_idx, time_end)
1118
+
1119
+ # get the last evolution step of contrail waypoints, if it exists
1120
+ latest_contrail = self.contrail_list[-1] if self.contrail_list else GeoVectorDataset()
1121
+
1122
+ # load new contrail segments from downwash_flight
1123
+ contrail_2_segments = self._get_contrail_2_segments(time_idx)
1124
+ if contrail_2_segments:
1125
+ logger.debug(
1126
+ "Discover %s new contrail waypoints formed by downwash_flight waypoints.",
1127
+ contrail_2_segments.size,
1128
+ )
1129
+ logger.debug("Previously persistent contrail size: %s", latest_contrail.size)
1130
+
1131
+ # Append new waypoints to latest contrail, or set as first contrail waypoints
1132
+ latest_contrail = latest_contrail + contrail_2_segments
1133
+
1134
+ # CRITICAL: When running in "Fleet" mode, the latest_contrail is no longer
1135
+ # sorted by flight_id. Fixing that below.
1136
+ if isinstance(self.source, Fleet):
1137
+ latest_contrail = latest_contrail.sort(["flight_id", "time"])
1138
+
1139
+ # Check for an empty contrail
1140
+ if not latest_contrail:
1141
+ logger.debug("Empty latest_contrail at timestep %s", time_end)
1142
+ if np.all(time_end > self._downwash_contrail["time"]):
1143
+ logger.debug("No remaining downwash_contrail waypoints. Break.")
1144
+ break
1145
+ continue
1146
+
1147
+ # Update met, rad slices as needed
1148
+ met, rad = self._maybe_downselect_met_rad(met, rad, latest_contrail, time_end)
1149
+
1150
+ # Recalculate latest_contrail with new values
1151
+ # NOTE: We are doing a substantial amount of redundant computation here
1152
+ # At waypoints for which the continuity hasn't changed, there is nothing
1153
+ # new going on, and so we are overwriting variables in latest_contrail
1154
+ # with the same values
1155
+ # NOTE: Both latest_contrail and contrail_2_segments contain all
1156
+ # 54 variables. The only difference is the change in continuity.
1157
+ # The change in continuity impacts:
1158
+ # - sin_a, cos_a, segment_length (in calc_timestep_geometry)
1159
+ # - dsn_dz (in calc_timestep_meteorology, then enhanced in contrail_properties)
1160
+ # And there is huge room to optimize this
1161
+ calc_continuous(latest_contrail)
1162
+ calc_timestep_geometry(latest_contrail)
1163
+ calc_timestep_meteorology(latest_contrail, met, self.params, **interp_kwargs)
1164
+ calc_contrail_properties(
1165
+ latest_contrail,
1166
+ self.params["effective_vertical_resolution"],
1167
+ self.params["wind_shear_enhancement_exponent"],
1168
+ self.params["sedimentation_impact_factor"],
1169
+ self.params["radiative_heating_effects"],
1170
+ )
1171
+
1172
+ final_contrail = calc_timestep_contrail_evolution(
1173
+ met=met,
1174
+ rad=rad,
1175
+ contrail_1=latest_contrail,
1176
+ time_2=time_end,
1177
+ params=self.params,
1178
+ **interp_kwargs,
1179
+ )
1180
+
1181
+ if contrail_contrail_overlapping:
1182
+ final_contrail = _contrail_contrail_overlapping(final_contrail, self.params)
1183
+
1184
+ self.contrail_list.append(final_contrail)
1185
+
1186
+ def _maybe_downselect_met_rad(
1187
+ self,
1188
+ met: MetDataset | None,
1189
+ rad: MetDataset | None,
1190
+ latest_contrail: GeoVectorDataset,
1191
+ time_end: np.datetime64,
1192
+ ) -> tuple[MetDataset, MetDataset]:
1193
+ """Downselect ``self.met`` and ``self.rad`` if necessary to cover ``time_end``.
1194
+
1195
+ If current ``met`` and ``rad`` slices to not include ``time_end``, new slices are selected
1196
+ from ``self.met`` and ``self.rad``. Downselection in space will cover
1197
+ - locations of current contrails (``latest_contrail``),
1198
+ - locations of additional contrails that will be loaded from ``self._downwash_flight``
1199
+ before the new slices expire,
1200
+ plus a user-defined buffer.
1201
+ """
1202
+ if met is None or time_end > met.indexes["time"].to_numpy()[-1]:
1203
+ logger.debug("Downselect met at time_end %s within Cocip evolution", time_end)
1204
+ met = self._definitely_downselect_met_or_rad(self.met, latest_contrail, time_end)
1205
+ met = add_tau_cirrus(met)
1206
+
1207
+ if rad is None or time_end > rad.indexes["time"].to_numpy()[-1]:
1208
+ logger.debug("Downselect rad at time_end %s within Cocip evolution", time_end)
1209
+ rad = self._definitely_downselect_met_or_rad(self.rad, latest_contrail, time_end)
1210
+
1211
+ return met, rad
1212
+
1213
+ def _definitely_downselect_met_or_rad(
1214
+ self, met: MetDataset, latest_contrail: GeoVectorDataset, time_end: np.datetime64
1215
+ ) -> MetDataset:
1216
+ """Perform downselection when required by :meth:`_maybe_downselect_met_rad`.
1217
+
1218
+ Downselects ``met`` (which should be one of ``self.met`` or ``self.rad``)
1219
+ to cover ``time_end``. Downselection in space covers
1220
+ - locations of current contrails (``latest_contrail``),
1221
+ - locations of additional contrails that will be loaded from ``self._downwash_flight``
1222
+ before the new slices expire,
1223
+ plus a user-defined buffer, as described in :meth:`_maybe_downselect_met_rad`.
1224
+ """
1225
+ # compute lookahead for future contrails from downwash_flight
1226
+ met_time = met.indexes["time"].to_numpy()
1227
+ mask = met_time >= time_end
1228
+ lookahead = np.min(met_time[mask]) if np.any(mask) else time_end
1229
+
1230
+ # create vector for downselection based on current + future contrails
1231
+ future_contrails = self._downwash_flight.filter(
1232
+ (self._downwash_flight["time"] >= time_end)
1233
+ & (self._downwash_flight["time"] <= lookahead),
1234
+ copy=False,
1235
+ )
1236
+ vector = GeoVectorDataset._from_fastpath(
1237
+ {
1238
+ key: np.concatenate((latest_contrail[key], future_contrails[key]))
1239
+ for key in ("longitude", "latitude", "level", "time")
1240
+ },
1241
+ )
1242
+
1243
+ # compute time buffer to ensure downselection extends to time_end
1244
+ buffers = {
1245
+ f"{coord}_buffer": self.params[f"met_{coord}_buffer"]
1246
+ for coord in ("longitude", "latitude", "level")
1247
+ }
1248
+ buffers["time_buffer"] = (
1249
+ np.timedelta64(0, "ns"),
1250
+ max(np.timedelta64(0, "ns"), time_end - vector["time"].max()),
1251
+ )
1252
+
1253
+ return vector.downselect_met(met, **buffers)
1254
+
1255
+ def _create_downwash_contrail(self) -> GeoVectorDataset:
1256
+ """Get Contrail representation of downwash flight."""
1257
+
1258
+ downwash_contrail_data = {
1259
+ "waypoint": self._downwash_flight["waypoint"],
1260
+ "flight_id": self._downwash_flight["flight_id"],
1261
+ "time": self._downwash_flight["time"],
1262
+ "longitude": self._downwash_flight["longitude"],
1263
+ "latitude": self._downwash_flight["latitude"],
1264
+ # intentionally specify altitude and level to avoid pressure level calculations
1265
+ "altitude": self._downwash_flight["altitude_1"],
1266
+ "level": self._downwash_flight["level_1"],
1267
+ "air_pressure": self._downwash_flight["air_pressure_1"],
1268
+ "width": self._downwash_flight["width"],
1269
+ "depth": self._downwash_flight["depth"],
1270
+ "sigma_yz": self._downwash_flight["sigma_yz"],
1271
+ "air_temperature": self._downwash_flight["air_temperature_1"],
1272
+ "specific_humidity": self._downwash_flight["specific_humidity_1"],
1273
+ "q_sat": self._downwash_flight["q_sat_1"],
1274
+ "rho_air": self._downwash_flight["rho_air_1"],
1275
+ "rhi": self._downwash_flight["rhi_1"],
1276
+ "iwc": self._downwash_flight["iwc_1"],
1277
+ "n_ice_per_m": self._downwash_flight["n_ice_per_m_1"],
1278
+ "persistent": self._downwash_flight["persistent_1"],
1279
+ }
1280
+
1281
+ contrail = GeoVectorDataset._from_fastpath(downwash_contrail_data).copy()
1282
+ contrail["formation_time"] = contrail["time"].copy()
1283
+ contrail["age"] = contrail["formation_time"] - contrail["time"]
1284
+
1285
+ # Heating rate, differential heating rate and
1286
+ # cumulative heat energy absorbed by the contrail
1287
+ if self.params["radiative_heating_effects"]:
1288
+ contrail["heat_rate"] = np.zeros_like(contrail["n_ice_per_m"])
1289
+ contrail["d_heat_rate"] = np.zeros_like(contrail["n_ice_per_m"])
1290
+
1291
+ # Increase in temperature averaged over the contrail plume
1292
+ contrail["cumul_heat"] = np.zeros_like(contrail["n_ice_per_m"])
1293
+
1294
+ # Temperature difference between the upper and lower half of the contrail plume
1295
+ contrail["cumul_differential_heat"] = np.zeros_like(contrail["n_ice_per_m"])
1296
+
1297
+ # Initially set energy forcing to 0 because the contrail just formed (age = 0)
1298
+ contrail["ef"] = np.zeros_like(contrail["n_ice_per_m"])
1299
+ if self.params["compute_atr20"]:
1300
+ contrail["global_yearly_mean_rf"] = np.zeros_like(contrail["n_ice_per_m"])
1301
+ contrail["atr20"] = np.zeros_like(contrail["n_ice_per_m"])
1302
+
1303
+ if not self.params["filter_sac"]:
1304
+ contrail["sac"] = self._downwash_flight["sac"]
1305
+ if not self.params["filter_initially_persistent"]:
1306
+ contrail["initially_persistent"] = self._downwash_flight["persistent_1"]
1307
+ if self.params["persistent_buffer"] is not None:
1308
+ contrail["end_of_life"] = np.full(contrail.size, np.datetime64("NaT", "ns"))
1309
+
1310
+ return contrail
1311
+
1312
+ def _get_contrail_2_segments(self, time_idx: int) -> GeoVectorDataset:
1313
+ """Get batch of newly formed contrail segments."""
1314
+ time = self._downwash_contrail["time"]
1315
+ t_cur = self.timesteps[time_idx]
1316
+ if time_idx == 0:
1317
+ filt = time < t_cur
1318
+ else:
1319
+ t_prev = self.timesteps[time_idx - 1]
1320
+ filt = (time < t_cur) & (time >= t_prev)
1321
+
1322
+ return self._downwash_contrail.filter(filt)
1323
+
1324
+ @override
1325
+ def _cleanup_indices(self) -> None:
1326
+ """Cleanup interpolation artifacts."""
1327
+
1328
+ if not self.params["interpolation_use_indices"]:
1329
+ return
1330
+
1331
+ if hasattr(self, "contrail_list"):
1332
+ for contrail in self.contrail_list:
1333
+ contrail._invalidate_indices()
1334
+
1335
+ self.source._invalidate_indices()
1336
+ self._sac_flight._invalidate_indices()
1337
+ if hasattr(self, "_downwash_flight"):
1338
+ self._downwash_flight._invalidate_indices()
1339
+ if hasattr(self, "_downwash_contrail"):
1340
+ self._downwash_contrail._invalidate_indices()
1341
+
1342
+ def _bundle_results(self) -> None:
1343
+ # ---
1344
+ # Create contrail dataframe (self.contrail)
1345
+ # ---
1346
+ self.contrail = GeoVectorDataset.sum(self.contrail_list).dataframe
1347
+ self.contrail["timestep"] = np.concatenate(
1348
+ [np.full(c.size, i) for i, c in enumerate(self.contrail_list)]
1349
+ )
1350
+
1351
+ # add age in hours to the contrail waypoint outputs
1352
+ age_hours = np.empty_like(self.contrail["ef"])
1353
+ np.divide(self.contrail["age"], np.timedelta64(1, "h"), out=age_hours)
1354
+ self.contrail["age_hours"] = age_hours
1355
+
1356
+ verbose_outputs = self.params["verbose_outputs"]
1357
+ if verbose_outputs:
1358
+ # Compute dt_integration -- logic is somewhat complicated, but
1359
+ # we're simply addressing that the first dt_integration
1360
+ # is different from the rest
1361
+
1362
+ # We call reset_index to introduces an `index` RangeIndex column,
1363
+ # Which we use in the `groupby` to identify the
1364
+ # index of the first evolution step at each waypoint.
1365
+ tmp = self.contrail.reset_index()
1366
+ cols = ["formation_time", "time", "index"]
1367
+ first_form_time = tmp.groupby("waypoint")[cols].first()
1368
+ first_dt = first_form_time["time"] - first_form_time["formation_time"]
1369
+ first_dt = first_dt.set_axis(first_form_time["index"])
1370
+
1371
+ self.contrail = tmp.set_index("index")
1372
+ self.contrail["dt_integration"] = first_dt
1373
+ self.contrail.fillna({"dt_integration": self.params["dt_integration"]}, inplace=True)
1374
+
1375
+ # ---
1376
+ # Create contrail xr.Dataset (self.contrail_dataset)
1377
+ # ---
1378
+ if isinstance(self.source, Fleet):
1379
+ keys = ["flight_id", "timestep", "waypoint"]
1380
+ else:
1381
+ keys = ["timestep", "waypoint"]
1382
+ self.contrail_dataset = xr.Dataset.from_dataframe(self.contrail.set_index(keys))
1383
+
1384
+ # ---
1385
+ # Create output Flight / Fleet (self.source)
1386
+ # ---
1387
+
1388
+ col_idx = ["flight_id", "waypoint"] if isinstance(self.source, Fleet) else ["waypoint"]
1389
+ del self.source["_met_intersection"]
1390
+
1391
+ # Attach intermediate calculations from `sac_flight` and `downwash_flight` to flight
1392
+ sac_cols = [
1393
+ "width",
1394
+ "depth",
1395
+ "rhi_1",
1396
+ "air_temperature_1",
1397
+ "specific_humidity_1",
1398
+ "altitude_1",
1399
+ "persistent_1",
1400
+ ]
1401
+
1402
+ # add additional columns
1403
+ if verbose_outputs:
1404
+ sac_cols += ["dT_dz", "ds_dz", "dz_max"]
1405
+
1406
+ downwash_cols = [
1407
+ "rho_air_1",
1408
+ "iwc_1",
1409
+ "f_surv",
1410
+ "n_ice_per_m_0",
1411
+ "n_ice_per_m_1",
1412
+ ]
1413
+ df = pd.concat(
1414
+ [
1415
+ self.source.dataframe.set_index(col_idx),
1416
+ self._sac_flight.dataframe.set_index(col_idx)[sac_cols],
1417
+ self._downwash_flight.dataframe.set_index(col_idx)[downwash_cols],
1418
+ ],
1419
+ axis=1,
1420
+ )
1421
+
1422
+ # Aggregate contrail data back to flight
1423
+ grouped = self.contrail.groupby(col_idx)
1424
+
1425
+ # Perform all aggregations
1426
+ agg_dict = {"ef": ["sum"], "age": ["max"]}
1427
+ if self.params["compute_atr20"]:
1428
+ agg_dict["global_yearly_mean_rf"] = ["sum"]
1429
+ agg_dict["atr20"] = ["sum"]
1430
+
1431
+ rad_keys = ["sdr", "rsr", "olr", "rf_sw", "rf_lw", "rf_net"]
1432
+ for key in rad_keys:
1433
+ if verbose_outputs:
1434
+ agg_dict[key] = ["mean", "min", "max"]
1435
+ else:
1436
+ agg_dict[key] = ["mean"]
1437
+
1438
+ aggregated = grouped.agg(agg_dict)
1439
+ aggregated.columns = [f"{k1}_{k2}" for k1, k2 in aggregated.columns]
1440
+ aggregated = aggregated.rename(columns={"ef_sum": "ef", "age_max": "contrail_age"})
1441
+ if self.params["compute_atr20"]:
1442
+ aggregated = aggregated.rename(
1443
+ columns={"global_yearly_mean_rf_sum": "global_yearly_mean_rf", "atr20_sum": "atr20"}
1444
+ )
1445
+
1446
+ # Join the two
1447
+ df = df.join(aggregated)
1448
+
1449
+ # Fill missing values for ef and contrail_age per conventions
1450
+ # Mean, max, and min radiative values are *not* filled with 0
1451
+ df.fillna({"ef": 0.0, "contrail_age": np.timedelta64(0, "ns")}, inplace=True)
1452
+
1453
+ # cocip flag for each waypoint
1454
+ # -1 if negative EF, 0 if no EF, 1 if positive EF,
1455
+ # or NaN for outside of domain of flight waypoints that don't persist
1456
+ df["cocip"] = np.sign(df["ef"])
1457
+ logger.debug("Total number of waypoints with nonzero EF: %s", df["cocip"].ne(0.0).sum())
1458
+
1459
+ # reset the index
1460
+ df = df.reset_index()
1461
+
1462
+ # Reassign to source
1463
+ self.source.data = VectorDataDict({k: v.to_numpy() for k, v in df.items()})
1464
+
1465
+ def _fill_empty_flight_results(self, return_list_flight: bool) -> Flight | list[Flight]:
1466
+ """Fill empty results into flight / fleet and return.
1467
+
1468
+ This method attaches an all nan array to each of the variables:
1469
+ - sdr
1470
+ - rsr
1471
+ - olr
1472
+ - rf_sw
1473
+ - rf_lw
1474
+ - rf_net
1475
+
1476
+ This method also attaches zeros (for trajectory points contained within met grid)
1477
+ or nans (for trajectory points outside of the met grid) to the following variables.
1478
+ - ef
1479
+ - cocip
1480
+ - contrail_age
1481
+ - persistent_1
1482
+
1483
+ Parameters
1484
+ ----------
1485
+ return_list_flight : bool
1486
+ If True, a list of :class:`Flight` is returned. In this case, :attr:`source`
1487
+ is assumed to be a :class:`Fleet`.
1488
+
1489
+ Returns
1490
+ -------
1491
+ Flight | list[Flight]
1492
+ Flight or list of Flight objects with empty variables.
1493
+ """
1494
+ self._cleanup_indices()
1495
+
1496
+ intersection = self.source.data.pop("_met_intersection")
1497
+ zeros_and_nans = np.zeros(intersection.shape, dtype=np.float32)
1498
+ zeros_and_nans[~intersection] = np.nan
1499
+ self.source["ef"] = zeros_and_nans.copy()
1500
+ self.source["persistent_1"] = zeros_and_nans.copy()
1501
+ self.source["cocip"] = np.sign(zeros_and_nans)
1502
+ self.source["contrail_age"] = zeros_and_nans.astype("timedelta64[ns]")
1503
+
1504
+ if return_list_flight:
1505
+ return self.source.to_flight_list() # type: ignore[attr-defined]
1506
+
1507
+ return self.source
1508
+
1509
+
1510
+ # ----------------------------------------
1511
+ # Functions used in Cocip and CocipGrid
1512
+ # ----------------------------------------
1513
+
1514
+
1515
+ def process_met_datasets(
1516
+ met: MetDataset,
1517
+ rad: MetDataset,
1518
+ compute_tau_cirrus: bool | Literal["auto"] = "auto",
1519
+ ) -> tuple[MetDataset, MetDataset]:
1520
+ """Process and verify ERA5 data for :class:`Cocip` and :class:`CocipGrid`.
1521
+
1522
+ The implementation uses :class:`Cocip` for the source of truth in determining
1523
+ which met variables are required.
1524
+
1525
+ .. versionchanged:: 0.25.0
1526
+
1527
+ This method is called in the :class:`CocipGrid` constructor regardless
1528
+ of the ``process_met`` parameter. The same approach is also taken
1529
+ in :class:`Cocip` in version 0.27.0.
1530
+
1531
+ .. versionchanged:: 0.48.0
1532
+
1533
+ Remove the ``shift_radiation_time`` parameter. This parameter is now
1534
+ inferred from the metadata on the `rad` instance.
1535
+
1536
+ Parameters
1537
+ ----------
1538
+ met : MetDataset
1539
+ Met pressure-level data
1540
+ rad : MetDataset
1541
+ Rad single-level data
1542
+ compute_tau_cirrus : bool | Literal["auto"]
1543
+ Whether to add ``"tau_cirrus"`` variable to pressure-level met data. If set to
1544
+ ``"auto"``, ``"tau_cirrus"`` will be computed iff the met data is dask-backed.
1545
+
1546
+ Returns
1547
+ -------
1548
+ met : MetDataset
1549
+ Met data, possibly with "tau_cirrus" variable attached.
1550
+ rad : MetDataset
1551
+ Rad data with time shifted to account for accumulated values.
1552
+
1553
+ Raises
1554
+ ------
1555
+ If a previous version of pycontrails has already scaled the gridded humidity
1556
+ data.
1557
+ """
1558
+ # Check for remnants of previous scaling.
1559
+ if "_pycontrails_modified" in met["specific_humidity"].attrs:
1560
+ raise ValueError(
1561
+ "Specific humidity enhancement of the raw specific humidity values in "
1562
+ "the underlying met data is deprecated."
1563
+ )
1564
+
1565
+ if compute_tau_cirrus == "auto":
1566
+ # If met data is dask-backed, compute tau_cirrus
1567
+ compute_tau_cirrus = met.data["air_temperature"].chunks is not None
1568
+
1569
+ if "tau_cirrus" not in met.data:
1570
+ met.ensure_vars(Cocip.met_variables)
1571
+ if compute_tau_cirrus:
1572
+ met = add_tau_cirrus(met)
1573
+ else:
1574
+ met.ensure_vars(Cocip.processed_met_variables)
1575
+
1576
+ rad.ensure_vars(Cocip.rad_variables)
1577
+ rad = _process_rad(rad)
1578
+
1579
+ return met, rad
1580
+
1581
+
1582
+ def add_tau_cirrus(met: MetDataset) -> MetDataset:
1583
+ """Add "tau_cirrus" variable to weather data if it does not exist already.
1584
+
1585
+ Parameters
1586
+ ----------
1587
+ met : MetDataset
1588
+ Met pressure-level data.
1589
+
1590
+ Returns
1591
+ -------
1592
+ met : MetDataset
1593
+ Met data with "tau_cirrus" variable attached.
1594
+ """
1595
+ if "tau_cirrus" not in met.data:
1596
+ met.data["tau_cirrus"] = tau_cirrus.tau_cirrus(met)
1597
+ return met
1598
+
1599
+
1600
+ def _process_rad(rad: MetDataset) -> MetDataset:
1601
+ """Process radiation specific variables for model.
1602
+
1603
+ These variables are used to calculate the reflected solar radiation (RSR),
1604
+ and outgoing longwave radiation (OLR).
1605
+
1606
+ The time stamp is adjusted by ``shift_radiation_time`` to account for
1607
+ accumulated values being averaged over the time period.
1608
+
1609
+ Parameters
1610
+ ----------
1611
+ rad : MetDataset
1612
+ Rad single-level data
1613
+
1614
+ Returns
1615
+ -------
1616
+ MetDataset
1617
+ Rad data with time shifted.
1618
+
1619
+ Raises
1620
+ ------
1621
+ ValueError
1622
+ If a "radiation_accumulated" field is not found on ``rad.attrs``.
1623
+
1624
+ Notes
1625
+ -----
1626
+ - https://www.ecmwf.int/sites/default/files/elibrary/2015/18490-radiation-quantities-ecmwf-model-and-mars.pdf
1627
+ - https://confluence.ecmwf.int/pages/viewpage.action?pageId=155337784
1628
+ """
1629
+ # If the time coordinate has already been shifted, early return
1630
+ if "shift_radiation_time" in rad["time"].attrs:
1631
+ return rad
1632
+
1633
+ provider = rad.provider_attr
1634
+
1635
+ # Only shift ECMWF data -- exit for anything else
1636
+ # A warning is emitted upstream if the provider is not ECMWF or NCEP
1637
+ if provider != "ECMWF":
1638
+ return rad
1639
+
1640
+ dataset = rad.dataset_attr
1641
+ product = rad.product_attr
1642
+
1643
+ if dataset == "HRES":
1644
+ try:
1645
+ radiation_accumulated = rad.attrs["radiation_accumulated"]
1646
+ except KeyError as exc:
1647
+ msg = (
1648
+ "HRES data must have a boolean 'radiation_accumulated' attribute. "
1649
+ "This attribute is used to determine whether the radiation data "
1650
+ "has been accumulated over the time period. This is the case for "
1651
+ "HRES data taken from a common time of forecast with multiple "
1652
+ "forecast steps. If this is not the case, set the "
1653
+ "'radiation_accumulated' attribute to False."
1654
+ )
1655
+ raise ValueError(msg) from exc
1656
+ if radiation_accumulated:
1657
+ # Don't assume that radiation data is uniformly spaced in time
1658
+ # Instead, infer the appropriate time shift
1659
+ time_diff = rad.data["time"].diff("time", label="upper")
1660
+ time_shift = -time_diff / 2
1661
+
1662
+ # Keep the original attrs -- we need these later on
1663
+ old_attrs = {k: v.attrs for k, v in rad.data.items()}
1664
+
1665
+ # Also need to keep dataset-level attrs, which are lost
1666
+ # when dividing a Dataset by a DataArray
1667
+ old_rad_attrs = rad.data.attrs
1668
+
1669
+ # NOTE: Taking the diff will remove the first time step
1670
+ # This is typically what we want (forecast step 0 is all zeros)
1671
+ # But, if the data has been downselected for a particular Flight / Fleet,
1672
+ # we lose the first time step of the data.
1673
+ #
1674
+ # Other portions of the code convert HRES accumulated fluxes (J/m2)
1675
+ # to averaged fluxes (W/m2) assuming that accumulations are over
1676
+ # one hour. For those conversions to work correctly, must normalize
1677
+ # accumulations by number of hours between steps
1678
+ time_diff_h = time_diff / np.timedelta64(1, "h")
1679
+ rad.data = rad.data.diff("time", label="upper") / time_diff_h
1680
+ rad.data.attrs = old_rad_attrs
1681
+
1682
+ # Add back the original attrs
1683
+ for k, v in rad.data.items():
1684
+ v.attrs = old_attrs[k]
1685
+
1686
+ # Short-circuit to avoid idiot check
1687
+ rad.data = rad.data.assign_coords({"time": rad.data["time"] + time_shift})
1688
+ if np.unique(time_shift).size == 1:
1689
+ rad.data["time"].attrs["shift_radiation_time"] = str(time_shift.values[0])
1690
+ else:
1691
+ rad.data["time"].attrs["shift_radiation_time"] = "variable"
1692
+ return rad
1693
+
1694
+ shift_radiation_time = -np.timedelta64(30, "m")
1695
+
1696
+ elif dataset == "ERA5" and product == "ensemble":
1697
+ shift_radiation_time = -np.timedelta64(90, "m")
1698
+ else:
1699
+ shift_radiation_time = -np.timedelta64(30, "m")
1700
+
1701
+ # Do a final idiot check -- most likely, the time resolution of the data will
1702
+ # agree with the shift_radiation_time. If not, emit a warning. There could be
1703
+ # a false positive here if the data has been downsampled in time.
1704
+ logger.debug("Shifting rad time by %s", shift_radiation_time)
1705
+ rad_time_diff = np.diff(rad.data["time"])
1706
+ if not np.all(rad_time_diff / 2 == -shift_radiation_time):
1707
+ warnings.warn(
1708
+ f"Shifting radiation time dimension by unexpected interval {shift_radiation_time}. "
1709
+ f"The rad data has metadata indicating it is {product} ECMWF data. "
1710
+ f"This dataset should have time steps of {-2 * shift_radiation_time}."
1711
+ )
1712
+
1713
+ rad.data = rad.data.assign_coords({"time": rad.data["time"] + shift_radiation_time})
1714
+ rad.data["time"].attrs["shift_radiation_time"] = str(shift_radiation_time)
1715
+
1716
+ return rad
1717
+
1718
+
1719
+ def _eval_aircraft_performance(
1720
+ aircraft_performance: AircraftPerformance | None, flight: Flight
1721
+ ) -> Flight:
1722
+ """Evaluate the :class:`AircraftPerformance` model.
1723
+
1724
+ Parameters
1725
+ ----------
1726
+ aircraft_performance : AircraftPerformance | None
1727
+ Input aircraft performance model
1728
+ flight : Flight
1729
+ Flight to evaluate
1730
+
1731
+ Returns
1732
+ -------
1733
+ Flight
1734
+ Output from aircraft performance model
1735
+
1736
+ Raises
1737
+ ------
1738
+ ValueError
1739
+ If ``aircraft_performance`` is None
1740
+ """
1741
+
1742
+ ap_vars = {"wingspan", "engine_efficiency", "fuel_flow", "aircraft_mass"}
1743
+ missing = ap_vars.difference(flight).difference(flight.attrs)
1744
+ if not missing:
1745
+ return flight
1746
+
1747
+ if aircraft_performance is None:
1748
+ msg = (
1749
+ f"An AircraftPerformance model parameter is required if the flight does not contain "
1750
+ f"the following variables: {ap_vars}. This flight is missing: {missing}. "
1751
+ "Instantiate the Cocip model with an AircraftPerformance model. "
1752
+ "For example, 'Cocip(..., aircraft_performance=PSFlight(...))'."
1753
+ )
1754
+ raise ValueError(msg)
1755
+
1756
+ return aircraft_performance.eval(source=flight, copy_source=False)
1757
+
1758
+
1759
+ def _eval_emissions(emissions: Emissions, flight: Flight) -> Flight:
1760
+ """Evaluate the :class:`Emissions` model.
1761
+
1762
+ Parameters
1763
+ ----------
1764
+ emissions : Emissions
1765
+ Emissions model
1766
+ flight : Flight
1767
+ Flight to evaluate
1768
+
1769
+ Returns
1770
+ -------
1771
+ Flight
1772
+ Output from emissions model
1773
+ """
1774
+
1775
+ emissions_outputs = "nvpm_ei_n"
1776
+ if flight.ensure_vars(emissions_outputs, False):
1777
+ return flight
1778
+ return emissions.eval(source=flight, copy_source=False)
1779
+
1780
+
1781
+ def calc_continuous(contrail: GeoVectorDataset) -> None:
1782
+ """Calculate the continuous segments of this timestep.
1783
+
1784
+ Mutates parameter ``contrail`` in place by setting or updating the
1785
+ "continuous" variable.
1786
+
1787
+ Parameters
1788
+ ----------
1789
+ contrail : GeoVectorDataset
1790
+ GeoVectorDataset instance onto which "continuous" is set.
1791
+
1792
+ Raises
1793
+ ------
1794
+ ValueError
1795
+ If ``contrail`` is empty.
1796
+ """
1797
+ if not contrail:
1798
+ raise ValueError("Cannot calculate continuous on an empty contrail")
1799
+ same_flight = contrail["flight_id"][:-1] == contrail["flight_id"][1:]
1800
+ consecutive_waypoint = np.diff(contrail["waypoint"]) == 1
1801
+ continuous = np.empty(contrail.size, dtype=bool)
1802
+ continuous[:-1] = same_flight & consecutive_waypoint
1803
+ continuous[-1] = False # This fails if contrail is empty
1804
+ contrail.update(continuous=continuous) # overwrite continuous
1805
+
1806
+
1807
+ def calc_timestep_geometry(contrail: GeoVectorDataset) -> None:
1808
+ """Calculate contrail segment-specific properties.
1809
+
1810
+ Mutates parameter ``contrail`` in place by setting or updating the variables
1811
+ - "sin_a"
1812
+ - "cos_a"
1813
+ - "segment_length"
1814
+
1815
+ Any nan values in these variables are set to 0. This ensures any segment-based property
1816
+ derived from the segment geometry does not contribute to contrail energy forcing.
1817
+
1818
+ See Also
1819
+ --------
1820
+ - :func:`wind_shear.wind_shear_normal` to see how "sin_a" and "cos_a"
1821
+ are used to compute wind shear terms.
1822
+ - :func:`calc_timestep_contrail_evolution` to see how "segment_length" is used.
1823
+
1824
+ Parameters
1825
+ ----------
1826
+ contrail : GeoVectorDataset
1827
+ GeoVectorDataset instance onto which variables are set.
1828
+ """
1829
+ # get contrail waypoints
1830
+ longitude = contrail["longitude"]
1831
+ latitude = contrail["latitude"]
1832
+ altitude = contrail.altitude
1833
+ continuous = contrail["continuous"]
1834
+
1835
+ # calculate segment properties
1836
+ segment_length = geo.segment_length(longitude, latitude, altitude)
1837
+ sin_a, cos_a = geo.segment_angle(longitude, latitude)
1838
+
1839
+ # NOTE: Set all nan and discontinuous values to 0. With our current implementation, this
1840
+ # ensures degenerate or discontinuous segments do not contribute to contrail impact.
1841
+ # At the same time, this prevents nan values from propagating through evolving contrails.
1842
+ #
1843
+ # If we want to change this, take note of the following consequences.
1844
+ # nan values flow from one variable to another as follows:
1845
+ # segment_length
1846
+ # seg_ratio
1847
+ # sigma_yz
1848
+ # area_eff_2
1849
+ # iwc, n_ice_per_m, r_vol_ice
1850
+ # terminal_fall_speed
1851
+ # level [level advection]
1852
+ # u_wind, v_wind [interp]
1853
+ # longitude, latitude [advection]
1854
+ # Finally, at the next evolution step, the previous waypoint will accrue
1855
+ # a nan value after segment_length is recalculated
1856
+
1857
+ segment_length[~continuous] = 0.0
1858
+ sin_a[~continuous] = 0.0
1859
+ cos_a[~continuous] = 0.0
1860
+
1861
+ np.nan_to_num(segment_length, copy=False)
1862
+ np.nan_to_num(sin_a, copy=False)
1863
+ np.nan_to_num(cos_a, copy=False)
1864
+
1865
+ # override values on model
1866
+ contrail.update(segment_length=segment_length)
1867
+ contrail.update(sin_a=sin_a)
1868
+ contrail.update(cos_a=cos_a)
1869
+
1870
+
1871
+ def calc_timestep_meteorology(
1872
+ contrail: GeoVectorDataset,
1873
+ met: MetDataset,
1874
+ params: dict[str, Any],
1875
+ **interp_kwargs: Any,
1876
+ ) -> None:
1877
+ """Get and store meteorology parameters.
1878
+
1879
+ Mutates parameter ``contrail`` in place by setting or updating the variables
1880
+ - "sin_a"
1881
+ - "cos_a"
1882
+ - "segment_length"
1883
+
1884
+ Parameters
1885
+ ----------
1886
+ contrail : GeoVectorDataset
1887
+ GeoVectorDataset object onto which meterology variables are attached.
1888
+ met : MetDataset
1889
+ MetDataset with meteorology data variables.
1890
+ params : dict[str, Any]
1891
+ Cocip model ``params``.
1892
+ **interp_kwargs : Any
1893
+ Cocip model ``interp_kwargs``.
1894
+ """
1895
+ # get contrail geometry
1896
+ sin_a = contrail["sin_a"]
1897
+ cos_a = contrail["cos_a"]
1898
+
1899
+ # get standard met parameters for timestep
1900
+ air_pressure = contrail.air_pressure
1901
+ air_temperature = contrail["air_temperature"]
1902
+ u_wind = interpolate_met(met, contrail, "eastward_wind", "u_wind", **interp_kwargs)
1903
+ v_wind = interpolate_met(met, contrail, "northward_wind", "v_wind", **interp_kwargs)
1904
+ interpolate_met(
1905
+ met,
1906
+ contrail,
1907
+ "lagrangian_tendency_of_air_pressure",
1908
+ "vertical_velocity",
1909
+ **interp_kwargs,
1910
+ )
1911
+ interpolate_met(met, contrail, "tau_cirrus", **interp_kwargs)
1912
+
1913
+ # get the pressure level `dz_m` lower than element pressure
1914
+ air_pressure_lower = thermo.pressure_dz(air_temperature, air_pressure, params["dz_m"])
1915
+
1916
+ # get meteorology at contrail waypoints interpolated at the pressure level `air_pressure_lower`
1917
+ level_lower = air_pressure_lower / 100.0 # Pa -> hPa
1918
+
1919
+ # if met is already interpolated, this will automatically skip interpolation
1920
+ air_temperature_lower = interpolate_met(
1921
+ met,
1922
+ contrail,
1923
+ "air_temperature",
1924
+ "air_temperature_lower",
1925
+ level=level_lower,
1926
+ **interp_kwargs,
1927
+ )
1928
+ u_wind_lower = interpolate_met(
1929
+ met,
1930
+ contrail,
1931
+ "eastward_wind",
1932
+ "u_wind_lower",
1933
+ level=level_lower,
1934
+ **interp_kwargs,
1935
+ )
1936
+ v_wind_lower = interpolate_met(
1937
+ met,
1938
+ contrail,
1939
+ "northward_wind",
1940
+ "v_wind_lower",
1941
+ level=level_lower,
1942
+ **interp_kwargs,
1943
+ )
1944
+
1945
+ # Temperature gradient
1946
+ dT_dz = thermo.T_potential_gradient(
1947
+ air_temperature,
1948
+ air_pressure,
1949
+ air_temperature_lower,
1950
+ air_pressure_lower,
1951
+ params["dz_m"],
1952
+ )
1953
+
1954
+ # wind shear
1955
+ ds_dz = wind_shear.wind_shear(u_wind, u_wind_lower, v_wind, v_wind_lower, params["dz_m"])
1956
+
1957
+ # wind shear normal
1958
+ dsn_dz = wind_shear.wind_shear_normal(
1959
+ u_wind_top=u_wind,
1960
+ u_wind_btm=u_wind_lower,
1961
+ v_wind_top=v_wind,
1962
+ v_wind_btm=v_wind_lower,
1963
+ cos_a=cos_a,
1964
+ sin_a=sin_a,
1965
+ dz=params["dz_m"],
1966
+ )
1967
+
1968
+ # store values on contrail model
1969
+ contrail.update(dT_dz=dT_dz)
1970
+ contrail.update(ds_dz=ds_dz)
1971
+ contrail.update(dsn_dz=dsn_dz)
1972
+
1973
+
1974
+ def calc_shortwave_radiation(
1975
+ rad: MetDataset,
1976
+ vector: GeoVectorDataset,
1977
+ **interp_kwargs: Any,
1978
+ ) -> None:
1979
+ """Calculate shortwave radiation variables.
1980
+
1981
+ Calculates theoretical incident (``sdr``) and
1982
+ reflected shortwave radiation (``rsr``) from the radiation data provided.
1983
+
1984
+ Mutates input ``vector`` to include ``"sdr"`` and ``"rsr"``
1985
+ keys with calculated SDR, RSR values, respectively.
1986
+
1987
+ Parameters
1988
+ ----------
1989
+ rad : MetDataset
1990
+ Radiation data
1991
+ vector : GeoVectorDataset
1992
+ Flight or GeoVectorDataset instance
1993
+ **interp_kwargs : Any
1994
+ Interpolation keyword arguments
1995
+
1996
+ Raises
1997
+ ------
1998
+ ValueError
1999
+ If ``rad`` does not contain ``"toa_net_downward_shortwave_flux"``,
2000
+ ``"toa_upward_shortwave_flux"`` or ``"top_net_solar_radiation"`` variable.
2001
+
2002
+ Notes
2003
+ -----
2004
+ In accordance with the original CoCiP Fortran algorithm,
2005
+ the SDR is set to 0 when the cosine of the solar zenith angle is below 0.01.
2006
+
2007
+ See Also
2008
+ --------
2009
+ :func:`geo.solar_direct_radiation`
2010
+ """
2011
+ if "sdr" in vector and "rsr" in vector:
2012
+ return
2013
+
2014
+ try:
2015
+ sdr = vector["sdr"]
2016
+ except KeyError:
2017
+ # calculate instantaneous theoretical solar direct radiation based on geo position and time
2018
+ longitude = vector["longitude"]
2019
+ latitude = vector["latitude"]
2020
+ time = vector["time"]
2021
+ sdr = geo.solar_direct_radiation(longitude, latitude, time, threshold_cos_sza=0.01)
2022
+ vector["sdr"] = sdr
2023
+
2024
+ # Generic contains net downward shortwave flux at TOA (SDR - RSR) in W/m2
2025
+ generic_key = "toa_net_downward_shortwave_flux"
2026
+ if generic_key in rad:
2027
+ tnsr = interpolate_met(rad, vector, generic_key, **interp_kwargs)
2028
+ vector["rsr"] = np.maximum(sdr - tnsr, 0.0)
2029
+ return
2030
+
2031
+ # GFS contains RSR (toa_upward_shortwave_flux) variable directly
2032
+ gfs_key = "toa_upward_shortwave_flux"
2033
+ if gfs_key in rad:
2034
+ interpolate_met(rad, vector, gfs_key, "rsr", **interp_kwargs)
2035
+ return
2036
+
2037
+ ecmwf_key = "top_net_solar_radiation"
2038
+ if ecmwf_key not in rad:
2039
+ msg = (
2040
+ f"'rad' data must contain either '{generic_key}' (generic), "
2041
+ f"'{gfs_key}' (GFS), or '{ecmwf_key}' (ECMWF) variable."
2042
+ )
2043
+ raise ValueError(msg)
2044
+
2045
+ # ECMWF also contains net downward shortwave flux at TOA, but possibly as an accumulation
2046
+ tnsr = interpolate_met(rad, vector, ecmwf_key, **interp_kwargs)
2047
+ tnsr = _rad_accumulation_to_average_instantaneous(rad, ecmwf_key, tnsr)
2048
+ vector.update({ecmwf_key: tnsr})
2049
+
2050
+ vector["rsr"] = np.maximum(sdr - tnsr, 0.0)
2051
+
2052
+
2053
+ def calc_outgoing_longwave_radiation(
2054
+ rad: MetDataset,
2055
+ vector: GeoVectorDataset,
2056
+ **interp_kwargs: Any,
2057
+ ) -> None:
2058
+ """Calculate outgoing longwave radiation (``olr``) from the radiation data provided.
2059
+
2060
+ Mutates input ``vector`` to include ``"olr"`` key with calculated OLR values.
2061
+
2062
+ Parameters
2063
+ ----------
2064
+ rad : MetDataset
2065
+ Radiation data
2066
+ vector : GeoVectorDataset
2067
+ Flight or GeoVectorDataset instance
2068
+ **interp_kwargs : Any
2069
+ Interpolation keyword arguments
2070
+
2071
+ Raises
2072
+ ------
2073
+ ValueError
2074
+ If ``rad`` does not contain a ``"toa_outgoing_longwave_flux"``,
2075
+ ``"toa_upward_longwave_flux"`` or ``"top_net_thermal_radiation"`` variable.
2076
+ """
2077
+
2078
+ if "olr" in vector:
2079
+ return
2080
+
2081
+ # Generic contains OLR (toa_outgoing_longwave_flux) directly
2082
+ generic_key = "toa_outgoing_longwave_flux"
2083
+ if generic_key in rad:
2084
+ interpolate_met(rad, vector, generic_key, "olr", **interp_kwargs)
2085
+ return
2086
+
2087
+ # GFS contains OLR (toa_upward_longwave_flux) directly
2088
+ gfs_key = "toa_upward_longwave_flux"
2089
+ if gfs_key in rad:
2090
+ interpolate_met(rad, vector, gfs_key, "olr", **interp_kwargs)
2091
+ return
2092
+
2093
+ # ECMWF contains "top_net_thermal_radiation" which is -1 * OLR
2094
+ ecmwf_key = "top_net_thermal_radiation"
2095
+ if ecmwf_key not in rad:
2096
+ msg = (
2097
+ f"'rad' data must contain either '{generic_key}' (generic), "
2098
+ f"'{gfs_key}' (GFS), or '{ecmwf_key}' (ECMWF) variable."
2099
+ )
2100
+ raise ValueError(msg)
2101
+
2102
+ tntr = interpolate_met(rad, vector, ecmwf_key, **interp_kwargs)
2103
+ tntr = _rad_accumulation_to_average_instantaneous(rad, ecmwf_key, tntr)
2104
+ vector.update({ecmwf_key: tntr})
2105
+
2106
+ vector["olr"] = np.maximum(-tntr, 0.0)
2107
+
2108
+
2109
+ def calc_radiative_properties(contrail: GeoVectorDataset, params: dict[str, Any]) -> None:
2110
+ """Calculate radiative properties for contrail.
2111
+
2112
+ This function is used by both :class:`Cocip` and :class`CocipGrid`.
2113
+
2114
+ Mutates original `contrail` parameter with additional keys:
2115
+ - "rf_sw"
2116
+ - "rf_lw"
2117
+ - "rf_net"
2118
+
2119
+ Parameters
2120
+ ----------
2121
+ contrail : GeoVectorDataset
2122
+ Grid points already interpolated against met and rad data. In particular,
2123
+ the variables
2124
+ - "air_temperature"
2125
+ - "r_ice_vol"
2126
+ - "tau_contrail"
2127
+ - "tau_cirrus"
2128
+ - "olr"
2129
+ - "rsr"
2130
+ - "sdr"
2131
+
2132
+ are required on the parameter `contrail`.
2133
+ params : dict[str, Any]
2134
+ Model parameters
2135
+ """
2136
+ time = contrail["time"]
2137
+ air_temperature = contrail["air_temperature"]
2138
+
2139
+ if params["radiative_heating_effects"]:
2140
+ air_temperature += contrail["cumul_heat"]
2141
+
2142
+ r_ice_vol = contrail["r_ice_vol"]
2143
+ tau_contrail = contrail["tau_contrail"]
2144
+ tau_cirrus_ = contrail["tau_cirrus"]
2145
+
2146
+ # calculate solar constant
2147
+ theta_rad = geo.orbital_position(time)
2148
+ sd0 = geo.solar_constant(theta_rad)
2149
+
2150
+ # radiation dataset with contrail waypoints at timestep
2151
+ sdr = contrail["sdr"]
2152
+ rsr = contrail["rsr"]
2153
+ olr = contrail["olr"]
2154
+
2155
+ # radiation calculations
2156
+ r_vol_um = r_ice_vol * 1e6
2157
+ habit_weights = radiative_forcing.habit_weights(
2158
+ r_vol_um, params["habit_distributions"], params["radius_threshold_um"]
2159
+ )
2160
+ rf_lw = radiative_forcing.longwave_radiative_forcing(
2161
+ r_vol_um, olr, air_temperature, tau_contrail, tau_cirrus_, habit_weights
2162
+ )
2163
+ rf_sw = radiative_forcing.shortwave_radiative_forcing(
2164
+ r_vol_um, sdr, rsr, sd0, tau_contrail, tau_cirrus_, habit_weights
2165
+ )
2166
+
2167
+ # scale RF by enhancement factors
2168
+ rf_lw_scaled = rf_lw * params["rf_lw_enhancement_factor"]
2169
+ rf_sw_scaled = rf_sw * params["rf_sw_enhancement_factor"]
2170
+ rf_net = radiative_forcing.net_radiative_forcing(rf_lw_scaled, rf_sw_scaled)
2171
+
2172
+ # store values on contrail
2173
+ contrail["rf_sw"] = rf_sw
2174
+ contrail["rf_lw"] = rf_lw
2175
+ contrail["rf_net"] = rf_net
2176
+
2177
+
2178
+ def calc_contrail_properties(
2179
+ contrail: GeoVectorDataset,
2180
+ effective_vertical_resolution: float | npt.NDArray[np.floating],
2181
+ wind_shear_enhancement_exponent: float | npt.NDArray[np.floating],
2182
+ sedimentation_impact_factor: float | npt.NDArray[np.floating],
2183
+ radiative_heating_effects: bool,
2184
+ ) -> None:
2185
+ """Calculate geometric and ice-related properties of contrail.
2186
+
2187
+ This function is used by both :class:`Cocip` and :class`CocipGrid`.
2188
+
2189
+ This function modifies parameter `contrail` in place:
2190
+ - Mutates contrail data variables:
2191
+ - "ds_dz"
2192
+ - "dsn_dz"
2193
+ - Attaches additional variables:
2194
+ - "area_eff"
2195
+ - "plume_mass_per_m"
2196
+ - "r_ice_vol"
2197
+ - "terminal_fall_speed"
2198
+ - "diffuse_h"
2199
+ - "diffuse_v"
2200
+ - "n_ice_per_vol"
2201
+ - "tau_contrail"
2202
+ - "dn_dt_agg"
2203
+ - "dn_dt_turb"
2204
+
2205
+ Parameters
2206
+ ----------
2207
+ contrail : GeoVectorDataset
2208
+ Grid points with many precomputed keys.
2209
+ effective_vertical_resolution : float | npt.NDArray[np.floating]
2210
+ Passed into :func:`wind_shear.wind_shear_enhancement_factor`.
2211
+ wind_shear_enhancement_exponent : float | npt.NDArray[np.floating]
2212
+ Passed into :func:`wind_shear.wind_shear_enhancement_factor`.
2213
+ sedimentation_impact_factor: float | npt.NDArray[np.floating]
2214
+ Passed into `contrail_properties.vertical_diffusivity`.
2215
+ radiative_heating_effects: bool
2216
+ Include radiative heating effects on contrail cirrus properties.
2217
+ """
2218
+ time = contrail["time"]
2219
+ iwc = contrail["iwc"]
2220
+ depth = contrail["depth"]
2221
+ width = contrail["width"]
2222
+ n_ice_per_m = contrail["n_ice_per_m"]
2223
+ t_cirrus_ = contrail["tau_cirrus"]
2224
+
2225
+ # get required meteorology
2226
+ air_temperature = contrail["air_temperature"]
2227
+ air_pressure = contrail.air_pressure
2228
+ rhi = contrail["rhi"]
2229
+ rho_air = contrail["rho_air"]
2230
+ dT_dz = contrail["dT_dz"]
2231
+ ds_dz = contrail["ds_dz"]
2232
+ dsn_dz = contrail["dsn_dz"]
2233
+ sigma_yz = contrail["sigma_yz"]
2234
+
2235
+ if radiative_heating_effects:
2236
+ air_temperature += contrail["cumul_heat"]
2237
+
2238
+ # get required radiation
2239
+ sdr = contrail["sdr"]
2240
+ rsr = contrail["rsr"]
2241
+ olr = contrail["olr"]
2242
+
2243
+ # shear enhancements
2244
+ shear_enhancement = wind_shear.wind_shear_enhancement_factor(
2245
+ contrail_depth=depth,
2246
+ effective_vertical_resolution=effective_vertical_resolution,
2247
+ wind_shear_enhancement_exponent=wind_shear_enhancement_exponent,
2248
+ )
2249
+ ds_dz = ds_dz * shear_enhancement
2250
+ dsn_dz = dsn_dz * shear_enhancement
2251
+
2252
+ # effective area
2253
+ area_eff = contrail_properties.plume_effective_cross_sectional_area(width, depth, sigma_yz)
2254
+ depth_eff = contrail_properties.plume_effective_depth(width, area_eff)
2255
+
2256
+ # ice particles
2257
+ n_ice_per_vol = contrail_properties.ice_particle_number_per_volume_of_plume(
2258
+ n_ice_per_m, area_eff
2259
+ )
2260
+ n_ice_per_kg_air = contrail_properties.ice_particle_number_per_mass_of_air(
2261
+ n_ice_per_vol, rho_air
2262
+ )
2263
+ plume_mass_per_m = contrail_properties.plume_mass_per_distance(area_eff, rho_air)
2264
+ r_ice_vol = contrail_properties.ice_particle_volume_mean_radius(iwc, n_ice_per_kg_air)
2265
+ tau_contrail = contrail_properties.contrail_optical_depth(r_ice_vol, n_ice_per_m, width)
2266
+
2267
+ terminal_fall_speed = contrail_properties.ice_particle_terminal_fall_speed(
2268
+ air_pressure, air_temperature, r_ice_vol
2269
+ )
2270
+ diffuse_h = contrail_properties.horizontal_diffusivity(ds_dz, depth)
2271
+
2272
+ if radiative_heating_effects:
2273
+ # theta_rad has float64 dtype, convert back to float32 if needed
2274
+ theta_rad = geo.orbital_position(time).astype(sdr.dtype, copy=False)
2275
+ sd0 = geo.solar_constant(theta_rad)
2276
+ heat_rate = radiative_heating.heating_rate(
2277
+ air_temperature=air_temperature,
2278
+ rhi=rhi,
2279
+ rho_air=rho_air,
2280
+ r_ice_vol=r_ice_vol,
2281
+ depth_eff=depth_eff,
2282
+ tau_contrail=tau_contrail,
2283
+ tau_cirrus=t_cirrus_,
2284
+ sd0=sd0,
2285
+ sdr=sdr,
2286
+ rsr=rsr,
2287
+ olr=olr,
2288
+ )
2289
+
2290
+ cumul_differential_heat = contrail["cumul_differential_heat"]
2291
+ d_heat_rate = radiative_heating.differential_heating_rate(
2292
+ air_temperature=air_temperature,
2293
+ rhi=rhi,
2294
+ rho_air=rho_air,
2295
+ r_ice_vol=r_ice_vol,
2296
+ depth_eff=depth_eff,
2297
+ tau_contrail=tau_contrail,
2298
+ tau_cirrus=t_cirrus_,
2299
+ sd0=sd0,
2300
+ sdr=sdr,
2301
+ rsr=rsr,
2302
+ olr=olr,
2303
+ )
2304
+
2305
+ eff_heat_rate = radiative_heating.effective_heating_rate(
2306
+ d_heat_rate, cumul_differential_heat, dT_dz, depth
2307
+ )
2308
+ else:
2309
+ eff_heat_rate = None
2310
+
2311
+ diffuse_v = contrail_properties.vertical_diffusivity(
2312
+ air_pressure=air_pressure,
2313
+ air_temperature=air_temperature,
2314
+ dT_dz=dT_dz,
2315
+ depth_eff=depth_eff,
2316
+ terminal_fall_speed=terminal_fall_speed,
2317
+ sedimentation_impact_factor=sedimentation_impact_factor,
2318
+ eff_heat_rate=eff_heat_rate,
2319
+ )
2320
+
2321
+ dn_dt_agg = contrail_properties.particle_losses_aggregation(
2322
+ r_ice_vol, terminal_fall_speed, area_eff
2323
+ )
2324
+ dn_dt_turb = contrail_properties.particle_losses_turbulence(
2325
+ width, depth, depth_eff, diffuse_h, diffuse_v
2326
+ )
2327
+
2328
+ # Set properties to model
2329
+ contrail.update(ds_dz=ds_dz)
2330
+ contrail.update(dsn_dz=dsn_dz)
2331
+ contrail.update(area_eff=area_eff)
2332
+ contrail.update(plume_mass_per_m=plume_mass_per_m)
2333
+ contrail.update(r_ice_vol=r_ice_vol)
2334
+ contrail.update(terminal_fall_speed=terminal_fall_speed)
2335
+ contrail.update(diffuse_h=diffuse_h)
2336
+ contrail.update(diffuse_v=diffuse_v)
2337
+ contrail.update(n_ice_per_vol=n_ice_per_vol)
2338
+ contrail.update(tau_contrail=tau_contrail)
2339
+ contrail.update(dn_dt_agg=dn_dt_agg)
2340
+ contrail.update(dn_dt_turb=dn_dt_turb)
2341
+ if radiative_heating_effects:
2342
+ contrail.update(heat_rate=heat_rate, d_heat_rate=d_heat_rate)
2343
+
2344
+
2345
+ def calc_timestep_contrail_evolution(
2346
+ met: MetDataset,
2347
+ rad: MetDataset,
2348
+ contrail_1: GeoVectorDataset,
2349
+ time_2: np.datetime64,
2350
+ params: dict[str, Any],
2351
+ **interp_kwargs: Any,
2352
+ ) -> GeoVectorDataset:
2353
+ """Calculate the contrail evolution across timestep (t1 -> t2).
2354
+
2355
+ Note the variable suffix "_1" is used to reference the current time
2356
+ and the suffix "_2" is used to refer to the time at the next timestep.
2357
+
2358
+ Parameters
2359
+ ----------
2360
+ met : MetDataset
2361
+ Meteorology data
2362
+ rad : MetDataset
2363
+ Radiation data
2364
+ contrail_1 : GeoVectorDataset
2365
+ Contrail waypoints at current timestep (1)
2366
+ time_2 : np.datetime64
2367
+ Time at the end of the evolution step (2)
2368
+ params : dict[str, Any]
2369
+ Model parameters
2370
+ **interp_kwargs : Any
2371
+ Interpolation keyword arguments
2372
+
2373
+ Returns
2374
+ -------
2375
+ GeoVectorDataset
2376
+ The contrail evolved to ``time_2``.
2377
+ """
2378
+
2379
+ # get lat/lon for current timestep (t1)
2380
+ longitude_1 = contrail_1["longitude"]
2381
+ latitude_1 = contrail_1["latitude"]
2382
+ level_1 = contrail_1.level
2383
+ time_1 = contrail_1["time"]
2384
+
2385
+ # get contrail_1 geometry
2386
+ segment_length_1 = contrail_1["segment_length"]
2387
+ width_1 = contrail_1["width"]
2388
+ depth_1 = contrail_1["depth"]
2389
+
2390
+ # get required met values for evolution calculations
2391
+ q_sat_1 = contrail_1["q_sat"]
2392
+ rho_air_1 = contrail_1["rho_air"]
2393
+ u_wind_1 = contrail_1["u_wind"]
2394
+ v_wind_1 = contrail_1["v_wind"]
2395
+
2396
+ specific_humidity_1 = contrail_1["specific_humidity"]
2397
+ vertical_velocity_1 = contrail_1["vertical_velocity"]
2398
+ iwc_1 = contrail_1["iwc"]
2399
+
2400
+ # get required contrail_1 properties
2401
+ sigma_yz_1 = contrail_1["sigma_yz"]
2402
+ dsn_dz_1 = contrail_1["dsn_dz"]
2403
+ terminal_fall_speed_1 = contrail_1["terminal_fall_speed"]
2404
+ diffuse_h_1 = contrail_1["diffuse_h"]
2405
+ diffuse_v_1 = contrail_1["diffuse_v"]
2406
+ plume_mass_per_m_1 = contrail_1["plume_mass_per_m"]
2407
+ dn_dt_agg_1 = contrail_1["dn_dt_agg"]
2408
+ dn_dt_turb_1 = contrail_1["dn_dt_turb"]
2409
+ n_ice_per_m_1 = contrail_1["n_ice_per_m"]
2410
+
2411
+ # get contrail_1 radiative properties
2412
+ rf_net_1 = contrail_1["rf_net"]
2413
+
2414
+ # initialize new timestep with evolved coordinates
2415
+ # assume waypoints are the same to start
2416
+ waypoint_2 = contrail_1["waypoint"]
2417
+ formation_time_2 = contrail_1["formation_time"]
2418
+ time_2_array = np.full_like(time_1, time_2)
2419
+ dt = time_2_array - time_1
2420
+
2421
+ # get new contrail location & segment properties after t_step
2422
+ longitude_2, latitude_2 = geo.advect_horizontal(longitude_1, latitude_1, u_wind_1, v_wind_1, dt)
2423
+ level_2 = geo.advect_level(level_1, vertical_velocity_1, rho_air_1, terminal_fall_speed_1, dt)
2424
+ altitude_2 = units.pl_to_m(level_2)
2425
+
2426
+ contrail_2 = GeoVectorDataset._from_fastpath(
2427
+ {
2428
+ "waypoint": waypoint_2,
2429
+ "flight_id": contrail_1["flight_id"],
2430
+ "formation_time": formation_time_2,
2431
+ "time": time_2_array,
2432
+ "age": time_2_array - formation_time_2,
2433
+ "longitude": longitude_2,
2434
+ "latitude": latitude_2,
2435
+ "altitude": altitude_2,
2436
+ "level": level_2,
2437
+ },
2438
+ )
2439
+ intersection = contrail_2.coords_intersect_met(met)
2440
+ if not np.any(intersection):
2441
+ warnings.warn(
2442
+ f"At time {time_2}, the contrail has no intersection with the met data. "
2443
+ "This is likely due to the contrail being advected outside the met domain."
2444
+ )
2445
+
2446
+ # Update cumulative radiative heating energy absorbed by the contrail
2447
+ # This will always be zero if radiative_heating_effects is not activated in cocip_params
2448
+ if params["radiative_heating_effects"]:
2449
+ dt_sec = dt / np.timedelta64(1, "s")
2450
+ heat_rate_1 = contrail_1["heat_rate"]
2451
+ cumul_heat = contrail_1["cumul_heat"]
2452
+ cumul_heat += heat_rate_1 * dt_sec
2453
+ cumul_heat.clip(max=1.5, out=cumul_heat) # Constrain additional heat to 1.5 K as precaution
2454
+ contrail_2["cumul_heat"] = cumul_heat
2455
+
2456
+ d_heat_rate_1 = contrail_1["d_heat_rate"]
2457
+ cumul_differential_heat = contrail_1["cumul_differential_heat"]
2458
+ cumul_differential_heat += -d_heat_rate_1 * dt_sec
2459
+ contrail_2["cumul_differential_heat"] = cumul_differential_heat
2460
+
2461
+ # Attach a few more artifacts for disabled filtering
2462
+ if not params["filter_sac"]:
2463
+ contrail_2["sac"] = contrail_1["sac"]
2464
+ if not params["filter_initially_persistent"]:
2465
+ contrail_2["initially_persistent"] = contrail_1["initially_persistent"]
2466
+ if params["persistent_buffer"] is not None:
2467
+ contrail_2["end_of_life"] = contrail_1["end_of_life"]
2468
+
2469
+ # calculate initial contrail properties for the next timestep
2470
+ calc_continuous(contrail_2)
2471
+ calc_timestep_geometry(contrail_2)
2472
+
2473
+ # get next segment lengths
2474
+ segment_length_2 = contrail_2["segment_length"]
2475
+
2476
+ # new contrail dimensions
2477
+ seg_ratio_2 = contrail_properties.segment_length_ratio(segment_length_1, segment_length_2)
2478
+ sigma_yy_2, sigma_zz_2, sigma_yz_2 = contrail_properties.plume_temporal_evolution(
2479
+ width_1,
2480
+ depth_1,
2481
+ sigma_yz_1,
2482
+ dsn_dz_1,
2483
+ diffuse_h_1,
2484
+ diffuse_v_1,
2485
+ seg_ratio_2,
2486
+ dt,
2487
+ max_depth=params["max_depth"],
2488
+ )
2489
+ width_2, depth_2 = contrail_properties.new_contrail_dimensions(sigma_yy_2, sigma_zz_2)
2490
+
2491
+ # store data back in model to support next time step calculations
2492
+ contrail_2["width"] = width_2
2493
+ contrail_2["depth"] = depth_2
2494
+ contrail_2["sigma_yz"] = sigma_yz_2
2495
+
2496
+ # new contrail meteorology parameters
2497
+ air_temperature_2 = interpolate_met(met, contrail_2, "air_temperature", **interp_kwargs)
2498
+
2499
+ if params["radiative_heating_effects"]:
2500
+ air_temperature_2 += contrail_2["cumul_heat"]
2501
+
2502
+ interpolate_met(met, contrail_2, "specific_humidity", **interp_kwargs)
2503
+
2504
+ humidity_scaling = params["humidity_scaling"]
2505
+ if humidity_scaling is not None:
2506
+ humidity_scaling.eval(contrail_2, copy_source=False)
2507
+ else:
2508
+ contrail_2["air_pressure"] = contrail_2.air_pressure
2509
+ contrail_2["rhi"] = thermo.rhi(
2510
+ contrail_2["specific_humidity"], air_temperature_2, contrail_2["air_pressure"]
2511
+ )
2512
+
2513
+ air_pressure_2 = contrail_2["air_pressure"]
2514
+ specific_humidity_2 = contrail_2["specific_humidity"]
2515
+ rho_air_2 = thermo.rho_d(air_temperature_2, air_pressure_2)
2516
+ q_sat_2 = thermo.q_sat_ice(air_temperature_2, air_pressure_2)
2517
+
2518
+ # store data back in model to support next ``calc_timestep_meteorology``
2519
+ contrail_2["rho_air"] = rho_air_2
2520
+ contrail_2["q_sat"] = q_sat_2
2521
+
2522
+ # New contrail ice particle mass and number
2523
+ area_eff_2 = contrail_properties.new_effective_area_from_sigma(
2524
+ sigma_yy_2, sigma_zz_2, sigma_yz_2
2525
+ )
2526
+ plume_mass_per_m_2 = contrail_properties.plume_mass_per_distance(area_eff_2, rho_air_2)
2527
+ iwc_2 = contrail_properties.new_ice_water_content(
2528
+ iwc_1,
2529
+ specific_humidity_1,
2530
+ specific_humidity_2,
2531
+ q_sat_1,
2532
+ q_sat_2,
2533
+ plume_mass_per_m_1,
2534
+ plume_mass_per_m_2,
2535
+ )
2536
+ n_ice_per_m_2 = contrail_properties.new_ice_particle_number(
2537
+ n_ice_per_m_1, dn_dt_agg_1, dn_dt_turb_1, seg_ratio_2, dt
2538
+ )
2539
+
2540
+ contrail_2["n_ice_per_m"] = n_ice_per_m_2
2541
+ contrail_2["iwc"] = iwc_2
2542
+
2543
+ # calculate next timestep meteorology, contrail, and radiative properties
2544
+ calc_timestep_meteorology(contrail_2, met, params, **interp_kwargs)
2545
+
2546
+ # Intersect with rad dataset
2547
+ calc_shortwave_radiation(rad, contrail_2, **interp_kwargs)
2548
+ calc_outgoing_longwave_radiation(rad, contrail_2, **interp_kwargs)
2549
+ calc_contrail_properties(
2550
+ contrail_2,
2551
+ params["effective_vertical_resolution"],
2552
+ params["wind_shear_enhancement_exponent"],
2553
+ params["sedimentation_impact_factor"],
2554
+ params["radiative_heating_effects"],
2555
+ )
2556
+ calc_radiative_properties(contrail_2, params)
2557
+
2558
+ # get properties to measure persistence
2559
+ latitude_2 = contrail_2["latitude"]
2560
+ altitude_2 = contrail_2.altitude
2561
+ age_2 = contrail_2["age"]
2562
+ tau_contrail_2 = contrail_2["tau_contrail"]
2563
+ n_ice_per_vol_2 = contrail_2["n_ice_per_vol"]
2564
+
2565
+ # calculate next persistent
2566
+ persistent_2 = contrail_2["persistent"] = contrail_properties.contrail_persistent(
2567
+ latitude=latitude_2,
2568
+ altitude=altitude_2,
2569
+ segment_length=segment_length_2,
2570
+ age=age_2,
2571
+ tau_contrail=tau_contrail_2,
2572
+ n_ice_per_m3=n_ice_per_vol_2,
2573
+ params=params,
2574
+ )
2575
+
2576
+ # Get energy forcing by looking forward to the next time step radiative forcing
2577
+ rf_net_2 = contrail_2["rf_net"]
2578
+ energy_forcing_2 = contrail_properties.energy_forcing(
2579
+ rf_net_t1=rf_net_1,
2580
+ rf_net_t2=rf_net_2,
2581
+ width_t1=width_1,
2582
+ width_t2=width_2,
2583
+ seg_length_t2=segment_length_2,
2584
+ dt=dt,
2585
+ )
2586
+
2587
+ # NOTE: Because of our geometry-continuity conventions, any waypoint without
2588
+ # continuity automatically has 0 EF. This is because calc_timestep_geometry
2589
+ # sets the segment_length entries to 0, thereby eliminating EF.
2590
+ # If we change conventions in calc_timestep_geometry, we may want to
2591
+ # explicitly set the discontinuous entries here to 0. We do this for age
2592
+ # here as well. It's somewhat of a hack, but it ensures nonzero contrail_age
2593
+ # is consistent with nonzero EF.
2594
+ contrail_2["ef"] = energy_forcing_2
2595
+ contrail_2["age"][energy_forcing_2 == 0.0] = np.timedelta64(0, "ns")
2596
+
2597
+ if params["compute_atr20"]:
2598
+ contrail_2["global_yearly_mean_rf"] = (
2599
+ contrail_2["ef"] / constants.surface_area_earth / constants.seconds_per_year
2600
+ )
2601
+ contrail_2["atr20"] = (
2602
+ params["global_rf_to_atr20_factor"] * contrail_2["global_yearly_mean_rf"]
2603
+ )
2604
+
2605
+ # filter contrail_2 by persistent waypoints, if any continuous segments are left
2606
+ logger.debug(
2607
+ "Fraction of waypoints surviving: %s / %s",
2608
+ persistent_2.sum(),
2609
+ persistent_2.size,
2610
+ )
2611
+
2612
+ if (buff := params["persistent_buffer"]) is not None:
2613
+ # Here mask gets waypoints that are just now losing persistence
2614
+ mask = (~persistent_2) & np.isnat(contrail_2["end_of_life"])
2615
+ contrail_2["end_of_life"][mask] = time_2
2616
+
2617
+ # Keep waypoints that are still persistent, which is determined by filt2
2618
+ # And waypoints within the persistent buffer, which is determined by filt1
2619
+ # So we only drop waypoints that are outside of the persistent buffer
2620
+ filt1 = contrail_2["time"] - contrail_2["end_of_life"] < buff
2621
+ filt2 = np.isnat(contrail_2["end_of_life"])
2622
+ filt = filt1 | filt2
2623
+ logger.debug(
2624
+ "Fraction of waypoints surviving with buffer %s: %s / %s",
2625
+ buff,
2626
+ filt.sum(),
2627
+ filt.size,
2628
+ )
2629
+ return contrail_2.filter(filt)
2630
+
2631
+ # filter persistent contrails
2632
+ final_contrail = contrail_2.filter(persistent_2)
2633
+
2634
+ # recalculate continuous contrail segments
2635
+ # and set EF, age for any newly discontinuous segments to 0
2636
+ if final_contrail:
2637
+ calc_continuous(final_contrail)
2638
+ continuous = final_contrail["continuous"]
2639
+ final_contrail["ef"][~continuous] = 0.0
2640
+ final_contrail["age"][~continuous] = np.timedelta64(0, "ns")
2641
+ if params["compute_atr20"]:
2642
+ final_contrail["global_yearly_mean_rf"][~continuous] = 0.0
2643
+ final_contrail["atr20"][~continuous] = 0.0
2644
+ return final_contrail
2645
+
2646
+
2647
+ def _rad_accumulation_to_average_instantaneous(
2648
+ rad: MetDataset,
2649
+ name: str,
2650
+ arr: npt.NDArray[np.floating],
2651
+ ) -> npt.NDArray[np.floating]:
2652
+ """Convert from radiation accumulation to average instantaneous values.
2653
+
2654
+ .. versionadded:: 0.48.0
2655
+
2656
+ Parameters
2657
+ ----------
2658
+ rad : MetDataset
2659
+ Radiation data
2660
+ name : str
2661
+ Variable name
2662
+ arr : npt.NDArray[np.floating]
2663
+ Array of values already interpolated from ``rad``
2664
+
2665
+ Returns
2666
+ -------
2667
+ npt.NDArray[np.floating]
2668
+ Array of values converted from accumulation to average instantaneous values
2669
+
2670
+ Raises
2671
+ ------
2672
+ KeyError
2673
+ If units are not provided on ``rad``.
2674
+ ValueError
2675
+ If unknown units are provided on ``rad``.
2676
+ """
2677
+ mda = rad[name]
2678
+ try:
2679
+ unit = mda.attrs["units"]
2680
+ except KeyError as e:
2681
+ msg = (
2682
+ f"Radiation data contains '{name}' variable "
2683
+ "but units are not specified. Provide units in the "
2684
+ f"rad['{name}'].attrs passed into Cocip."
2685
+ )
2686
+ raise KeyError(msg) from e
2687
+
2688
+ # The unit is already instantaneous
2689
+ if unit == "W m**-2":
2690
+ return arr
2691
+
2692
+ if unit != "J m**-2":
2693
+ msg = f"Unexpected units '{unit}' for '{name}' variable. Expected 'J m**-2' or 'W m**-2'."
2694
+ raise ValueError(msg)
2695
+
2696
+ # Convert from J m**-2 to W m**-2
2697
+ if rad.dataset_attr == "ERA5" and rad.product_attr == "ensemble":
2698
+ n_seconds = 3.0 * 3600.0 # 3 hour interval
2699
+ else:
2700
+ n_seconds = 3600.0 # 1 hour interval
2701
+
2702
+ return arr / n_seconds
2703
+
2704
+
2705
+ def _emissions_variables() -> tuple[str, ...]:
2706
+ """Return variables required for emissions calculation."""
2707
+ return (
2708
+ "engine_efficiency",
2709
+ "fuel_flow",
2710
+ "aircraft_mass",
2711
+ "nvpm_ei_n",
2712
+ "wingspan",
2713
+ )
2714
+
2715
+
2716
+ def _contrail_contrail_overlapping(
2717
+ contrail: GeoVectorDataset, params: dict[str, Any]
2718
+ ) -> GeoVectorDataset:
2719
+ """Mutate ``contrail`` to account for contrail-contrail overlapping effects."""
2720
+
2721
+ if not contrail:
2722
+ return contrail
2723
+
2724
+ contrail = radiative_forcing.contrail_contrail_overlap_radiative_effects(
2725
+ contrail,
2726
+ habit_distributions=params["habit_distributions"],
2727
+ radius_threshold_um=params["radius_threshold_um"],
2728
+ min_altitude_m=params["min_altitude_m"],
2729
+ max_altitude_m=params["max_altitude_m"],
2730
+ dz_overlap_m=params["dz_overlap_m"],
2731
+ )
2732
+
2733
+ contrail.update(
2734
+ rsr=contrail.data.pop("rsr_overlap"),
2735
+ olr=contrail.data.pop("olr_overlap"),
2736
+ tau_cirrus=contrail.data.pop("tau_cirrus_overlap"),
2737
+ rf_sw=contrail.data.pop("rf_sw_overlap"),
2738
+ rf_lw=contrail.data.pop("rf_lw_overlap"),
2739
+ rf_net=contrail.data.pop("rf_net_overlap"),
2740
+ )
2741
+
2742
+ return contrail