pycontrails 0.58.0__cp314-cp314-macosx_10_13_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 (122) 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 +2931 -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 +757 -0
  37. pycontrails/datalib/himawari/__init__.py +27 -0
  38. pycontrails/datalib/himawari/header_struct.py +266 -0
  39. pycontrails/datalib/himawari/himawari.py +667 -0
  40. pycontrails/datalib/landsat.py +589 -0
  41. pycontrails/datalib/leo_utils/__init__.py +5 -0
  42. pycontrails/datalib/leo_utils/correction.py +266 -0
  43. pycontrails/datalib/leo_utils/landsat_metadata.py +300 -0
  44. pycontrails/datalib/leo_utils/search.py +250 -0
  45. pycontrails/datalib/leo_utils/sentinel_metadata.py +748 -0
  46. pycontrails/datalib/leo_utils/static/bq_roi_query.sql +6 -0
  47. pycontrails/datalib/leo_utils/vis.py +59 -0
  48. pycontrails/datalib/sentinel.py +650 -0
  49. pycontrails/datalib/spire/__init__.py +5 -0
  50. pycontrails/datalib/spire/exceptions.py +62 -0
  51. pycontrails/datalib/spire/spire.py +604 -0
  52. pycontrails/ext/bada.py +42 -0
  53. pycontrails/ext/cirium.py +14 -0
  54. pycontrails/ext/empirical_grid.py +140 -0
  55. pycontrails/ext/synthetic_flight.py +431 -0
  56. pycontrails/models/__init__.py +1 -0
  57. pycontrails/models/accf.py +425 -0
  58. pycontrails/models/apcemm/__init__.py +8 -0
  59. pycontrails/models/apcemm/apcemm.py +983 -0
  60. pycontrails/models/apcemm/inputs.py +226 -0
  61. pycontrails/models/apcemm/static/apcemm_yaml_template.yaml +183 -0
  62. pycontrails/models/apcemm/utils.py +437 -0
  63. pycontrails/models/cocip/__init__.py +29 -0
  64. pycontrails/models/cocip/cocip.py +2742 -0
  65. pycontrails/models/cocip/cocip_params.py +305 -0
  66. pycontrails/models/cocip/cocip_uncertainty.py +291 -0
  67. pycontrails/models/cocip/contrail_properties.py +1530 -0
  68. pycontrails/models/cocip/output_formats.py +2270 -0
  69. pycontrails/models/cocip/radiative_forcing.py +1260 -0
  70. pycontrails/models/cocip/radiative_heating.py +520 -0
  71. pycontrails/models/cocip/unterstrasser_wake_vortex.py +508 -0
  72. pycontrails/models/cocip/wake_vortex.py +396 -0
  73. pycontrails/models/cocip/wind_shear.py +120 -0
  74. pycontrails/models/cocipgrid/__init__.py +9 -0
  75. pycontrails/models/cocipgrid/cocip_grid.py +2552 -0
  76. pycontrails/models/cocipgrid/cocip_grid_params.py +138 -0
  77. pycontrails/models/dry_advection.py +602 -0
  78. pycontrails/models/emissions/__init__.py +21 -0
  79. pycontrails/models/emissions/black_carbon.py +599 -0
  80. pycontrails/models/emissions/emissions.py +1353 -0
  81. pycontrails/models/emissions/ffm2.py +336 -0
  82. pycontrails/models/emissions/static/default-engine-uids.csv +239 -0
  83. pycontrails/models/emissions/static/edb-gaseous-v29b-engines.csv +596 -0
  84. pycontrails/models/emissions/static/edb-nvpm-v29b-engines.csv +215 -0
  85. pycontrails/models/extended_k15.py +1327 -0
  86. pycontrails/models/humidity_scaling/__init__.py +37 -0
  87. pycontrails/models/humidity_scaling/humidity_scaling.py +1075 -0
  88. pycontrails/models/humidity_scaling/quantiles/era5-model-level-quantiles.pq +0 -0
  89. pycontrails/models/humidity_scaling/quantiles/era5-pressure-level-quantiles.pq +0 -0
  90. pycontrails/models/issr.py +210 -0
  91. pycontrails/models/pcc.py +326 -0
  92. pycontrails/models/pcr.py +154 -0
  93. pycontrails/models/ps_model/__init__.py +18 -0
  94. pycontrails/models/ps_model/ps_aircraft_params.py +381 -0
  95. pycontrails/models/ps_model/ps_grid.py +701 -0
  96. pycontrails/models/ps_model/ps_model.py +1000 -0
  97. pycontrails/models/ps_model/ps_operational_limits.py +525 -0
  98. pycontrails/models/ps_model/static/ps-aircraft-params-20250328.csv +69 -0
  99. pycontrails/models/ps_model/static/ps-synonym-list-20250328.csv +104 -0
  100. pycontrails/models/sac.py +442 -0
  101. pycontrails/models/tau_cirrus.py +183 -0
  102. pycontrails/physics/__init__.py +1 -0
  103. pycontrails/physics/constants.py +117 -0
  104. pycontrails/physics/geo.py +1138 -0
  105. pycontrails/physics/jet.py +968 -0
  106. pycontrails/physics/static/iata-cargo-load-factors-20250221.csv +74 -0
  107. pycontrails/physics/static/iata-passenger-load-factors-20250221.csv +74 -0
  108. pycontrails/physics/thermo.py +551 -0
  109. pycontrails/physics/units.py +472 -0
  110. pycontrails/py.typed +0 -0
  111. pycontrails/utils/__init__.py +1 -0
  112. pycontrails/utils/dependencies.py +66 -0
  113. pycontrails/utils/iteration.py +13 -0
  114. pycontrails/utils/json.py +187 -0
  115. pycontrails/utils/temp.py +50 -0
  116. pycontrails/utils/types.py +163 -0
  117. pycontrails-0.58.0.dist-info/METADATA +180 -0
  118. pycontrails-0.58.0.dist-info/RECORD +122 -0
  119. pycontrails-0.58.0.dist-info/WHEEL +6 -0
  120. pycontrails-0.58.0.dist-info/licenses/LICENSE +178 -0
  121. pycontrails-0.58.0.dist-info/licenses/NOTICE +43 -0
  122. pycontrails-0.58.0.dist-info/top_level.txt +3 -0
