pycontrails 0.54.0__cp312-cp312-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 (109) hide show
  1. pycontrails/__init__.py +70 -0
  2. pycontrails/_version.py +16 -0
  3. pycontrails/core/__init__.py +30 -0
  4. pycontrails/core/aircraft_performance.py +641 -0
  5. pycontrails/core/airports.py +226 -0
  6. pycontrails/core/cache.py +881 -0
  7. pycontrails/core/coordinates.py +174 -0
  8. pycontrails/core/fleet.py +470 -0
  9. pycontrails/core/flight.py +2314 -0
  10. pycontrails/core/flightplan.py +220 -0
  11. pycontrails/core/fuel.py +140 -0
  12. pycontrails/core/interpolation.py +721 -0
  13. pycontrails/core/met.py +2833 -0
  14. pycontrails/core/met_var.py +307 -0
  15. pycontrails/core/models.py +1181 -0
  16. pycontrails/core/polygon.py +549 -0
  17. pycontrails/core/rgi_cython.cpython-312-darwin.so +0 -0
  18. pycontrails/core/vector.py +2190 -0
  19. pycontrails/datalib/__init__.py +12 -0
  20. pycontrails/datalib/_leo_utils/search.py +250 -0
  21. pycontrails/datalib/_leo_utils/static/bq_roi_query.sql +6 -0
  22. pycontrails/datalib/_leo_utils/vis.py +59 -0
  23. pycontrails/datalib/_met_utils/metsource.py +746 -0
  24. pycontrails/datalib/ecmwf/__init__.py +73 -0
  25. pycontrails/datalib/ecmwf/arco_era5.py +340 -0
  26. pycontrails/datalib/ecmwf/common.py +109 -0
  27. pycontrails/datalib/ecmwf/era5.py +550 -0
  28. pycontrails/datalib/ecmwf/era5_model_level.py +487 -0
  29. pycontrails/datalib/ecmwf/hres.py +782 -0
  30. pycontrails/datalib/ecmwf/hres_model_level.py +459 -0
  31. pycontrails/datalib/ecmwf/ifs.py +284 -0
  32. pycontrails/datalib/ecmwf/model_levels.py +434 -0
  33. pycontrails/datalib/ecmwf/static/model_level_dataframe_v20240418.csv +139 -0
  34. pycontrails/datalib/ecmwf/variables.py +267 -0
  35. pycontrails/datalib/gfs/__init__.py +28 -0
  36. pycontrails/datalib/gfs/gfs.py +646 -0
  37. pycontrails/datalib/gfs/variables.py +100 -0
  38. pycontrails/datalib/goes.py +772 -0
  39. pycontrails/datalib/landsat.py +569 -0
  40. pycontrails/datalib/sentinel.py +511 -0
  41. pycontrails/datalib/spire.py +739 -0
  42. pycontrails/ext/bada.py +41 -0
  43. pycontrails/ext/cirium.py +14 -0
  44. pycontrails/ext/empirical_grid.py +140 -0
  45. pycontrails/ext/synthetic_flight.py +430 -0
  46. pycontrails/models/__init__.py +1 -0
  47. pycontrails/models/accf.py +406 -0
  48. pycontrails/models/apcemm/__init__.py +8 -0
  49. pycontrails/models/apcemm/apcemm.py +982 -0
  50. pycontrails/models/apcemm/inputs.py +226 -0
  51. pycontrails/models/apcemm/static/apcemm_yaml_template.yaml +183 -0
  52. pycontrails/models/apcemm/utils.py +437 -0
  53. pycontrails/models/cocip/__init__.py +29 -0
  54. pycontrails/models/cocip/cocip.py +2616 -0
  55. pycontrails/models/cocip/cocip_params.py +299 -0
  56. pycontrails/models/cocip/cocip_uncertainty.py +285 -0
  57. pycontrails/models/cocip/contrail_properties.py +1517 -0
  58. pycontrails/models/cocip/output_formats.py +2261 -0
  59. pycontrails/models/cocip/radiative_forcing.py +1262 -0
  60. pycontrails/models/cocip/radiative_heating.py +520 -0
  61. pycontrails/models/cocip/unterstrasser_wake_vortex.py +403 -0
  62. pycontrails/models/cocip/wake_vortex.py +396 -0
  63. pycontrails/models/cocip/wind_shear.py +120 -0
  64. pycontrails/models/cocipgrid/__init__.py +9 -0
  65. pycontrails/models/cocipgrid/cocip_grid.py +2573 -0
  66. pycontrails/models/cocipgrid/cocip_grid_params.py +138 -0
  67. pycontrails/models/dry_advection.py +494 -0
  68. pycontrails/models/emissions/__init__.py +21 -0
  69. pycontrails/models/emissions/black_carbon.py +594 -0
  70. pycontrails/models/emissions/emissions.py +1353 -0
  71. pycontrails/models/emissions/ffm2.py +336 -0
  72. pycontrails/models/emissions/static/default-engine-uids.csv +239 -0
  73. pycontrails/models/emissions/static/edb-gaseous-v29b-engines.csv +596 -0
  74. pycontrails/models/emissions/static/edb-nvpm-v29b-engines.csv +215 -0
  75. pycontrails/models/humidity_scaling/__init__.py +37 -0
  76. pycontrails/models/humidity_scaling/humidity_scaling.py +1025 -0
  77. pycontrails/models/humidity_scaling/quantiles/era5-model-level-quantiles.pq +0 -0
  78. pycontrails/models/humidity_scaling/quantiles/era5-pressure-level-quantiles.pq +0 -0
  79. pycontrails/models/issr.py +210 -0
  80. pycontrails/models/pcc.py +327 -0
  81. pycontrails/models/pcr.py +154 -0
  82. pycontrails/models/ps_model/__init__.py +17 -0
  83. pycontrails/models/ps_model/ps_aircraft_params.py +376 -0
  84. pycontrails/models/ps_model/ps_grid.py +505 -0
  85. pycontrails/models/ps_model/ps_model.py +1017 -0
  86. pycontrails/models/ps_model/ps_operational_limits.py +540 -0
  87. pycontrails/models/ps_model/static/ps-aircraft-params-20240524.csv +68 -0
  88. pycontrails/models/ps_model/static/ps-synonym-list-20240524.csv +103 -0
  89. pycontrails/models/sac.py +459 -0
  90. pycontrails/models/tau_cirrus.py +168 -0
  91. pycontrails/physics/__init__.py +1 -0
  92. pycontrails/physics/constants.py +116 -0
  93. pycontrails/physics/geo.py +989 -0
  94. pycontrails/physics/jet.py +837 -0
  95. pycontrails/physics/thermo.py +451 -0
  96. pycontrails/physics/units.py +472 -0
  97. pycontrails/py.typed +0 -0
  98. pycontrails/utils/__init__.py +1 -0
  99. pycontrails/utils/dependencies.py +66 -0
  100. pycontrails/utils/iteration.py +13 -0
  101. pycontrails/utils/json.py +188 -0
  102. pycontrails/utils/temp.py +50 -0
  103. pycontrails/utils/types.py +165 -0
  104. pycontrails-0.54.0.dist-info/LICENSE +178 -0
  105. pycontrails-0.54.0.dist-info/METADATA +179 -0
  106. pycontrails-0.54.0.dist-info/NOTICE +43 -0
  107. pycontrails-0.54.0.dist-info/RECORD +109 -0
  108. pycontrails-0.54.0.dist-info/WHEEL +5 -0
  109. pycontrails-0.54.0.dist-info/top_level.txt +3 -0