@@ -0,0 +1,2270 @@
1
+ """CoCiP output formats.
2
+
3
+ This module includes functions to produce additional output formats, including the:
4
+ (1) Flight waypoint outputs.
5
+ See :func:`flight_waypoint_summary_statistics`.
6
+ (2) Contrail flight summary outputs.
7
+ See :func:`contrail_flight_summary_statistics`.
8
+ (3) Gridded outputs.
9
+ See :func:`longitude_latitude_grid`.
10
+ (4) Time-slice statistics.
11
+ See :func:`time_slice_statistics`.
12
+ (5) Aggregate contrail segment optical depth/RF to a high-resolution longitude-latitude grid.
13
+ See :func:`contrails_to_hi_res_grid`.
14
+ (6) Increase spatial resolution of natural cirrus properties, required to estimate the
15
+ high-resolution contrail cirrus coverage for (5).
16
+ See :func:`natural_cirrus_properties_to_hi_res_grid`.
17
+ (7) Comparing simulated contrails from CoCiP with GOES satellite imagery.
18
+ See :func:`compare_cocip_with_goes`.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import pathlib
24
+ import warnings
25
+ from collections.abc import Hashable
26
+
27
+ import numpy as np
28
+ import numpy.typing as npt
29
+ import pandas as pd
30
+ import xarray as xr
31
+
32
+ from pycontrails.core.met import MetDataArray, MetDataset
33
+ from pycontrails.core.vector import GeoVectorDataset, vector_to_lon_lat_grid
34
+ from pycontrails.models.cocip.contrail_properties import contrail_edges, plume_mass_per_distance
35
+ from pycontrails.models.cocip.radiative_forcing import albedo
36
+ from pycontrails.models.humidity_scaling import HumidityScaling
37
+ from pycontrails.models.tau_cirrus import tau_cirrus
38
+ from pycontrails.physics import geo, thermo, units
39
+ from pycontrails.utils import dependencies
40
+
41
+ # -----------------------
42
+ # Flight waypoint outputs
43
+ # -----------------------
44
+
45
+
46
+ def flight_waypoint_summary_statistics(
47
+ flight_waypoints: GeoVectorDataset | pd.DataFrame,
48
+ contrails: GeoVectorDataset | pd.DataFrame,
49
+ ) -> GeoVectorDataset:
50
+ """
51
+ Calculate the contrail summary statistics at each flight waypoint.
52
+
53
+ Parameters
54
+ ----------
55
+ flight_waypoints : GeoVectorDataset | pd.DataFrame
56
+ Flight waypoints that were used in :meth:`Cocip.eval` to produce ``contrails``.
57
+ contrails : GeoVectorDataset | pd.DataFrame
58
+ Contrail evolution outputs from CoCiP, :attr:`Cocip.contrail`
59
+
60
+ Returns
61
+ -------
62
+ GeoVectorDataset
63
+ Contrail summary statistics attached to each flight waypoint.
64
+
65
+ Notes
66
+ -----
67
+ Outputs and units:
68
+ - ``mean_contrail_altitude``, [:math:`m`]
69
+ - ``mean_rhi``, [dimensionless]
70
+ - ``mean_n_ice_per_m``, [:math:`m^{-1}`]
71
+ - ``mean_r_ice_vol``, [:math:`m`]
72
+ - ``mean_width``, [:math:`m`]
73
+ - ``mean_depth``, [:math:`m`]
74
+ - ``mean_tau_contrail``, [dimensionless]
75
+ - ``mean_tau_cirrus``, [dimensionless]
76
+ - ``max_age``, [:math:`h`]
77
+ - ``mean_rf_sw``, [:math:`W m^{-2}`]
78
+ - ``mean_rf_lw``, [:math:`W m^{-2}`]
79
+ - ``mean_rf_net``, [:math:`W m^{-2}`]
80
+ - ``ef``, [:math:`J`]
81
+ - ``mean_olr``, [:math:`W m^{-2}`]
82
+ - ``mean_sdr``, [:math:`W m^{-2}`]
83
+ - ``mean_rsr``, [:math:`W m^{-2}`]
84
+ """
85
+ # Aggregation map
86
+ agg_map = {
87
+ # Location, ambient meteorology and properties
88
+ "altitude": "mean",
89
+ "rhi": ["mean", "std"],
90
+ "n_ice_per_m": ["mean", "std"],
91
+ "r_ice_vol": "mean",
92
+ "width": "mean",
93
+ "depth": "mean",
94
+ "tau_contrail": "mean",
95
+ "tau_cirrus": "mean",
96
+ "age": "max",
97
+ # Radiative properties
98
+ "rf_sw": "mean",
99
+ "rf_lw": "mean",
100
+ "rf_net": "mean",
101
+ "olr": "mean",
102
+ "sdr": "mean",
103
+ "rsr": "mean",
104
+ }
105
+ if "ef" not in flight_waypoints:
106
+ agg_map["ef"] = "sum"
107
+
108
+ # Check and pre-process `flights`
109
+ if isinstance(flight_waypoints, GeoVectorDataset):
110
+ flight_waypoints.ensure_vars(["flight_id", "waypoint"])
111
+ flight_waypoints = flight_waypoints.dataframe
112
+
113
+ flight_waypoints = flight_waypoints.set_index(["flight_id", "waypoint"])
114
+
115
+ # Check and pre-process `contrails`
116
+ if isinstance(contrails, GeoVectorDataset):
117
+ contrail_vars = ["flight_id", "waypoint", "formation_time", *agg_map]
118
+ contrail_vars.remove("age")
119
+ contrails.ensure_vars(contrail_vars)
120
+ contrails = contrails.dataframe
121
+
122
+ contrails["age"] = (contrails["time"] - contrails["formation_time"]) / np.timedelta64(1, "h")
123
+
124
+ # Calculate contrail statistics at each flight waypoint
125
+ contrails = contrails.groupby(["flight_id", "waypoint"]).agg(agg_map)
126
+ contrails.columns = (
127
+ contrails.columns.get_level_values(1) + "_" + contrails.columns.get_level_values(0)
128
+ )
129
+ rename_cols = {"mean_altitude": "mean_contrail_altitude", "sum_ef": "ef"}
130
+ contrails = contrails.rename(columns=rename_cols)
131
+
132
+ # Concatenate to flight-waypoint outputs
133
+ out = flight_waypoints.join(contrails, how="left")
134
+ out = out.reset_index()
135
+ return GeoVectorDataset(out)
136
+
137
+
138
+ # -------------------------------
139
+ # Contrail flight summary outputs
140
+ # -------------------------------
141
+
142
+
143
+ def contrail_flight_summary_statistics(flight_waypoints: GeoVectorDataset) -> pd.DataFrame:
144
+ """
145
+ Calculate contrail summary statistics for each flight.
146
+
147
+ Parameters
148
+ ----------
149
+ flight_waypoints : GeoVectorDataset
150
+ Flight waypoint outputs with contrail summary statistics attached.
151
+ See :func:`flight_waypoint_summary_statistics`.
152
+
153
+ Returns
154
+ -------
155
+ pd.DataFrame
156
+ Contrail summary statistics for each flight
157
+
158
+ Notes
159
+ -----
160
+ Outputs and units:
161
+ - ``total_flight_distance_flown``, [:math:`m`]
162
+ - ``total_contrails_formed``, [:math:`m`]
163
+ - ``total_persistent_contrails_formed``, [:math:`m`]
164
+ - ``mean_lifetime_contrail_altitude``, [:math:`m`]
165
+ - ``mean_lifetime_rhi``, [dimensionless]
166
+ - ``mean_lifetime_n_ice_per_m``, [:math:`m^{-1}`]
167
+ - ``mean_lifetime_r_ice_vol``, [:math:`m`]
168
+ - ``mean_lifetime_contrail_width``, [:math:`m`]
169
+ - ``mean_lifetime_contrail_depth``, [:math:`m`]
170
+ - ``mean_lifetime_tau_contrail``, [dimensionless]
171
+ - ``mean_lifetime_tau_cirrus``, [dimensionless]
172
+ - ``mean_contrail_lifetime``, [:math:`h`]
173
+ - ``max_contrail_lifetime``, [:math:`h`]
174
+ - ``mean_lifetime_rf_sw``, [:math:`W m^{-2}`]
175
+ - ``mean_lifetime_rf_lw``, [:math:`W m^{-2}`]
176
+ - ``mean_lifetime_rf_net``, [:math:`W m^{-2}`]
177
+ - ``total_energy_forcing``, [:math:`J`]
178
+ - ``mean_lifetime_olr``, [:math:`W m^{-2}`]
179
+ - ``mean_lifetime_sdr``, [:math:`W m^{-2}`]
180
+ - ``mean_lifetime_rsr``, [:math:`W m^{-2}`]
181
+ """
182
+ # Aggregation map
183
+ agg_map = {
184
+ # Contrail properties and ambient meteorology
185
+ "segment_length": "sum",
186
+ "contrail_length": "sum",
187
+ "persistent_contrail_length": "sum",
188
+ "mean_contrail_altitude": "mean",
189
+ "mean_rhi": "mean",
190
+ "mean_n_ice_per_m": "mean",
191
+ "mean_r_ice_vol": "mean",
192
+ "mean_width": "mean",
193
+ "mean_depth": "mean",
194
+ "mean_tau_contrail": "mean",
195
+ "mean_tau_cirrus": "mean",
196
+ "max_age": ["mean", "max"],
197
+ # Radiative properties
198
+ "mean_rf_sw": "mean",
199
+ "mean_rf_lw": "mean",
200
+ "mean_rf_net": "mean",
201
+ "ef": "sum",
202
+ "mean_olr": "mean",
203
+ "mean_sdr": "mean",
204
+ "mean_rsr": "mean",
205
+ }
206
+
207
+ # Check and pre-process `flight_waypoints`
208
+ vars_required = ["flight_id", "sac", *agg_map]
209
+ vars_required.remove("contrail_length")
210
+ vars_required.remove("persistent_contrail_length")
211
+ flight_waypoints.ensure_vars(vars_required)
212
+
213
+ flight_waypoints["contrail_length"] = np.where(
214
+ flight_waypoints["sac"] == 1.0, flight_waypoints["segment_length"], 0.0
215
+ )
216
+
217
+ flight_waypoints["persistent_contrail_length"] = np.where(
218
+ np.nan_to_num(flight_waypoints["ef"]) == 0.0, 0.0, flight_waypoints["segment_length"]
219
+ )
220
+
221
+ # Calculate contrail statistics for each flight
222
+ flight_summary = flight_waypoints.dataframe.groupby(["flight_id"]).agg(agg_map)
223
+ flight_summary.columns = (
224
+ flight_summary.columns.get_level_values(1)
225
+ + "_"
226
+ + flight_summary.columns.get_level_values(0)
227
+ )
228
+
229
+ rename_flight_summary_cols = {
230
+ "sum_segment_length": "total_flight_distance_flown",
231
+ "sum_contrail_length": "total_contrails_formed",
232
+ "sum_persistent_contrail_length": "total_persistent_contrails_formed",
233
+ "mean_mean_contrail_altitude": "mean_lifetime_contrail_altitude",
234
+ "mean_mean_rhi": "mean_lifetime_rhi",
235
+ "mean_mean_n_ice_per_m": "mean_lifetime_n_ice_per_m",
236
+ "mean_mean_r_ice_vol": "mean_lifetime_r_ice_vol",
237
+ "mean_mean_width": "mean_lifetime_contrail_width",
238
+ "mean_mean_depth": "mean_lifetime_contrail_depth",
239
+ "mean_mean_tau_contrail": "mean_lifetime_tau_contrail",
240
+ "mean_mean_tau_cirrus": "mean_lifetime_tau_cirrus",
241
+ "mean_max_age": "mean_contrail_lifetime",
242
+ "max_max_age": "max_contrail_lifetime",
243
+ "mean_mean_rf_sw": "mean_lifetime_rf_sw",
244
+ "mean_mean_rf_lw": "mean_lifetime_rf_lw",
245
+ "mean_mean_rf_net": "mean_lifetime_rf_net",
246
+ "sum_ef": "total_energy_forcing",
247
+ "mean_mean_olr": "mean_lifetime_olr",
248
+ "mean_mean_sdr": "mean_lifetime_sdr",
249
+ "mean_mean_rsr": "mean_lifetime_rsr",
250
+ }
251
+
252
+ return flight_summary.rename(columns=rename_flight_summary_cols).reset_index(["flight_id"])
253
+
254
+
255
+ # ---------------
256
+ # Gridded outputs
257
+ # ---------------
258
+
259
+
260
+ def longitude_latitude_grid(
261
+ t_start: np.datetime64 | pd.Timestamp,
262
+ t_end: np.datetime64 | pd.Timestamp,
263
+ flight_waypoints: GeoVectorDataset,
264
+ contrails: GeoVectorDataset,
265
+ *,
266
+ met: MetDataset,
267
+ spatial_bbox: tuple[float, float, float, float] = (-180.0, -90.0, 180.0, 90.0),
268
+ spatial_grid_res: float = 0.5,
269
+ ) -> xr.Dataset:
270
+ r"""
271
+ Aggregate air traffic and contrail outputs to a longitude-latitude grid.
272
+
273
+ Parameters
274
+ ----------
275
+ t_start : np.datetime64 | pd.Timestamp
276
+ UTC time at beginning of time step.
277
+ t_end : np.datetime64 | pd.Timestamp
278
+ UTC time at end of time step.
279
+ flight_waypoints : GeoVectorDataset
280
+ Flight waypoint outputs with contrail summary statistics attached.
281
+ See :func:`flight_waypoint_summary_statistics`.
282
+ contrails : GeoVectorDataset
283
+ Contrail evolution outputs from CoCiP, :attr:`Cocip.contrail`.
284
+ met : MetDataset
285
+ Pressure level dataset containing 'air_temperature', 'specific_humidity',
286
+ 'specific_cloud_ice_water_content', and 'geopotential'.
287
+ spatial_bbox : tuple[float, float, float, float]
288
+ Spatial bounding box, ``(lon_min, lat_min, lon_max, lat_max)``, [:math:`\deg`]
289
+ spatial_grid_res : float
290
+ Spatial grid resolution, [:math:`\deg`]
291
+
292
+ Returns
293
+ -------
294
+ xr.Dataset
295
+ Air traffic and contrail outputs at a longitude-latitude grid.
296
+ """
297
+ # Ensure the required columns are included in `flight_waypoints`, `contrails` and `met`
298
+ flight_waypoints.ensure_vars(("segment_length", "ef"))
299
+ contrails.ensure_vars(
300
+ (
301
+ "formation_time",
302
+ "segment_length",
303
+ "width",
304
+ "tau_contrail",
305
+ "rf_sw",
306
+ "rf_lw",
307
+ "rf_net",
308
+ "ef",
309
+ )
310
+ )
311
+ met.ensure_vars(
312
+ ("air_temperature", "specific_humidity", "specific_cloud_ice_water_content", "geopotential")
313
+ )
314
+
315
+ # Downselect `met` to specified spatial bounding box
316
+ met = met.downselect(spatial_bbox)
317
+
318
+ # Ensure that `flight_waypoints` and `contrails` are within `t_start` and `t_end`
319
+ is_in_time = flight_waypoints.dataframe["time"].between(t_start, t_end, inclusive="right")
320
+ if not np.all(is_in_time):
321
+ warnings.warn(
322
+ "Flight waypoints have times that are outside the range of `t_start` and `t_end`. "
323
+ "Waypoints outside the defined time bounds are removed. "
324
+ )
325
+ flight_waypoints = flight_waypoints.filter(is_in_time)
326
+
327
+ is_in_time = contrails.dataframe["time"].between(t_start, t_end, inclusive="right")
328
+
329
+ if not np.all(is_in_time):
330
+ warnings.warn(
331
+ "Contrail waypoints have times that are outside the range of `t_start` and `t_end`."
332
+ "Waypoints outside the defined time bounds are removed. "
333
+ )
334
+ contrails = contrails.filter(is_in_time)
335
+
336
+ # Calculate additional variables
337
+ t_slices = np.unique(contrails["time"])
338
+ dt_integration_sec = (t_slices[1] - t_slices[0]) / np.timedelta64(1, "s")
339
+
340
+ da_area = geo.grid_surface_area(met["longitude"].values, met["latitude"].values)
341
+
342
+ flight_waypoints["persistent_contrails"] = np.where(
343
+ np.isnan(flight_waypoints["ef"]), 0.0, flight_waypoints["segment_length"]
344
+ )
345
+
346
+ # ----------------
347
+ # Grid aggregation
348
+ # ----------------
349
+ # (1) Waypoint properties between `t_start` and `t_end`
350
+ is_between_time = flight_waypoints.dataframe["time"].between(t_start, t_end, inclusive="right")
351
+ ds_wypts_t = vector_to_lon_lat_grid(
352
+ flight_waypoints.filter(is_between_time, copy=True),
353
+ agg={"segment_length": "sum", "persistent_contrails": "sum", "ef": "sum"},
354
+ spatial_bbox=spatial_bbox,
355
+ spatial_grid_res=spatial_grid_res,
356
+ )
357
+
358
+ # (2) Contrail properties at `t_end`
359
+ contrails_t_end = contrails.filter(contrails["time"] == t_end)
360
+
361
+ contrails_t_end["tau_contrail_area"] = (
362
+ contrails_t_end["tau_contrail"]
363
+ * contrails_t_end["segment_length"]
364
+ * contrails_t_end["width"]
365
+ )
366
+
367
+ contrails_t_end["age"] = (
368
+ contrails_t_end["time"] - contrails_t_end["formation_time"]
369
+ ) / np.timedelta64(1, "h")
370
+
371
+ ds_contrails_t_end = vector_to_lon_lat_grid(
372
+ contrails_t_end,
373
+ agg={"segment_length": "sum", "tau_contrail_area": "sum", "age": "mean"},
374
+ spatial_bbox=spatial_bbox,
375
+ spatial_grid_res=spatial_grid_res,
376
+ )
377
+ ds_contrails_t_end["tau_contrail"] = ds_contrails_t_end["tau_contrail_area"] / da_area
378
+
379
+ # (3) Contrail and natural cirrus coverage area at `t_end`
380
+ mds_cirrus_coverage = cirrus_coverage_single_level(t_end, met, contrails)
381
+ ds_cirrus_coverage = mds_cirrus_coverage.data.squeeze(dim=["level", "time"])
382
+
383
+ # (4) Contrail climate forcing between `t_start` and `t_end`
384
+ contrails["ef_sw"] = np.where(
385
+ contrails["ef"] == 0.0,
386
+ 0.0,
387
+ contrails["rf_sw"] * contrails["segment_length"] * contrails["width"] * dt_integration_sec,
388
+ )
389
+ contrails["ef_lw"] = np.where(
390
+ contrails["ef"] == 0.0,
391
+ 0.0,
392
+ contrails["rf_lw"] * contrails["segment_length"] * contrails["width"] * dt_integration_sec,
393
+ )
394
+
395
+ ds_forcing = vector_to_lon_lat_grid(
396
+ contrails,
397
+ agg={"ef_sw": "sum", "ef_lw": "sum", "ef": "sum"},
398
+ spatial_bbox=spatial_bbox,
399
+ spatial_grid_res=spatial_grid_res,
400
+ )
401
+ ds_forcing["rf_sw"] = ds_forcing["ef_sw"] / (da_area * dt_integration_sec)
402
+ ds_forcing["rf_lw"] = ds_forcing["ef_lw"] / (da_area * dt_integration_sec)
403
+ ds_forcing["rf_net"] = ds_forcing["ef"] / (da_area * dt_integration_sec)
404
+
405
+ # -----------------------
406
+ # Package gridded outputs
407
+ # -----------------------
408
+ ds = xr.Dataset(
409
+ data_vars=dict(
410
+ flight_distance_flown=ds_wypts_t["segment_length"] / 1000.0,
411
+ persistent_contrails_formed=ds_wypts_t["persistent_contrails"] / 1000.0,
412
+ persistent_contrails=ds_contrails_t_end["segment_length"] / 1000.0,
413
+ tau_contrail=ds_contrails_t_end["tau_contrail"],
414
+ contrail_age=ds_contrails_t_end["age"],
415
+ cc_natural_cirrus=ds_cirrus_coverage["natural_cirrus"],
416
+ cc_contrails=ds_cirrus_coverage["contrails"],
417
+ cc_contrails_clear_sky=ds_cirrus_coverage["contrails_clear_sky"],
418
+ rf_sw=ds_forcing["rf_sw"] * 1000.0,
419
+ rf_lw=ds_forcing["rf_lw"] * 1000.0,
420
+ rf_net=ds_forcing["rf_net"] * 1000.0,
421
+ ef=ds_forcing["ef"],
422
+ ef_initial_loc=ds_wypts_t["ef"],
423
+ ),
424
+ coords=ds_wypts_t.coords,
425
+ )
426
+ ds = ds.fillna(0.0)
427
+ ds = ds.expand_dims({"time": np.array([t_end])})
428
+
429
+ # Assign attributes
430
+ attrs = _create_attributes()
431
+
432
+ for name in ds.data_vars:
433
+ ds[name].attrs = attrs[name]
434
+
435
+ return ds
436
+
437
+
438
+ def _create_attributes() -> dict[Hashable, dict[str, str]]:
439
+ return {
440
+ "flight_distance_flown": {
441
+ "long_name": "Total flight distance flown between t_start and t_end",
442
+ "units": "km",
443
+ },
444
+ "persistent_contrails_formed": {
445
+ "long_name": "Persistent contrails formed between t_start and t_end",
446
+ "units": "km",
447
+ },
448
+ "persistent_contrails": {
449
+ "long_name": "Persistent contrails at t_end",
450
+ "units": "km",
451
+ },
452
+ "tau_contrail": {
453
+ "long_name": "Area-normalised mean contrail optical depth at t_end",
454
+ "units": " ",
455
+ },
456
+ "contrail_age": {
457
+ "long_name": "Mean contrail age at t_end",
458
+ "units": "h",
459
+ },
460
+ "cc_natural_cirrus": {
461
+ "long_name": "Natural cirrus cover at t_end",
462
+ "units": " ",
463
+ },
464
+ "cc_contrails": {
465
+ "long_name": "Contrail cirrus cover at t_end",
466
+ "units": " ",
467
+ },
468
+ "cc_contrails_clear_sky": {
469
+ "long_name": "Contrail cirrus cover under clear sky conditions at t_end",
470
+ "units": " ",
471
+ },
472
+ "rf_sw": {
473
+ "long_name": "Mean contrail cirrus shortwave radiative forcing at t_end",
474
+ "units": "mW/m**2",
475
+ },
476
+ "rf_lw": {
477
+ "long_name": "Mean contrail cirrus longwave radiative forcing at t_end",
478
+ "units": "mW/m**2",
479
+ },
480
+ "rf_net": {
481
+ "long_name": "Mean contrail cirrus net radiative forcing at t_end",
482
+ "units": "mW/m**2",
483
+ },
484
+ "ef": {
485
+ "long_name": "Total contrail energy forcing between t_start and t_end",
486
+ "units": "J",
487
+ },
488
+ "ef_initial_loc": {
489
+ "long_name": "Total contrail energy forcing attributed back to the flight waypoint.",
490
+ "units": "J",
491
+ },
492
+ "contrails_clear_sky": {
493
+ "long_name": "Contrail cirrus cover in clear sky conditions.",
494
+ "units": " ",
495
+ },
496
+ "natural_cirrus": {
497
+ "long_name": "Natural cirrus cover.",
498
+ "units": " ",
499
+ },
500
+ "contrails": {
501
+ "long_name": "Contrail cirrus cover without overlap with natural cirrus.",
502
+ "units": " ",
503
+ },
504
+ }
505
+
506
+
507
+ def cirrus_coverage_single_level(
508
+ time: np.datetime64 | pd.Timestamp,
509
+ met: MetDataset,
510
+ contrails: GeoVectorDataset,
511
+ *,
512
+ optical_depth_threshold: float = 0.1,
513
+ ) -> MetDataset:
514
+ """
515
+ Identify presence of contrail and natural cirrus in a longitude-latitude grid.
516
+
517
+ Parameters
518
+ ----------
519
+ met : MetDataset
520
+ Pressure level dataset containing 'air_temperature', 'specific_cloud_ice_water_content',
521
+ and 'geopotential' fields.
522
+ contrails : GeoVectorDataset
523
+ Contrail waypoints containing 'tau_contrail' field.
524
+ time : np.datetime64 | pd.Timestamp
525
+ Time when the cirrus statistics is computed.
526
+ optical_depth_threshold : float
527
+ Sensitivity of cirrus detection, set at 0.1 to match the capability of satellites.
528
+
529
+ Returns
530
+ -------
531
+ MetDataset
532
+ Single level dataset containing the contrail and natural cirrus coverage.
533
+ """
534
+ # Ensure `met` and `contrails` contains the required variables
535
+ met.ensure_vars(("air_temperature", "specific_cloud_ice_water_content", "geopotential"))
536
+ contrails.ensure_vars("tau_contrail")
537
+
538
+ # Spatial bounding box and resolution of `met`
539
+ spatial_bbox = (
540
+ np.min(met["longitude"].values),
541
+ np.min(met["latitude"].values),
542
+ np.max(met["longitude"].values),
543
+ np.max(met["latitude"].values),
544
+ )
545
+ spatial_grid_res = np.diff(met["longitude"].values)[0]
546
+
547
+ # Contrail cirrus optical depth in a longitude-latitude grid
548
+ tau_contrail = vector_to_lon_lat_grid(
549
+ contrails.filter(contrails["time"] == time),
550
+ agg={"tau_contrail": "sum"},
551
+ spatial_bbox=spatial_bbox,
552
+ spatial_grid_res=spatial_grid_res,
553
+ )["tau_contrail"]
554
+ tau_contrail = tau_contrail.expand_dims({"level": np.array([-1])})
555
+ tau_contrail = tau_contrail.expand_dims({"time": np.array([time])})
556
+ mda_tau_contrail = MetDataArray(tau_contrail)
557
+
558
+ # Natural cirrus optical depth in a longitude-latitude grid
559
+ met["tau_cirrus"] = tau_cirrus(met)
560
+ tau_cirrus_max = met["tau_cirrus"].data.sel(level=met["level"].data[-1], time=time)
561
+ tau_cirrus_max = tau_cirrus_max.expand_dims({"level": np.array([-1])})
562
+ tau_cirrus_max = tau_cirrus_max.expand_dims({"time": np.array([time])})
563
+ mda_tau_cirrus_max = MetDataArray(tau_cirrus_max)
564
+ mda_tau_all = MetDataArray(mda_tau_contrail.data + mda_tau_cirrus_max.data)
565
+
566
+ # Contrail and natural cirrus coverage in a longitude-latitude grid
567
+ mda_cc_contrails_clear_sky = optical_depth_to_cirrus_coverage(
568
+ mda_tau_contrail, threshold=optical_depth_threshold
569
+ )
570
+ mda_cc_natural_cirrus = optical_depth_to_cirrus_coverage(
571
+ mda_tau_cirrus_max, threshold=optical_depth_threshold
572
+ )
573
+ mda_cc_total = optical_depth_to_cirrus_coverage(mda_tau_all, threshold=optical_depth_threshold)
574
+ mda_cc_contrails = MetDataArray(mda_cc_total.data - mda_cc_natural_cirrus.data)
575
+
576
+ # Concatenate data
577
+ ds = xr.Dataset(
578
+ data_vars=dict(
579
+ contrails_clear_sky=mda_cc_contrails_clear_sky.data,
580
+ natural_cirrus=mda_cc_natural_cirrus.data,
581
+ contrails=mda_cc_contrails.data,
582
+ ),
583
+ coords=mda_cc_contrails_clear_sky.coords,
584
+ )
585
+
586
+ # Update attributes
587
+ attrs = _create_attributes()
588
+
589
+ for name in ds.data_vars:
590
+ ds[name].attrs = attrs[name]
591
+
592
+ return MetDataset(ds)
593
+
594
+
595
+ def optical_depth_to_cirrus_coverage(
596
+ optical_depth: MetDataArray,
597
+ *,
598
+ threshold: float = 0.1,
599
+ ) -> MetDataArray:
600
+ """
601
+ Calculate contrail or natural cirrus coverage in a longitude-latitude grid.
602
+
603
+ A grid cell is assumed to be covered by cirrus if the optical depth is above ``threshold``.
604
+
605
+ Parameters
606
+ ----------
607
+ optical_depth : MetDataArray
608
+ Contrail or natural cirrus optical depth in a longitude-latitude grid
609
+ threshold : float
610
+ Sensitivity of cirrus detection, set at 0.1 to match the capability of satellites.
611
+
612
+ Returns
613
+ -------
614
+ MetDataArray
615
+ Contrail or natural cirrus coverage in a longitude-latitude grid
616
+ """
617
+ cirrus_cover = (optical_depth.data > threshold).astype(int)
618
+ return MetDataArray(cirrus_cover)
619
+
620
+
621
+ def regional_statistics(da_var: xr.DataArray, *, agg: str) -> pd.Series:
622
+ """
623
+ Calculate regional statistics from longitude-latitude grid.
624
+
625
+ Parameters
626
+ ----------
627
+ da_var : xr.DataArray
628
+ Air traffic or contrail variable in a longitude-latitude grid.
629
+ agg : str
630
+ Function selected for aggregation, (i.e., "sum" and "mean").
631
+
632
+ Returns
633
+ -------
634
+ pd.Series
635
+ Regional statistics
636
+
637
+ Notes
638
+ -----
639
+ - The spatial bounding box for each region is defined in Teoh et al. (2023)
640
+ - Teoh, R., Engberg, Z., Shapiro, M., Dray, L., and Stettler, M.: A high-resolution Global
641
+ Aviation emissions Inventory based on ADS-B (GAIA) for 2019-2021, EGUsphere [preprint],
642
+ https://doi.org/10.5194/egusphere-2023-724, 2023.
643
+ """
644
+ if (agg == "mean") and (len(da_var.time) > 1):
645
+ da_var = da_var.mean(dim=["time"])
646
+ da_var = da_var.fillna(0.0)
647
+
648
+ # Get regional domain
649
+ vars_regional = _regional_data_arrays(da_var)
650
+
651
+ if agg == "sum":
652
+ vals = {
653
+ "World": np.nansum(vars_regional["world"].values),
654
+ "USA": np.nansum(vars_regional["usa"].values),
655
+ "Europe": np.nansum(vars_regional["europe"].values),
656
+ "East Asia": np.nansum(vars_regional["east_asia"].values),
657
+ "SEA": np.nansum(vars_regional["sea"].values),
658
+ "Latin America": np.nansum(vars_regional["latin_america"].values),
659
+ "Africa": np.nansum(vars_regional["africa"].values),
660
+ "China": np.nansum(vars_regional["china"].values),
661
+ "India": np.nansum(vars_regional["india"].values),
662
+ "North Atlantic": np.nansum(vars_regional["n_atlantic"].values),
663
+ "North Pacific": np.nansum(vars_regional["n_pacific_1"].values)
664
+ + np.nansum(vars_regional["n_pacific_2"].values),
665
+ "Arctic": np.nansum(vars_regional["arctic"].values),
666
+ }
667
+ elif agg == "mean":
668
+ area_world = geo.grid_surface_area(da_var["longitude"].values, da_var["latitude"].values)
669
+ area_regional = _regional_data_arrays(area_world)
670
+
671
+ vals = {
672
+ "World": _area_mean_properties(vars_regional["world"], area_regional["world"]),
673
+ "USA": _area_mean_properties(vars_regional["usa"], area_regional["usa"]),
674
+ "Europe": _area_mean_properties(vars_regional["europe"], area_regional["europe"]),
675
+ "East Asia": _area_mean_properties(
676
+ vars_regional["east_asia"], area_regional["east_asia"]
677
+ ),
678
+ "SEA": _area_mean_properties(vars_regional["sea"], area_regional["sea"]),
679
+ "Latin America": _area_mean_properties(
680
+ vars_regional["latin_america"], area_regional["latin_america"]
681
+ ),
682
+ "Africa": _area_mean_properties(vars_regional["africa"], area_regional["africa"]),
683
+ "China": _area_mean_properties(vars_regional["china"], area_regional["china"]),
684
+ "India": _area_mean_properties(vars_regional["india"], area_regional["india"]),
685
+ "North Atlantic": _area_mean_properties(
686
+ vars_regional["n_atlantic"], area_regional["n_atlantic"]
687
+ ),
688
+ "North Pacific": 0.4
689
+ * _area_mean_properties(vars_regional["n_pacific_1"], area_regional["n_pacific_1"])
690
+ + 0.6
691
+ * _area_mean_properties(vars_regional["n_pacific_2"], area_regional["n_pacific_2"]),
692
+ "Arctic": _area_mean_properties(vars_regional["arctic"], area_regional["arctic"]),
693
+ }
694
+ else:
695
+ raise NotImplementedError('Aggregation only accepts operations of "mean" or "sum".')
696
+
697
+ return pd.Series(vals)
698
+
699
+
700
+ def _regional_data_arrays(da_global: xr.DataArray) -> dict[str, xr.DataArray]:
701
+ """
702
+ Extract regional data arrays from global data array.
703
+
704
+ Parameters
705
+ ----------
706
+ da_global : xr.DataArray
707
+ Global air traffic or contrail variable in a longitude-latitude grid.
708
+
709
+ Returns
710
+ -------
711
+ dict[str, xr.DataArray]
712
+ Regional data arrays.
713
+
714
+ Notes
715
+ -----
716
+ - The spatial bounding box for each region is defined in Teoh et al. (2023)
717
+ - Teoh, R., Engberg, Z., Shapiro, M., Dray, L., and Stettler, M.: A high-resolution Global
718
+ Aviation emissions Inventory based on ADS-B (GAIA) for 2019-2021, EGUsphere [preprint],
719
+ https://doi.org/10.5194/egusphere-2023-724, 2023.
720
+ """
721
+ return {
722
+ "world": da_global.copy(),
723
+ "usa": da_global.sel(longitude=slice(-126.0, -66.0), latitude=slice(23.0, 50.0)),
724
+ "europe": da_global.sel(longitude=slice(-12.0, 20.0), latitude=slice(35.0, 60.0)),
725
+ "east_asia": da_global.sel(longitude=slice(103.0, 150.0), latitude=slice(15.0, 48.0)),
726
+ "sea": da_global.sel(longitude=slice(87.5, 130.0), latitude=slice(-10.0, 20.0)),
727
+ "latin_america": da_global.sel(longitude=slice(-85.0, -35.0), latitude=slice(-60.0, 15.0)),
728
+ "africa": da_global.sel(longitude=slice(-20.0, 50.0), latitude=slice(-35.0, 40.0)),
729
+ "china": da_global.sel(longitude=slice(73.5, 135.0), latitude=slice(18.0, 53.5)),
730
+ "india": da_global.sel(longitude=slice(68.0, 97.5), latitude=slice(8.0, 35.5)),
731
+ "n_atlantic": da_global.sel(longitude=slice(-70.0, -5.0), latitude=slice(40.0, 63.0)),
732
+ "n_pacific_1": da_global.sel(longitude=slice(-180.0, -140.0), latitude=slice(35.0, 65.0)),
733
+ "n_pacific_2": da_global.sel(longitude=slice(120.0, 180.0), latitude=slice(35.0, 65.0)),
734
+ "arctic": da_global.sel(latitude=slice(66.5, 90.0)),
735
+ }
736
+
737
+
738
+ def _area_mean_properties(da_var_region: xr.DataArray, da_area_region: xr.DataArray) -> float:
739
+ """
740
+ Calculate area-mean properties.
741
+
742
+ Parameters
743
+ ----------
744
+ da_var_region : xr.DataArray
745
+ Regional air traffic or contrail variable in a longitude-latitude grid.
746
+ da_area_region : xr.DataArray
747
+ Regional surface area in a longitude-latitude grid.
748
+
749
+ Returns
750
+ -------
751
+ float
752
+ Area-mean properties
753
+ """
754
+ return np.nansum(da_var_region.values * da_area_region.values) / np.nansum(
755
+ da_area_region.values
756
+ )
757
+
758
+
759
+ # ---------------------
760
+ # Time-slice statistics
761
+ # ---------------------
762
+
763
+
764
+ def time_slice_statistics(
765
+ t_start: np.datetime64 | pd.Timestamp,
766
+ t_end: np.datetime64 | pd.Timestamp,
767
+ flight_waypoints: GeoVectorDataset,
768
+ contrails: GeoVectorDataset,
769
+ *,
770
+ humidity_scaling: HumidityScaling,
771
+ met: MetDataset | None = None,
772
+ rad: MetDataset | None = None,
773
+ spatial_bbox: tuple[float, float, float, float] = (-180.0, -90.0, 180.0, 90.0),
774
+ ) -> pd.Series:
775
+ r"""
776
+ Calculate the flight and contrail summary statistics between `t_start` and `t_end`.
777
+
778
+ Parameters
779
+ ----------
780
+ t_start : np.datetime64 | pd.Timestamp
781
+ UTC time at beginning of time step.
782
+ t_end : np.datetime64 | pd.Timestamp
783
+ UTC time at end of time step.
784
+ flight_waypoints : GeoVectorDataset
785
+ Flight waypoint outputs.
786
+ contrails : GeoVectorDataset
787
+ Contrail evolution outputs from CoCiP, `cocip.contrail`.
788
+ humidity_scaling : HumidityScaling
789
+ Humidity scaling methodology.
790
+ See :attr:`CocipParams.humidity_scaling`
791
+ met : MetDataset | None
792
+ Pressure level dataset containing 'air_temperature', 'specific_humidity',
793
+ 'specific_cloud_ice_water_content', and 'geopotential'.
794
+ Meteorological statistics will not be computed if `None` is provided.
795
+ rad : MetDataset | None
796
+ Single level dataset containing the `sdr`, `rsr` and `olr`.Radiation statistics
797
+ will not be computed if `None` is provided.
798
+
799
+ spatial_bbox : tuple[float, float, float, float]
800
+ Spatial bounding box, `(lon_min, lat_min, lon_max, lat_max)`, [:math:`\deg`]
801
+
802
+ Returns
803
+ -------
804
+ pd.Series
805
+ Flight and contrail summary statistics. Contrail statistics are provided at `t_end`.
806
+ The units for each output are outlined in `Notes`.
807
+
808
+ Notes
809
+ -----
810
+ Outputs and units:
811
+ - ``n_flights``, [dimensionless]
812
+ - ``n_flights_forming_contrails``, [dimensionless]
813
+ - ``n_flights_forming_persistent_contrails``, [dimensionless]
814
+ - ``n_flights_with_persistent_contrails_at_t_end``, [dimensionless]
815
+
816
+ - ``n_waypoints``, [dimensionless]
817
+ - ``n_waypoints_forming_contrails``, [dimensionless]
818
+ - ``n_waypoints_forming_persistent_contrails``, [dimensionless]
819
+ - ``n_waypoints_with_persistent_contrails_at_t_end``, [dimensionless]
820
+ - ``n_contrail_waypoints_at_night``, [dimensionless]
821
+ - ``pct_contrail_waypoints_at_night``, [%]
822
+
823
+ - ``total_flight_distance``, [:math:`km`]
824
+ - ``total_contrails_formed``, [:math:`km`]
825
+ - ``total_persistent_contrails_formed``, [:math:`km`]
826
+ - ``total_persistent_contrails_at_t_end``, [:math:`km`]
827
+
828
+ - ``total_fuel_burn``, [:math:`kg`]
829
+ - ``mean_propulsion_efficiency_all_flights``, [dimensionless]
830
+ - ``mean_propulsion_efficiency_flights_with_persistent_contrails``, [dimensionless]
831
+ - ``mean_nvpm_ei_n_all_flights``, [:math:`kg^{-1}`]
832
+ - ``mean_nvpm_ei_n_flights_with_persistent_contrails``, [:math:`kg^{-1}`]
833
+
834
+ - ``mean_contrail_age``, [:math:`h`]
835
+ - ``max_contrail_age``, [:math:`h`]
836
+ - ``mean_n_ice_per_m``, [:math:`m^{-1}`]
837
+ - ``mean_contrail_ice_water_path``, [:math:`kg m^{-2}`]
838
+ - ``area_mean_contrail_ice_radius``, [:math:`\mu m`]
839
+ - ``volume_mean_contrail_ice_radius``, [:math:`\mu m`]
840
+ - ``mean_contrail_ice_effective_radius``, [:math:`\mu m`]
841
+ - ``mean_tau_contrail``, [dimensionless]
842
+ - ``mean_tau_cirrus``, [dimensionless]
843
+
844
+ - ``mean_rf_sw``, [:math:`W m^{-2}`]
845
+ - ``mean_rf_lw``, [:math:`W m^{-2}`]
846
+ - ``mean_rf_net``, [:math:`W m^{-2}`]
847
+ - ``total_contrail_ef``, [:math:`J`]
848
+
849
+ - ``issr_percentage_coverage``, [%]
850
+ - ``mean_rhi_in_issr``, [dimensionless]
851
+ - ``contrail_cirrus_percentage_coverage``, [%]
852
+ - ``contrail_cirrus_clear_sky_percentage_coverage``, [%]
853
+ - ``natural_cirrus_percentage_coverage``, [%]
854
+ - ``cloud_contrail_overlap_percentage``, [%]
855
+
856
+ - ``mean_sdr_domain``, [:math:`W m^{-2}`]
857
+ - ``mean_sdr_at_contrail_wypts``, [:math:`W m^{-2}`]
858
+ - ``mean_rsr_domain``, [:math:`W m^{-2}`]
859
+ - ``mean_rsr_at_contrail_wypts``, [:math:`W m^{-2}`]
860
+ - ``mean_olr_domain``, [:math:`W m^{-2}`]
861
+ - ``mean_olr_at_contrail_wypts``, [:math:`W m^{-2}`]
862
+ - ``mean_albedo_at_contrail_wypts``, [dimensionless]
863
+ """
864
+ # Ensure the required columns are included in `flight_waypoints`, `contrails`, `met` and `rad`
865
+ flight_waypoints.ensure_vars(
866
+ (
867
+ "flight_id",
868
+ "segment_length",
869
+ "true_airspeed",
870
+ "fuel_flow",
871
+ "engine_efficiency",
872
+ "nvpm_ei_n",
873
+ "sac",
874
+ "persistent_1",
875
+ )
876
+ )
877
+ contrails.ensure_vars(
878
+ (
879
+ "flight_id",
880
+ "segment_length",
881
+ "air_temperature",
882
+ "iwc",
883
+ "r_ice_vol",
884
+ "n_ice_per_m",
885
+ "tau_contrail",
886
+ "tau_cirrus",
887
+ "width",
888
+ "area_eff",
889
+ "sdr",
890
+ "rsr",
891
+ "olr",
892
+ "rf_sw",
893
+ "rf_lw",
894
+ "rf_net",
895
+ "ef",
896
+ )
897
+ )
898
+
899
+ # Ensure that the waypoints are within `t_start` and `t_end`
900
+ is_in_time = flight_waypoints.dataframe["time"].between(t_start, t_end, inclusive="right")
901
+
902
+ if not np.all(is_in_time):
903
+ warnings.warn(
904
+ "Flight waypoints have times that are outside the range of `t_start` and `t_end`. "
905
+ "Waypoints outside the defined time bounds are removed. "
906
+ )
907
+ flight_waypoints = flight_waypoints.filter(is_in_time)
908
+
909
+ is_in_time = contrails.dataframe["time"].between(t_start, t_end, inclusive="right")
910
+ if not np.all(is_in_time):
911
+ warnings.warn(
912
+ "Contrail waypoints have times that are outside the range of `t_start` and `t_end`."
913
+ "Waypoints outside the defined time bounds are removed. "
914
+ )
915
+ contrails = contrails.filter(is_in_time)
916
+
917
+ # Additional variables
918
+ flight_waypoints["fuel_burn"] = (
919
+ flight_waypoints["fuel_flow"]
920
+ * (1 / flight_waypoints["true_airspeed"])
921
+ * flight_waypoints["segment_length"]
922
+ )
923
+ contrails["pressure"] = units.m_to_pl(contrails["altitude"])
924
+ contrails["rho_air"] = thermo.rho_d(contrails["air_temperature"], contrails["pressure"])
925
+ contrails["plume_mass_per_m"] = plume_mass_per_distance(
926
+ contrails["area_eff"], contrails["rho_air"]
927
+ )
928
+ contrails["age"] = (contrails["time"] - contrails["formation_time"]) / np.timedelta64(1, "h")
929
+
930
+ # Meteorology domain statistics
931
+ if met is not None:
932
+ met.ensure_vars(
933
+ (
934
+ "air_temperature",
935
+ "specific_humidity",
936
+ "specific_cloud_ice_water_content",
937
+ "geopotential",
938
+ )
939
+ )
940
+ met = met.downselect(spatial_bbox)
941
+ met_stats = meteorological_time_slice_statistics(t_end, contrails, met, humidity_scaling)
942
+
943
+ # Radiation domain statistics
944
+ if rad is not None:
945
+ rad.ensure_vars(("sdr", "rsr", "olr"))
946
+ rad = rad.downselect(spatial_bbox)
947
+ rad_stats = radiation_time_slice_statistics(rad, t_end)
948
+
949
+ # Calculate time-slice statistics
950
+ is_sac = flight_waypoints["sac"] == 1.0
951
+ is_persistent = flight_waypoints["persistent_1"] == 1.0
952
+ is_at_t_end = contrails["time"] == t_end
953
+ is_night_time = contrails["sdr"] < 0.1
954
+ domain_area = geo.domain_surface_area(spatial_bbox)
955
+
956
+ stats_t = {
957
+ "time_start": t_start,
958
+ "time_end": t_end,
959
+ # Flight statistics
960
+ "n_flights": len(flight_waypoints.dataframe["flight_id"].unique()),
961
+ "n_flights_forming_contrails": len(
962
+ flight_waypoints.filter(is_sac).dataframe["flight_id"].unique()
963
+ ),
964
+ "n_flights_forming_persistent_contrails": len(
965
+ flight_waypoints.filter(is_persistent).dataframe["flight_id"].unique()
966
+ ),
967
+ "n_flights_with_persistent_contrails_at_t_end": len(
968
+ contrails.filter(is_at_t_end).dataframe["flight_id"].unique()
969
+ ),
970
+ # Waypoint statistics
971
+ "n_waypoints": len(flight_waypoints),
972
+ "n_waypoints_forming_contrails": len(flight_waypoints.filter(is_sac)),
973
+ "n_waypoints_forming_persistent_contrails": len(flight_waypoints.filter(is_persistent)),
974
+ "n_waypoints_with_persistent_contrails_at_t_end": len(contrails.filter(is_at_t_end)),
975
+ "n_contrail_waypoints_at_night": len(contrails.filter(is_at_t_end)),
976
+ "pct_contrail_waypoints_at_night": (
977
+ len(contrails.filter(is_night_time)) / len(contrails) * 100
978
+ ),
979
+ # Distance statistics
980
+ "total_flight_distance": np.nansum(flight_waypoints["segment_length"]) / 1000,
981
+ "total_contrails_formed": (
982
+ np.nansum(flight_waypoints.filter(is_sac)["segment_length"]) / 1000
983
+ ),
984
+ "total_persistent_contrails_formed": (
985
+ np.nansum(flight_waypoints.filter(is_persistent)["segment_length"]) / 1000
986
+ ),
987
+ "total_persistent_contrails_at_t_end": (
988
+ np.nansum(contrails.filter(is_at_t_end)["segment_length"]) / 1000
989
+ ),
990
+ # Aircraft performance statistics
991
+ "total_fuel_burn": np.nansum(flight_waypoints["fuel_burn"]),
992
+ "mean_propulsion_efficiency_all_flights": np.nanmean(flight_waypoints["engine_efficiency"]),
993
+ "mean_propulsion_efficiency_flights_with_persistent_contrails": (
994
+ np.nanmean(flight_waypoints.filter(is_persistent)["engine_efficiency"])
995
+ if np.any(is_persistent)
996
+ else np.nan
997
+ ),
998
+ "mean_nvpm_ei_n_all_flights": np.nanmean(flight_waypoints["nvpm_ei_n"]),
999
+ "mean_nvpm_ei_n_flights_with_persistent_contrails": (
1000
+ np.nanmean(flight_waypoints.filter(is_persistent)["nvpm_ei_n"])
1001
+ if np.any(is_persistent)
1002
+ else np.nan
1003
+ ),
1004
+ # Contrail properties at `time_end`
1005
+ "mean_contrail_age": (
1006
+ np.nanmean(contrails.filter(is_at_t_end)["age"]) if np.any(is_at_t_end) else np.nan
1007
+ ),
1008
+ "max_contrail_age": (
1009
+ np.nanmax(contrails.filter(is_at_t_end)["age"]) if np.any(is_at_t_end) else np.nan
1010
+ ),
1011
+ "mean_n_ice_per_m": (
1012
+ np.nanmean(contrails.filter(is_at_t_end)["n_ice_per_m"])
1013
+ if np.any(is_at_t_end)
1014
+ else np.nan
1015
+ ),
1016
+ "mean_contrail_ice_water_path": (
1017
+ area_mean_ice_water_path(
1018
+ contrails.filter(is_at_t_end)["iwc"],
1019
+ contrails.filter(is_at_t_end)["plume_mass_per_m"],
1020
+ contrails.filter(is_at_t_end)["segment_length"],
1021
+ domain_area,
1022
+ )
1023
+ if np.any(is_at_t_end)
1024
+ else np.nan
1025
+ ),
1026
+ "area_mean_contrail_ice_radius": (
1027
+ area_mean_ice_particle_radius(
1028
+ contrails.filter(is_at_t_end)["r_ice_vol"],
1029
+ contrails.filter(is_at_t_end)["n_ice_per_m"],
1030
+ contrails.filter(is_at_t_end)["segment_length"],
1031
+ )
1032
+ if np.any(is_at_t_end)
1033
+ else np.nan
1034
+ ),
1035
+ "volume_mean_contrail_ice_radius": (
1036
+ volume_mean_ice_particle_radius(
1037
+ contrails.filter(is_at_t_end)["r_ice_vol"],
1038
+ contrails.filter(is_at_t_end)["n_ice_per_m"],
1039
+ contrails.filter(is_at_t_end)["segment_length"],
1040
+ )
1041
+ if np.any(is_at_t_end)
1042
+ else np.nan
1043
+ ),
1044
+ "mean_contrail_ice_effective_radius": (
1045
+ mean_ice_particle_effective_radius(
1046
+ contrails.filter(is_at_t_end)["r_ice_vol"],
1047
+ contrails.filter(is_at_t_end)["n_ice_per_m"],
1048
+ contrails.filter(is_at_t_end)["segment_length"],
1049
+ )
1050
+ if np.any(is_at_t_end)
1051
+ else np.nan
1052
+ ),
1053
+ "mean_tau_contrail": (
1054
+ area_mean_contrail_property(
1055
+ contrails.filter(is_at_t_end)["tau_contrail"],
1056
+ contrails.filter(is_at_t_end)["segment_length"],
1057
+ contrails.filter(is_at_t_end)["width"],
1058
+ domain_area,
1059
+ )
1060
+ if np.any(is_at_t_end)
1061
+ else np.nan
1062
+ ),
1063
+ "mean_tau_cirrus": (
1064
+ area_mean_contrail_property(
1065
+ contrails.filter(is_at_t_end)["tau_cirrus"],
1066
+ contrails.filter(is_at_t_end)["segment_length"],
1067
+ contrails.filter(is_at_t_end)["width"],
1068
+ domain_area,
1069
+ )
1070
+ if np.any(is_at_t_end)
1071
+ else np.nan
1072
+ ),
1073
+ # Contrail climate forcing
1074
+ "mean_rf_sw": (
1075
+ area_mean_contrail_property(
1076
+ contrails.filter(is_at_t_end)["rf_sw"],
1077
+ contrails.filter(is_at_t_end)["segment_length"],
1078
+ contrails.filter(is_at_t_end)["width"],
1079
+ domain_area,
1080
+ )
1081
+ if np.any(is_at_t_end)
1082
+ else np.nan
1083
+ ),
1084
+ "mean_rf_lw": (
1085
+ area_mean_contrail_property(
1086
+ contrails.filter(is_at_t_end)["rf_lw"],
1087
+ contrails.filter(is_at_t_end)["segment_length"],
1088
+ contrails.filter(is_at_t_end)["width"],
1089
+ domain_area,
1090
+ )
1091
+ if np.any(is_at_t_end)
1092
+ else np.nan
1093
+ ),
1094
+ "mean_rf_net": (
1095
+ area_mean_contrail_property(
1096
+ contrails.filter(is_at_t_end)["rf_net"],
1097
+ contrails.filter(is_at_t_end)["segment_length"],
1098
+ contrails.filter(is_at_t_end)["width"],
1099
+ domain_area,
1100
+ )
1101
+ if np.any(is_at_t_end)
1102
+ else np.nan
1103
+ ),
1104
+ "total_contrail_ef": np.nansum(contrails["ef"]) if np.any(is_at_t_end) else np.nan,
1105
+ # Meteorology statistics
1106
+ "issr_percentage_coverage": (
1107
+ (met_stats["issr_percentage_coverage"]) if met is not None else np.nan
1108
+ ),
1109
+ "mean_rhi_in_issr": met_stats["mean_rhi_in_issr"] if met is not None else np.nan,
1110
+ "contrail_cirrus_percentage_coverage": (
1111
+ (met_stats["contrail_cirrus_percentage_coverage"]) if met is not None else np.nan
1112
+ ),
1113
+ "contrail_cirrus_clear_sky_percentage_coverage": (
1114
+ (met_stats["contrail_cirrus_clear_sky_percentage_coverage"])
1115
+ if met is not None
1116
+ else np.nan
1117
+ ),
1118
+ "natural_cirrus_percentage_coverage": (
1119
+ (met_stats["natural_cirrus_percentage_coverage"]) if met is not None else np.nan
1120
+ ),
1121
+ "cloud_contrail_overlap_percentage": (
1122
+ percentage_cloud_contrail_overlap(
1123
+ met_stats["contrail_cirrus_percentage_coverage"],
1124
+ met_stats["contrail_cirrus_clear_sky_percentage_coverage"],
1125
+ )
1126
+ if met is not None
1127
+ else np.nan
1128
+ ),
1129
+ # Radiation statistics
1130
+ "mean_sdr_domain": rad_stats["mean_sdr_domain"] if rad is not None else np.nan,
1131
+ "mean_sdr_at_contrail_wypts": (
1132
+ np.nanmean(contrails.filter(is_at_t_end)["sdr"]) if np.any(is_at_t_end) else np.nan
1133
+ ),
1134
+ "mean_rsr_domain": rad_stats["mean_rsr_domain"] if rad is not None else np.nan,
1135
+ "mean_rsr_at_contrail_wypts": (
1136
+ np.nanmean(contrails.filter(is_at_t_end)["rsr"]) if np.any(is_at_t_end) else np.nan
1137
+ ),
1138
+ "mean_olr_domain": rad_stats["mean_olr_domain"] if rad is not None else np.nan,
1139
+ "mean_olr_at_contrail_wypts": (
1140
+ np.nanmean(contrails.filter(is_at_t_end)["olr"]) if np.any(is_at_t_end) else np.nan
1141
+ ),
1142
+ "mean_albedo_at_contrail_wypts": (
1143
+ np.nanmean(
1144
+ albedo(contrails.filter(is_at_t_end)["sdr"], contrails.filter(is_at_t_end)["rsr"])
1145
+ )
1146
+ if np.any(is_at_t_end)
1147
+ else np.nan
1148
+ ),
1149
+ }
1150
+ return pd.Series(stats_t)
1151
+
1152
+
1153
+ def meteorological_time_slice_statistics(
1154
+ time: np.datetime64 | pd.Timestamp,
1155
+ contrails: GeoVectorDataset,
1156
+ met: MetDataset,
1157
+ humidity_scaling: HumidityScaling,
1158
+ cirrus_coverage: MetDataset | None = None,
1159
+ ) -> pd.Series:
1160
+ """
1161
+ Calculate meteorological statistics in the domain provided.
1162
+
1163
+ Parameters
1164
+ ----------
1165
+ time : np.datetime64 | pd.Timestamp
1166
+ Time when the meteorological statistics is computed.
1167
+ contrails : GeoVectorDataset
1168
+ Contrail waypoints containing `tau_contrail`.
1169
+ met : MetDataset
1170
+ Pressure level dataset containing 'air_temperature', 'specific_humidity',
1171
+ 'specific_cloud_ice_water_content', and 'geopotential'
1172
+ humidity_scaling : HumidityScaling
1173
+ Humidity scaling methodology.
1174
+ See :attr:`CocipParams.humidity_scaling`
1175
+ cirrus_coverage : MetDataset
1176
+ Single level dataset containing the contrail and natural cirrus coverage, including
1177
+ `cc_contrails_clear_sky`, `cc_natural_cirrus`, `cc_contrails`
1178
+
1179
+ Returns
1180
+ -------
1181
+ pd.Series
1182
+ Mean ISSR characteristics, and the percentage of contrail and natural cirrus coverage in
1183
+ domain area.
1184
+ """
1185
+ # Ensure vars
1186
+ met.ensure_vars(
1187
+ ("air_temperature", "specific_humidity", "specific_cloud_ice_water_content", "geopotential")
1188
+ )
1189
+
1190
+ # ISSR: Volume of airspace with RHi > 100% between FL300 and FL450
1191
+ met_cruise = MetDataset(met.data.sel(level=slice(150, 300)))
1192
+ rhi = humidity_scaling.eval(met_cruise)["rhi"].data
1193
+
1194
+ try:
1195
+ # If the given time is already in the dataset, select the time slice
1196
+ i = rhi.get_index("time").get_loc(time)
1197
+ except KeyError:
1198
+ rhi = rhi.interp(time=time)
1199
+ else:
1200
+ rhi = rhi.isel(time=i)
1201
+
1202
+ is_issr = rhi > 1.0
1203
+
1204
+ # Cirrus in a longitude-latitude grid
1205
+ if cirrus_coverage is None:
1206
+ cirrus_coverage = cirrus_coverage_single_level(time, met, contrails)
1207
+
1208
+ # Calculate statistics
1209
+ area = geo.grid_surface_area(met["longitude"].values, met["latitude"].values)
1210
+ weights = area / np.nansum(area)
1211
+
1212
+ stats = {
1213
+ "issr_percentage_coverage": (
1214
+ np.nansum(is_issr * weights) / (np.nansum(weights) * len(rhi.level))
1215
+ )
1216
+ * 100,
1217
+ "mean_rhi_in_issr": np.nanmean(rhi.values[is_issr.values]),
1218
+ "contrail_cirrus_percentage_coverage": (
1219
+ np.nansum(area * cirrus_coverage["contrails"].data) / np.nansum(area)
1220
+ )
1221
+ * 100,
1222
+ "contrail_cirrus_clear_sky_percentage_coverage": (
1223
+ np.nansum(area * cirrus_coverage["contrails_clear_sky"].data) / np.nansum(area)
1224
+ )
1225
+ * 100,
1226
+ "natural_cirrus_percentage_coverage": (
1227
+ np.nansum(area * cirrus_coverage["natural_cirrus"].data) / np.nansum(area)
1228
+ )
1229
+ * 100,
1230
+ }
1231
+ return pd.Series(stats)
1232
+
1233
+
1234
+ def radiation_time_slice_statistics(
1235
+ rad: MetDataset, time: np.datetime64 | pd.Timestamp
1236
+ ) -> pd.Series:
1237
+ """
1238
+ Calculate radiation statistics in the domain provided.
1239
+
1240
+ Parameters
1241
+ ----------
1242
+ rad : MetDataset
1243
+ Single level dataset containing the `sdr`, `rsr` and `olr`.
1244
+ time : np.datetime64 | pd.Timestamp
1245
+ Time when the radiation statistics is computed.
1246
+
1247
+ Returns
1248
+ -------
1249
+ pd.Series
1250
+ Mean SDR, RSR and OLR in domain area.
1251
+ """
1252
+ rad.ensure_vars(("sdr", "rsr", "olr"))
1253
+ surface_area = geo.grid_surface_area(rad["longitude"].values, rad["latitude"].values)
1254
+ weights = surface_area.values / np.nansum(surface_area)
1255
+ stats = {
1256
+ "mean_sdr_domain": np.nansum(
1257
+ np.squeeze(rad["sdr"].data.interp(time=time).values) * weights
1258
+ ),
1259
+ "mean_rsr_domain": np.nansum(
1260
+ np.squeeze(rad["rsr"].data.interp(time=time).values) * weights
1261
+ ),
1262
+ "mean_olr_domain": np.nansum(
1263
+ np.squeeze(rad["olr"].data.interp(time=time).values) * weights
1264
+ ),
1265
+ }
1266
+ return pd.Series(stats)
1267
+
1268
+
1269
+ def area_mean_ice_water_path(
1270
+ iwc: npt.NDArray[np.floating],
1271
+ plume_mass_per_m: npt.NDArray[np.floating],
1272
+ segment_length: npt.NDArray[np.floating],
1273
+ domain_area: float,
1274
+ ) -> float:
1275
+ """
1276
+ Calculate area-mean contrail ice water path.
1277
+
1278
+ Ice water path (IWC) is the contrail ice mass divided by the domain area of interest.
1279
+
1280
+ Parameters
1281
+ ----------
1282
+ iwc : npt.NDArray[np.floating]
1283
+ Contrail ice water content, i.e., contrail ice mass per kg of
1284
+ air, [:math:`kg_{H_{2}O}/kg_{air}`]
1285
+ plume_mass_per_m : npt.NDArray[np.floating]
1286
+ Contrail plume mass per unit length, [:math:`kg m^{-1}`]
1287
+ segment_length : npt.NDArray[np.floating]
1288
+ Contrail segment length for each waypoint, [:math:`m`]
1289
+ domain_area : float
1290
+ Domain surface area, [:math:`m^{2}`]
1291
+
1292
+ Returns
1293
+ -------
1294
+ float
1295
+ Mean contrail ice water path, [:math:`kg m^{-2}`]
1296
+ """
1297
+ return np.nansum(iwc * plume_mass_per_m * segment_length) / domain_area
1298
+
1299
+
1300
+ def area_mean_ice_particle_radius(
1301
+ r_ice_vol: npt.NDArray[np.floating],
1302
+ n_ice_per_m: npt.NDArray[np.floating],
1303
+ segment_length: npt.NDArray[np.floating],
1304
+ ) -> float:
1305
+ r"""
1306
+ Calculate the area-mean contrail ice particle radius.
1307
+
1308
+ Parameters
1309
+ ----------
1310
+ r_ice_vol : npt.NDArray[np.floating]
1311
+ Ice particle volume mean radius for each waypoint, [:math:`m`]
1312
+ n_ice_per_m : npt.NDArray[np.floating]
1313
+ Number of ice particles per distance for each waypoint, [:math:`m^{-1}`]
1314
+ segment_length : npt.NDArray[np.floating]
1315
+ Contrail segment length for each waypoint, [:math:`m`]
1316
+
1317
+ Returns
1318
+ -------
1319
+ float
1320
+ Area-mean contrail ice particle radius `r_area`, [:math:`\mu m`]
1321
+
1322
+ Notes
1323
+ -----
1324
+ - Re-arranged from `tot_ice_cross_sec_area` = `tot_n_ice_particles` * (np.pi * `r_ice_vol`**2)
1325
+ - Assumes that the contrail ice crystals are spherical.
1326
+ """
1327
+ tot_ice_cross_sec_area = _total_ice_particle_cross_sectional_area(
1328
+ r_ice_vol, n_ice_per_m, segment_length
1329
+ )
1330
+ tot_n_ice_particles = _total_ice_particle_number(n_ice_per_m, segment_length)
1331
+ return (tot_ice_cross_sec_area / (np.pi * tot_n_ice_particles)) ** (1 / 2) * 10**6
1332
+
1333
+
1334
+ def volume_mean_ice_particle_radius(
1335
+ r_ice_vol: npt.NDArray[np.floating],
1336
+ n_ice_per_m: npt.NDArray[np.floating],
1337
+ segment_length: npt.NDArray[np.floating],
1338
+ ) -> float:
1339
+ r"""
1340
+ Calculate the volume-mean contrail ice particle radius.
1341
+
1342
+ Parameters
1343
+ ----------
1344
+ r_ice_vol : npt.NDArray[np.floating]
1345
+ Ice particle volume mean radius for each waypoint, [:math:`m`]
1346
+ n_ice_per_m : npt.NDArray[np.floating]
1347
+ Number of ice particles per distance for each waypoint, [:math:`m^{-1}`]
1348
+ segment_length : npt.NDArray[np.floating]
1349
+ Contrail segment length for each waypoint, [:math:`m`]
1350
+
1351
+ Returns
1352
+ -------
1353
+ float
1354
+ Volume-mean contrail ice particle radius `r_vol`, [:math:`\mu m`]
1355
+
1356
+ Notes
1357
+ -----
1358
+ - Re-arranged from `tot_ice_vol` = `tot_n_ice_particles` * (4 / 3 * np.pi * `r_ice_vol`**3)
1359
+ - Assumes that the contrail ice crystals are spherical.
1360
+ """
1361
+ tot_ice_vol = _total_ice_particle_volume(r_ice_vol, n_ice_per_m, segment_length)
1362
+ tot_n_ice_particles = _total_ice_particle_number(n_ice_per_m, segment_length)
1363
+ return (tot_ice_vol / ((4 / 3) * np.pi * tot_n_ice_particles)) ** (1 / 3) * 10**6
1364
+
1365
+
1366
+ def mean_ice_particle_effective_radius(
1367
+ r_ice_vol: npt.NDArray[np.floating],
1368
+ n_ice_per_m: npt.NDArray[np.floating],
1369
+ segment_length: npt.NDArray[np.floating],
1370
+ ) -> float:
1371
+ r"""
1372
+ Calculate the mean contrail ice particle effective radius.
1373
+
1374
+ Parameters
1375
+ ----------
1376
+ r_ice_vol : npt.NDArray[np.floating]
1377
+ Ice particle volume mean radius for each waypoint, [:math:`m`]
1378
+ n_ice_per_m : npt.NDArray[np.floating]
1379
+ Number of ice particles per distance for each waypoint, [:math:`m^{-1}`]
1380
+ segment_length : npt.NDArray[np.floating]
1381
+ Contrail segment length for each waypoint, [:math:`m`]
1382
+
1383
+ Returns
1384
+ -------
1385
+ float
1386
+ Mean contrail ice particle effective radius `r_eff`, [:math:`\mu m`]
1387
+
1388
+ Notes
1389
+ -----
1390
+ - `r_eff` is the ratio of the particle volume to particle projected area.
1391
+ - `r_eff` = (3 / 4) * (`tot_ice_vol` / `tot_ice_cross_sec_area`)
1392
+ - See Eq. (62) of :cite:`schumannContrailCirrusPrediction2012`.
1393
+ """
1394
+ tot_ice_vol = _total_ice_particle_volume(r_ice_vol, n_ice_per_m, segment_length)
1395
+ tot_ice_cross_sec_area = _total_ice_particle_cross_sectional_area(
1396
+ r_ice_vol, n_ice_per_m, segment_length
1397
+ )
1398
+ return (3 / 4) * (tot_ice_vol / tot_ice_cross_sec_area) * 10**6
1399
+
1400
+
1401
+ def _total_ice_particle_cross_sectional_area(
1402
+ r_ice_vol: npt.NDArray[np.floating],
1403
+ n_ice_per_m: npt.NDArray[np.floating],
1404
+ segment_length: npt.NDArray[np.floating],
1405
+ ) -> float:
1406
+ """
1407
+ Calculate total contrail ice particle cross-sectional area.
1408
+
1409
+ Parameters
1410
+ ----------
1411
+ r_ice_vol : npt.NDArray[np.floating]
1412
+ Ice particle volume mean radius for each waypoint, [:math:`m`]
1413
+ n_ice_per_m : npt.NDArray[np.floating]
1414
+ Number of ice particles per distance for each waypoint, [:math:`m^{-1}`]
1415
+ segment_length : npt.NDArray[np.floating]
1416
+ Contrail segment length for each waypoint, [:math:`m`]
1417
+
1418
+ Returns
1419
+ -------
1420
+ float
1421
+ Total ice particle cross-sectional area from all contrail waypoints, [:math:`m^{2}`]
1422
+ """
1423
+ ice_cross_sec_area = 0.9 * np.pi * r_ice_vol**2
1424
+ return np.nansum(ice_cross_sec_area * n_ice_per_m * segment_length)
1425
+
1426
+
1427
+ def _total_ice_particle_volume(
1428
+ r_ice_vol: npt.NDArray[np.floating],
1429
+ n_ice_per_m: npt.NDArray[np.floating],
1430
+ segment_length: npt.NDArray[np.floating],
1431
+ ) -> float:
1432
+ """
1433
+ Calculate total contrail ice particle volume.
1434
+
1435
+ Parameters
1436
+ ----------
1437
+ r_ice_vol : npt.NDArray[np.floating]
1438
+ Ice particle volume mean radius for each waypoint, [:math:`m`]
1439
+ n_ice_per_m : npt.NDArray[np.floating]
1440
+ Number of ice particles per distance for each waypoint, [:math:`m^{-1}`]
1441
+ segment_length : npt.NDArray[np.floating]
1442
+ Contrail segment length for each waypoint, [:math:`m`]
1443
+
1444
+ Returns
1445
+ -------
1446
+ float
1447
+ Total ice particle volume from all contrail waypoints, [:math:`m^{2}`]
1448
+ """
1449
+ ice_vol = (4 / 3) * np.pi * r_ice_vol**3
1450
+ return np.nansum(ice_vol * n_ice_per_m * segment_length)
1451
+
1452
+
1453
+ def _total_ice_particle_number(
1454
+ n_ice_per_m: npt.NDArray[np.floating], segment_length: npt.NDArray[np.floating]
1455
+ ) -> float:
1456
+ """
1457
+ Calculate total number of contrail ice particles.
1458
+
1459
+ Parameters
1460
+ ----------
1461
+ n_ice_per_m : npt.NDArray[np.floating]
1462
+ Number of ice particles per distance for each waypoint, [:math:`m^{-1}`]
1463
+ segment_length : npt.NDArray[np.floating]
1464
+ Contrail segment length for each waypoint, [:math:`m`]
1465
+
1466
+ Returns
1467
+ -------
1468
+ float
1469
+ Total number of ice particles from all contrail waypoints.
1470
+ """
1471
+ return np.nansum(n_ice_per_m * segment_length)
1472
+
1473
+
1474
+ def area_mean_contrail_property(
1475
+ contrail_property: npt.NDArray[np.floating],
1476
+ segment_length: npt.NDArray[np.floating],
1477
+ width: npt.NDArray[np.floating],
1478
+ domain_area: float,
1479
+ ) -> float:
1480
+ """
1481
+ Calculate area mean contrail property.
1482
+
1483
+ Used to calculate the area mean `tau_contrail`, `tau_cirrus`, `sdr`, `rsr`, `olr`, `rf_sw`,
1484
+ `rf_lw` and `rf_net`.
1485
+
1486
+ Parameters
1487
+ ----------
1488
+ contrail_property : npt.NDArray[np.floating]
1489
+ Selected contrail property for each waypoint
1490
+ segment_length : npt.NDArray[np.floating]
1491
+ Contrail segment length for each waypoint, [:math:`m`]
1492
+ width : npt.NDArray[np.floating]
1493
+ Contrail width for each waypoint, [:math:`m`]
1494
+ domain_area : float
1495
+ Domain surface area, [:math:`m^{2}`]
1496
+
1497
+ Returns
1498
+ -------
1499
+ float
1500
+ Area mean contrail property
1501
+ """
1502
+ return np.nansum(contrail_property * segment_length * width) / domain_area
1503
+
1504
+
1505
+ def percentage_cloud_contrail_overlap(
1506
+ contrail_cover: float | np.ndarray, contrail_cover_clear_sky: float | np.ndarray
1507
+ ) -> float | np.ndarray:
1508
+ """
1509
+ Calculate the percentage area of cloud-contrail overlap.
1510
+
1511
+ Parameters
1512
+ ----------
1513
+ contrail_cover : float | np.ndarray
1514
+ Percentage of contrail cirrus cover without overlap with natural cirrus.
1515
+ See `cirrus_coverage_single_level` function.
1516
+ contrail_cover_clear_sky : float | np.ndarray
1517
+ Percentage of contrail cirrus cover in clear sky conditions.
1518
+ See `cirrus_coverage_single_level` function.
1519
+
1520
+ Returns
1521
+ -------
1522
+ float | np.ndarray
1523
+ Percentage of cloud-contrail overlap
1524
+ """
1525
+ return np.where(
1526
+ contrail_cover_clear_sky > 0,
1527
+ 100 - (contrail_cover / contrail_cover_clear_sky * 100),
1528
+ 0,
1529
+ )
1530
+
1531
+
1532
+ # ---------------------------------------
1533
+ # High resolution grid: contrail segments
1534
+ # ---------------------------------------
1535
+
1536
+
1537
+ def contrails_to_hi_res_grid(
1538
+ time: pd.Timestamp | np.datetime64,
1539
+ contrails_t: GeoVectorDataset,
1540
+ *,
1541
+ var_name: str,
1542
+ spatial_bbox: tuple[float, float, float, float] = (-180.0, -90.0, 180.0, 90.0),
1543
+ spatial_grid_res: float = 0.05,
1544
+ ) -> xr.DataArray:
1545
+ r"""
1546
+ Aggregate contrail segments to a high-resolution longitude-latitude grid.
1547
+
1548
+ Parameters
1549
+ ----------
1550
+ time : pd.Timestamp | np.datetime64
1551
+ UTC time of interest.
1552
+ contrails_t : GeoVectorDataset
1553
+ All contrail waypoint outputs at `time`.
1554
+ var_name : str
1555
+ Contrail property for aggregation, where `var_name` must be included in `contrail_segment`.
1556
+ For example, `tau_contrail`, `rf_sw`, `rf_lw`, and `rf_net`
1557
+ spatial_bbox : tuple[float, float, float, float]
1558
+ Spatial bounding box, `(lon_min, lat_min, lon_max, lat_max)`, [:math:`\deg`]
1559
+ spatial_grid_res : float
1560
+ Spatial grid resolution, [:math:`\deg`]
1561
+
1562
+ Returns
1563
+ -------
1564
+ xr.DataArray
1565
+ Contrail segments and their properties aggregated to a longitude-latitude grid.
1566
+ """
1567
+ # Ensure the required columns are included in `contrails_t`
1568
+ cols_req = [
1569
+ "flight_id",
1570
+ "waypoint",
1571
+ "longitude",
1572
+ "latitude",
1573
+ "altitude",
1574
+ "time",
1575
+ "sin_a",
1576
+ "cos_a",
1577
+ "width",
1578
+ var_name,
1579
+ ]
1580
+ contrails_t.ensure_vars(cols_req)
1581
+
1582
+ # Ensure that the times in `contrails_t` are the same.
1583
+ is_in_time = contrails_t["time"] == time
1584
+ if not np.all(is_in_time):
1585
+ warnings.warn(
1586
+ f"Contrails have inconsistent times. Waypoints that are not in {time} are removed."
1587
+ )
1588
+ contrails_t = contrails_t.filter(is_in_time)
1589
+
1590
+ main_grid = _initialise_longitude_latitude_grid(spatial_bbox, spatial_grid_res)
1591
+
1592
+ # Contrail head and tails: continuous segments only
1593
+ heads_t = contrails_t.dataframe
1594
+ heads_t = heads_t.sort_values(["flight_id", "waypoint"])
1595
+ tails_t = heads_t.shift(periods=-1)
1596
+
1597
+ is_continuous = heads_t["continuous"]
1598
+ heads_t = heads_t[is_continuous].copy()
1599
+ tails_t = tails_t[is_continuous].copy()
1600
+ tails_t["waypoint"] = tails_t["waypoint"].astype("int")
1601
+
1602
+ heads_t = heads_t.set_index(["flight_id", "waypoint"], drop=False)
1603
+ tails_t.index = heads_t.index
1604
+
1605
+ # Aggregate contrail segments to a high resolution longitude-latitude grid
1606
+ try:
1607
+ from tqdm.auto import tqdm
1608
+ except ModuleNotFoundError as exc:
1609
+ dependencies.raise_module_not_found_error(
1610
+ name="contrails_to_hi_res_grid function",
1611
+ package_name="tqdm",
1612
+ module_not_found_error=exc,
1613
+ )
1614
+
1615
+ for i in tqdm(heads_t.index):
1616
+ contrail_segment = GeoVectorDataset(
1617
+ pd.concat([heads_t[cols_req].loc[i], tails_t[cols_req].loc[i]], axis=1).T, copy=True
1618
+ )
1619
+
1620
+ segment_grid = segment_property_to_hi_res_grid(
1621
+ contrail_segment, var_name=var_name, spatial_grid_res=spatial_grid_res
1622
+ )
1623
+ main_grid = _add_segment_to_main_grid(main_grid, segment_grid)
1624
+
1625
+ return main_grid
1626
+
1627
+
1628
+ def _initialise_longitude_latitude_grid(
1629
+ spatial_bbox: tuple[float, float, float, float] = (-180.0, -90.0, 180.0, 90.0),
1630
+ spatial_grid_res: float = 0.05,
1631
+ ) -> xr.DataArray:
1632
+ r"""
1633
+ Create longitude-latitude grid of specified coordinates and spatial resolution.
1634
+
1635
+ Parameters
1636
+ ----------
1637
+ spatial_bbox : tuple[float, float, float, float]
1638
+ Spatial bounding box, `(lon_min, lat_min, lon_max, lat_max)`, [:math:`\deg`]
1639
+ spatial_grid_res : float
1640
+ Spatial grid resolution, [:math:`\deg`]
1641
+
1642
+ Returns
1643
+ -------
1644
+ xr.DataArray
1645
+ Longitude-latitude grid of specified coordinates and spatial resolution, filled with zeros.
1646
+
1647
+ Notes
1648
+ -----
1649
+ This empty grid is used to store the aggregated contrail properties of the individual
1650
+ contrail segments, such as the gridded contrail optical depth and radiative forcing.
1651
+ """
1652
+ lon_coords = np.arange(spatial_bbox[0], spatial_bbox[2] + spatial_grid_res, spatial_grid_res)
1653
+ lat_coords = np.arange(spatial_bbox[1], spatial_bbox[3] + spatial_grid_res, spatial_grid_res)
1654
+ return xr.DataArray(
1655
+ np.zeros((len(lon_coords), len(lat_coords))),
1656
+ dims=["longitude", "latitude"],
1657
+ coords={"longitude": lon_coords, "latitude": lat_coords},
1658
+ )
1659
+
1660
+
1661
+ def segment_property_to_hi_res_grid(
1662
+ contrail_segment: GeoVectorDataset,
1663
+ *,
1664
+ var_name: str,
1665
+ spatial_grid_res: float = 0.05,
1666
+ ) -> xr.DataArray:
1667
+ r"""
1668
+ Convert the contrail segment property to a high-resolution longitude-latitude grid.
1669
+
1670
+ Parameters
1671
+ ----------
1672
+ contrail_segment : GeoVectorDataset
1673
+ Contrail segment waypoints (head and tail).
1674
+ var_name : str
1675
+ Contrail property of interest, where `var_name` must be included in `contrail_segment`.
1676
+ For example, `tau_contrail`, `rf_sw`, `rf_lw`, and `rf_net`
1677
+ spatial_grid_res : float
1678
+ Spatial grid resolution, [:math:`\deg`]
1679
+
1680
+ Returns
1681
+ -------
1682
+ xr.DataArray
1683
+ Contrail segment dimension and property projected to a longitude-latitude grid.
1684
+
1685
+ Notes
1686
+ -----
1687
+ - See Appendix A11 and A12 of :cite:`schumannContrailCirrusPrediction2012`.
1688
+ """
1689
+ # Ensure that `contrail_segment` contains the required variables
1690
+ contrail_segment.ensure_vars(("sin_a", "cos_a", "width", var_name))
1691
+
1692
+ # Ensure that `contrail_segment` only contains two waypoints and have the same time.
1693
+ assert len(contrail_segment) == 2
1694
+ assert contrail_segment["time"][0] == contrail_segment["time"][1]
1695
+
1696
+ # Calculate contrail edges
1697
+ (
1698
+ contrail_segment["lon_edge_l"],
1699
+ contrail_segment["lat_edge_l"],
1700
+ contrail_segment["lon_edge_r"],
1701
+ contrail_segment["lat_edge_r"],
1702
+ ) = contrail_edges(
1703
+ contrail_segment["longitude"],
1704
+ contrail_segment["latitude"],
1705
+ contrail_segment["sin_a"],
1706
+ contrail_segment["cos_a"],
1707
+ contrail_segment["width"],
1708
+ )
1709
+
1710
+ # Initialise contrail segment grid with spatial domain that covers the contrail area.
1711
+ lon_edges = np.concatenate(
1712
+ [contrail_segment["lon_edge_l"], contrail_segment["lon_edge_r"]], axis=0
1713
+ )
1714
+ lat_edges = np.concatenate(
1715
+ [contrail_segment["lat_edge_l"], contrail_segment["lat_edge_r"]], axis=0
1716
+ )
1717
+ spatial_bbox = geo.spatial_bounding_box(lon_edges, lat_edges, buffer=0.5)
1718
+ segment_grid = _initialise_longitude_latitude_grid(spatial_bbox, spatial_grid_res)
1719
+
1720
+ # Calculate gridded contrail segment properties
1721
+ weights = _pixel_weights(contrail_segment, segment_grid)
1722
+ dist_perpendicular = _segment_perpendicular_distance_to_pixels(contrail_segment, weights)
1723
+ plume_concentration = _gaussian_plume_concentration(
1724
+ contrail_segment, weights, dist_perpendicular
1725
+ )
1726
+
1727
+ # Distribute selected contrail property to grid
1728
+ return plume_concentration * (
1729
+ weights * xr.ones_like(weights) * contrail_segment[var_name][1]
1730
+ + (1 - weights) * xr.ones_like(weights) * contrail_segment[var_name][0]
1731
+ )
1732
+
1733
+
1734
+ def _pixel_weights(contrail_segment: GeoVectorDataset, segment_grid: xr.DataArray) -> xr.DataArray:
1735
+ """
1736
+ Calculate the pixel weights for `segment_grid`.
1737
+
1738
+ Parameters
1739
+ ----------
1740
+ contrail_segment : GeoVectorDataset
1741
+ Contrail segment waypoints (head and tail).
1742
+ segment_grid : xr.DataArray
1743
+ Contrail segment grid with spatial domain that covers the contrail area.
1744
+
1745
+ Returns
1746
+ -------
1747
+ xr.DataArray
1748
+ Pixel weights for `segment_grid`
1749
+
1750
+ Notes
1751
+ -----
1752
+ - See Appendix A12 of :cite:`schumannContrailCirrusPrediction2012`.
1753
+ - This is the weights (from the beginning of the contrail segment) to the nearest longitude and
1754
+ latitude pixel in the `segment_grid`.
1755
+ - The contrail segment do not contribute to the pixel if weight < 0 or > 1.
1756
+ """
1757
+ head = contrail_segment.dataframe.iloc[0]
1758
+ tail = contrail_segment.dataframe.iloc[1]
1759
+
1760
+ # Calculate determinant
1761
+ dx = units.longitude_distance_to_m(
1762
+ (tail["longitude"] - head["longitude"]),
1763
+ 0.5 * (head["latitude"] + tail["latitude"]),
1764
+ )
1765
+ dy = units.latitude_distance_to_m(tail["latitude"] - head["latitude"])
1766
+ det = dx**2 + dy**2
1767
+
1768
+ # Calculate pixel weights
1769
+ lon_grid, lat_grid = np.meshgrid(
1770
+ segment_grid["longitude"].values, segment_grid["latitude"].values
1771
+ )
1772
+ dx_grid = units.longitude_distance_to_m(
1773
+ (lon_grid - head["longitude"]),
1774
+ 0.5 * (head["latitude"] + lat_grid),
1775
+ )
1776
+ dy_grid = units.latitude_distance_to_m(lat_grid - head["latitude"])
1777
+ weights = (dx * dx_grid + dy * dy_grid) / det
1778
+ return xr.DataArray(
1779
+ data=weights.T,
1780
+ dims=["longitude", "latitude"],
1781
+ coords={"longitude": segment_grid["longitude"], "latitude": segment_grid["latitude"]},
1782
+ )
1783
+
1784
+
1785
+ def _segment_perpendicular_distance_to_pixels(
1786
+ contrail_segment: GeoVectorDataset, weights: xr.DataArray
1787
+ ) -> xr.DataArray:
1788
+ """
1789
+ Calculate perpendicular distance from contrail segment to each segment grid pixel.
1790
+
1791
+ Parameters
1792
+ ----------
1793
+ contrail_segment : GeoVectorDataset
1794
+ Contrail segment waypoints (head and tail).
1795
+ weights : xr.DataArray
1796
+ Pixel weights for `segment_grid`.
1797
+ See `_pixel_weights` function.
1798
+
1799
+ Returns
1800
+ -------
1801
+ xr.DataArray
1802
+ Perpendicular distance from contrail segment to each segment grid pixel, [:math:`m`]
1803
+
1804
+ Notes
1805
+ -----
1806
+ - See Figure A7 of :cite:`schumannContrailCirrusPrediction2012`.
1807
+ """
1808
+ head = contrail_segment.dataframe.iloc[0]
1809
+ tail = contrail_segment.dataframe.iloc[1]
1810
+
1811
+ # Longitude and latitude along contrail segment
1812
+ lon_grid, lat_grid = np.meshgrid(weights["longitude"].values, weights["latitude"].values)
1813
+
1814
+ lon_s = head["longitude"] + weights.T.values * (tail["longitude"] - head["longitude"])
1815
+ lat_s = head["latitude"] + weights.T.values * (tail["latitude"] - head["latitude"])
1816
+
1817
+ lon_dist = units.longitude_distance_to_m(np.abs(lon_grid - lon_s), 0.5 * (lat_s + lat_grid))
1818
+
1819
+ lat_dist = units.latitude_distance_to_m(np.abs(lat_grid - lat_s))
1820
+ dist_perp = (lon_dist**2 + lat_dist**2) ** 0.5
1821
+ return xr.DataArray(dist_perp.T, coords=weights.coords)
1822
+
1823
+
1824
+ def _gaussian_plume_concentration(
1825
+ contrail_segment: GeoVectorDataset,
1826
+ weights: xr.DataArray,
1827
+ dist_perpendicular: xr.DataArray,
1828
+ ) -> xr.DataArray:
1829
+ """
1830
+ Calculate relative gaussian plume concentration along the contrail width.
1831
+
1832
+ Parameters
1833
+ ----------
1834
+ contrail_segment : GeoVectorDataset
1835
+ Contrail segment waypoints (head and tail).
1836
+ weights : xr.DataArray
1837
+ Pixel weights for `segment_grid`.
1838
+ See `_pixel_weights` function.
1839
+ dist_perpendicular : xr.DataArray
1840
+ Perpendicular distance from contrail segment to each segment grid pixel, [:math:`m`]
1841
+ See `_segment_perpendicular_distance_to_pixels` function.
1842
+
1843
+ Returns
1844
+ -------
1845
+ xr.DataArray
1846
+ Relative gaussian plume concentration along the contrail width
1847
+
1848
+ Notes
1849
+ -----
1850
+ - Assume a one-dimensional Gaussian plume.
1851
+ - See Appendix A11 of :cite:`schumannContrailCirrusPrediction2012`.
1852
+ """
1853
+ head = contrail_segment.dataframe.iloc[0]
1854
+ tail = contrail_segment.dataframe.iloc[1]
1855
+
1856
+ width = weights.values * tail["width"] + (1 - weights.values) * head["width"]
1857
+ sigma_yy = 0.125 * width**2
1858
+
1859
+ concentration = np.where(
1860
+ (weights.values < 0) | (weights.values > 1),
1861
+ 0,
1862
+ (4 / np.pi) ** 0.5 * np.exp(-0.5 * dist_perpendicular.values**2 / sigma_yy),
1863
+ )
1864
+ return xr.DataArray(concentration, coords=weights.coords)
1865
+
1866
+
1867
+ def _add_segment_to_main_grid(main_grid: xr.DataArray, segment_grid: xr.DataArray) -> xr.DataArray:
1868
+ """
1869
+ Add the gridded contrail segment to the main grid.
1870
+
1871
+ Parameters
1872
+ ----------
1873
+ main_grid : xr.DataArray
1874
+ Aggregated contrail segment properties in a longitude-latitude grid.
1875
+ segment_grid : xr.DataArray
1876
+ Contrail segment dimension and property projected to a longitude-latitude grid.
1877
+
1878
+ Returns
1879
+ -------
1880
+ xr.DataArray
1881
+ Aggregated contrail segment properties, including `segment_grid`.
1882
+
1883
+ Notes
1884
+ -----
1885
+ - The spatial domain of `segment_grid` only covers the contrail segment, which is added to
1886
+ the `main_grid` which is expected to have a larger spatial domain than the `segment_grid`.
1887
+ - This architecture is used to reduce the computational resources.
1888
+ """
1889
+ lon_main = main_grid["longitude"].values
1890
+ lat_main = main_grid["latitude"].values
1891
+
1892
+ lon_segment_grid = np.round(segment_grid["longitude"].values, decimals=2)
1893
+ lat_segment_grid = np.round(segment_grid["latitude"].values, decimals=2)
1894
+
1895
+ main_grid_arr = main_grid.values
1896
+ subgrid_arr = segment_grid.values
1897
+
1898
+ try:
1899
+ ix_ = np.searchsorted(lon_main, lon_segment_grid[0])
1900
+ ix = np.searchsorted(lon_main, lon_segment_grid[-1]) + 1
1901
+ iy_ = np.searchsorted(lat_main, lat_segment_grid[0])
1902
+ iy = np.searchsorted(lat_main, lat_segment_grid[-1]) + 1
1903
+ except IndexError:
1904
+ warnings.warn(
1905
+ "Contrail segment ignored as it is outside spatial bounding box of the main grid. "
1906
+ )
1907
+ else:
1908
+ main_grid_arr[ix_:ix, iy_:iy] = main_grid_arr[ix_:ix, iy_:iy] + subgrid_arr
1909
+
1910
+ return xr.DataArray(main_grid_arr, coords=main_grid.coords)
1911
+
1912
+
1913
+ # ------------------------------------
1914
+ # High resolution grid: natural cirrus
1915
+ # ------------------------------------
1916
+
1917
+
1918
+ def natural_cirrus_properties_to_hi_res_grid(
1919
+ met: MetDataset,
1920
+ *,
1921
+ spatial_grid_res: float = 0.05,
1922
+ optical_depth_threshold: float = 0.1,
1923
+ random_state: np.random.Generator | int | None = None,
1924
+ ) -> MetDataset:
1925
+ r"""
1926
+ Increase the longitude-latitude resolution of natural cirrus cover and optical depth.
1927
+
1928
+ Parameters
1929
+ ----------
1930
+ met : MetDataset
1931
+ Pressure level dataset for one time step containing 'air_temperature', 'specific_humidity',
1932
+ 'specific_cloud_ice_water_content', 'geopotential',and `fraction_of_cloud_cover`
1933
+ spatial_grid_res : float
1934
+ Spatial grid resolution for the output, [:math:`\deg`]
1935
+ optical_depth_threshold : float
1936
+ Sensitivity of cirrus detection, set at 0.1 to match the capability of satellites.
1937
+ random_state : np.random.Generator | int | None
1938
+ A number used to initialize a pseudorandom number generator.
1939
+
1940
+ Returns
1941
+ -------
1942
+ MetDataset
1943
+ Single-level dataset containing the high resolution natural cirrus properties.
1944
+
1945
+ References
1946
+ ----------
1947
+ - :cite:`schumannContrailCirrusPrediction2012`
1948
+
1949
+ Notes
1950
+ -----
1951
+ - The high-resolution natural cirrus coverage and optical depth is distributed randomly,
1952
+ ensuring that the mean value is equal to the value of the original grid.
1953
+ - Enhancing the spatial resolution is necessary because the existing spatial resolution of
1954
+ numerical weather prediction (NWP) models are too coarse to resolve the coverage area of
1955
+ relatively narrow contrails.
1956
+ """
1957
+ # Ensure the required columns are included in `met`
1958
+ met.ensure_vars(
1959
+ (
1960
+ "air_temperature",
1961
+ "specific_humidity",
1962
+ "specific_cloud_ice_water_content",
1963
+ "geopotential",
1964
+ "fraction_of_cloud_cover",
1965
+ )
1966
+ )
1967
+
1968
+ # Ensure `met` only contains one time step, constraint can be relaxed in the future.
1969
+ if len(met["time"].data) > 1:
1970
+ raise AssertionError(
1971
+ "`met` contains more than one time step, but function only accepts one time step. "
1972
+ )
1973
+
1974
+ # Calculate tau_cirrus as observed by satellites
1975
+ met["tau_cirrus"] = tau_cirrus(met)
1976
+ tau_cirrus_max = met["tau_cirrus"].data.sel(level=met["level"].data[-1])
1977
+
1978
+ # Calculate cirrus coverage as observed by satellites, cc_max(x,y,t) = max[cc(x,y,z,t)]
1979
+ cirrus_cover_max = met["fraction_of_cloud_cover"].data.max(dim="level")
1980
+
1981
+ # Increase resolution of longitude and latitude dimensions
1982
+ lon_coords_hi_res, lat_coords_hi_res = _hi_res_grid_coordinates(
1983
+ met["longitude"].values, met["latitude"].values, spatial_grid_res=spatial_grid_res
1984
+ )
1985
+
1986
+ # Increase spatial resolution by repeating existing values (temporarily)
1987
+ n_reps = int(
1988
+ np.round(np.diff(met["longitude"].values)[0], decimals=2)
1989
+ / np.round(np.diff(lon_coords_hi_res)[0], decimals=2)
1990
+ )
1991
+ cc_rep = _repeat_rows_and_columns(cirrus_cover_max.values, n_reps=n_reps)
1992
+ tau_cirrus_rep = _repeat_rows_and_columns(tau_cirrus_max.values, n_reps=n_reps)
1993
+
1994
+ # Enhance resolution of `tau_cirrus`
1995
+ rng = np.random.default_rng(random_state)
1996
+ rand_number = rng.uniform(0, 1, np.shape(tau_cirrus_rep))
1997
+ dx = 0.03 # Prevent division of small values: calibrated to match the original cirrus cover
1998
+ has_cirrus = rand_number > (1 + dx - cc_rep)
1999
+
2000
+ tau_cirrus_hi_res = np.zeros_like(tau_cirrus_rep)
2001
+ tau_cirrus_hi_res[has_cirrus] = tau_cirrus_rep[has_cirrus] / cc_rep[has_cirrus]
2002
+
2003
+ # Enhance resolution of `cirrus coverage`
2004
+ cirrus_cover_hi_res = np.where(tau_cirrus_hi_res > optical_depth_threshold, 1, 0)
2005
+
2006
+ # Package outputs
2007
+ ds_hi_res = xr.Dataset(
2008
+ data_vars=dict(
2009
+ tau_cirrus=(["longitude", "latitude"], tau_cirrus_hi_res),
2010
+ cc_natural_cirrus=(["longitude", "latitude"], cirrus_cover_hi_res),
2011
+ ),
2012
+ coords=dict(longitude=lon_coords_hi_res, latitude=lat_coords_hi_res),
2013
+ )
2014
+ ds_hi_res = ds_hi_res.expand_dims({"level": np.array([-1])})
2015
+ ds_hi_res = ds_hi_res.expand_dims({"time": met["time"].values})
2016
+ return MetDataset(ds_hi_res)
2017
+
2018
+
2019
+ def _hi_res_grid_coordinates(
2020
+ lon_coords: npt.NDArray[np.floating],
2021
+ lat_coords: npt.NDArray[np.floating],
2022
+ *,
2023
+ spatial_grid_res: float = 0.05,
2024
+ ) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]:
2025
+ r"""
2026
+ Calculate longitude and latitude coordinates for the high resolution grid.
2027
+
2028
+ Parameters
2029
+ ----------
2030
+ lon_coords : npt.NDArray[np.floating]
2031
+ Longitude coordinates provided by the original `MetDataset`.
2032
+ lat_coords : npt.NDArray[np.floating]
2033
+ Latitude coordinates provided by the original `MetDataset`.
2034
+ spatial_grid_res : float
2035
+ Spatial grid resolution for the output, [:math:`\deg`]
2036
+
2037
+ Returns
2038
+ -------
2039
+ tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]
2040
+ Longitude and latitude coordinates for the high resolution grid.
2041
+ """
2042
+ d_lon = np.abs(np.diff(lon_coords)[0])
2043
+ d_lat = np.abs(np.diff(lat_coords)[0])
2044
+ is_whole_number = (d_lon / spatial_grid_res) - int(d_lon / spatial_grid_res) == 0
2045
+
2046
+ if (d_lon <= spatial_grid_res) | (d_lat <= spatial_grid_res):
2047
+ raise ArithmeticError(
2048
+ "Spatial resolution of `met` is already higher than `spatial_grid_res`"
2049
+ )
2050
+
2051
+ if not is_whole_number:
2052
+ raise ArithmeticError(
2053
+ "Select a spatial grid resolution where `spatial_grid_res / existing_grid_res` is "
2054
+ "a whole number. "
2055
+ )
2056
+
2057
+ lon_coords_hi_res = np.arange(
2058
+ lon_coords[0], lon_coords[-1] + spatial_grid_res, spatial_grid_res, dtype=float
2059
+ )
2060
+
2061
+ lat_coords_hi_res = np.arange(
2062
+ lat_coords[0], lat_coords[-1] + spatial_grid_res, spatial_grid_res, dtype=float
2063
+ )
2064
+
2065
+ return (np.round(lon_coords_hi_res, decimals=3), np.round(lat_coords_hi_res, decimals=3))
2066
+
2067
+
2068
+ def _repeat_rows_and_columns(
2069
+ array_2d: npt.NDArray[np.floating], *, n_reps: int
2070
+ ) -> npt.NDArray[np.floating]:
2071
+ """
2072
+ Repeat the elements in `array_2d` along each row and column.
2073
+
2074
+ Parameters
2075
+ ----------
2076
+ array_2d : npt.NDArray[np.float64, np.float64]
2077
+ 2D array containing `tau_cirrus` or `cirrus_coverage` across longitude and latitude.
2078
+ n_reps : int
2079
+ Number of repetitions.
2080
+
2081
+ Returns
2082
+ -------
2083
+ npt.NDArray[np.float64, np.float64]
2084
+ 2D array containing `tau_cirrus` or `cirrus_coverage` at a higher spatial resolution.
2085
+ See :func:`_hi_res_grid_coordinates`.
2086
+ """
2087
+ dimension = np.shape(array_2d)
2088
+
2089
+ # Repeating elements along axis=1
2090
+ array_1d_rep = [np.repeat(array_2d[i, :], n_reps) for i in np.arange(dimension[0])]
2091
+ stacked = np.vstack(array_1d_rep)
2092
+
2093
+ # Repeating elements along axis=0
2094
+ array_2d_rep = np.repeat(stacked, n_reps, axis=0)
2095
+
2096
+ # Do not repeat final row and column as they are on the edge
2097
+ return array_2d_rep[: -(n_reps - 1), : -(n_reps - 1)]
2098
+
2099
+
2100
+ # -----------------------------------------
2101
+ # Compare CoCiP outputs with GOES satellite
2102
+ # -----------------------------------------
2103
+
2104
+
2105
+ def compare_cocip_with_goes(
2106
+ time: np.timedelta64 | pd.Timestamp,
2107
+ flight: GeoVectorDataset | pd.DataFrame,
2108
+ contrail: GeoVectorDataset | pd.DataFrame,
2109
+ *,
2110
+ spatial_bbox: tuple[float, float, float, float] = (-160.0, -80.0, 10.0, 80.0),
2111
+ region: str = "F",
2112
+ path_write_img: pathlib.Path | None = None,
2113
+ ) -> None | pathlib.Path:
2114
+ r"""
2115
+ Compare simulated persistent contrails from CoCiP with GOES satellite imagery.
2116
+
2117
+ Parameters
2118
+ ----------
2119
+ time : np.timedelta64 | pd.Timestamp
2120
+ Time of GOES satellite image.
2121
+ flight : GeoVectorDataset | pd.DataFrame
2122
+ Flight waypoints.
2123
+ Best to use the returned output :class:`Flight` from
2124
+ :meth:`pycontrails.models.cocip.Cocip.eval`.
2125
+ contrail : GeoVectorDataset | pd.DataFrame,
2126
+ Contrail evolution outputs (:attr:`pycontrails.models.cocip.Cocip.contrail`)
2127
+ set during :meth:`pycontrails.models.cocip.Cocip.eval`.
2128
+ spatial_bbox : tuple[float, float, float, float]
2129
+ Spatial bounding box, ``(lon_min, lat_min, lon_max, lat_max)``, [:math:`\deg`]
2130
+ region : str
2131
+ 'F' for full disk (image provided every 10 m), and 'C' for CONUS (image provided every 5 m)
2132
+ path_write_img : None | pathlib.Path
2133
+ File path to save the CoCiP-GOES image.
2134
+
2135
+ Returns
2136
+ -------
2137
+ None | pathlib.Path
2138
+ File path of saved CoCiP-GOES image if ``path_write_img`` is provided.
2139
+ """
2140
+
2141
+ # We'll get a nice error message if dependencies are not installed
2142
+ from pycontrails.datalib import goes
2143
+
2144
+ try:
2145
+ import cartopy.crs as ccrs
2146
+ from cartopy.mpl.ticker import LatitudeFormatter, LongitudeFormatter
2147
+ except ModuleNotFoundError as e:
2148
+ dependencies.raise_module_not_found_error(
2149
+ name="compare_cocip_with_goes function",
2150
+ package_name="cartopy",
2151
+ module_not_found_error=e,
2152
+ pycontrails_optional_package="sat",
2153
+ )
2154
+
2155
+ try:
2156
+ import matplotlib.pyplot as plt
2157
+ except ModuleNotFoundError as e:
2158
+ dependencies.raise_module_not_found_error(
2159
+ name="compare_cocip_with_goes function",
2160
+ package_name="matplotlib",
2161
+ module_not_found_error=e,
2162
+ pycontrails_optional_package="vis",
2163
+ )
2164
+
2165
+ # Round `time` to nearest GOES image time slice
2166
+ if isinstance(time, np.timedelta64):
2167
+ time = pd.to_datetime(time)
2168
+
2169
+ if region == "F":
2170
+ time = time.round("10min")
2171
+ elif region == "C":
2172
+ time = time.round("5min")
2173
+ else:
2174
+ raise AssertionError("`region` only accepts inputs of `F` (full disk) or `C` (CONUS)")
2175
+
2176
+ _flight = GeoVectorDataset(flight)
2177
+ _contrail = GeoVectorDataset(contrail)
2178
+
2179
+ # Ensure the required columns are included in `flight_waypoints` and `contrails`
2180
+ _flight.ensure_vars(["flight_id", "waypoint"])
2181
+ _contrail.ensure_vars(
2182
+ ["flight_id", "waypoint", "sin_a", "cos_a", "width", "tau_contrail", "age_hours"]
2183
+ )
2184
+
2185
+ # Downselect `_flight` only to spatial domain covered by GOES full disk
2186
+ is_in_lon = _flight.dataframe["longitude"].between(spatial_bbox[0], spatial_bbox[2])
2187
+ is_in_lat = _flight.dataframe["latitude"].between(spatial_bbox[1], spatial_bbox[3])
2188
+ is_in_lon_lat = is_in_lon & is_in_lat
2189
+
2190
+ if not np.any(is_in_lon_lat):
2191
+ warnings.warn(
2192
+ "Flight trajectory does not intersect with the defined spatial bounding box or spatial "
2193
+ "domain covered by GOES."
2194
+ )
2195
+
2196
+ _flight = _flight.filter(is_in_lon_lat)
2197
+
2198
+ # Filter `_flight` if time bounds were previously defined.
2199
+ is_before_time = _flight["time"] < time
2200
+
2201
+ if not np.any(is_before_time):
2202
+ warnings.warn("No flight waypoints were recorded before the specified `time`.")
2203
+
2204
+ _flight = _flight.filter(is_before_time)
2205
+
2206
+ # Downselect `_contrail` only to include the filtered flight waypoints
2207
+ is_in_domain = _contrail.dataframe["waypoint"].isin(_flight["waypoint"])
2208
+
2209
+ if not np.any(is_in_domain):
2210
+ warnings.warn(
2211
+ "No persistent contrails were formed within the defined spatial bounding box."
2212
+ )
2213
+
2214
+ _contrail = _contrail.filter(is_in_domain)
2215
+
2216
+ # Download GOES image at `time`
2217
+ da = goes.GOES(region=region).get(time)
2218
+ rgb, transform, extent = goes.extract_goes_visualization(da)
2219
+ bbox = spatial_bbox[0], spatial_bbox[2], spatial_bbox[1], spatial_bbox[3]
2220
+
2221
+ # Calculate optimal figure dimensions
2222
+ d_lon = spatial_bbox[2] - spatial_bbox[0]
2223
+ d_lat = spatial_bbox[3] - spatial_bbox[1]
2224
+ x_dim = 9.99
2225
+ y_dim = x_dim * (d_lat / d_lon)
2226
+
2227
+ # Plot data
2228
+ fig = plt.figure(figsize=(1.2 * x_dim, y_dim))
2229
+ pc = ccrs.PlateCarree()
2230
+ ax = fig.add_subplot(projection=pc, extent=bbox)
2231
+ ax.coastlines()
2232
+ ax.imshow(rgb, extent=extent, transform=transform)
2233
+
2234
+ ax.set_xticks([spatial_bbox[0], spatial_bbox[2]], crs=ccrs.PlateCarree())
2235
+ ax.set_yticks([spatial_bbox[1], spatial_bbox[3]], crs=ccrs.PlateCarree())
2236
+ lon_formatter = LongitudeFormatter(zero_direction_label=True)
2237
+ lat_formatter = LatitudeFormatter()
2238
+ ax.xaxis.set_major_formatter(lon_formatter)
2239
+ ax.yaxis.set_major_formatter(lat_formatter)
2240
+
2241
+ # Plot flight trajectory up to `time`
2242
+ ax.plot(_flight["longitude"], _flight["latitude"], c="k", linewidth=2.5)
2243
+ plt.legend(["Flight trajectory"])
2244
+
2245
+ # Plot persistent contrails at `time`
2246
+ is_time = (_contrail["time"] == time) & (~np.isnan(_contrail["age_hours"]))
2247
+ im = ax.scatter(
2248
+ _contrail["longitude"][is_time],
2249
+ _contrail["latitude"][is_time],
2250
+ c=_contrail["tau_contrail"][is_time],
2251
+ s=4,
2252
+ cmap="YlOrRd_r",
2253
+ vmin=0,
2254
+ vmax=0.2,
2255
+ )
2256
+ cbar = plt.colorbar(im)
2257
+ cbar.set_label(r"$\tau_{\rm contrail}$")
2258
+ ax.set_title(f"{time}")
2259
+ plt.tight_layout()
2260
+
2261
+ # return output path if `path_write_img` is not None
2262
+ if path_write_img is not None:
2263
+ t_str = time.strftime("%Y%m%d_%H%M%S")
2264
+ file_name = f"goes_{t_str}.png"
2265
+ output_path = path_write_img.joinpath(file_name)
2266
+ plt.savefig(output_path, dpi=150, bbox_inches="tight")
2267
+ plt.close()
2268
+
2269
+ return output_path
2270
+ return None