@@ -0,0 +1,2261 @@
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 = humidity_scaling.eval(met)
1192
+ rhi = met["rhi"].data.sel(level=slice(150, 300))
1193
+ rhi = rhi.interp(time=time)
1194
+ is_issr = rhi > 1
1195
+
1196
+ # Cirrus in a longitude-latitude grid
1197
+ if cirrus_coverage is None:
1198
+ cirrus_coverage = cirrus_coverage_single_level(time, met, contrails)
1199
+
1200
+ # Calculate statistics
1201
+ area = geo.grid_surface_area(met["longitude"].values, met["latitude"].values)
1202
+ weights = area / np.nansum(area)
1203
+
1204
+ stats = {
1205
+ "issr_percentage_coverage": (
1206
+ np.nansum(is_issr * weights) / (np.nansum(weights) * len(rhi.level))
1207
+ )
1208
+ * 100,
1209
+ "mean_rhi_in_issr": np.nanmean(rhi.values[is_issr.values]),
1210
+ "contrail_cirrus_percentage_coverage": (
1211
+ np.nansum(area * cirrus_coverage["contrails"].data) / np.nansum(area)
1212
+ )
1213
+ * 100,
1214
+ "contrail_cirrus_clear_sky_percentage_coverage": (
1215
+ np.nansum(area * cirrus_coverage["contrails_clear_sky"].data) / np.nansum(area)
1216
+ )
1217
+ * 100,
1218
+ "natural_cirrus_percentage_coverage": (
1219
+ np.nansum(area * cirrus_coverage["natural_cirrus"].data) / np.nansum(area)
1220
+ )
1221
+ * 100,
1222
+ }
1223
+ return pd.Series(stats)
1224
+
1225
+
1226
+ def radiation_time_slice_statistics(
1227
+ rad: MetDataset, time: np.datetime64 | pd.Timestamp
1228
+ ) -> pd.Series:
1229
+ """
1230
+ Calculate radiation statistics in the domain provided.
1231
+
1232
+ Parameters
1233
+ ----------
1234
+ rad : MetDataset
1235
+ Single level dataset containing the `sdr`, `rsr` and `olr`.
1236
+ time : np.datetime64 | pd.Timestamp
1237
+ Time when the radiation statistics is computed.
1238
+
1239
+ Returns
1240
+ -------
1241
+ pd.Series
1242
+ Mean SDR, RSR and OLR in domain area.
1243
+ """
1244
+ rad.ensure_vars(("sdr", "rsr", "olr"))
1245
+ surface_area = geo.grid_surface_area(rad["longitude"].values, rad["latitude"].values)
1246
+ weights = surface_area.values / np.nansum(surface_area)
1247
+ stats = {
1248
+ "mean_sdr_domain": np.nansum(
1249
+ np.squeeze(rad["sdr"].data.interp(time=time).values) * weights
1250
+ ),
1251
+ "mean_rsr_domain": np.nansum(
1252
+ np.squeeze(rad["rsr"].data.interp(time=time).values) * weights
1253
+ ),
1254
+ "mean_olr_domain": np.nansum(
1255
+ np.squeeze(rad["olr"].data.interp(time=time).values) * weights
1256
+ ),
1257
+ }
1258
+ return pd.Series(stats)
1259
+
1260
+
1261
+ def area_mean_ice_water_path(
1262
+ iwc: npt.NDArray[np.float64],
1263
+ plume_mass_per_m: npt.NDArray[np.float64],
1264
+ segment_length: npt.NDArray[np.float64],
1265
+ domain_area: float,
1266
+ ) -> float:
1267
+ """
1268
+ Calculate area-mean contrail ice water path.
1269
+
1270
+ Ice water path (IWC) is the contrail ice mass divided by the domain area of interest.
1271
+
1272
+ Parameters
1273
+ ----------
1274
+ iwc : npt.NDArray[np.float64]
1275
+ Contrail ice water content, i.e., contrail ice mass per kg of
1276
+ air, [:math:`kg_{H_{2}O}/kg_{air}`]
1277
+ plume_mass_per_m : npt.NDArray[np.float64]
1278
+ Contrail plume mass per unit length, [:math:`kg m^{-1}`]
1279
+ segment_length : npt.NDArray[np.float64]
1280
+ Contrail segment length for each waypoint, [:math:`m`]
1281
+ domain_area : float
1282
+ Domain surface area, [:math:`m^{2}`]
1283
+
1284
+ Returns
1285
+ -------
1286
+ float
1287
+ Mean contrail ice water path, [:math:`kg m^{-2}`]
1288
+ """
1289
+ return np.nansum(iwc * plume_mass_per_m * segment_length) / domain_area
1290
+
1291
+
1292
+ def area_mean_ice_particle_radius(
1293
+ r_ice_vol: npt.NDArray[np.float64],
1294
+ n_ice_per_m: npt.NDArray[np.float64],
1295
+ segment_length: npt.NDArray[np.float64],
1296
+ ) -> float:
1297
+ r"""
1298
+ Calculate the area-mean contrail ice particle radius.
1299
+
1300
+ Parameters
1301
+ ----------
1302
+ r_ice_vol : npt.NDArray[np.float64]
1303
+ Ice particle volume mean radius for each waypoint, [:math:`m`]
1304
+ n_ice_per_m : npt.NDArray[np.float64]
1305
+ Number of ice particles per distance for each waypoint, [:math:`m^{-1}`]
1306
+ segment_length : npt.NDArray[np.float64]
1307
+ Contrail segment length for each waypoint, [:math:`m`]
1308
+
1309
+ Returns
1310
+ -------
1311
+ float
1312
+ Area-mean contrail ice particle radius `r_area`, [:math:`\mu m`]
1313
+
1314
+ Notes
1315
+ -----
1316
+ - Re-arranged from `tot_ice_cross_sec_area` = `tot_n_ice_particles` * (np.pi * `r_ice_vol`**2)
1317
+ - Assumes that the contrail ice crystals are spherical.
1318
+ """
1319
+ tot_ice_cross_sec_area = _total_ice_particle_cross_sectional_area(
1320
+ r_ice_vol, n_ice_per_m, segment_length
1321
+ )
1322
+ tot_n_ice_particles = _total_ice_particle_number(n_ice_per_m, segment_length)
1323
+ return (tot_ice_cross_sec_area / (np.pi * tot_n_ice_particles)) ** (1 / 2) * 10**6
1324
+
1325
+
1326
+ def volume_mean_ice_particle_radius(
1327
+ r_ice_vol: npt.NDArray[np.float64],
1328
+ n_ice_per_m: npt.NDArray[np.float64],
1329
+ segment_length: npt.NDArray[np.float64],
1330
+ ) -> float:
1331
+ r"""
1332
+ Calculate the volume-mean contrail ice particle radius.
1333
+
1334
+ Parameters
1335
+ ----------
1336
+ r_ice_vol : npt.NDArray[np.float64]
1337
+ Ice particle volume mean radius for each waypoint, [:math:`m`]
1338
+ n_ice_per_m : npt.NDArray[np.float64]
1339
+ Number of ice particles per distance for each waypoint, [:math:`m^{-1}`]
1340
+ segment_length : npt.NDArray[np.float64]
1341
+ Contrail segment length for each waypoint, [:math:`m`]
1342
+
1343
+ Returns
1344
+ -------
1345
+ float
1346
+ Volume-mean contrail ice particle radius `r_vol`, [:math:`\mu m`]
1347
+
1348
+ Notes
1349
+ -----
1350
+ - Re-arranged from `tot_ice_vol` = `tot_n_ice_particles` * (4 / 3 * np.pi * `r_ice_vol`**3)
1351
+ - Assumes that the contrail ice crystals are spherical.
1352
+ """
1353
+ tot_ice_vol = _total_ice_particle_volume(r_ice_vol, n_ice_per_m, segment_length)
1354
+ tot_n_ice_particles = _total_ice_particle_number(n_ice_per_m, segment_length)
1355
+ return (tot_ice_vol / ((4 / 3) * np.pi * tot_n_ice_particles)) ** (1 / 3) * 10**6
1356
+
1357
+
1358
+ def mean_ice_particle_effective_radius(
1359
+ r_ice_vol: npt.NDArray[np.float64],
1360
+ n_ice_per_m: npt.NDArray[np.float64],
1361
+ segment_length: npt.NDArray[np.float64],
1362
+ ) -> float:
1363
+ r"""
1364
+ Calculate the mean contrail ice particle effective radius.
1365
+
1366
+ Parameters
1367
+ ----------
1368
+ r_ice_vol : npt.NDArray[np.float64]
1369
+ Ice particle volume mean radius for each waypoint, [:math:`m`]
1370
+ n_ice_per_m : npt.NDArray[np.float64]
1371
+ Number of ice particles per distance for each waypoint, [:math:`m^{-1}`]
1372
+ segment_length : npt.NDArray[np.float64]
1373
+ Contrail segment length for each waypoint, [:math:`m`]
1374
+
1375
+ Returns
1376
+ -------
1377
+ float
1378
+ Mean contrail ice particle effective radius `r_eff`, [:math:`\mu m`]
1379
+
1380
+ Notes
1381
+ -----
1382
+ - `r_eff` is the ratio of the particle volume to particle projected area.
1383
+ - `r_eff` = (3 / 4) * (`tot_ice_vol` / `tot_ice_cross_sec_area`)
1384
+ - See Eq. (62) of :cite:`schumannContrailCirrusPrediction2012`.
1385
+ """
1386
+ tot_ice_vol = _total_ice_particle_volume(r_ice_vol, n_ice_per_m, segment_length)
1387
+ tot_ice_cross_sec_area = _total_ice_particle_cross_sectional_area(
1388
+ r_ice_vol, n_ice_per_m, segment_length
1389
+ )
1390
+ return (3 / 4) * (tot_ice_vol / tot_ice_cross_sec_area) * 10**6
1391
+
1392
+
1393
+ def _total_ice_particle_cross_sectional_area(
1394
+ r_ice_vol: npt.NDArray[np.float64],
1395
+ n_ice_per_m: npt.NDArray[np.float64],
1396
+ segment_length: npt.NDArray[np.float64],
1397
+ ) -> float:
1398
+ """
1399
+ Calculate total contrail ice particle cross-sectional area.
1400
+
1401
+ Parameters
1402
+ ----------
1403
+ r_ice_vol : npt.NDArray[np.float64]
1404
+ Ice particle volume mean radius for each waypoint, [:math:`m`]
1405
+ n_ice_per_m : npt.NDArray[np.float64]
1406
+ Number of ice particles per distance for each waypoint, [:math:`m^{-1}`]
1407
+ segment_length : npt.NDArray[np.float64]
1408
+ Contrail segment length for each waypoint, [:math:`m`]
1409
+
1410
+ Returns
1411
+ -------
1412
+ float
1413
+ Total ice particle cross-sectional area from all contrail waypoints, [:math:`m^{2}`]
1414
+ """
1415
+ ice_cross_sec_area = 0.9 * np.pi * r_ice_vol**2
1416
+ return np.nansum(ice_cross_sec_area * n_ice_per_m * segment_length)
1417
+
1418
+
1419
+ def _total_ice_particle_volume(
1420
+ r_ice_vol: npt.NDArray[np.float64],
1421
+ n_ice_per_m: npt.NDArray[np.float64],
1422
+ segment_length: npt.NDArray[np.float64],
1423
+ ) -> float:
1424
+ """
1425
+ Calculate total contrail ice particle volume.
1426
+
1427
+ Parameters
1428
+ ----------
1429
+ r_ice_vol : npt.NDArray[np.float64]
1430
+ Ice particle volume mean radius for each waypoint, [:math:`m`]
1431
+ n_ice_per_m : npt.NDArray[np.float64]
1432
+ Number of ice particles per distance for each waypoint, [:math:`m^{-1}`]
1433
+ segment_length : npt.NDArray[np.float64]
1434
+ Contrail segment length for each waypoint, [:math:`m`]
1435
+
1436
+ Returns
1437
+ -------
1438
+ float
1439
+ Total ice particle volume from all contrail waypoints, [:math:`m^{2}`]
1440
+ """
1441
+ ice_vol = (4 / 3) * np.pi * r_ice_vol**3
1442
+ return np.nansum(ice_vol * n_ice_per_m * segment_length)
1443
+
1444
+
1445
+ def _total_ice_particle_number(
1446
+ n_ice_per_m: npt.NDArray[np.float64], segment_length: npt.NDArray[np.float64]
1447
+ ) -> float:
1448
+ """
1449
+ Calculate total number of contrail ice particles.
1450
+
1451
+ Parameters
1452
+ ----------
1453
+ n_ice_per_m : npt.NDArray[np.float64]
1454
+ Number of ice particles per distance for each waypoint, [:math:`m^{-1}`]
1455
+ segment_length : npt.NDArray[np.float64]
1456
+ Contrail segment length for each waypoint, [:math:`m`]
1457
+
1458
+ Returns
1459
+ -------
1460
+ float
1461
+ Total number of ice particles from all contrail waypoints.
1462
+ """
1463
+ return np.nansum(n_ice_per_m * segment_length)
1464
+
1465
+
1466
+ def area_mean_contrail_property(
1467
+ contrail_property: npt.NDArray[np.float64],
1468
+ segment_length: npt.NDArray[np.float64],
1469
+ width: npt.NDArray[np.float64],
1470
+ domain_area: float,
1471
+ ) -> float:
1472
+ """
1473
+ Calculate area mean contrail property.
1474
+
1475
+ Used to calculate the area mean `tau_contrail`, `tau_cirrus`, `sdr`, `rsr`, `olr`, `rf_sw`,
1476
+ `rf_lw` and `rf_net`.
1477
+
1478
+ Parameters
1479
+ ----------
1480
+ contrail_property : npt.NDArray[np.float64]
1481
+ Selected contrail property for each waypoint
1482
+ segment_length : npt.NDArray[np.float64]
1483
+ Contrail segment length for each waypoint, [:math:`m`]
1484
+ width : npt.NDArray[np.float64]
1485
+ Contrail width for each waypoint, [:math:`m`]
1486
+ domain_area : float
1487
+ Domain surface area, [:math:`m^{2}`]
1488
+
1489
+ Returns
1490
+ -------
1491
+ float
1492
+ Area mean contrail property
1493
+ """
1494
+ return np.nansum(contrail_property * segment_length * width) / domain_area
1495
+
1496
+
1497
+ def percentage_cloud_contrail_overlap(
1498
+ contrail_cover: float | np.ndarray, contrail_cover_clear_sky: float | np.ndarray
1499
+ ) -> float | np.ndarray:
1500
+ """
1501
+ Calculate the percentage area of cloud-contrail overlap.
1502
+
1503
+ Parameters
1504
+ ----------
1505
+ contrail_cover : float | np.ndarray
1506
+ Percentage of contrail cirrus cover without overlap with natural cirrus.
1507
+ See `cirrus_coverage_single_level` function.
1508
+ contrail_cover_clear_sky : float | np.ndarray
1509
+ Percentage of contrail cirrus cover in clear sky conditions.
1510
+ See `cirrus_coverage_single_level` function.
1511
+
1512
+ Returns
1513
+ -------
1514
+ float | np.ndarray
1515
+ Percentage of cloud-contrail overlap
1516
+ """
1517
+ return np.where(
1518
+ contrail_cover_clear_sky > 0,
1519
+ 100 - (contrail_cover / contrail_cover_clear_sky * 100),
1520
+ 0,
1521
+ )
1522
+
1523
+
1524
+ # ---------------------------------------
1525
+ # High resolution grid: contrail segments
1526
+ # ---------------------------------------
1527
+
1528
+
1529
+ def contrails_to_hi_res_grid(
1530
+ time: pd.Timestamp | np.datetime64,
1531
+ contrails_t: GeoVectorDataset,
1532
+ *,
1533
+ var_name: str,
1534
+ spatial_bbox: tuple[float, float, float, float] = (-180.0, -90.0, 180.0, 90.0),
1535
+ spatial_grid_res: float = 0.05,
1536
+ ) -> xr.DataArray:
1537
+ r"""
1538
+ Aggregate contrail segments to a high-resolution longitude-latitude grid.
1539
+
1540
+ Parameters
1541
+ ----------
1542
+ time : pd.Timestamp | np.datetime64
1543
+ UTC time of interest.
1544
+ contrails_t : GeoVectorDataset
1545
+ All contrail waypoint outputs at `time`.
1546
+ var_name : str
1547
+ Contrail property for aggregation, where `var_name` must be included in `contrail_segment`.
1548
+ For example, `tau_contrail`, `rf_sw`, `rf_lw`, and `rf_net`
1549
+ spatial_bbox : tuple[float, float, float, float]
1550
+ Spatial bounding box, `(lon_min, lat_min, lon_max, lat_max)`, [:math:`\deg`]
1551
+ spatial_grid_res : float
1552
+ Spatial grid resolution, [:math:`\deg`]
1553
+
1554
+ Returns
1555
+ -------
1556
+ xr.DataArray
1557
+ Contrail segments and their properties aggregated to a longitude-latitude grid.
1558
+ """
1559
+ # Ensure the required columns are included in `contrails_t`
1560
+ cols_req = [
1561
+ "flight_id",
1562
+ "waypoint",
1563
+ "longitude",
1564
+ "latitude",
1565
+ "altitude",
1566
+ "time",
1567
+ "sin_a",
1568
+ "cos_a",
1569
+ "width",
1570
+ var_name,
1571
+ ]
1572
+ contrails_t.ensure_vars(cols_req)
1573
+
1574
+ # Ensure that the times in `contrails_t` are the same.
1575
+ is_in_time = contrails_t["time"] == time
1576
+ if not np.all(is_in_time):
1577
+ warnings.warn(
1578
+ f"Contrails have inconsistent times. Waypoints that are not in {time} are removed."
1579
+ )
1580
+ contrails_t = contrails_t.filter(is_in_time)
1581
+
1582
+ main_grid = _initialise_longitude_latitude_grid(spatial_bbox, spatial_grid_res)
1583
+
1584
+ # Contrail head and tails: continuous segments only
1585
+ heads_t = contrails_t.dataframe
1586
+ heads_t = heads_t.sort_values(["flight_id", "waypoint"])
1587
+ tails_t = heads_t.shift(periods=-1)
1588
+
1589
+ is_continuous = heads_t["continuous"]
1590
+ heads_t = heads_t[is_continuous].copy()
1591
+ tails_t = tails_t[is_continuous].copy()
1592
+ tails_t["waypoint"] = tails_t["waypoint"].astype("int")
1593
+
1594
+ heads_t = heads_t.set_index(["flight_id", "waypoint"], drop=False)
1595
+ tails_t.index = heads_t.index
1596
+
1597
+ # Aggregate contrail segments to a high resolution longitude-latitude grid
1598
+ try:
1599
+ from tqdm.auto import tqdm
1600
+ except ModuleNotFoundError as exc:
1601
+ dependencies.raise_module_not_found_error(
1602
+ name="contrails_to_hi_res_grid function",
1603
+ package_name="tqdm",
1604
+ module_not_found_error=exc,
1605
+ )
1606
+
1607
+ for i in tqdm(heads_t.index):
1608
+ contrail_segment = GeoVectorDataset(
1609
+ pd.concat([heads_t[cols_req].loc[i], tails_t[cols_req].loc[i]], axis=1).T, copy=True
1610
+ )
1611
+
1612
+ segment_grid = segment_property_to_hi_res_grid(
1613
+ contrail_segment, var_name=var_name, spatial_grid_res=spatial_grid_res
1614
+ )
1615
+ main_grid = _add_segment_to_main_grid(main_grid, segment_grid)
1616
+
1617
+ return main_grid
1618
+
1619
+
1620
+ def _initialise_longitude_latitude_grid(
1621
+ spatial_bbox: tuple[float, float, float, float] = (-180.0, -90.0, 180.0, 90.0),
1622
+ spatial_grid_res: float = 0.05,
1623
+ ) -> xr.DataArray:
1624
+ r"""
1625
+ Create longitude-latitude grid of specified coordinates and spatial resolution.
1626
+
1627
+ Parameters
1628
+ ----------
1629
+ spatial_bbox : tuple[float, float, float, float]
1630
+ Spatial bounding box, `(lon_min, lat_min, lon_max, lat_max)`, [:math:`\deg`]
1631
+ spatial_grid_res : float
1632
+ Spatial grid resolution, [:math:`\deg`]
1633
+
1634
+ Returns
1635
+ -------
1636
+ xr.DataArray
1637
+ Longitude-latitude grid of specified coordinates and spatial resolution, filled with zeros.
1638
+
1639
+ Notes
1640
+ -----
1641
+ This empty grid is used to store the aggregated contrail properties of the individual
1642
+ contrail segments, such as the gridded contrail optical depth and radiative forcing.
1643
+ """
1644
+ lon_coords = np.arange(spatial_bbox[0], spatial_bbox[2] + spatial_grid_res, spatial_grid_res)
1645
+ lat_coords = np.arange(spatial_bbox[1], spatial_bbox[3] + spatial_grid_res, spatial_grid_res)
1646
+ return xr.DataArray(
1647
+ np.zeros((len(lon_coords), len(lat_coords))),
1648
+ dims=["longitude", "latitude"],
1649
+ coords={"longitude": lon_coords, "latitude": lat_coords},
1650
+ )
1651
+
1652
+
1653
+ def segment_property_to_hi_res_grid(
1654
+ contrail_segment: GeoVectorDataset,
1655
+ *,
1656
+ var_name: str,
1657
+ spatial_grid_res: float = 0.05,
1658
+ ) -> xr.DataArray:
1659
+ r"""
1660
+ Convert the contrail segment property to a high-resolution longitude-latitude grid.
1661
+
1662
+ Parameters
1663
+ ----------
1664
+ contrail_segment : GeoVectorDataset
1665
+ Contrail segment waypoints (head and tail).
1666
+ var_name : str
1667
+ Contrail property of interest, where `var_name` must be included in `contrail_segment`.
1668
+ For example, `tau_contrail`, `rf_sw`, `rf_lw`, and `rf_net`
1669
+ spatial_grid_res : float
1670
+ Spatial grid resolution, [:math:`\deg`]
1671
+
1672
+ Returns
1673
+ -------
1674
+ xr.DataArray
1675
+ Contrail segment dimension and property projected to a longitude-latitude grid.
1676
+
1677
+ Notes
1678
+ -----
1679
+ - See Appendix A11 and A12 of :cite:`schumannContrailCirrusPrediction2012`.
1680
+ """
1681
+ # Ensure that `contrail_segment` contains the required variables
1682
+ contrail_segment.ensure_vars(("sin_a", "cos_a", "width", var_name))
1683
+
1684
+ # Ensure that `contrail_segment` only contains two waypoints and have the same time.
1685
+ assert len(contrail_segment) == 2
1686
+ assert contrail_segment["time"][0] == contrail_segment["time"][1]
1687
+
1688
+ # Calculate contrail edges
1689
+ (
1690
+ contrail_segment["lon_edge_l"],
1691
+ contrail_segment["lat_edge_l"],
1692
+ contrail_segment["lon_edge_r"],
1693
+ contrail_segment["lat_edge_r"],
1694
+ ) = contrail_edges(
1695
+ contrail_segment["longitude"],
1696
+ contrail_segment["latitude"],
1697
+ contrail_segment["sin_a"],
1698
+ contrail_segment["cos_a"],
1699
+ contrail_segment["width"],
1700
+ )
1701
+
1702
+ # Initialise contrail segment grid with spatial domain that covers the contrail area.
1703
+ lon_edges = np.concatenate(
1704
+ [contrail_segment["lon_edge_l"], contrail_segment["lon_edge_r"]], axis=0
1705
+ )
1706
+ lat_edges = np.concatenate(
1707
+ [contrail_segment["lat_edge_l"], contrail_segment["lat_edge_r"]], axis=0
1708
+ )
1709
+ spatial_bbox = geo.spatial_bounding_box(lon_edges, lat_edges, buffer=0.5)
1710
+ segment_grid = _initialise_longitude_latitude_grid(spatial_bbox, spatial_grid_res)
1711
+
1712
+ # Calculate gridded contrail segment properties
1713
+ weights = _pixel_weights(contrail_segment, segment_grid)
1714
+ dist_perpendicular = _segment_perpendicular_distance_to_pixels(contrail_segment, weights)
1715
+ plume_concentration = _gaussian_plume_concentration(
1716
+ contrail_segment, weights, dist_perpendicular
1717
+ )
1718
+
1719
+ # Distribute selected contrail property to grid
1720
+ return plume_concentration * (
1721
+ weights * xr.ones_like(weights) * contrail_segment[var_name][1]
1722
+ + (1 - weights) * xr.ones_like(weights) * contrail_segment[var_name][0]
1723
+ )
1724
+
1725
+
1726
+ def _pixel_weights(contrail_segment: GeoVectorDataset, segment_grid: xr.DataArray) -> xr.DataArray:
1727
+ """
1728
+ Calculate the pixel weights for `segment_grid`.
1729
+
1730
+ Parameters
1731
+ ----------
1732
+ contrail_segment : GeoVectorDataset
1733
+ Contrail segment waypoints (head and tail).
1734
+ segment_grid : xr.DataArray
1735
+ Contrail segment grid with spatial domain that covers the contrail area.
1736
+
1737
+ Returns
1738
+ -------
1739
+ xr.DataArray
1740
+ Pixel weights for `segment_grid`
1741
+
1742
+ Notes
1743
+ -----
1744
+ - See Appendix A12 of :cite:`schumannContrailCirrusPrediction2012`.
1745
+ - This is the weights (from the beginning of the contrail segment) to the nearest longitude and
1746
+ latitude pixel in the `segment_grid`.
1747
+ - The contrail segment do not contribute to the pixel if weight < 0 or > 1.
1748
+ """
1749
+ head = contrail_segment.dataframe.iloc[0]
1750
+ tail = contrail_segment.dataframe.iloc[1]
1751
+
1752
+ # Calculate determinant
1753
+ dx = units.longitude_distance_to_m(
1754
+ (tail["longitude"] - head["longitude"]),
1755
+ 0.5 * (head["latitude"] + tail["latitude"]),
1756
+ )
1757
+ dy = units.latitude_distance_to_m(tail["latitude"] - head["latitude"])
1758
+ det = dx**2 + dy**2
1759
+
1760
+ # Calculate pixel weights
1761
+ lon_grid, lat_grid = np.meshgrid(
1762
+ segment_grid["longitude"].values, segment_grid["latitude"].values
1763
+ )
1764
+ dx_grid = units.longitude_distance_to_m(
1765
+ (lon_grid - head["longitude"]),
1766
+ 0.5 * (head["latitude"] + lat_grid),
1767
+ )
1768
+ dy_grid = units.latitude_distance_to_m(lat_grid - head["latitude"])
1769
+ weights = (dx * dx_grid + dy * dy_grid) / det
1770
+ return xr.DataArray(
1771
+ data=weights.T,
1772
+ dims=["longitude", "latitude"],
1773
+ coords={"longitude": segment_grid["longitude"], "latitude": segment_grid["latitude"]},
1774
+ )
1775
+
1776
+
1777
+ def _segment_perpendicular_distance_to_pixels(
1778
+ contrail_segment: GeoVectorDataset, weights: xr.DataArray
1779
+ ) -> xr.DataArray:
1780
+ """
1781
+ Calculate perpendicular distance from contrail segment to each segment grid pixel.
1782
+
1783
+ Parameters
1784
+ ----------
1785
+ contrail_segment : GeoVectorDataset
1786
+ Contrail segment waypoints (head and tail).
1787
+ weights : xr.DataArray
1788
+ Pixel weights for `segment_grid`.
1789
+ See `_pixel_weights` function.
1790
+
1791
+ Returns
1792
+ -------
1793
+ xr.DataArray
1794
+ Perpendicular distance from contrail segment to each segment grid pixel, [:math:`m`]
1795
+
1796
+ Notes
1797
+ -----
1798
+ - See Figure A7 of :cite:`schumannContrailCirrusPrediction2012`.
1799
+ """
1800
+ head = contrail_segment.dataframe.iloc[0]
1801
+ tail = contrail_segment.dataframe.iloc[1]
1802
+
1803
+ # Longitude and latitude along contrail segment
1804
+ lon_grid, lat_grid = np.meshgrid(weights["longitude"].values, weights["latitude"].values)
1805
+
1806
+ lon_s = head["longitude"] + weights.T.values * (tail["longitude"] - head["longitude"])
1807
+ lat_s = head["latitude"] + weights.T.values * (tail["latitude"] - head["latitude"])
1808
+
1809
+ lon_dist = units.longitude_distance_to_m(np.abs(lon_grid - lon_s), 0.5 * (lat_s + lat_grid))
1810
+
1811
+ lat_dist = units.latitude_distance_to_m(np.abs(lat_grid - lat_s))
1812
+ dist_perp = (lon_dist**2 + lat_dist**2) ** 0.5
1813
+ return xr.DataArray(dist_perp.T, coords=weights.coords)
1814
+
1815
+
1816
+ def _gaussian_plume_concentration(
1817
+ contrail_segment: GeoVectorDataset,
1818
+ weights: xr.DataArray,
1819
+ dist_perpendicular: xr.DataArray,
1820
+ ) -> xr.DataArray:
1821
+ """
1822
+ Calculate relative gaussian plume concentration along the contrail width.
1823
+
1824
+ Parameters
1825
+ ----------
1826
+ contrail_segment : GeoVectorDataset
1827
+ Contrail segment waypoints (head and tail).
1828
+ weights : xr.DataArray
1829
+ Pixel weights for `segment_grid`.
1830
+ See `_pixel_weights` function.
1831
+ dist_perpendicular : xr.DataArray
1832
+ Perpendicular distance from contrail segment to each segment grid pixel, [:math:`m`]
1833
+ See `_segment_perpendicular_distance_to_pixels` function.
1834
+
1835
+ Returns
1836
+ -------
1837
+ xr.DataArray
1838
+ Relative gaussian plume concentration along the contrail width
1839
+
1840
+ Notes
1841
+ -----
1842
+ - Assume a one-dimensional Gaussian plume.
1843
+ - See Appendix A11 of :cite:`schumannContrailCirrusPrediction2012`.
1844
+ """
1845
+ head = contrail_segment.dataframe.iloc[0]
1846
+ tail = contrail_segment.dataframe.iloc[1]
1847
+
1848
+ width = weights.values * tail["width"] + (1 - weights.values) * head["width"]
1849
+ sigma_yy = 0.125 * width**2
1850
+
1851
+ concentration = np.where(
1852
+ (weights.values < 0) | (weights.values > 1),
1853
+ 0,
1854
+ (4 / np.pi) ** 0.5 * np.exp(-0.5 * dist_perpendicular.values**2 / sigma_yy),
1855
+ )
1856
+ return xr.DataArray(concentration, coords=weights.coords)
1857
+
1858
+
1859
+ def _add_segment_to_main_grid(main_grid: xr.DataArray, segment_grid: xr.DataArray) -> xr.DataArray:
1860
+ """
1861
+ Add the gridded contrail segment to the main grid.
1862
+
1863
+ Parameters
1864
+ ----------
1865
+ main_grid : xr.DataArray
1866
+ Aggregated contrail segment properties in a longitude-latitude grid.
1867
+ segment_grid : xr.DataArray
1868
+ Contrail segment dimension and property projected to a longitude-latitude grid.
1869
+
1870
+ Returns
1871
+ -------
1872
+ xr.DataArray
1873
+ Aggregated contrail segment properties, including `segment_grid`.
1874
+
1875
+ Notes
1876
+ -----
1877
+ - The spatial domain of `segment_grid` only covers the contrail segment, which is added to
1878
+ the `main_grid` which is expected to have a larger spatial domain than the `segment_grid`.
1879
+ - This architecture is used to reduce the computational resources.
1880
+ """
1881
+ lon_main = main_grid["longitude"].values
1882
+ lat_main = main_grid["latitude"].values
1883
+
1884
+ lon_segment_grid = np.round(segment_grid["longitude"].values, decimals=2)
1885
+ lat_segment_grid = np.round(segment_grid["latitude"].values, decimals=2)
1886
+
1887
+ main_grid_arr = main_grid.values
1888
+ subgrid_arr = segment_grid.values
1889
+
1890
+ try:
1891
+ ix_ = np.searchsorted(lon_main, lon_segment_grid[0])
1892
+ ix = np.searchsorted(lon_main, lon_segment_grid[-1]) + 1
1893
+ iy_ = np.searchsorted(lat_main, lat_segment_grid[0])
1894
+ iy = np.searchsorted(lat_main, lat_segment_grid[-1]) + 1
1895
+ except IndexError:
1896
+ warnings.warn(
1897
+ "Contrail segment ignored as it is outside spatial bounding box of the main grid. "
1898
+ )
1899
+ else:
1900
+ main_grid_arr[ix_:ix, iy_:iy] = main_grid_arr[ix_:ix, iy_:iy] + subgrid_arr
1901
+
1902
+ return xr.DataArray(main_grid_arr, coords=main_grid.coords)
1903
+
1904
+
1905
+ # ------------------------------------
1906
+ # High resolution grid: natural cirrus
1907
+ # ------------------------------------
1908
+
1909
+
1910
+ def natural_cirrus_properties_to_hi_res_grid(
1911
+ met: MetDataset,
1912
+ *,
1913
+ spatial_grid_res: float = 0.05,
1914
+ optical_depth_threshold: float = 0.1,
1915
+ random_state: np.random.Generator | int | None = None,
1916
+ ) -> MetDataset:
1917
+ r"""
1918
+ Increase the longitude-latitude resolution of natural cirrus cover and optical depth.
1919
+
1920
+ Parameters
1921
+ ----------
1922
+ met : MetDataset
1923
+ Pressure level dataset for one time step containing 'air_temperature', 'specific_humidity',
1924
+ 'specific_cloud_ice_water_content', 'geopotential',and `fraction_of_cloud_cover`
1925
+ spatial_grid_res : float
1926
+ Spatial grid resolution for the output, [:math:`\deg`]
1927
+ optical_depth_threshold : float
1928
+ Sensitivity of cirrus detection, set at 0.1 to match the capability of satellites.
1929
+ random_state : np.random.Generator | int | None
1930
+ A number used to initialize a pseudorandom number generator.
1931
+
1932
+ Returns
1933
+ -------
1934
+ MetDataset
1935
+ Single-level dataset containing the high resolution natural cirrus properties.
1936
+
1937
+ References
1938
+ ----------
1939
+ - :cite:`schumannContrailCirrusPrediction2012`
1940
+
1941
+ Notes
1942
+ -----
1943
+ - The high-resolution natural cirrus coverage and optical depth is distributed randomly,
1944
+ ensuring that the mean value is equal to the value of the original grid.
1945
+ - Enhancing the spatial resolution is necessary because the existing spatial resolution of
1946
+ numerical weather prediction (NWP) models are too coarse to resolve the coverage area of
1947
+ relatively narrow contrails.
1948
+ """
1949
+ # Ensure the required columns are included in `met`
1950
+ met.ensure_vars(
1951
+ (
1952
+ "air_temperature",
1953
+ "specific_humidity",
1954
+ "specific_cloud_ice_water_content",
1955
+ "geopotential",
1956
+ "fraction_of_cloud_cover",
1957
+ )
1958
+ )
1959
+
1960
+ # Ensure `met` only contains one time step, constraint can be relaxed in the future.
1961
+ if len(met["time"].data) > 1:
1962
+ raise AssertionError(
1963
+ "`met` contains more than one time step, but function only accepts one time step. "
1964
+ )
1965
+
1966
+ # Calculate tau_cirrus as observed by satellites
1967
+ met["tau_cirrus"] = tau_cirrus(met)
1968
+ tau_cirrus_max = met["tau_cirrus"].data.sel(level=met["level"].data[-1])
1969
+
1970
+ # Calculate cirrus coverage as observed by satellites, cc_max(x,y,t) = max[cc(x,y,z,t)]
1971
+ cirrus_cover_max = met["fraction_of_cloud_cover"].data.max(dim="level")
1972
+
1973
+ # Increase resolution of longitude and latitude dimensions
1974
+ lon_coords_hi_res, lat_coords_hi_res = _hi_res_grid_coordinates(
1975
+ met["longitude"].values, met["latitude"].values, spatial_grid_res=spatial_grid_res
1976
+ )
1977
+
1978
+ # Increase spatial resolution by repeating existing values (temporarily)
1979
+ n_reps = int(
1980
+ np.round(np.diff(met["longitude"].values)[0], decimals=2)
1981
+ / np.round(np.diff(lon_coords_hi_res)[0], decimals=2)
1982
+ )
1983
+ cc_rep = _repeat_rows_and_columns(cirrus_cover_max.values, n_reps=n_reps)
1984
+ tau_cirrus_rep = _repeat_rows_and_columns(tau_cirrus_max.values, n_reps=n_reps)
1985
+
1986
+ # Enhance resolution of `tau_cirrus`
1987
+ rng = np.random.default_rng(random_state)
1988
+ rand_number = rng.uniform(0, 1, np.shape(tau_cirrus_rep))
1989
+ dx = 0.03 # Prevent division of small values: calibrated to match the original cirrus cover
1990
+ has_cirrus = rand_number > (1 + dx - cc_rep)
1991
+
1992
+ tau_cirrus_hi_res = np.zeros_like(tau_cirrus_rep)
1993
+ tau_cirrus_hi_res[has_cirrus] = tau_cirrus_rep[has_cirrus] / cc_rep[has_cirrus]
1994
+
1995
+ # Enhance resolution of `cirrus coverage`
1996
+ cirrus_cover_hi_res = np.where(tau_cirrus_hi_res > optical_depth_threshold, 1, 0)
1997
+
1998
+ # Package outputs
1999
+ ds_hi_res = xr.Dataset(
2000
+ data_vars=dict(
2001
+ tau_cirrus=(["longitude", "latitude"], tau_cirrus_hi_res),
2002
+ cc_natural_cirrus=(["longitude", "latitude"], cirrus_cover_hi_res),
2003
+ ),
2004
+ coords=dict(longitude=lon_coords_hi_res, latitude=lat_coords_hi_res),
2005
+ )
2006
+ ds_hi_res = ds_hi_res.expand_dims({"level": np.array([-1])})
2007
+ ds_hi_res = ds_hi_res.expand_dims({"time": met["time"].values})
2008
+ return MetDataset(ds_hi_res)
2009
+
2010
+
2011
+ def _hi_res_grid_coordinates(
2012
+ lon_coords: npt.NDArray[np.float64],
2013
+ lat_coords: npt.NDArray[np.float64],
2014
+ *,
2015
+ spatial_grid_res: float = 0.05,
2016
+ ) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]:
2017
+ r"""
2018
+ Calculate longitude and latitude coordinates for the high resolution grid.
2019
+
2020
+ Parameters
2021
+ ----------
2022
+ lon_coords : npt.NDArray[np.float64]
2023
+ Longitude coordinates provided by the original `MetDataset`.
2024
+ lat_coords : npt.NDArray[np.float64]
2025
+ Latitude coordinates provided by the original `MetDataset`.
2026
+ spatial_grid_res : float
2027
+ Spatial grid resolution for the output, [:math:`\deg`]
2028
+
2029
+ Returns
2030
+ -------
2031
+ tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]
2032
+ Longitude and latitude coordinates for the high resolution grid.
2033
+ """
2034
+ d_lon = np.abs(np.diff(lon_coords)[0])
2035
+ d_lat = np.abs(np.diff(lat_coords)[0])
2036
+ is_whole_number = (d_lon / spatial_grid_res) - int(d_lon / spatial_grid_res) == 0
2037
+
2038
+ if (d_lon <= spatial_grid_res) | (d_lat <= spatial_grid_res):
2039
+ raise ArithmeticError(
2040
+ "Spatial resolution of `met` is already higher than `spatial_grid_res`"
2041
+ )
2042
+
2043
+ if not is_whole_number:
2044
+ raise ArithmeticError(
2045
+ "Select a spatial grid resolution where `spatial_grid_res / existing_grid_res` is "
2046
+ "a whole number. "
2047
+ )
2048
+
2049
+ lon_coords_hi_res = np.arange(
2050
+ lon_coords[0], lon_coords[-1] + spatial_grid_res, spatial_grid_res, dtype=float
2051
+ )
2052
+
2053
+ lat_coords_hi_res = np.arange(
2054
+ lat_coords[0], lat_coords[-1] + spatial_grid_res, spatial_grid_res, dtype=float
2055
+ )
2056
+
2057
+ return (np.round(lon_coords_hi_res, decimals=3), np.round(lat_coords_hi_res, decimals=3))
2058
+
2059
+
2060
+ def _repeat_rows_and_columns(
2061
+ array_2d: npt.NDArray[np.float64], *, n_reps: int
2062
+ ) -> npt.NDArray[np.float64]:
2063
+ """
2064
+ Repeat the elements in `array_2d` along each row and column.
2065
+
2066
+ Parameters
2067
+ ----------
2068
+ array_2d : npt.NDArray[np.float64, np.float64]
2069
+ 2D array containing `tau_cirrus` or `cirrus_coverage` across longitude and latitude.
2070
+ n_reps : int
2071
+ Number of repetitions.
2072
+
2073
+ Returns
2074
+ -------
2075
+ npt.NDArray[np.float64, np.float64]
2076
+ 2D array containing `tau_cirrus` or `cirrus_coverage` at a higher spatial resolution.
2077
+ See :func:`_hi_res_grid_coordinates`.
2078
+ """
2079
+ dimension = np.shape(array_2d)
2080
+
2081
+ # Repeating elements along axis=1
2082
+ array_1d_rep = [np.repeat(array_2d[i, :], n_reps) for i in np.arange(dimension[0])]
2083
+ stacked = np.vstack(array_1d_rep)
2084
+
2085
+ # Repeating elements along axis=0
2086
+ array_2d_rep = np.repeat(stacked, n_reps, axis=0)
2087
+
2088
+ # Do not repeat final row and column as they are on the edge
2089
+ return array_2d_rep[: -(n_reps - 1), : -(n_reps - 1)]
2090
+
2091
+
2092
+ # -----------------------------------------
2093
+ # Compare CoCiP outputs with GOES satellite
2094
+ # -----------------------------------------
2095
+
2096
+
2097
+ def compare_cocip_with_goes(
2098
+ time: np.timedelta64 | pd.Timestamp,
2099
+ flight: GeoVectorDataset | pd.DataFrame,
2100
+ contrail: GeoVectorDataset | pd.DataFrame,
2101
+ *,
2102
+ spatial_bbox: tuple[float, float, float, float] = (-160.0, -80.0, 10.0, 80.0),
2103
+ region: str = "F",
2104
+ path_write_img: pathlib.Path | None = None,
2105
+ ) -> None | pathlib.Path:
2106
+ r"""
2107
+ Compare simulated persistent contrails from CoCiP with GOES satellite imagery.
2108
+
2109
+ Parameters
2110
+ ----------
2111
+ time : np.timedelta64 | pd.Timestamp
2112
+ Time of GOES satellite image.
2113
+ flight : GeoVectorDataset | pd.DataFrame
2114
+ Flight waypoints.
2115
+ Best to use the returned output :class:`Flight` from
2116
+ :meth:`pycontrails.models.cocip.Cocip.eval`.
2117
+ contrail : GeoVectorDataset | pd.DataFrame,
2118
+ Contrail evolution outputs (:attr:`pycontrails.models.cocip.Cocip.contrail`)
2119
+ set during :meth:`pycontrails.models.cocip.Cocip.eval`.
2120
+ spatial_bbox : tuple[float, float, float, float]
2121
+ Spatial bounding box, ``(lon_min, lat_min, lon_max, lat_max)``, [:math:`\deg`]
2122
+ region : str
2123
+ 'F' for full disk (image provided every 10 m), and 'C' for CONUS (image provided every 5 m)
2124
+ path_write_img : None | pathlib.Path
2125
+ File path to save the CoCiP-GOES image.
2126
+
2127
+ Returns
2128
+ -------
2129
+ None | pathlib.Path
2130
+ File path of saved CoCiP-GOES image if ``path_write_img`` is provided.
2131
+ """
2132
+
2133
+ from pycontrails.datalib.goes import GOES, extract_goes_visualization
2134
+
2135
+ try:
2136
+ import cartopy.crs as ccrs
2137
+ from cartopy.mpl.ticker import LatitudeFormatter, LongitudeFormatter
2138
+ except ModuleNotFoundError as e:
2139
+ dependencies.raise_module_not_found_error(
2140
+ name="compare_cocip_with_goes function",
2141
+ package_name="cartopy",
2142
+ module_not_found_error=e,
2143
+ pycontrails_optional_package="sat",
2144
+ )
2145
+
2146
+ try:
2147
+ import matplotlib.pyplot as plt
2148
+ except ModuleNotFoundError as e:
2149
+ dependencies.raise_module_not_found_error(
2150
+ name="compare_cocip_with_goes function",
2151
+ package_name="matplotlib",
2152
+ module_not_found_error=e,
2153
+ pycontrails_optional_package="vis",
2154
+ )
2155
+
2156
+ # Round `time` to nearest GOES image time slice
2157
+ if isinstance(time, np.timedelta64):
2158
+ time = pd.to_datetime(time)
2159
+
2160
+ if region == "F":
2161
+ time = time.round("10min")
2162
+ elif region == "C":
2163
+ time = time.round("5min")
2164
+ else:
2165
+ raise AssertionError("`region` only accepts inputs of `F` (full disk) or `C` (CONUS)")
2166
+
2167
+ _flight = GeoVectorDataset(flight)
2168
+ _contrail = GeoVectorDataset(contrail)
2169
+
2170
+ # Ensure the required columns are included in `flight_waypoints` and `contrails`
2171
+ _flight.ensure_vars(["flight_id", "waypoint"])
2172
+ _contrail.ensure_vars(
2173
+ ["flight_id", "waypoint", "sin_a", "cos_a", "width", "tau_contrail", "age_hours"]
2174
+ )
2175
+
2176
+ # Downselect `_flight` only to spatial domain covered by GOES full disk
2177
+ is_in_lon = _flight.dataframe["longitude"].between(spatial_bbox[0], spatial_bbox[2])
2178
+ is_in_lat = _flight.dataframe["latitude"].between(spatial_bbox[1], spatial_bbox[3])
2179
+ is_in_lon_lat = is_in_lon & is_in_lat
2180
+
2181
+ if not np.any(is_in_lon_lat):
2182
+ warnings.warn(
2183
+ "Flight trajectory does not intersect with the defined spatial bounding box or spatial "
2184
+ "domain covered by GOES."
2185
+ )
2186
+
2187
+ _flight = _flight.filter(is_in_lon_lat)
2188
+
2189
+ # Filter `_flight` if time bounds were previously defined.
2190
+ is_before_time = _flight["time"] < time
2191
+
2192
+ if not np.any(is_before_time):
2193
+ warnings.warn("No flight waypoints were recorded before the specified `time`.")
2194
+
2195
+ _flight = _flight.filter(is_before_time)
2196
+
2197
+ # Downselect `_contrail` only to include the filtered flight waypoints
2198
+ is_in_domain = _contrail.dataframe["waypoint"].isin(_flight["waypoint"])
2199
+
2200
+ if not np.any(is_in_domain):
2201
+ warnings.warn(
2202
+ "No persistent contrails were formed within the defined spatial bounding box."
2203
+ )
2204
+
2205
+ _contrail = _contrail.filter(is_in_domain)
2206
+
2207
+ # Download GOES image at `time`
2208
+ goes = GOES(region=region)
2209
+ da = goes.get(time)
2210
+ rgb, transform, extent = extract_goes_visualization(da)
2211
+ bbox = spatial_bbox[0], spatial_bbox[2], spatial_bbox[1], spatial_bbox[3]
2212
+
2213
+ # Calculate optimal figure dimensions
2214
+ d_lon = spatial_bbox[2] - spatial_bbox[0]
2215
+ d_lat = spatial_bbox[3] - spatial_bbox[1]
2216
+ x_dim = 9.99
2217
+ y_dim = x_dim * (d_lat / d_lon)
2218
+
2219
+ # Plot data
2220
+ fig = plt.figure(figsize=(1.2 * x_dim, y_dim))
2221
+ pc = ccrs.PlateCarree()
2222
+ ax = fig.add_subplot(projection=pc, extent=bbox)
2223
+ ax.coastlines() # type: ignore[attr-defined]
2224
+ ax.imshow(rgb, extent=extent, transform=transform)
2225
+
2226
+ ax.set_xticks([spatial_bbox[0], spatial_bbox[2]], crs=ccrs.PlateCarree())
2227
+ ax.set_yticks([spatial_bbox[1], spatial_bbox[3]], crs=ccrs.PlateCarree())
2228
+ lon_formatter = LongitudeFormatter(zero_direction_label=True)
2229
+ lat_formatter = LatitudeFormatter()
2230
+ ax.xaxis.set_major_formatter(lon_formatter)
2231
+ ax.yaxis.set_major_formatter(lat_formatter)
2232
+
2233
+ # Plot flight trajectory up to `time`
2234
+ ax.plot(_flight["longitude"], _flight["latitude"], c="k", linewidth=2.5)
2235
+ plt.legend(["Flight trajectory"])
2236
+
2237
+ # Plot persistent contrails at `time`
2238
+ is_time = (_contrail["time"] == time) & (~np.isnan(_contrail["age_hours"]))
2239
+ im = ax.scatter(
2240
+ _contrail["longitude"][is_time],
2241
+ _contrail["latitude"][is_time],
2242
+ c=_contrail["tau_contrail"][is_time],
2243
+ s=4,
2244
+ cmap="YlOrRd_r",
2245
+ vmin=0,
2246
+ vmax=0.2,
2247
+ )
2248
+ cbar = plt.colorbar(im)
2249
+ cbar.set_label(r"$\tau_{\rm contrail}$")
2250
+ ax.set_title(f"{time}")
2251
+ plt.tight_layout()
2252
+
2253
+ # return output path if `path_write_img` is not None
2254
+ if path_write_img is not None:
2255
+ t_str = time.strftime("%Y%m%d_%H%M%S")
2256
+ file_name = f"goes_{t_str}.png"
2257
+ output_path = path_write_img.joinpath(file_name)
2258
+ plt.savefig(output_path, dpi=150, bbox_inches="tight")
2259
+ plt.close()
2260
+
2261
+ return output_path