pycontrails 0.53.0__cp313-cp313-win_amd64.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 +2312 -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.cp313-win_amd64.pyd +0 -0
  18. pycontrails/core/vector.py +2191 -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 +743 -0
  24. pycontrails/datalib/ecmwf/__init__.py +53 -0
  25. pycontrails/datalib/ecmwf/arco_era5.py +527 -0
  26. pycontrails/datalib/ecmwf/common.py +109 -0
  27. pycontrails/datalib/ecmwf/era5.py +538 -0
  28. pycontrails/datalib/ecmwf/era5_model_level.py +482 -0
  29. pycontrails/datalib/ecmwf/hres.py +782 -0
  30. pycontrails/datalib/ecmwf/hres_model_level.py +495 -0
  31. pycontrails/datalib/ecmwf/ifs.py +284 -0
  32. pycontrails/datalib/ecmwf/model_levels.py +79 -0
  33. pycontrails/datalib/ecmwf/static/model_level_dataframe_v20240418.csv +139 -0
  34. pycontrails/datalib/ecmwf/variables.py +256 -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 +568 -0
  40. pycontrails/datalib/sentinel.py +512 -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 +426 -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 +983 -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 +2617 -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 +486 -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.53.0.dist-info/LICENSE +178 -0
  105. pycontrails-0.53.0.dist-info/METADATA +181 -0
  106. pycontrails-0.53.0.dist-info/NOTICE +43 -0
  107. pycontrails-0.53.0.dist-info/RECORD +109 -0
  108. pycontrails-0.53.0.dist-info/WHEEL +5 -0
  109. pycontrails-0.53.0.dist-info/top_level.txt +3 -0
@@ -0,0 +1,2573 @@
1
+ """Gridded CoCiP model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import itertools
6
+ import logging
7
+ import warnings
8
+ from collections.abc import Generator, Iterable, Iterator, Sequence
9
+ from typing import TYPE_CHECKING, Any, NoReturn, TypeVar, overload
10
+
11
+ import numpy as np
12
+ import numpy.typing as npt
13
+ import pandas as pd
14
+ import xarray as xr
15
+
16
+ import pycontrails
17
+ from pycontrails.core import models
18
+ from pycontrails.core.met import MetDataset
19
+ from pycontrails.core.vector import GeoVectorDataset, VectorDataset
20
+ from pycontrails.models import humidity_scaling, sac
21
+ from pycontrails.models.cocip import cocip, contrail_properties, wake_vortex, wind_shear
22
+ from pycontrails.models.cocipgrid.cocip_grid_params import CocipGridParams
23
+ from pycontrails.models.emissions import Emissions
24
+ from pycontrails.physics import constants, geo, thermo, units
25
+ from pycontrails.utils import dependencies
26
+
27
+ if TYPE_CHECKING:
28
+ import tqdm
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ class CocipGrid(models.Model):
34
+ """Run CoCiP simulation on a grid.
35
+
36
+ See :meth:`eval` for a description of model evaluation ``source`` parameters.
37
+
38
+ Parameters
39
+ ----------
40
+ met, rad : MetDataset
41
+ CoCiP-specific met data to interpolate against
42
+ params : dict[str, Any], optional
43
+ Override :class:`CocipGridParams` defaults. Most notably, the model is highly
44
+ dependent on the parameter ``dt_integration``. Memory usage is also affected by
45
+ parameters ``met_slice_dt`` and ``target_split_size``.
46
+ param_kwargs : Any
47
+ Override CocipGridParams defaults with arbitrary keyword arguments.
48
+
49
+ Notes
50
+ -----
51
+ - If ``rad`` contains accumulated radiative fluxes, differencing to obtain
52
+ time-averaged fluxes will reduce the time coverage of ``rad`` by half a forecast
53
+ step. A warning will be produced during :meth:`eval` if the time coverage of
54
+ ``rad`` (after differencing) is too short given the model evaluation parameters.
55
+ If this occurs, provide an additional step of radiation data at the start or end
56
+ of ``rad``.
57
+
58
+ References
59
+ ----------
60
+ - :cite:`schumannPotentialReduceClimate2011`
61
+ - :cite:`schumannContrailsVisibleAviation2012`
62
+
63
+ See Also
64
+ --------
65
+ :class:`CocipGridParams`
66
+ :class:`Cocip`
67
+ :mod:`wake_vortex`
68
+ :mod:`contrail_properties`
69
+ :mod:`radiative_forcing`
70
+ :mod:`humidity_scaling`
71
+ :class:`Emissions`
72
+ :mod:`sac`
73
+ :mod:`tau_cirrus`
74
+ """
75
+
76
+ __slots__ = (
77
+ "rad",
78
+ "timesteps",
79
+ "contrail",
80
+ "contrail_list",
81
+ "_target_dtype",
82
+ )
83
+
84
+ name = "contrail_grid"
85
+ long_name = "Gridded Contrail Cirrus Prediction Model"
86
+ default_params = CocipGridParams
87
+
88
+ # Reference Cocip as the source of truth for met variables
89
+ met_variables = cocip.Cocip.met_variables
90
+ rad_variables = cocip.Cocip.rad_variables
91
+ processed_met_variables = cocip.Cocip.processed_met_variables
92
+
93
+ #: Met data is not optional
94
+ met: MetDataset
95
+ met_required = True
96
+ rad: MetDataset
97
+
98
+ #: Last evaluated input source
99
+ source: MetDataset | GeoVectorDataset
100
+
101
+ #: Artifacts attached when parameter ``verbose_outputs_evolution`` is True
102
+ #: These allow for some additional information and parity with the approach
103
+ #: taken by :class:`Cocip`.
104
+ contrail_list: list[GeoVectorDataset]
105
+ contrail: pd.DataFrame
106
+
107
+ def __init__(
108
+ self,
109
+ met: MetDataset,
110
+ rad: MetDataset,
111
+ params: dict[str, Any] | None = None,
112
+ **params_kwargs: Any,
113
+ ):
114
+ super().__init__(met, params=params, **params_kwargs)
115
+
116
+ compute_tau_cirrus = self.params["compute_tau_cirrus_in_model_init"]
117
+ self.met, self.rad = cocip.process_met_datasets(met, rad, compute_tau_cirrus)
118
+
119
+ # Convenience -- only used in `run_interpolators`
120
+ self.params["_interp_kwargs"] = self.interp_kwargs
121
+
122
+ if self.params["radiative_heating_effects"]:
123
+ msg = "Parameter 'radiative_heating_effects' is not yet implemented in CocipGrid"
124
+ raise NotImplementedError(msg)
125
+
126
+ if self.params["unterstrasser_ice_survival_fraction"]:
127
+ msg = (
128
+ "Parameter 'unterstrasser_ice_survival_fraction' is not "
129
+ "yet implemented in CocipGrid"
130
+ )
131
+ raise NotImplementedError(msg)
132
+
133
+ self._target_dtype = np.result_type(*self.met.data.values())
134
+
135
+ @overload
136
+ def eval(self, source: GeoVectorDataset, **params: Any) -> GeoVectorDataset: ...
137
+
138
+ @overload
139
+ def eval(self, source: MetDataset, **params: Any) -> MetDataset: ...
140
+
141
+ @overload
142
+ def eval(self, source: None = ..., **params: Any) -> NoReturn: ...
143
+
144
+ def eval(
145
+ self, source: GeoVectorDataset | MetDataset | None = None, **params: Any
146
+ ) -> GeoVectorDataset | MetDataset:
147
+ """Run CoCiP simulation on a 4d coordinate grid or arbitrary set of 4d points.
148
+
149
+ If the :attr:`params` ``verbose_outputs_evolution`` is True, the model holds
150
+ :attr:`contrail_list` and :attr:`contrail` attributes for viewing intermediate
151
+ artifacts. If ``source`` data is large, these intermediate vectors may consume
152
+ substantial memory.
153
+
154
+ .. versionchanged:: 0.25.0
155
+
156
+ No longer explicitly support :class:`Flight` as a source. Any flight source
157
+ will be viewed as a :class:`GeoVectorDataset`. In order to evaluate CoCiP
158
+ predictions over a flight trajectory, it is best to use the :class:`Cocip`
159
+ model. It's also possible to pre-compute segment azimuth and true airspeed
160
+ before passing the flight trajectory in here.
161
+
162
+ Parameters
163
+ ----------
164
+ source : GeoVectorDataset | MetDataset | None
165
+ Input :class:`GeoVectorDataset` or :class:`MetDataset`. If None,
166
+ a ``NotImplementedError`` is raised. If any subclass of :class:`GeoVectorDataset`
167
+ is passed (e.g., :class:`Flight`), the additional structure is forgotten and
168
+ the model is evaluated as if it were a :class:`GeoVectorDataset`.
169
+ Additional variables may be passed as ``source`` data or attrs. These
170
+ include:
171
+
172
+ - ``aircraft_type``: This overrides any value in :attr:`params`. Must be included
173
+ in the source attrs (not data).
174
+ - ``fuel_flow``, ``engine_efficiency``, ``true_airspeed``, ``wingspan``,
175
+ ``aircraft_mass``: These override any value in :attr:`params`.
176
+ - ``azimuth``: This overrides any value in :attr:`params`.
177
+ - ``segment_length``: This overrides any value in :attr:`params`.
178
+ **params : Any
179
+ Overwrite model parameters before eval
180
+
181
+ Returns
182
+ -------
183
+ GeoVectorDataset | MetDataset
184
+ CoCiP predictions for each point in ``source``. Output data contains variables
185
+ ``contrail_age`` and ``ef_per_m``. Additional variables specified by the model
186
+ :attr:`params` ``verbose_outputs_formation`` are also included.
187
+
188
+ Raises
189
+ ------
190
+ NotImplementedError
191
+ If ``source`` is None
192
+
193
+ Notes
194
+ -----
195
+ At a high level, the model is broken down into the following steps:
196
+ - Convert any :class:`MetDataset` ``source`` to :class:`GeoVectorDataset`.
197
+ - Split the ``source`` into chunks of size ``params["target_split_size"]``.
198
+ - For each timestep in :attr:`timesteps`:
199
+
200
+ - Generate any new waypoints from the source data. Calculate aircraft performance
201
+ and run the CoCiP downwash routine over the new waypoints.
202
+ - For each "active" contrail (i.e., a contrail that has been initialized but
203
+ has not yet reach its end of life), evolve the contrail forward one step.
204
+ Filter any waypoint that has reached its end of life.
205
+
206
+ - Aggregate contrail age and energy forcing predictions to a single
207
+ output variable to return.
208
+ """
209
+ self.update_params(params)
210
+ if source is None:
211
+ # Unclear how to implement this
212
+ # We expect met and rad to contain time slices beyond what is found
213
+ # in the source (we need to evolve contrails forward in time).
214
+ # Perhaps we could use the isel(time=0) slice to construct the source
215
+ # from the met and rad data.
216
+ msg = "CocipGrid.eval() with 'source=None' is not implemented."
217
+ raise NotImplementedError(msg)
218
+ self.set_source(source)
219
+
220
+ self.met, self.rad = _downselect_met(self.source, self.met, self.rad, self.params)
221
+ self.met = cocip.add_tau_cirrus(self.met)
222
+ self._check_met_covers_source()
223
+
224
+ # Save humidity scaling type to output attrs
225
+ humidity_scaling = self.params["humidity_scaling"]
226
+ if humidity_scaling is not None:
227
+ for k, v in humidity_scaling.description.items():
228
+ self.source.attrs[f"humidity_scaling_{k}"] = v
229
+
230
+ self._parse_verbose_outputs()
231
+
232
+ self._set_timesteps()
233
+ pbar = self._init_pbar()
234
+
235
+ met: MetDataset | None = None
236
+ rad: MetDataset | None = None
237
+
238
+ ef_summary: list[VectorDataset] = []
239
+ verbose_dicts: list[dict[str, pd.Series]] = []
240
+ contrail_list: list[GeoVectorDataset] = []
241
+ existing_vectors: Iterator[GeoVectorDataset] = iter(())
242
+
243
+ for time_idx, time_end in enumerate(self.timesteps):
244
+ met, rad = self._maybe_downselect_met_rad(met, rad, time_end)
245
+
246
+ evolved_this_step = []
247
+ ef_summary_this_step = []
248
+ downwash_vectors_this_step = []
249
+ for vector in self._generate_new_vectors(time_idx):
250
+ downwash, verbose_dict = _run_downwash(vector, met, rad, self.params)
251
+
252
+ if downwash:
253
+ # T_crit_sac is no longer needed. If verbose_outputs_formation is True,
254
+ # it's already storied in the verbose_dict data
255
+ downwash.data.pop("T_crit_sac", None)
256
+ downwash_vectors_this_step.append(downwash)
257
+ if self.params["verbose_outputs_evolution"]:
258
+ contrail_list.append(downwash)
259
+
260
+ if self.params["verbose_outputs_formation"] and verbose_dict:
261
+ verbose_dicts.append(verbose_dict)
262
+
263
+ if pbar is not None:
264
+ pbar.update()
265
+
266
+ for vector in itertools.chain(existing_vectors, downwash_vectors_this_step):
267
+ contrail, ef = _evolve_vector(
268
+ vector,
269
+ met=met,
270
+ rad=rad,
271
+ params=self.params,
272
+ t=time_end,
273
+ )
274
+ if ef:
275
+ evolved_this_step.append(contrail)
276
+ ef_summary_this_step.append(ef)
277
+ if self.params["verbose_outputs_evolution"]:
278
+ contrail_list.append(contrail)
279
+
280
+ if pbar is not None:
281
+ pbar.update()
282
+
283
+ if not evolved_this_step:
284
+ if np.all(time_end > self.source_time):
285
+ break
286
+ continue
287
+
288
+ existing_vectors = combine_vectors(evolved_this_step, self.params["target_split_size"])
289
+
290
+ summary = VectorDataset.sum(ef_summary_this_step)
291
+ if summary:
292
+ ef_summary.append(summary)
293
+
294
+ if pbar is not None:
295
+ logger.debug("Close progress bar")
296
+ pbar.refresh()
297
+ pbar.close()
298
+
299
+ self._attach_verbose_outputs_evolution(contrail_list)
300
+ total_ef_summary = _aggregate_ef_summary(ef_summary)
301
+ return self._bundle_results(total_ef_summary, verbose_dicts)
302
+
303
+ def _maybe_downselect_met_rad(
304
+ self,
305
+ met: MetDataset | None,
306
+ rad: MetDataset | None,
307
+ time_end: np.datetime64,
308
+ ) -> tuple[MetDataset, MetDataset]:
309
+ """Downselect ``self.met`` and ``self.rad`` if necessary to cover ``time_end``.
310
+
311
+ If the currently used ``met`` and ``rad`` slices do not include the time
312
+ ``time_end``, new slices are selected from the larger ``self.met`` and
313
+ ``self.rad`` data. The slicing only occurs in the time domain.
314
+
315
+ The end of currently-used ``met`` and ``rad`` will be used as the start
316
+ of newly-selected met slices when possible to avoid losing and re-loading
317
+ already-loaded met data.
318
+
319
+ If ``self.params["downselect_met"]`` is True, :func:`_downselect_met` has
320
+ already performed a spatial downselection of the met data.
321
+ """
322
+
323
+ if met is None:
324
+ # idx is the first index at which self.met.variables["time"].to_numpy() >= time_end
325
+ idx = np.searchsorted(self.met.indexes["time"].to_numpy(), time_end)
326
+ sl = slice(max(0, idx - 1), idx + 1)
327
+ logger.debug("Select met slice %s", sl)
328
+ met = MetDataset(self.met.data.isel(time=sl), copy=False)
329
+
330
+ elif time_end > met.indexes["time"].to_numpy()[-1]:
331
+ current_times = met.indexes["time"].to_numpy()
332
+ all_times = self.met.indexes["time"].to_numpy()
333
+ # idx is the first index at which all_times >= time_end
334
+ idx = np.searchsorted(all_times, time_end)
335
+ sl = slice(max(0, idx - 1), idx + 1)
336
+
337
+ # case 1: cannot re-use end of current met as start of new met
338
+ if current_times[-1] != all_times[sl.start]:
339
+ logger.debug("Select met slice %s", sl)
340
+ met = MetDataset(self.met.data.isel(time=sl), copy=False)
341
+ # case 2: can re-use end of current met plus one step of new met
342
+ elif sl.start < all_times.size - 1:
343
+ sl = slice(sl.start + 1, sl.stop)
344
+ logger.debug("Reuse end of met and select met slice %s", sl)
345
+ met = MetDataset(
346
+ xr.concat((met.data.isel(time=[-1]), self.met.data.isel(time=sl)), dim="time"),
347
+ copy=False,
348
+ )
349
+ # case 3: can re-use end of current met and nothing else
350
+ else:
351
+ logger.debug("Reuse end of met")
352
+ met = MetDataset(met.data.isel(time=[-1]), copy=False)
353
+
354
+ if rad is None:
355
+ # idx is the first index at which self.rad.variables["time"].to_numpy() >= time_end
356
+ idx = np.searchsorted(self.rad.indexes["time"].to_numpy(), time_end)
357
+ sl = slice(max(0, idx - 1), idx + 1)
358
+ logger.debug("Select rad slice %s", sl)
359
+ rad = MetDataset(self.rad.data.isel(time=sl), copy=False)
360
+
361
+ elif time_end > rad.indexes["time"].to_numpy()[-1]:
362
+ current_times = rad.indexes["time"].to_numpy()
363
+ all_times = self.rad.indexes["time"].to_numpy()
364
+ # idx is the first index at which all_times >= time_end
365
+ idx = np.searchsorted(all_times, time_end)
366
+ sl = slice(max(0, idx - 1), idx + 1)
367
+
368
+ # case 1: cannot re-use end of current rad as start of new rad
369
+ if current_times[-1] != all_times[sl.start]:
370
+ logger.debug("Select rad slice %s", sl)
371
+ rad = MetDataset(self.rad.data.isel(time=sl), copy=False)
372
+ # case 2: can re-use end of current rad plus one step of new rad
373
+ elif sl.start < all_times.size - 1:
374
+ sl = slice(sl.start + 1, sl.stop)
375
+ logger.debug("Reuse end of rad and select rad slice %s", sl)
376
+ rad = MetDataset(
377
+ xr.concat((rad.data.isel(time=[-1]), self.rad.data.isel(time=sl)), dim="time"),
378
+ copy=False,
379
+ )
380
+ # case 3: can re-use end of current rad and nothing else
381
+ else:
382
+ logger.debug("Reuse end of rad")
383
+ rad = MetDataset(rad.data.isel(time=[-1]), copy=False)
384
+
385
+ return met, rad
386
+
387
+ def _attach_verbose_outputs_evolution(self, contrail_list: list[GeoVectorDataset]) -> None:
388
+ """Attach intermediate artifacts to the model.
389
+
390
+ This method attaches :attr:`contrail_list` and :attr:`contrail` when
391
+ :attr:`params["verbose_outputs_evolution"]` is True.
392
+
393
+ Mirrors implementation in :class:`Cocip`. We could do additional work here
394
+ if this turns out to be useful.
395
+ """
396
+ if not self.params["verbose_outputs_evolution"]:
397
+ return
398
+
399
+ self.contrail_list = contrail_list # attach raw data
400
+
401
+ if contrail_list:
402
+ # And the contrail DataFrame (pd.concat is expensive here)
403
+ dfs = [contrail.dataframe for contrail in contrail_list]
404
+ dfs = [df.assign(timestep=t_idx) for t_idx, df in enumerate(dfs)]
405
+ self.contrail = pd.concat(dfs)
406
+ else:
407
+ self.contrail = pd.DataFrame()
408
+
409
+ def _bundle_results(
410
+ self,
411
+ summary: VectorDataset | None,
412
+ verbose_dicts: list[dict[str, pd.Series]],
413
+ ) -> GeoVectorDataset | MetDataset:
414
+ """Gather and massage model outputs for return."""
415
+ max_age = self.params["max_age"]
416
+ dt_integration = self.params["dt_integration"]
417
+ azimuth = self.get_source_param("azimuth")
418
+ segment_length = self.get_source_param("segment_length")
419
+ if segment_length is None:
420
+ segment_length = 1.0
421
+
422
+ # Deal with verbose_dicts
423
+ verbose_dict = _concat_verbose_dicts(
424
+ verbose_dicts, self.source.size, self.params["verbose_outputs_formation"]
425
+ )
426
+
427
+ # Make metadata in attrs more readable
428
+ if max_age.astype("timedelta64[h]") == max_age:
429
+ max_age_str = str(max_age.astype("timedelta64[h]"))
430
+ else:
431
+ max_age_str = str(max_age.astype("timedelta64[m]"))
432
+ if dt_integration.astype("timedelta64[m]") == dt_integration:
433
+ dt_integration_str = str(dt_integration.astype("timedelta64[m]"))
434
+ else:
435
+ dt_integration_str = str(dt_integration.astype("timedelta64[s]"))
436
+
437
+ self.transfer_met_source_attrs()
438
+ attrs: dict[str, Any] = {
439
+ "description": self.long_name,
440
+ "max_age": max_age_str,
441
+ "dt_integration": dt_integration_str,
442
+ "aircraft_type": self.get_source_param("aircraft_type"),
443
+ "pycontrails_version": pycontrails.__version__,
444
+ **self.source.attrs, # type: ignore[dict-item]
445
+ }
446
+ if ap_model := self.params["aircraft_performance"]:
447
+ attrs["ap_model"] = type(ap_model).__name__
448
+
449
+ if isinstance(azimuth, np.floating | np.integer):
450
+ attrs["azimuth"] = azimuth.item()
451
+ elif isinstance(azimuth, float | int):
452
+ attrs["azimuth"] = azimuth
453
+
454
+ if isinstance(self.source, MetDataset):
455
+ self.source = result_to_metdataset(
456
+ result=summary,
457
+ verbose_dict=verbose_dict,
458
+ source=self.source,
459
+ nominal_segment_length=segment_length,
460
+ attrs=attrs,
461
+ )
462
+
463
+ if self.params["compute_atr20"]:
464
+ self.source["global_yearly_mean_rf_per_m"] = (
465
+ self.source["ef_per_m"].data
466
+ / constants.surface_area_earth
467
+ / constants.seconds_per_year
468
+ )
469
+ self.source["atr20_per_m"] = (
470
+ self.params["global_rf_to_atr20_factor"]
471
+ * self.source["global_yearly_mean_rf_per_m"].data
472
+ )
473
+ else:
474
+ self.source = result_merge_source(
475
+ result=summary,
476
+ verbose_dict=verbose_dict,
477
+ source=self.source,
478
+ nominal_segment_length=segment_length,
479
+ attrs=attrs,
480
+ )
481
+
482
+ if self.params["compute_atr20"]:
483
+ self.source["global_yearly_mean_rf_per_m"] = (
484
+ self.source["ef_per_m"]
485
+ / constants.surface_area_earth
486
+ / constants.seconds_per_year
487
+ )
488
+ self.source["atr20_per_m"] = (
489
+ self.params["global_rf_to_atr20_factor"]
490
+ * self.source["global_yearly_mean_rf_per_m"]
491
+ )
492
+
493
+ return self.source
494
+
495
+ # ---------------------------
496
+ # Common Methods & Properties
497
+ # ---------------------------
498
+
499
+ @property
500
+ def source_time(self) -> npt.NDArray[np.datetime64]:
501
+ """Return the time array of the :attr:`source` data."""
502
+ try:
503
+ source = self.source
504
+ except AttributeError as exc:
505
+ msg = "No source set"
506
+ raise AttributeError(msg) from exc
507
+
508
+ if isinstance(source, GeoVectorDataset):
509
+ return source["time"]
510
+ if isinstance(source, MetDataset):
511
+ return source.indexes["time"].values
512
+
513
+ msg = f"Cannot calculate timesteps for {source}"
514
+ raise TypeError(msg)
515
+
516
+ def _set_timesteps(self) -> None:
517
+ """Set the :attr:`timesteps` based on the ``source`` time range."""
518
+ source_time = self.source_time
519
+ tmin = source_time.min()
520
+ tmax = source_time.max()
521
+
522
+ tmin = pd.to_datetime(tmin)
523
+ tmax = pd.to_datetime(tmax)
524
+ dt = pd.to_timedelta(self.params["dt_integration"])
525
+
526
+ t_start = tmin.ceil(dt)
527
+ t_end = tmax.floor(dt) + self.params["max_age"] + dt
528
+
529
+ # Pass in t_end (as opposed to tmax) to ensure that the met and rad data
530
+ # cover the entire evolution period.
531
+ _check_met_rad_time(self.met, self.rad, tmin, t_end)
532
+
533
+ self.timesteps = np.arange(t_start, t_end, dt)
534
+
535
+ def _init_pbar(self) -> tqdm.tqdm | None:
536
+ """Initialize a progress bar for model evaluation.
537
+
538
+ The total number of steps is estimated in a very crude way. Do not
539
+ rely on the progress bar for accurate estimates of runtime.
540
+
541
+ Returns
542
+ -------
543
+ tqdm.tqdm | None
544
+ A progress bar for model evaluation. If ``show_progress`` is False, returns None.
545
+ """
546
+
547
+ if not self.params["show_progress"]:
548
+ return None
549
+
550
+ try:
551
+ from tqdm.auto import tqdm
552
+ except ModuleNotFoundError as exc:
553
+ dependencies.raise_module_not_found_error(
554
+ name="CocipGrid._init_pbar method",
555
+ package_name="tqdm",
556
+ module_not_found_error=exc,
557
+ extra="Alternatively, set model parameter 'show_progress=False'.",
558
+ )
559
+
560
+ split_size = self.params["target_split_size"]
561
+ if isinstance(self.source, MetDataset):
562
+ n_splits_by_time = self._metdataset_source_n_splits()
563
+ n_splits = len(self.source_time) * n_splits_by_time
564
+ else:
565
+ tmp1 = self.source_time[:, None] < self.timesteps[1:]
566
+ tmp2 = self.source_time[:, None] >= self.timesteps[:-1]
567
+ n_points_by_timestep = np.sum(tmp1 & tmp2, axis=0)
568
+
569
+ init_split_size = self.params["target_split_size_pre_SAC_boost"] * split_size
570
+ n_splits_by_time = np.ceil(n_points_by_timestep / init_split_size)
571
+ n_splits = np.sum(n_splits_by_time)
572
+
573
+ n_init_surv = 0.1 * n_splits # assume 10% of points survive the downwash
574
+ n_evo_steps = len(self.timesteps) * n_init_surv
575
+ total = n_splits + n_evo_steps
576
+
577
+ return tqdm(total=int(total), desc=f"{type(self).__name__} eval")
578
+
579
+ def _metdataset_source_n_splits(self) -> int:
580
+ """Compute the number of splits at a given time for a :class:`MetDataset` source.
581
+
582
+ This method assumes :attr:`source` is a :class:`MetDataset`.
583
+
584
+ Returns
585
+ -------
586
+ int
587
+ The number of splits.
588
+ """
589
+ if not isinstance(self.source, MetDataset):
590
+ msg = f"Expected source to be a MetDataset, found {type(self.source)}"
591
+ raise TypeError(msg)
592
+
593
+ indexes = self.source.indexes
594
+ grid_size = indexes["longitude"].size * indexes["latitude"].size * indexes["level"].size
595
+
596
+ split_size = int(
597
+ self.params["target_split_size_pre_SAC_boost"] * self.params["target_split_size"]
598
+ )
599
+ return max(grid_size // split_size, 1)
600
+
601
+ def _parse_verbose_outputs(self) -> None:
602
+ """Confirm param "verbose_outputs" has the expected type for grid and path mode.
603
+
604
+ This function mutates the "verbose_outputs" field on :attr:`params`.
605
+
606
+ Currently, the list of all supported variables for verbose outputs
607
+ is determine by :func:`_supported_verbose_outputs`.
608
+ """
609
+ if self.params["verbose_outputs"]:
610
+ msg = (
611
+ "Parameter 'verbose_outputs' is no longer supported for grid mode. "
612
+ "Instead, use 'verbose_outputs_formation' and 'verbose_outputs_evolution'."
613
+ )
614
+ raise ValueError(msg)
615
+ vo = self.params["verbose_outputs_formation"]
616
+ supported = _supported_verbose_outputs_formation()
617
+
618
+ # Parse to set of strings
619
+ if isinstance(vo, str):
620
+ vo = {vo}
621
+ elif isinstance(vo, bool):
622
+ vo = supported if vo else set()
623
+ else:
624
+ vo = set(vo)
625
+
626
+ unknown_vars = vo - supported
627
+ if unknown_vars:
628
+ warnings.warn(
629
+ f"Unknown variables in 'verbose_outputs': {unknown_vars}. "
630
+ "Presently, CocipGrid only supports verbose outputs for "
631
+ f"variables {supported}. The unknown variables will be ignored."
632
+ )
633
+ self.params["verbose_outputs_formation"] = vo & supported
634
+
635
+ def _generate_new_vectors(self, time_idx: int) -> Generator[GeoVectorDataset, None, None]:
636
+ """Generate :class:`GeoVectorDataset` instances from :attr:`source`.
637
+
638
+ Parameters
639
+ ----------
640
+ time_idx : int
641
+ The index of the current time slice in :attr:`timesteps`.
642
+
643
+ Yields
644
+ ------
645
+ GeoVectorDataset
646
+ Unevolved vectors arising from :attr`self.source_time` filtered by ``filt``.
647
+ When :attr:`source` is a :class:`MetDataset`, each yielded dataset has a
648
+ constant time value.
649
+ """
650
+ if "index" in self.source:
651
+ # FIXME: We can simply change the internal variable to __index
652
+ msg = "The variable 'index' is used internally. Found in source."
653
+ raise RuntimeError(msg)
654
+
655
+ source_time = self.source_time
656
+ t_cur = self.timesteps[time_idx]
657
+ if time_idx == 0:
658
+ filt = source_time < t_cur
659
+ else:
660
+ t_prev = self.timesteps[time_idx - 1]
661
+ filt = (source_time >= t_prev) & (source_time < t_cur)
662
+
663
+ if not filt.any():
664
+ return
665
+
666
+ if isinstance(self.source, MetDataset):
667
+ times_in_filt = source_time[filt]
668
+ filt_start_idx = np.argmax(filt).item() # needed to ensure globally unique indexes
669
+
670
+ n_splits = self._metdataset_source_n_splits()
671
+ for idx, time in enumerate(times_in_filt):
672
+ # For now, sticking with the convention that every vector should
673
+ # have a constant time value.
674
+ source_slice = MetDataset(self.source.data.sel(time=[time]))
675
+
676
+ # Convert the 4D grid to a vector
677
+ vector = source_slice.to_vector()
678
+ vector.update(
679
+ longitude=vector["longitude"].astype(self._target_dtype, copy=False),
680
+ latitude=vector["latitude"].astype(self._target_dtype, copy=False),
681
+ level=vector["level"].astype(self._target_dtype, copy=False),
682
+ )
683
+ vector["index"] = source_time.size * np.arange(vector.size) + filt_start_idx + idx
684
+
685
+ # Split into chunks
686
+ for subvector in vector.generate_splits(n_splits):
687
+ subvector = self._build_subvector(subvector)
688
+ logger.debug(
689
+ "Yield new vector at time %s with size %s",
690
+ time.astype("datetime64[m]"),
691
+ subvector.size,
692
+ )
693
+ yield subvector
694
+
695
+ elif isinstance(self.source, GeoVectorDataset):
696
+ split_size = (
697
+ self.params["target_split_size_pre_SAC_boost"] * self.params["target_split_size"]
698
+ )
699
+ n_splits = max(filt.sum() // split_size, 1)
700
+ # Don't copy here ... we copy when we call `generate_splits`
701
+ vector = self.source.filter(filt, copy=False)
702
+ if vector:
703
+ vector["index"] = np.flatnonzero(filt)
704
+
705
+ # Split into chunks
706
+ for subvector in vector.generate_splits(n_splits, copy=True):
707
+ subvector = self._build_subvector(subvector)
708
+ logger.debug("Yield new vector with size %s", subvector.size)
709
+ yield subvector
710
+
711
+ else:
712
+ msg = f"Unknown source {self.source}"
713
+ raise TypeError(msg)
714
+
715
+ def _build_subvector(self, vector: GeoVectorDataset) -> GeoVectorDataset:
716
+ """Mutate `vector` by adding additional keys."""
717
+ # Add time
718
+ vector["formation_time"] = vector["time"]
719
+ vector["age"] = np.full(vector.size, np.timedelta64(0, "ns"))
720
+
721
+ # Precompute
722
+ vector["air_pressure"] = vector.air_pressure
723
+ vector["altitude"] = vector.altitude
724
+
725
+ # Add nominals -- it's a little strange that segment_length
726
+ # is also a nominal
727
+ for key in _nominal_params():
728
+ _setdefault_from_params(key, vector, self.params)
729
+
730
+ segment_length = self._get_source_param_override("segment_length", vector)
731
+ azimuth = self._get_source_param_override("azimuth", vector)
732
+
733
+ # Experimental segment-free mode logic
734
+ if azimuth is None and segment_length is None:
735
+ return vector
736
+ if azimuth is None:
737
+ msg = "Set 'segment_length' to None for experimental segment-free model"
738
+ raise ValueError(msg)
739
+ if segment_length is None:
740
+ msg = "Set 'azimuth' to None for experimental segment-free model"
741
+ raise ValueError(msg)
742
+ if self.params["dsn_dz_factor"]:
743
+ msg = "'dsn_dz_factor' not supported outside of the segment-free mode"
744
+ raise ValueError(msg)
745
+
746
+ lons = vector["longitude"]
747
+ lats = vector["latitude"]
748
+ dist = segment_length / 2.0
749
+
750
+ # These should probably not be included in model input ... so
751
+ # we'll get a warning if they get overwritten
752
+ lon_head, lat_head = geo.forward_azimuth(lons=lons, lats=lats, az=azimuth, dist=dist)
753
+ vector["longitude_head"] = lon_head.astype(self._target_dtype, copy=False)
754
+ vector["latitude_head"] = lat_head.astype(self._target_dtype, copy=False)
755
+
756
+ lon_tail, lat_tail = geo.forward_azimuth(lons=lons, lats=lats, az=azimuth, dist=-dist)
757
+ vector["longitude_tail"] = lon_tail.astype(self._target_dtype, copy=False)
758
+ vector["latitude_tail"] = lat_tail.astype(self._target_dtype, copy=False)
759
+
760
+ return vector
761
+
762
+ def _check_met_covers_source(self) -> None:
763
+ """Ensure that the met and rad data cover the source data.
764
+
765
+ See also :func:`_check_met_rad_time` which checks the time coverage
766
+ in more detail.
767
+ """
768
+ try:
769
+ source = self.source
770
+ except AttributeError as exc:
771
+ msg = "No source set"
772
+ raise AttributeError(msg) from exc
773
+
774
+ if isinstance(source, MetDataset):
775
+ indexes = source.indexes
776
+ longitude = indexes["longitude"].to_numpy()
777
+ latitude = indexes["latitude"].to_numpy()
778
+ level = indexes["level"].to_numpy()
779
+ time = indexes["time"].to_numpy()
780
+ else:
781
+ longitude = source["longitude"]
782
+ latitude = source["latitude"]
783
+ level = source.level
784
+ time = source["time"]
785
+
786
+ indexes = self.met.indexes
787
+ _check_coverage(indexes["longitude"].to_numpy(), longitude, "longitude", "met")
788
+ _check_coverage(indexes["latitude"].to_numpy(), latitude, "latitude", "met")
789
+ _check_coverage(indexes["level"].to_numpy(), level, "level", "met")
790
+ _check_coverage(indexes["time"].to_numpy(), time, "time", "met")
791
+
792
+ indexes = self.rad.indexes
793
+ _check_coverage(indexes["longitude"].to_numpy(), longitude, "longitude", "rad")
794
+ _check_coverage(indexes["latitude"].to_numpy(), latitude, "latitude", "rad")
795
+ _check_coverage(indexes["time"].to_numpy(), time, "time", "rad")
796
+
797
+ _warn_not_wrap(self.met)
798
+ _warn_not_wrap(self.rad)
799
+
800
+ def _get_source_param_override(self, key: str, vector: GeoVectorDataset) -> Any:
801
+ return _get_source_param_override(key, vector, self.params)
802
+
803
+ # ------------
804
+ # Constructors
805
+ # ------------
806
+
807
+ @staticmethod
808
+ def create_source(
809
+ level: npt.NDArray[np.float64] | list[float] | float,
810
+ time: npt.NDArray[np.datetime64] | list[np.datetime64] | np.datetime64,
811
+ longitude: npt.NDArray[np.float64] | list[float] | None = None,
812
+ latitude: npt.NDArray[np.float64] | list[float] | None = None,
813
+ lon_step: float = 1.0,
814
+ lat_step: float = 1.0,
815
+ ) -> MetDataset:
816
+ """
817
+ Shortcut to create a :class:`MetDataset` source from coordinate arrays.
818
+
819
+ Parameters
820
+ ----------
821
+ level : level: npt.NDArray[np.float64] | list[float] | float
822
+ Pressure levels for gridded cocip.
823
+ To avoid interpolating outside of the passed ``met`` and ``rad`` data, this
824
+ parameter should avoid the extreme values of the ``met`` and `rad` levels.
825
+ If ``met`` is already defined, a good choice for ``level`` is
826
+ ``met.data['level'].values[1: -1]``.
827
+ time: npt.NDArray[np.datetime64 | list[np.datetime64] | np.datetime64,
828
+ One or more time values for gridded cocip.
829
+ longitude, latitude : npt.NDArray[np.float64] | list[float], optional
830
+ Longitude and latitude arrays, by default None. If not specified, values of
831
+ ``lon_step`` and ``lat_step`` are used to define ``longitude`` and ``latitude``.
832
+ To avoid model degradation at the poles, latitude values are expected to be
833
+ between -80 and 80 degrees.
834
+ lon_step, lat_step : float, optional
835
+ Longitude and latitude resolution, by default 1.0.
836
+ Only used if parameter ``longitude`` (respective ``latitude``) not specified.
837
+
838
+ Returns
839
+ -------
840
+ MetDataset
841
+ MetDataset that can be used as ``source`` input to :meth:`CocipGrid.eval(source=...)`
842
+
843
+ See Also
844
+ --------
845
+ :meth:`MetDataset.from_coords`
846
+ """
847
+ if longitude is None:
848
+ longitude = np.arange(-180, 180, lon_step, dtype=float)
849
+ if latitude is None:
850
+ latitude = np.arange(-80, 80.000001, lat_step, dtype=float)
851
+
852
+ out = MetDataset.from_coords(longitude=longitude, latitude=latitude, level=level, time=time)
853
+
854
+ if np.any(out.data.latitude > 80.0001) or np.any(out.data.latitude < -80.0001):
855
+ msg = "Model only supports latitude between -80 and 80."
856
+ raise ValueError(msg)
857
+
858
+ return out
859
+
860
+
861
+ ################################
862
+ # Functions used by CocipGrid
863
+ ################################
864
+
865
+
866
+ def _get_source_param_override(key: str, vector: GeoVectorDataset, params: dict[str, Any]) -> Any:
867
+ """Mimic logic in :meth:`Models.get_source_param` replacing :attr:`source` with a ``vector``."""
868
+ try:
869
+ return vector[key]
870
+ except KeyError:
871
+ pass
872
+
873
+ try:
874
+ return vector.attrs[key]
875
+ except KeyError:
876
+ pass
877
+
878
+ val = params[key]
879
+ vector.attrs[key] = val
880
+ return val
881
+
882
+
883
+ def _setdefault_from_params(key: str, vector: GeoVectorDataset, params: dict[str, Any]) -> None:
884
+ """Set a parameter on ``vector`` if it is not already set.
885
+
886
+ This method only sets "scalar" values.
887
+ If ``params[key]`` is None, the parameter is not set.
888
+ If ``params[key]`` is not a scalar, a TypeError is raised.
889
+ """
890
+
891
+ if key in vector:
892
+ return
893
+ if key in vector.attrs:
894
+ return
895
+
896
+ scalar = params[key]
897
+ if scalar is None:
898
+ return
899
+
900
+ if not isinstance(scalar, int | float):
901
+ msg = (
902
+ f"Parameter {key} must be a scalar. For non-scalar values, directly "
903
+ "set the data on the 'source'."
904
+ )
905
+ raise TypeError(msg)
906
+ vector.attrs[key] = float(scalar)
907
+
908
+
909
+ def _nominal_params() -> Iterable[str]:
910
+ """Return fields from :class:`CocipGridParams` that override values computed by the model.
911
+
912
+ Each of the fields returned by this method is included in :class:`CocipGridParams`
913
+ with a default value of None. When a non-None scalar value is provided for one of
914
+ these fields and the :attr:`source` data does not provide a value, the scalar value
915
+ is used (and broadcast over :attr:`source`) instead of running the AP or Emissions models.
916
+
917
+ If non-scalar values are desired, they should be provided directly on
918
+ :attr:`source` instead.
919
+
920
+ Returns
921
+ -------
922
+ Iterable[str]
923
+ """
924
+ return (
925
+ "wingspan",
926
+ "aircraft_mass",
927
+ "true_airspeed",
928
+ "engine_efficiency",
929
+ "fuel_flow",
930
+ )
931
+
932
+
933
+ def run_interpolators(
934
+ vector: GeoVectorDataset,
935
+ met: MetDataset,
936
+ rad: MetDataset | None = None,
937
+ *,
938
+ dz_m: float | None = None,
939
+ humidity_scaling: humidity_scaling.HumidityScaling | None = None,
940
+ keys: Sequence[str] | None = None,
941
+ **interp_kwargs: Any,
942
+ ) -> GeoVectorDataset:
943
+ """Run interpolators over ``vector``.
944
+
945
+ Intersect ``vector`` with DataArrays in met and rad needed for CoCiP. In addition, calculate
946
+ three "lower level" intersections in which the level of the ``vector`` data is decreased
947
+ according to the "dz_m" key in ``params``.
948
+
949
+ Modifies ``vector`` in place and returns it.
950
+
951
+ This function avoids overwriting existing variables on ``vector``.
952
+
953
+ Aim to confine all interpolation to this function
954
+
955
+ Parameters
956
+ ----------
957
+ vector : GeoVectorDataset
958
+ Grid points.
959
+ met, rad : MetDataset
960
+ CoCiP met and rad slices. See :class:`CocipGrid`.
961
+ dz_m : float | None, optional
962
+ Difference in altitude between top and bottom layer for stratification calculations (m).
963
+ Must be specified if ``keys`` is None.
964
+ humidity_scaling : humidity_scaling.HumidityScaling | None, optional
965
+ Specific humidity scaling scheme. Must be specified if ``keys`` is None.
966
+ keys : list[str]
967
+ Only run interpolators for select keys from ``met``
968
+ **interp_kwargs : Any
969
+ Interpolation keyword arguments
970
+
971
+ Returns
972
+ -------
973
+ GeoVectorDataset
974
+ Parameter ``vector`` with interpolated variables
975
+
976
+ Raises
977
+ ------
978
+ TypeError
979
+ If a required parameter is None
980
+ ValueError
981
+ If parameters `keys` and `rad` are both defined
982
+ """
983
+ # Avoid scaling specific humidity twice
984
+ humidity_interpolated = "specific_humidity" not in vector
985
+
986
+ if keys:
987
+ if rad is not None:
988
+ msg = "The 'keys' override only valid for 'met' input"
989
+ raise ValueError(msg)
990
+
991
+ for met_key in keys:
992
+ # NOTE: Changed in v0.43: no longer overwrites existing variables
993
+ models.interpolate_met(met, vector, met_key, **interp_kwargs)
994
+
995
+ return _apply_humidity_scaling(vector, humidity_scaling, humidity_interpolated)
996
+
997
+ if dz_m is None:
998
+ msg = "Specify 'dz_m'."
999
+ raise TypeError(msg)
1000
+ if rad is None:
1001
+ msg = "Specify 'rad'."
1002
+ raise TypeError(msg)
1003
+
1004
+ # Interpolation at usual level
1005
+ # Excluded keys are not needed -- only used to initially compute tau_cirrus
1006
+ excluded = {
1007
+ "specific_cloud_ice_water_content",
1008
+ "ice_water_mixing_ratio",
1009
+ "geopotential",
1010
+ "geopotential_height",
1011
+ }
1012
+ for met_key in met:
1013
+ if met_key in excluded:
1014
+ continue
1015
+ models.interpolate_met(met, vector, met_key, **interp_kwargs)
1016
+
1017
+ # calculate radiative properties
1018
+ cocip.calc_shortwave_radiation(rad, vector, **interp_kwargs)
1019
+ cocip.calc_outgoing_longwave_radiation(rad, vector, **interp_kwargs)
1020
+
1021
+ # Interpolation at lower level
1022
+ air_temperature = vector["air_temperature"]
1023
+ air_pressure = vector.air_pressure
1024
+ air_pressure_lower = thermo.pressure_dz(air_temperature, air_pressure, dz_m)
1025
+ lower_level = air_pressure_lower / 100.0
1026
+
1027
+ # Advect at lower_level
1028
+ for met_key in ("air_temperature", "eastward_wind", "northward_wind"):
1029
+ vector_key = f"{met_key}_lower"
1030
+ models.interpolate_met(
1031
+ met,
1032
+ vector,
1033
+ met_key,
1034
+ vector_key,
1035
+ **interp_kwargs,
1036
+ level=lower_level,
1037
+ )
1038
+
1039
+ # Experimental segment-free model
1040
+ if _is_segment_free_mode(vector):
1041
+ return _apply_humidity_scaling(vector, humidity_scaling, humidity_interpolated)
1042
+
1043
+ longitude_head = vector["longitude_head"]
1044
+ latitude_head = vector["latitude_head"]
1045
+ longitude_tail = vector["longitude_tail"]
1046
+ latitude_tail = vector["latitude_tail"]
1047
+
1048
+ # Advect at head and tail
1049
+ # NOTE: Not using head_tail_dt here to offset time. We could do this for slightly
1050
+ # more accurate interpolation, but we would have to load an additional met time
1051
+ # slice at t_{-1}. After t_0, the head_tail_dt offset is not used.
1052
+ for met_key in ("eastward_wind", "northward_wind"):
1053
+ vector_key = f"{met_key}_head"
1054
+ models.interpolate_met(
1055
+ met,
1056
+ vector,
1057
+ met_key,
1058
+ vector_key,
1059
+ **interp_kwargs,
1060
+ longitude=longitude_head,
1061
+ latitude=latitude_head,
1062
+ )
1063
+
1064
+ vector_key = f"{met_key}_tail"
1065
+ models.interpolate_met(
1066
+ met,
1067
+ vector,
1068
+ met_key,
1069
+ vector_key,
1070
+ **interp_kwargs,
1071
+ longitude=longitude_tail,
1072
+ latitude=latitude_tail,
1073
+ )
1074
+
1075
+ return _apply_humidity_scaling(vector, humidity_scaling, humidity_interpolated)
1076
+
1077
+
1078
+ def _apply_humidity_scaling(
1079
+ vector: GeoVectorDataset,
1080
+ humidity_scaling: humidity_scaling.HumidityScaling | None,
1081
+ humidity_interpolated: bool,
1082
+ ) -> GeoVectorDataset:
1083
+ """Scale specific humidity if it has been added by interpolator.
1084
+
1085
+ Assumes that air_temperature and pressure are available on ``vector``.
1086
+ """
1087
+ if "specific_humidity" not in vector:
1088
+ return vector
1089
+
1090
+ if humidity_scaling is not None and humidity_interpolated:
1091
+ humidity_scaling.eval(vector, copy_source=False)
1092
+ return vector
1093
+
1094
+ if "rhi" in vector:
1095
+ return vector
1096
+
1097
+ vector["rhi"] = thermo.rhi(
1098
+ vector["specific_humidity"], vector["air_temperature"], vector.air_pressure
1099
+ )
1100
+
1101
+ return vector
1102
+
1103
+
1104
+ def _evolve_vector(
1105
+ vector: GeoVectorDataset,
1106
+ *,
1107
+ met: MetDataset,
1108
+ rad: MetDataset,
1109
+ params: dict[str, Any],
1110
+ t: np.datetime64,
1111
+ ) -> tuple[GeoVectorDataset, VectorDataset]:
1112
+ """Evolve ``vector`` to time ``t``.
1113
+
1114
+ Return surviving contrail at end of evolution and aggregate metrics from evolution.
1115
+
1116
+ .. versionchanged:: 0.25.0
1117
+
1118
+ No longer expect ``vector`` to have a constant time variable. Consequently,
1119
+ time step handling now mirrors that in :class:`Cocip`. Moreover, this method now
1120
+ handles both :class:`GeoVectorDataset` and :class:`MetDataset` vectors derived
1121
+ from :attr:`source`.
1122
+
1123
+ Parameters
1124
+ ----------
1125
+ vector : GeoVectorDataset
1126
+ Contrail points that have been initialized and are ready for evolution.
1127
+ met, rad : MetDataset
1128
+ CoCiP met and rad slices. See :class:`CocipGrid`.
1129
+ params : dict[str, Any]
1130
+ CoCiP model parameters. See :class:`CocipGrid`.
1131
+ t : np.datetime64
1132
+ Time to evolve to.
1133
+
1134
+ Returns
1135
+ -------
1136
+ contrail : GeoVectorDataset
1137
+ Evolved contrail at end of the evolution step.
1138
+ ef_summary : VectorDataset
1139
+ The ``contrail`` summary statistics. The result of
1140
+ ``contrail.select(("index", "age", "ef"), copy=False)``.
1141
+ """
1142
+ dt = t - vector["time"]
1143
+
1144
+ if _is_segment_free_mode(vector):
1145
+ dt_head = None
1146
+ dt_tail = None
1147
+ else:
1148
+ head_tail_dt = vector["head_tail_dt"]
1149
+ half_head_tail_dt = head_tail_dt / 2
1150
+ dt_head = dt - half_head_tail_dt # type: ignore[operator]
1151
+ dt_tail = dt + half_head_tail_dt # type: ignore[operator]
1152
+
1153
+ # After advection, out has time t
1154
+ out = advect(vector, dt, dt_head, dt_tail) # type: ignore[arg-type]
1155
+
1156
+ out = run_interpolators(
1157
+ out,
1158
+ met,
1159
+ rad,
1160
+ dz_m=params["dz_m"],
1161
+ humidity_scaling=params["humidity_scaling"],
1162
+ **params["_interp_kwargs"],
1163
+ )
1164
+ out = calc_evolve_one_step(vector, out, params)
1165
+ ef_summary = out.select(("index", "age", "ef"), copy=False)
1166
+
1167
+ return out, ef_summary
1168
+
1169
+
1170
+ def _run_downwash(
1171
+ vector: GeoVectorDataset, met: MetDataset, rad: MetDataset, params: dict[str, Any]
1172
+ ) -> tuple[GeoVectorDataset, dict[str, pd.Series]]:
1173
+ """Perform calculations involving downwash and initial contrail.
1174
+
1175
+ .. versionchanged:: 0.25.0
1176
+
1177
+ No longer return ``summary_data``. This was previously a vector of zeros,
1178
+ and does not give any useful information.
1179
+
1180
+ Parameters
1181
+ ----------
1182
+ vector : GeoVectorDataset
1183
+ Grid values
1184
+ met, rad : MetDataset
1185
+ CoCiP met and rad slices. See :class:`CocipGrid`.
1186
+ params : dict[str, Any]
1187
+ CoCiP model parameters. See :class:`CocipGrid`.
1188
+
1189
+ Returns
1190
+ -------
1191
+ vector : GeoVectorDataset
1192
+ Downwash vector.
1193
+ verbose_dict : dict[str, pd.Series]
1194
+ Dictionary of verbose outputs.
1195
+ """
1196
+ # All extra variables as required by verbose_outputs are computed on the
1197
+ # initial calculations involving the downwash contrail.
1198
+ verbose_dict: dict[str, pd.Series] = {}
1199
+ verbose_outputs_formation = params["verbose_outputs_formation"]
1200
+
1201
+ keys = "air_temperature", "specific_humidity"
1202
+ vector = run_interpolators(
1203
+ vector,
1204
+ met,
1205
+ humidity_scaling=params["humidity_scaling"],
1206
+ keys=keys,
1207
+ **params["_interp_kwargs"],
1208
+ )
1209
+ calc_emissions(vector, params)
1210
+
1211
+ # Get verbose outputs from emissions. These include fuel_flow, nvpm_ei_n, true_airspeed.
1212
+ for key in verbose_outputs_formation:
1213
+ if (val := vector.get(key)) is not None:
1214
+ verbose_dict[key] = pd.Series(data=val, index=vector["index"])
1215
+
1216
+ # Get verbose outputs from SAC calculation.
1217
+ vector, sac_ = find_initial_contrail_regions(vector, params)
1218
+ if (key := "sac") in verbose_outputs_formation:
1219
+ verbose_dict[key] = sac_
1220
+ if (key := "T_crit_sac") in verbose_outputs_formation and (val := vector.get(key)) is not None:
1221
+ verbose_dict[key] = pd.Series(data=val, index=vector["index"])
1222
+
1223
+ # Early exit if nothing in vector passes the SAC
1224
+ if not vector:
1225
+ logger.debug("No vector waypoints satisfy SAC")
1226
+ return vector, verbose_dict
1227
+
1228
+ vector = run_interpolators(vector, met, rad, dz_m=params["dz_m"], **params["_interp_kwargs"])
1229
+ out = simulate_wake_vortex_downwash(vector, params)
1230
+
1231
+ out = run_interpolators(
1232
+ out,
1233
+ met,
1234
+ rad,
1235
+ dz_m=params["dz_m"],
1236
+ humidity_scaling=params["humidity_scaling"],
1237
+ **params["_interp_kwargs"],
1238
+ )
1239
+ out, persistent = find_initial_persistent_contrails(vector, out, params)
1240
+
1241
+ if (key := "persistent") in verbose_outputs_formation:
1242
+ verbose_dict[key] = persistent
1243
+ if (key := "iwc") in verbose_outputs_formation and (data := out.get(key)) is not None:
1244
+ verbose_dict[key] = pd.Series(data=data, index=out["index"])
1245
+
1246
+ return out, verbose_dict
1247
+
1248
+
1249
+ def combine_vectors(
1250
+ vectors: list[GeoVectorDataset],
1251
+ target_split_size: int,
1252
+ ) -> Generator[GeoVectorDataset, None, None]:
1253
+ """Combine vectors until size exceeds ``target_split_size``.
1254
+
1255
+ .. versionchanged:: 0.25.0
1256
+
1257
+ Ignore common end of life constraint previously imposed.
1258
+
1259
+ Change function to return a generator.
1260
+
1261
+ Parameters
1262
+ ----------
1263
+ vectors : list[GeoVectorDataset]
1264
+ Vectors to combine
1265
+ target_split_size : int
1266
+ Target vector size in combined vectors
1267
+
1268
+ Yields
1269
+ ------
1270
+ GeoVectorDataset
1271
+ Combined vectors.
1272
+ """
1273
+ # Loop through vectors until we've accumulated more grid points than the
1274
+ # target size. Once have, concatenate and yield
1275
+ i0 = 0
1276
+ cum_size = 0
1277
+ for i1, vector in enumerate(vectors):
1278
+ cum_size += vector.size
1279
+ if cum_size >= target_split_size:
1280
+ yield GeoVectorDataset.sum(vectors[i0 : i1 + 1])
1281
+ i0 = i1 + 1
1282
+ cum_size = 0
1283
+
1284
+ # If there is anything nontrivial left over, yield it
1285
+ if cum_size:
1286
+ yield GeoVectorDataset.sum(vectors[i0:])
1287
+
1288
+
1289
+ def find_initial_contrail_regions(
1290
+ vector: GeoVectorDataset, params: dict[str, Any]
1291
+ ) -> tuple[GeoVectorDataset, pd.Series]:
1292
+ """Filter ``vector`` according to the SAC.
1293
+
1294
+ This function also attaches the ``T_crit_sac`` variable to the returned
1295
+ GeoVectorDataset instance.
1296
+
1297
+ Parameters
1298
+ ----------
1299
+ vector : GeoVectorDataset
1300
+ Data to apply SAC. Must contain variables
1301
+ - "air_temperature"
1302
+ - "specific_humidity"
1303
+
1304
+ params : dict[str, Any]
1305
+ CoCiP model parameters. See :class:`CocipGrid`. Must contain keys
1306
+ - "fuel"
1307
+ - "filter_sac"
1308
+
1309
+ Returns
1310
+ -------
1311
+ filtered_vector : GeoVectorDataset
1312
+ Input parameter ``vector`` filtered according to SAC (if ``param["filter_sac"]``).
1313
+ sac_series : pd.Series
1314
+ SAC values for each point in input ``vector``. The :class:`pd.Series` is
1315
+ indexed by ``vector["index"]``
1316
+ """
1317
+ air_temperature = vector["air_temperature"]
1318
+ specific_humidity = vector["specific_humidity"]
1319
+ air_pressure = vector.air_pressure
1320
+ engine_efficiency = vector["engine_efficiency"]
1321
+ fuel = vector.attrs["fuel"]
1322
+ ei_h2o = fuel.ei_h2o
1323
+ q_fuel = fuel.q_fuel
1324
+
1325
+ G = sac.slope_mixing_line(specific_humidity, air_pressure, engine_efficiency, ei_h2o, q_fuel)
1326
+ t_sat_liq = sac.T_sat_liquid(G)
1327
+ rh = thermo.rh(specific_humidity, air_temperature, air_pressure)
1328
+ rh_crit = sac.rh_critical_sac(air_temperature, t_sat_liq, G)
1329
+ sac_ = sac.sac(rh, rh_crit)
1330
+
1331
+ filt = sac_ == 1.0
1332
+ logger.debug(
1333
+ "Fraction of grid points satisfying the SAC: %s / %s",
1334
+ filt.sum(),
1335
+ vector.size,
1336
+ )
1337
+
1338
+ if params["filter_sac"]:
1339
+ filtered_vector = vector.filter(filt)
1340
+ else:
1341
+ filt = np.ones(vector.size, dtype=bool) # needed below in T_crit_sac
1342
+ filtered_vector = vector.copy()
1343
+ logger.debug("Not filtering SAC")
1344
+
1345
+ # If filtered_vector is already empty, sac.T_critical_sac will raise an error
1346
+ # in the Newton approximation
1347
+ # So just return the empty vector here
1348
+ if not filtered_vector:
1349
+ return filtered_vector, pd.Series([], dtype=float)
1350
+
1351
+ # This is only used in `calc_first_contrail`, but we compute it here in order
1352
+ # to do everything SAC related at once.
1353
+ # It is slightly more performant to compute this AFTER we filter by sac_ == 1,
1354
+ # which is why we compute it here
1355
+ T_crit_sac = sac.T_critical_sac(t_sat_liq[filt], rh[filt], G[filt])
1356
+ filtered_vector["T_crit_sac"] = T_crit_sac
1357
+ return filtered_vector, pd.Series(data=sac_, index=vector["index"])
1358
+
1359
+
1360
+ def simulate_wake_vortex_downwash(
1361
+ vector: GeoVectorDataset, params: dict[str, Any]
1362
+ ) -> GeoVectorDataset:
1363
+ """Calculate regions of initial contrail formation.
1364
+
1365
+ This function calculates data effective flight downwash, then constructs a
1366
+ GeoVectorDataset object consisting persistent downwash regions. No filtering
1367
+ occurs here; the length of the returned GeoVectorDataset equals the length
1368
+ of the parameter ``vector``.
1369
+
1370
+ Of all steps in the gridded cocip pipeline, this one is generally the slowest
1371
+ since grid points have not yet been filtered by persistence (only SAC filtering
1372
+ has been applied in the CocipGrid pipeline). This function includes abundant
1373
+ logging.
1374
+
1375
+ Parameters
1376
+ ----------
1377
+ vector : GeoVectorDataset
1378
+ Grid points from which initial contrail regions are calculated.
1379
+ Must already be interpolated against CoCiP met data.
1380
+ params : dict[str, Any]
1381
+ CoCiP model parameters. See :class:`CocipGrid`.
1382
+
1383
+ Returns
1384
+ -------
1385
+ GeoVectorDataset
1386
+ Initial regions of persistent contrail.
1387
+ """
1388
+ # stored in `calc_emissions`
1389
+ true_airspeed = vector["true_airspeed"]
1390
+
1391
+ # NOTE: The `calc_wind_shear` function is run on both the downwash and contrail object.
1392
+ # This is the only time it is called with the `is_downwash` flag on
1393
+ calc_wind_shear(
1394
+ vector,
1395
+ dz_m=params["dz_m"],
1396
+ is_downwash=True,
1397
+ dsn_dz_factor=params["dsn_dz_factor"],
1398
+ )
1399
+
1400
+ # Stored in `calc_wind_shear`
1401
+ dT_dz = vector["dT_dz"]
1402
+ ds_dz = vector["ds_dz"]
1403
+
1404
+ air_pressure = vector.air_pressure
1405
+ air_temperature = vector["air_temperature"]
1406
+ T_crit_sac = vector["T_crit_sac"]
1407
+
1408
+ wsee = _get_source_param_override("wind_shear_enhancement_exponent", vector, params)
1409
+ wingspan = _get_source_param_override("wingspan", vector, params)
1410
+ aircraft_mass = _get_source_param_override("aircraft_mass", vector, params)
1411
+
1412
+ dz_max = wake_vortex.max_downward_displacement(
1413
+ wingspan=wingspan,
1414
+ true_airspeed=true_airspeed,
1415
+ aircraft_mass=aircraft_mass,
1416
+ air_temperature=air_temperature,
1417
+ dT_dz=dT_dz,
1418
+ ds_dz=ds_dz,
1419
+ air_pressure=air_pressure,
1420
+ effective_vertical_resolution=params["effective_vertical_resolution"],
1421
+ wind_shear_enhancement_exponent=wsee,
1422
+ )
1423
+
1424
+ width = wake_vortex.initial_contrail_width(wingspan, dz_max)
1425
+ iwvd = _get_source_param_override("initial_wake_vortex_depth", vector, params)
1426
+ depth = wake_vortex.initial_contrail_depth(dz_max, iwvd)
1427
+ # Initially, sigma_yz is set to 0
1428
+ # See bottom left paragraph p. 552 Schumann 2012 beginning with:
1429
+ # >>> "The contrail model starts from initial values ..."
1430
+ sigma_yz = np.zeros_like(width)
1431
+
1432
+ index = vector["index"]
1433
+ time = vector["time"]
1434
+ longitude = vector["longitude"]
1435
+ latitude = vector["latitude"]
1436
+ altitude = vector.altitude
1437
+ formation_time = vector["formation_time"]
1438
+ age = vector["age"]
1439
+
1440
+ # Initial contrail is constructed at a lower altitude
1441
+ altitude_1 = altitude - 0.5 * depth
1442
+ level_1 = units.m_to_pl(altitude_1)
1443
+ air_pressure_1 = 100.0 * level_1
1444
+
1445
+ data = {
1446
+ "index": index,
1447
+ "longitude": longitude,
1448
+ "latitude": latitude,
1449
+ "level": level_1,
1450
+ "altitude": altitude_1,
1451
+ "air_pressure": air_pressure_1,
1452
+ "time": time,
1453
+ "formation_time": formation_time,
1454
+ "age": age,
1455
+ "T_crit_sac": T_crit_sac,
1456
+ "width": width,
1457
+ "depth": depth,
1458
+ "sigma_yz": sigma_yz,
1459
+ **_get_uncertainty_params(vector),
1460
+ }
1461
+
1462
+ # Experimental segment-free model
1463
+ if _is_segment_free_mode(vector):
1464
+ return GeoVectorDataset(data, attrs=vector.attrs, copy=True)
1465
+
1466
+ # Stored in `_generate_new_grid_vectors`
1467
+ data["longitude_head"] = vector["longitude_head"]
1468
+ data["latitude_head"] = vector["latitude_head"]
1469
+ data["longitude_tail"] = vector["longitude_tail"]
1470
+ data["latitude_tail"] = vector["latitude_tail"]
1471
+ data["head_tail_dt"] = vector["head_tail_dt"]
1472
+
1473
+ segment_length = _get_source_param_override("segment_length", vector, params)
1474
+ if isinstance(segment_length, np.ndarray):
1475
+ data["segment_length"] = segment_length
1476
+ else:
1477
+ # This should be broadcast over the source: subsequent vectors created during
1478
+ # evolution always recompute the segment length. GeoVectorDataset.sum will
1479
+ # raise an error if the wake vortex GeoVectorDataset does not contain a
1480
+ # segment_length variable.
1481
+ data["segment_length"] = np.full_like(data["longitude"], segment_length)
1482
+
1483
+ return GeoVectorDataset(data, attrs=vector.attrs, copy=True)
1484
+
1485
+
1486
+ def find_initial_persistent_contrails(
1487
+ vector: GeoVectorDataset, contrail: GeoVectorDataset, params: dict[str, Any]
1488
+ ) -> tuple[GeoVectorDataset, pd.Series]:
1489
+ """Calculate first contrail immediately after downwash calculation.
1490
+
1491
+ This function filters according to :func:`contrail_properties.initial_persistant`.
1492
+
1493
+ The ``_1`` naming convention represents conditions are the wake vortex phase.
1494
+
1495
+ Parameters
1496
+ ----------
1497
+ vector : GeoVectorDataset
1498
+ Data from original grid points after SAC filtering.
1499
+ contrail : GeoVectorDataset
1500
+ Output of :func:`simulate_wake_vortex_downwash`.
1501
+ params : dict[str, Any]
1502
+ CoCiP model parameters. See :class:`CocipGrid`.
1503
+
1504
+ Returns
1505
+ -------
1506
+ tuple[GeoVectorDataset, pd.Series]
1507
+ The first entry in the tuple holds the first contrail after filtering
1508
+ by initially persistent. This GeoVectorDataset instance is equipped with
1509
+ all necessary keys to begin main contrail evolution. The second entry is
1510
+ the raw output of the :func:`contrail_properties.initial_persistant`
1511
+ computation as needed if "persistent" is in the "verbose_outputs"
1512
+ parameter. The :class:`pd.Series` is indexed by ``vector["index"]``.
1513
+ """
1514
+ # Gridpoint data
1515
+ # NOTE: In the Cocip implementation, these are variables on sac_flight
1516
+ # without the suffix "_1"
1517
+ air_pressure = vector.air_pressure
1518
+ air_temperature = vector["air_temperature"]
1519
+ specific_humidity = vector["specific_humidity"]
1520
+ fuel_dist = vector["fuel_flow"] / vector["true_airspeed"]
1521
+ nvpm_ei_n = vector["nvpm_ei_n"]
1522
+ ei_h2o = params["fuel"].ei_h2o
1523
+
1524
+ # Contrail data
1525
+ T_crit_sac = contrail["T_crit_sac"]
1526
+ width = contrail["width"]
1527
+ depth = contrail["depth"]
1528
+ air_pressure_1 = contrail.air_pressure
1529
+
1530
+ # Initialize initial contrail properties (ice water content, number of ice particles)
1531
+ # The logic here is fairly different from the later timesteps
1532
+ iwc = contrail_properties.initial_iwc(
1533
+ air_temperature=air_temperature,
1534
+ specific_humidity=specific_humidity,
1535
+ air_pressure=air_pressure,
1536
+ fuel_dist=fuel_dist,
1537
+ width=width,
1538
+ depth=depth,
1539
+ ei_h2o=ei_h2o,
1540
+ )
1541
+ iwc_ad = contrail_properties.iwc_adiabatic_heating(
1542
+ air_temperature=air_temperature,
1543
+ air_pressure=air_pressure,
1544
+ air_pressure_1=air_pressure_1,
1545
+ )
1546
+ iwc_1 = contrail_properties.iwc_post_wake_vortex(iwc, iwc_ad)
1547
+ f_surv = contrail_properties.ice_particle_survival_fraction(iwc, iwc_1)
1548
+ n_ice_per_m = contrail_properties.ice_particle_number(
1549
+ nvpm_ei_n=nvpm_ei_n,
1550
+ fuel_dist=fuel_dist,
1551
+ f_surv=f_surv,
1552
+ air_temperature=air_temperature,
1553
+ T_crit_sac=T_crit_sac,
1554
+ min_ice_particle_number_nvpm_ei_n=params["min_ice_particle_number_nvpm_ei_n"],
1555
+ )
1556
+
1557
+ # The logic below corresponds to Cocip._create_downwash_contrail (roughly)
1558
+ contrail["iwc"] = iwc_1
1559
+ contrail["n_ice_per_m"] = n_ice_per_m
1560
+
1561
+ # Check for persistent initial_contrails
1562
+ rhi_1 = contrail["rhi"]
1563
+ persistent_1 = contrail_properties.initial_persistent(iwc_1=iwc_1, rhi_1=rhi_1)
1564
+
1565
+ logger.debug(
1566
+ "Fraction of grid points with persistent initial contrails: %s / %s",
1567
+ int(persistent_1.sum()),
1568
+ contrail.size,
1569
+ )
1570
+
1571
+ # Filter by persistent
1572
+ if params["filter_initially_persistent"]:
1573
+ persistent_contrail = contrail.filter(persistent_1.astype(bool))
1574
+ else:
1575
+ persistent_contrail = contrail.copy()
1576
+ logger.debug("Not filtering initially persistent")
1577
+
1578
+ # Attach a bunch of other initialization variables
1579
+ # (Previously, this was done before filtering. It's computationally more
1580
+ # efficient to do it down here)
1581
+ calc_thermal_properties(persistent_contrail)
1582
+ calc_wind_shear(
1583
+ persistent_contrail,
1584
+ is_downwash=False,
1585
+ dz_m=params["dz_m"],
1586
+ dsn_dz_factor=params["dsn_dz_factor"],
1587
+ )
1588
+
1589
+ effective_vertical_resolution = persistent_contrail.get(
1590
+ "effective_vertical_resolution", params["effective_vertical_resolution"]
1591
+ )
1592
+ wind_shear_enhancement_exponent = persistent_contrail.get(
1593
+ "wind_shear_enhancement_exponent", params["wind_shear_enhancement_exponent"]
1594
+ )
1595
+ sedimentation_impact_factor = persistent_contrail.get(
1596
+ "sedimentation_impact_factor", params["sedimentation_impact_factor"]
1597
+ )
1598
+ cocip.calc_contrail_properties(
1599
+ persistent_contrail,
1600
+ effective_vertical_resolution=effective_vertical_resolution,
1601
+ wind_shear_enhancement_exponent=wind_shear_enhancement_exponent,
1602
+ sedimentation_impact_factor=sedimentation_impact_factor,
1603
+ radiative_heating_effects=False, # Not yet supported in CocipGrid
1604
+ )
1605
+
1606
+ # assumes "sdr", "rsr", and "olr" are already available on vector
1607
+ cocip.calc_radiative_properties(persistent_contrail, params)
1608
+
1609
+ # no EF forcing on first contrail
1610
+ persistent_contrail["ef"] = np.zeros_like(persistent_contrail["n_ice_per_m"])
1611
+
1612
+ persistent_series = pd.Series(data=persistent_1, index=contrail["index"])
1613
+ return persistent_contrail, persistent_series
1614
+
1615
+
1616
+ def calc_evolve_one_step(
1617
+ curr_contrail: GeoVectorDataset,
1618
+ next_contrail: GeoVectorDataset,
1619
+ params: dict[str, Any],
1620
+ ) -> GeoVectorDataset:
1621
+ """Calculate contrail properties of ``next_contrail``.
1622
+
1623
+ This function attaches additional variables to ``next_contrail``, then
1624
+ filters by :func:`contrail_properties.contrail_persistent`.
1625
+
1626
+ Parameters
1627
+ ----------
1628
+ curr_contrail : GeoVectorDataset
1629
+ Existing contrail
1630
+ next_contrail : GeoVectorDataset
1631
+ Result of advecting existing contrail already interpolated against CoCiP met data
1632
+ params : dict[str, Any]
1633
+ CoCiP model parameters. See :class:`CocipGrid`.
1634
+
1635
+ Returns
1636
+ -------
1637
+ GeoVectorDataset
1638
+ Parameter ``next_contrail`` filtered by persistence.
1639
+ """
1640
+ calc_wind_shear(
1641
+ next_contrail,
1642
+ is_downwash=False,
1643
+ dz_m=params["dz_m"],
1644
+ dsn_dz_factor=params["dsn_dz_factor"],
1645
+ )
1646
+ calc_thermal_properties(next_contrail)
1647
+
1648
+ iwc_t1 = curr_contrail["iwc"]
1649
+ specific_humidity_t1 = curr_contrail["specific_humidity"]
1650
+ specific_humidity_t2 = next_contrail["specific_humidity"]
1651
+ q_sat_t1 = curr_contrail["q_sat"]
1652
+ q_sat_t2 = next_contrail["q_sat"]
1653
+ plume_mass_per_m_t1 = curr_contrail["plume_mass_per_m"]
1654
+ width_t1 = curr_contrail["width"]
1655
+ depth_t1 = curr_contrail["depth"]
1656
+ sigma_yz_t1 = curr_contrail["sigma_yz"]
1657
+ dsn_dz_t1 = curr_contrail["dsn_dz"]
1658
+ diffuse_h_t1 = curr_contrail["diffuse_h"]
1659
+ diffuse_v_t1 = curr_contrail["diffuse_v"]
1660
+
1661
+ # Segment-free mode logic
1662
+ segment_length_t2: np.ndarray | float
1663
+ seg_ratio_t12: np.ndarray | float
1664
+ if _is_segment_free_mode(curr_contrail):
1665
+ segment_length_t2 = 1.0
1666
+ seg_ratio_t12 = 1.0
1667
+ else:
1668
+ segment_length_t1 = curr_contrail["segment_length"]
1669
+ segment_length_t2 = next_contrail["segment_length"]
1670
+ seg_ratio_t12 = contrail_properties.segment_length_ratio(
1671
+ segment_length_t1, segment_length_t2
1672
+ )
1673
+
1674
+ sigma_yy_t2, sigma_zz_t2, sigma_yz_t2 = contrail_properties.plume_temporal_evolution(
1675
+ width_t1=width_t1,
1676
+ depth_t1=depth_t1,
1677
+ sigma_yz_t1=sigma_yz_t1,
1678
+ dsn_dz_t1=dsn_dz_t1,
1679
+ diffuse_h_t1=diffuse_h_t1,
1680
+ diffuse_v_t1=diffuse_v_t1,
1681
+ seg_ratio=seg_ratio_t12,
1682
+ dt=params["dt_integration"],
1683
+ max_depth=params["max_depth"],
1684
+ )
1685
+
1686
+ width_t2, depth_t2 = contrail_properties.new_contrail_dimensions(sigma_yy_t2, sigma_zz_t2)
1687
+ next_contrail["sigma_yz"] = sigma_yz_t2
1688
+ next_contrail["width"] = width_t2
1689
+ next_contrail["depth"] = depth_t2
1690
+
1691
+ area_eff_t2 = contrail_properties.new_effective_area_from_sigma(
1692
+ sigma_yy=sigma_yy_t2,
1693
+ sigma_zz=sigma_zz_t2,
1694
+ sigma_yz=sigma_yz_t2,
1695
+ )
1696
+
1697
+ rho_air_t2 = next_contrail["rho_air"]
1698
+ plume_mass_per_m_t2 = contrail_properties.plume_mass_per_distance(area_eff_t2, rho_air_t2)
1699
+ iwc_t2 = contrail_properties.new_ice_water_content(
1700
+ iwc_t1=iwc_t1,
1701
+ q_t1=specific_humidity_t1,
1702
+ q_t2=specific_humidity_t2,
1703
+ q_sat_t1=q_sat_t1,
1704
+ q_sat_t2=q_sat_t2,
1705
+ mass_plume_t1=plume_mass_per_m_t1,
1706
+ mass_plume_t2=plume_mass_per_m_t2,
1707
+ )
1708
+ next_contrail["iwc"] = iwc_t2
1709
+
1710
+ n_ice_per_m_t1 = curr_contrail["n_ice_per_m"]
1711
+ dn_dt_agg = curr_contrail["dn_dt_agg"]
1712
+ dn_dt_turb = curr_contrail["dn_dt_turb"]
1713
+
1714
+ n_ice_per_m_t2 = contrail_properties.new_ice_particle_number(
1715
+ n_ice_per_m_t1=n_ice_per_m_t1,
1716
+ dn_dt_agg=dn_dt_agg,
1717
+ dn_dt_turb=dn_dt_turb,
1718
+ seg_ratio=seg_ratio_t12,
1719
+ dt=params["dt_integration"],
1720
+ )
1721
+ next_contrail["n_ice_per_m"] = n_ice_per_m_t2
1722
+
1723
+ cocip.calc_contrail_properties(
1724
+ next_contrail,
1725
+ params["effective_vertical_resolution"],
1726
+ params["wind_shear_enhancement_exponent"],
1727
+ params["sedimentation_impact_factor"],
1728
+ radiative_heating_effects=False, # Not yet supported in CocipGrid
1729
+ )
1730
+ cocip.calc_radiative_properties(next_contrail, params)
1731
+
1732
+ rf_net_t1 = curr_contrail["rf_net"]
1733
+ rf_net_t2 = next_contrail["rf_net"]
1734
+ ef = contrail_properties.energy_forcing(
1735
+ rf_net_t1=rf_net_t1,
1736
+ rf_net_t2=rf_net_t2,
1737
+ width_t1=width_t1,
1738
+ width_t2=width_t2,
1739
+ seg_length_t2=segment_length_t2,
1740
+ dt=params["dt_integration"],
1741
+ )
1742
+ # NOTE: This will get masked below if `persistent` is False
1743
+ # That is, we are taking a right Riemann sum of a decreasing function, so we are
1744
+ # underestimating the truth. With dt small enough, this is fine.
1745
+ next_contrail["ef"] = ef
1746
+
1747
+ # NOTE: Only dealing with `next_contrail` here
1748
+ latitude = next_contrail["latitude"]
1749
+ altitude = next_contrail["altitude"]
1750
+ tau_contrail = next_contrail["tau_contrail"]
1751
+ n_ice_per_vol = next_contrail["n_ice_per_vol"]
1752
+ age = next_contrail["age"]
1753
+
1754
+ # Both tau_contrail and n_ice_per_vol could have nan values
1755
+ # These are mostly due to out of bounds interpolation
1756
+ # Both are computed in cocip.calc_contrail_properties
1757
+ # Interpolation out-of-bounds nan values first appear in tau_contrail,
1758
+ # then in n_ice_per_vol at the next time step.
1759
+ # We can use something like np.nan(tau_contrail) to get values that
1760
+ # are filled with nan in interpolation.
1761
+ persistent = contrail_properties.contrail_persistent(
1762
+ latitude=latitude,
1763
+ altitude=altitude,
1764
+ segment_length=segment_length_t2, # type: ignore[arg-type]
1765
+ age=age,
1766
+ tau_contrail=tau_contrail,
1767
+ n_ice_per_m3=n_ice_per_vol,
1768
+ params=params,
1769
+ )
1770
+
1771
+ # Filter by persistent
1772
+ logger.debug(
1773
+ "Fraction of grid points surviving: %s / %s",
1774
+ np.sum(persistent),
1775
+ next_contrail.size,
1776
+ )
1777
+ if params["persistent_buffer"] is not None:
1778
+ # See Cocip implementation if we want to support this
1779
+ raise NotImplementedError
1780
+ return next_contrail.filter(persistent)
1781
+
1782
+
1783
+ def calc_emissions(vector: GeoVectorDataset, params: dict[str, Any]) -> None:
1784
+ """Calculate aircraft performance (AP) and emissions data.
1785
+
1786
+ This function mutates the ``vector`` parameter in-place by setting keys:
1787
+ - "true_airspeed": computed by the aircraft performance model
1788
+ - "engine_efficiency": computed by the aircraft performance model
1789
+ - "fuel_flow": computed by the aircraft performance model
1790
+ - "nvpm_ei_n": computed by the :class:`Emissions` model
1791
+ - "head_tail_dt"
1792
+
1793
+ The ``params`` parameter is also mutated in-place by setting keys:
1794
+ - "wingspan": aircraft wingspan
1795
+ - "aircraft_mass": mass of aircraft
1796
+
1797
+ Implementation note: Previously, this function computed "fuel_dist" instead of
1798
+ "fuel_flow". While "fuel_dist" is the only variabled needed in
1799
+ :func:`find_initial_persistent_contrails`, "fuel_flow" is needed for verbose
1800
+ outputs. Moreover, we are anticipating having "fuel_flow" as a preexisting
1801
+ variable on the input ``source``, whereas "fuel_dist" is less common and
1802
+ less interpretable. So, we set "fuel_flow" here and then calculate "fuel_dist"
1803
+ in :func:`find_initial_persistent_contrails`.
1804
+
1805
+ Parameters
1806
+ ----------
1807
+ vector : GeoVectorDataset
1808
+ Grid points already interpolated against CoCiP met data
1809
+ params : dict[str, Any]
1810
+ CoCiP model parameters. See :class:`CocipGrid`.
1811
+
1812
+ Raises
1813
+ ------
1814
+ NotImplementedError
1815
+ Aircraft type in `params` not found in EDB
1816
+ """
1817
+ logger.debug("Process emissions")
1818
+
1819
+ # PART 1: Fuel flow data
1820
+ vector.attrs.setdefault("aircraft_type", params["aircraft_type"])
1821
+
1822
+ # Important: If params["engine_uid"] is None (the default value), let Emissions
1823
+ # overwrite with the assumed value.
1824
+ # Otherwise, set the non-None value on vector here
1825
+ if param_engine_uid := params["engine_uid"]:
1826
+ vector.attrs.setdefault("engine_uid", param_engine_uid)
1827
+ vector.attrs.setdefault("fuel", params["fuel"])
1828
+
1829
+ ap_vars = {
1830
+ "true_airspeed",
1831
+ "engine_efficiency",
1832
+ "fuel_flow",
1833
+ "aircraft_mass",
1834
+ "n_engine",
1835
+ "wingspan",
1836
+ }
1837
+
1838
+ # Look across both vector.data and vector.attrs
1839
+ missing = ap_vars.difference(vector).difference(vector.attrs)
1840
+
1841
+ if missing == {"true_airspeed"}:
1842
+ # If we're only missing true_airspeed but mach_number is present,
1843
+ # we can still proceed
1844
+ mach_number = vector.get_data_or_attr("mach_number", None)
1845
+ if mach_number is not None:
1846
+ air_temperature = vector["air_temperature"]
1847
+ vector["true_airspeed"] = units.mach_number_to_tas(mach_number, air_temperature)
1848
+ missing = set()
1849
+
1850
+ if missing:
1851
+ ap_model = params["aircraft_performance"]
1852
+ if ap_model is None:
1853
+ msg = (
1854
+ f"Missing variables: {missing} and no aircraft_performance included in "
1855
+ "params. Instantiate 'CocipGrid' with an 'aircraft_performance' param. "
1856
+ "For example: 'CocipGrid(..., aircraft_performance=PSGrid())'"
1857
+ )
1858
+ raise ValueError(msg)
1859
+ ap_model.eval(vector, copy_source=False)
1860
+
1861
+ # PART 2: True airspeed logic
1862
+ # NOTE: This doesn't exactly fit here, but it is closely related to
1863
+ # true_airspeed calculations, so it's convenient to get it done now
1864
+ # For the purpose of Cocip <> CocipGrid model parity, we attach a
1865
+ # "head_tail_dt" variable. This variable is used the first time `advect`
1866
+ # is called. It makes a small but noticeable difference in model outputs.
1867
+ true_airspeed = vector.get_data_or_attr("true_airspeed")
1868
+
1869
+ if not _is_segment_free_mode(vector):
1870
+ segment_length = _get_source_param_override("segment_length", vector, params)
1871
+ head_tail_dt_s = segment_length / true_airspeed
1872
+ head_tail_dt_ns = 1_000_000_000.0 * head_tail_dt_s
1873
+ head_tail_dt = head_tail_dt_ns.astype("timedelta64[ns]")
1874
+ vector["head_tail_dt"] = head_tail_dt
1875
+
1876
+ # PART 3: Emissions data
1877
+ factor = _get_source_param_override("nvpm_ei_n_enhancement_factor", vector, params)
1878
+ default_nvpm_ei_n = params["default_nvpm_ei_n"]
1879
+
1880
+ # Early exit
1881
+ if not params["process_emissions"]:
1882
+ vector.attrs.setdefault("nvpm_ei_n", factor * default_nvpm_ei_n)
1883
+ return
1884
+
1885
+ emissions = Emissions()
1886
+ emissions.eval(vector, copy_source=False)
1887
+ vector.update(nvpm_ei_n=factor * vector["nvpm_ei_n"])
1888
+
1889
+
1890
+ def calc_wind_shear(
1891
+ contrail: GeoVectorDataset,
1892
+ dz_m: float,
1893
+ *,
1894
+ is_downwash: bool,
1895
+ dsn_dz_factor: float,
1896
+ ) -> None:
1897
+ """Calculate wind shear data.
1898
+
1899
+ This function is used for both `first_contrail` calculation and `evolve_one_step`. The
1900
+ data requirements of these two functions is slightly different, and the `is_downwash` flag
1901
+ allows for this discrepancy.
1902
+
1903
+ This function modifies the `contrail` parameter in-place by attaching `data` keys:
1904
+ - "dT_dz"
1905
+ - "ds_dz"
1906
+ - "dsn_dz" (attached only if `is_downwash=False`)
1907
+
1908
+ NOTE: This is the only function involving interpolation at a different `level`.
1909
+
1910
+ Parameters
1911
+ ----------
1912
+ contrail : GeoVectorDataset
1913
+ Grid points already interpolated against CoCiP met data
1914
+ dz_m : float
1915
+ Difference in altitude between top and bottom layer for stratification calculations (m)
1916
+ is_downwash : bool
1917
+ Only used initially in the `first_contrail` function
1918
+ dsn_dz_factor : float
1919
+ Experimental parameter for segment-free model.
1920
+ """
1921
+ air_temperature = contrail["air_temperature"]
1922
+ air_pressure = contrail.air_pressure
1923
+
1924
+ u_wind = contrail["eastward_wind"]
1925
+ v_wind = contrail["northward_wind"]
1926
+
1927
+ air_pressure_lower = thermo.pressure_dz(air_temperature, air_pressure, dz_m)
1928
+ air_temperature_lower = contrail["air_temperature_lower"]
1929
+ u_wind_lower = contrail["eastward_wind_lower"]
1930
+ v_wind_lower = contrail["northward_wind_lower"]
1931
+
1932
+ dT_dz = thermo.T_potential_gradient(
1933
+ air_temperature, air_pressure, air_temperature_lower, air_pressure_lower, dz_m
1934
+ )
1935
+ ds_dz = wind_shear.wind_shear(u_wind, u_wind_lower, v_wind, v_wind_lower, dz_m)
1936
+
1937
+ contrail["dT_dz"] = dT_dz
1938
+ contrail["ds_dz"] = ds_dz
1939
+
1940
+ # Calculate wind shear normal: NOT needed for downwash step
1941
+ if is_downwash:
1942
+ return
1943
+
1944
+ # Experimental segment-free mode
1945
+ # Instead of calculating dsn_dz, just multiply ds_dz with some scalar
1946
+ if _is_segment_free_mode(contrail):
1947
+ contrail["dsn_dz"] = dsn_dz_factor * ds_dz
1948
+ return
1949
+
1950
+ # NOTE: This is the only function requiring cos_a and sin_a
1951
+ # Consequently, we don't store sin_a and cos_a on the contrail, but just
1952
+ # use them once here to compute dsn_dz
1953
+ sin_a, cos_a = geo.longitudinal_angle(
1954
+ lons0=contrail["longitude_tail"],
1955
+ lats0=contrail["latitude_tail"],
1956
+ lons1=contrail["longitude_head"],
1957
+ lats1=contrail["latitude_head"],
1958
+ )
1959
+ dsn_dz = wind_shear.wind_shear_normal(
1960
+ u_wind_top=u_wind,
1961
+ u_wind_btm=u_wind_lower,
1962
+ v_wind_top=v_wind,
1963
+ v_wind_btm=v_wind_lower,
1964
+ cos_a=cos_a,
1965
+ sin_a=sin_a,
1966
+ dz=dz_m,
1967
+ )
1968
+ contrail["dsn_dz"] = dsn_dz
1969
+
1970
+
1971
+ def calc_thermal_properties(contrail: GeoVectorDataset) -> None:
1972
+ """Calculate contrail thermal properties.
1973
+
1974
+ Modifies parameter `contrail` in place by attaching keys:
1975
+ - "q_sat"
1976
+ - "rho_air"
1977
+
1978
+ Parameters
1979
+ ----------
1980
+ contrail : GeoVectorDataset
1981
+ Grid points already interpolated against CoCiP met data.
1982
+ """
1983
+ air_pressure = contrail.air_pressure
1984
+ air_temperature = contrail["air_temperature"]
1985
+
1986
+ # calculate thermo properties
1987
+ contrail["q_sat"] = thermo.q_sat_ice(air_temperature, air_pressure)
1988
+ contrail["rho_air"] = thermo.rho_d(air_temperature, air_pressure)
1989
+
1990
+
1991
+ def advect(
1992
+ contrail: GeoVectorDataset,
1993
+ dt: np.timedelta64 | npt.NDArray[np.timedelta64],
1994
+ dt_head: np.timedelta64 | None,
1995
+ dt_tail: np.timedelta64 | None,
1996
+ ) -> GeoVectorDataset:
1997
+ """Form new contrail by advecting existing contrail.
1998
+
1999
+ Parameter ``contrail`` is not modified.
2000
+
2001
+ .. versionchanged:: 0.25.0
2002
+
2003
+ The ``dt_head`` and ``dt_tail`` parameters are no longer optional.
2004
+ Set these to ``dt`` to evolve the contrail uniformly over a constant time.
2005
+ Set to None for segment-free mode.
2006
+
2007
+ Parameters
2008
+ ----------
2009
+ contrail : GeoVectorDataset
2010
+ Grid points already interpolated against wind data
2011
+ dt : np.timedelta64 | npt.NDArray[np.timedelta64]
2012
+ Time step for advection
2013
+ dt_head : np.timedelta64 | None
2014
+ Time step for segment head advection. Use None for segment-free mode.
2015
+ dt_tail : np.timedelta64 | None
2016
+ Time step for segment tail advection. Use None for segment-free mode.
2017
+
2018
+ Returns
2019
+ -------
2020
+ GeoVectorDataset
2021
+ New contrail instance with keys:
2022
+ - "index"
2023
+ - "longitude"
2024
+ - "latitude"
2025
+ - "level"
2026
+ - "air_pressure"
2027
+ - "altitude",
2028
+ - "time"
2029
+ - "formation_time"
2030
+ - "age"
2031
+ - "longitude_head" (only if `is_segment_free=False`)
2032
+ - "latitude_head" (only if `is_segment_free=False`)
2033
+ - "longitude_tail" (only if `is_segment_free=False`)
2034
+ - "longitude_tail" (only if `is_segment_free=False`)
2035
+ - "segment_length" (only if `is_segment_free=False`)
2036
+ - "head_tail_dt" (only if `is_segment_free=False`)
2037
+ """
2038
+ longitude = contrail["longitude"]
2039
+ latitude = contrail["latitude"]
2040
+ level = contrail["level"]
2041
+ time = contrail["time"]
2042
+ formation_time = contrail["formation_time"]
2043
+ age = contrail["age"]
2044
+ u_wind = contrail["eastward_wind"]
2045
+ v_wind = contrail["northward_wind"]
2046
+ vertical_velocity = contrail["lagrangian_tendency_of_air_pressure"]
2047
+ rho_air = contrail["rho_air"]
2048
+ terminal_fall_speed = contrail["terminal_fall_speed"]
2049
+
2050
+ # Using the _t2 convention for post-advection data
2051
+ index_t2 = contrail["index"]
2052
+ time_t2 = time + dt
2053
+ age_t2 = age + dt
2054
+
2055
+ longitude_t2 = geo.advect_longitude(
2056
+ longitude=longitude, latitude=latitude, u_wind=u_wind, dt=dt
2057
+ )
2058
+ latitude_t2 = geo.advect_latitude(latitude=latitude, v_wind=v_wind, dt=dt)
2059
+ level_t2 = geo.advect_level(level, vertical_velocity, rho_air, terminal_fall_speed, dt)
2060
+ altitude_t2 = units.pl_to_m(level_t2)
2061
+
2062
+ data = {
2063
+ "index": index_t2,
2064
+ "longitude": longitude_t2,
2065
+ "latitude": latitude_t2,
2066
+ "level": level_t2,
2067
+ "air_pressure": 100.0 * level_t2,
2068
+ "altitude": altitude_t2,
2069
+ "time": time_t2,
2070
+ "formation_time": formation_time,
2071
+ "age": age_t2,
2072
+ **_get_uncertainty_params(contrail),
2073
+ }
2074
+
2075
+ if dt_tail is None or dt_head is None:
2076
+ assert _is_segment_free_mode(contrail)
2077
+ assert dt_tail is None
2078
+ assert dt_head is None
2079
+ return GeoVectorDataset(data, attrs=contrail.attrs, copy=True)
2080
+
2081
+ longitude_head = contrail["longitude_head"]
2082
+ latitude_head = contrail["latitude_head"]
2083
+ longitude_tail = contrail["longitude_tail"]
2084
+ latitude_tail = contrail["latitude_tail"]
2085
+ u_wind_head = contrail["eastward_wind_head"]
2086
+ v_wind_head = contrail["northward_wind_head"]
2087
+ u_wind_tail = contrail["eastward_wind_tail"]
2088
+ v_wind_tail = contrail["northward_wind_tail"]
2089
+
2090
+ longitude_head_t2 = geo.advect_longitude(
2091
+ longitude=longitude_head, latitude=latitude_head, u_wind=u_wind_head, dt=dt_head
2092
+ )
2093
+ latitude_head_t2 = geo.advect_latitude(latitude=latitude_head, v_wind=v_wind_head, dt=dt_head)
2094
+
2095
+ longitude_tail_t2 = geo.advect_longitude(
2096
+ longitude=longitude_tail, latitude=latitude_tail, u_wind=u_wind_tail, dt=dt_tail
2097
+ )
2098
+ latitude_tail_t2 = geo.advect_latitude(latitude=latitude_tail, v_wind=v_wind_tail, dt=dt_tail)
2099
+
2100
+ segment_length_t2 = geo.haversine(
2101
+ lons0=longitude_head_t2,
2102
+ lats0=latitude_head_t2,
2103
+ lons1=longitude_tail_t2,
2104
+ lats1=latitude_tail_t2,
2105
+ )
2106
+
2107
+ head_tail_dt_t2 = np.full(contrail.size, np.timedelta64(0, "ns")) # trivial
2108
+
2109
+ data["longitude_head"] = longitude_head_t2
2110
+ data["latitude_head"] = latitude_head_t2
2111
+ data["longitude_tail"] = longitude_tail_t2
2112
+ data["latitude_tail"] = latitude_tail_t2
2113
+ data["segment_length"] = segment_length_t2
2114
+ data["head_tail_dt"] = head_tail_dt_t2
2115
+
2116
+ return GeoVectorDataset(data, attrs=contrail.attrs, copy=True)
2117
+
2118
+
2119
+ def _aggregate_ef_summary(vector_list: list[VectorDataset]) -> VectorDataset | None:
2120
+ """Aggregate EF results after cocip simulation.
2121
+
2122
+ Results are summed over each vector in ``vector_list``.
2123
+
2124
+ If ``vector_list`` is empty, return None.
2125
+
2126
+ Parameters
2127
+ ----------
2128
+ vector_list : list[VectorDataset]
2129
+ List of :class:`VectorDataset` objects each containing keys "index", "age", and "ef".
2130
+
2131
+ Returns
2132
+ -------
2133
+ VectorDataset | None
2134
+ Dataset with keys:
2135
+ - "index": Used to join to :attr:`CocipGrid.source`
2136
+ - "ef": Sum of ef values
2137
+ - "age": Contrail age associated to each index
2138
+ Only return points with non-zero ef or age.
2139
+ """
2140
+ if not vector_list:
2141
+ return None
2142
+
2143
+ i0 = min(v["index"].min() for v in vector_list)
2144
+ i1 = max(v["index"].max() for v in vector_list)
2145
+ index = np.arange(i0, i1 + 1)
2146
+
2147
+ # Use the dtype of the first vector to determine the dtype of the aggregate
2148
+ v0 = vector_list[0]
2149
+ ef = np.zeros(index.shape, dtype=v0["ef"].dtype)
2150
+ age = np.zeros(index.shape, dtype=v0["age"].dtype)
2151
+
2152
+ for v in vector_list:
2153
+ idx = v["index"] - i0
2154
+ ef[idx] += v["ef"]
2155
+ age[idx] = np.maximum(age[idx], v["age"])
2156
+
2157
+ # Only return points with non-zero ef or age
2158
+ cond = age.astype(bool) | ef.astype(bool)
2159
+ index = index[cond].copy()
2160
+ ef = ef[cond].copy()
2161
+ age = age[cond].copy()
2162
+
2163
+ data = {"index": index, "ef": ef, "age": age}
2164
+ return VectorDataset(data, copy=False)
2165
+
2166
+
2167
+ def result_to_metdataset(
2168
+ result: VectorDataset | None,
2169
+ verbose_dict: dict[str, npt.NDArray[np.float64]],
2170
+ source: MetDataset,
2171
+ nominal_segment_length: float,
2172
+ attrs: dict[str, str],
2173
+ ) -> MetDataset:
2174
+ """Convert aggregated data in ``result`` to MetDataset.
2175
+
2176
+ Parameters
2177
+ ----------
2178
+ result : VectorDataset | None
2179
+ Aggregated data arising from contrail evolution. Expected to contain keys:
2180
+ ``index``, ``age``, ``ef``.
2181
+ verbose_dict : dict[str, npt.NDArray[np.float64]]:
2182
+ Verbose outputs to attach to results.
2183
+ source : MetDataset
2184
+ :attr:`CocipGrid.`source` data on which to attach results.
2185
+ nominal_segment_length : float
2186
+ Used to normalize energy forcing cumulative sum.
2187
+ attrs : dict[str, str]
2188
+ Additional global attributes to attach to xr.Dataset.
2189
+
2190
+ Returns
2191
+ -------
2192
+ MetDataset
2193
+ Data with variables ``contrail_age``, ``ef_per_m``, and any other keys
2194
+ in ``verbose_dicts`.
2195
+ """
2196
+ logger.debug("Desparsify grid results into 4D numpy array")
2197
+
2198
+ shape = tuple(value.size for value in source.coords.values())
2199
+ size = np.prod(shape)
2200
+
2201
+ dtype = result["ef"].dtype if result else np.float32
2202
+ contrail_age = np.zeros(size, dtype=np.float32)
2203
+ ef_per_m = np.zeros(size, dtype=dtype)
2204
+
2205
+ if result:
2206
+ contrail_idx = result["index"]
2207
+ # Step 1: Contrail age. Convert from timedelta to float
2208
+ contrail_age[contrail_idx] = result["age"] / np.timedelta64(1, "h")
2209
+ # Step 2: EF
2210
+ ef_per_m[contrail_idx] = result["ef"] / nominal_segment_length
2211
+
2212
+ contrail_age = contrail_age.reshape(shape)
2213
+ ef_per_m = ef_per_m.reshape(shape)
2214
+
2215
+ # Step 3: Dataset dims and attrs
2216
+ dims = tuple(source.coords)
2217
+ local_attrs = _contrail_grid_variable_attrs()
2218
+
2219
+ # Step 4: Dataset core variables
2220
+ data_vars = {
2221
+ "contrail_age": (dims, contrail_age, local_attrs["contrail_age"]),
2222
+ "ef_per_m": (dims, ef_per_m, local_attrs["ef_per_m"]),
2223
+ }
2224
+
2225
+ # Step 5: Dataset variables from verbose_dicts
2226
+ for k, v in verbose_dict.items():
2227
+ data_vars[k] = (dims, v.reshape(shape), local_attrs[k])
2228
+
2229
+ # Update source
2230
+ for k, v in data_vars.items(): # type: ignore[assignment]
2231
+ source[k] = v
2232
+ source.attrs.update(attrs) # type: ignore[arg-type]
2233
+
2234
+ # Return reference to source
2235
+ return source
2236
+
2237
+
2238
+ def result_merge_source(
2239
+ result: VectorDataset | None,
2240
+ verbose_dict: dict[str, npt.NDArray[np.float64]],
2241
+ source: GeoVectorDataset,
2242
+ nominal_segment_length: float | npt.NDArray[np.float64],
2243
+ attrs: dict[str, str],
2244
+ ) -> GeoVectorDataset:
2245
+ """Merge ``results`` and ``verbose_dict`` onto ``source``."""
2246
+
2247
+ # Initialize the main output arrays to all zeros
2248
+ dtype = result["age"].dtype if result else "timedelta64[ns]"
2249
+ contrail_age = np.zeros(source.size, dtype=dtype)
2250
+
2251
+ dtype = result["ef"].dtype if result else np.float32
2252
+ ef_per_m = np.zeros(source.size, dtype=dtype)
2253
+
2254
+ # If there are results, merge them in
2255
+ if result:
2256
+ index = result["index"]
2257
+ contrail_age[index] = result["age"]
2258
+
2259
+ if isinstance(nominal_segment_length, np.ndarray):
2260
+ ef_per_m[index] = result["ef"] / nominal_segment_length[index]
2261
+ else:
2262
+ ef_per_m[index] = result["ef"] / nominal_segment_length
2263
+
2264
+ # Set the output variables onto the source
2265
+ source["contrail_age"] = contrail_age
2266
+ source["ef_per_m"] = ef_per_m
2267
+ for k, v in verbose_dict.items():
2268
+ source.setdefault(k, v)
2269
+ source.attrs.update(attrs)
2270
+
2271
+ return source
2272
+
2273
+
2274
+ def _concat_verbose_dicts(
2275
+ verbose_dicts: list[dict[str, pd.Series]],
2276
+ source_size: int,
2277
+ verbose_outputs_formation: set[str],
2278
+ ) -> dict[str, npt.NDArray[np.float64]]:
2279
+ # Concatenate the values and return
2280
+ ret: dict[str, np.ndarray] = {}
2281
+ for key in verbose_outputs_formation:
2282
+ series_list = [v for d in verbose_dicts if d and (v := d.get(key)) is not None]
2283
+ data = np.concatenate(series_list)
2284
+ index = np.concatenate([s.index for s in series_list])
2285
+
2286
+ # Reindex to source_size. Assuming all verbose_dicts have float dtype
2287
+ out = np.full(source_size, np.nan, dtype=data.dtype)
2288
+ out[index] = data
2289
+ ret[key] = out
2290
+
2291
+ return ret
2292
+
2293
+
2294
+ def _contrail_grid_variable_attrs() -> dict[str, dict[str, str]]:
2295
+ """Get attributes for each variables in :class:`CocipGrid` gridded output.
2296
+
2297
+ TODO: It might be better for these to live elsewhere (ie, in some `variables.py`).
2298
+ """
2299
+ return {
2300
+ "contrail_age": {
2301
+ "long_name": "Total age in hours of persistent contrail",
2302
+ "units": "hours",
2303
+ },
2304
+ "ef_per_m": {
2305
+ "long_name": "Energy forcing per meter of flight trajectory",
2306
+ "units": "J / m",
2307
+ },
2308
+ "sac": {"long_name": "Schmidt-Appleman Criterion"},
2309
+ "persistent": {"long_name": "Contrail initially persistent state"},
2310
+ "T_crit_sac": {
2311
+ "long_name": "Schmidt-Appleman critical temperature threshold",
2312
+ "units": "K",
2313
+ },
2314
+ "engine_efficiency": {"long_name": "Engine efficiency"},
2315
+ "true_airspeed": {"long_name": "True airspeed", "units": "m / s"},
2316
+ "aircraft_mass": {"long_name": "Aircraft mass", "units": "kg"},
2317
+ "nvpm_ei_n": {
2318
+ "long_name": "Black carbon emissions index number",
2319
+ "units": "kg^{-1}",
2320
+ },
2321
+ "fuel_flow": {"long_name": "Jet engine fuel flow", "units": "kg / s"},
2322
+ "specific_humidity": {"long_name": "Specific humidity", "units": "kg / kg"},
2323
+ "air_temperature": {"long_name": "Air temperature", "units": "K"},
2324
+ "rhi": {"long_name": "Relative humidity", "units": "dimensionless"},
2325
+ "iwc": {
2326
+ "long_name": "Ice water content after the wake vortex phase",
2327
+ "units": "kg_h2o / kg_air",
2328
+ },
2329
+ "global_yearly_mean_rf_per_m": {
2330
+ "long_name": "Global yearly mean RF per meter of flight trajectory",
2331
+ "units": "W / m**2 / m",
2332
+ },
2333
+ "atr20_per_m": {
2334
+ "long_name": "Average Temperature Response over a 20 year horizon",
2335
+ "units": "K / m",
2336
+ },
2337
+ }
2338
+
2339
+
2340
+ def _supported_verbose_outputs_formation() -> set[str]:
2341
+ """Get supported keys for verbose outputs.
2342
+
2343
+ Uses output of :func:`_contrail_grid_variable_attrs` as a source of truth.
2344
+ """
2345
+ return set(_contrail_grid_variable_attrs()) - {
2346
+ "contrail_age",
2347
+ "ef_per_m",
2348
+ "global_yearly_mean_rf_per_m",
2349
+ "atr20_per_m",
2350
+ }
2351
+
2352
+
2353
+ def _warn_not_wrap(met: MetDataset) -> None:
2354
+ """Warn user if parameter met should be wrapped.
2355
+
2356
+ Parameters
2357
+ ----------
2358
+ met : MetDataset
2359
+ Met dataset
2360
+ """
2361
+ if met.is_wrapped:
2362
+ return
2363
+ lon = met.indexes["longitude"]
2364
+ if lon.min() == -180.0 and lon.max() == 179.75:
2365
+ warnings.warn(
2366
+ "The MetDataset `met` not been wrapped. The CocipGrid model may "
2367
+ "perform better if `met.wrap_longitude()` is called first."
2368
+ )
2369
+
2370
+
2371
+ def _get_uncertainty_params(contrail: VectorDataset) -> dict[str, npt.NDArray[np.float64]]:
2372
+ """Return uncertainty parameters in ``contrail``.
2373
+
2374
+ This function assumes the underlying humidity scaling model is
2375
+ :class:`ConstantHumidityScaling`. This function should get revised if other
2376
+ humidity scaling models are used for uncertainty analysis.
2377
+
2378
+ For each of the keys:
2379
+ - "rhi_adj",
2380
+ - "rhi_boost_exponent",
2381
+ - "sedimentation_impact_factor",
2382
+ - "wind_shear_enhancement_exponent",
2383
+
2384
+ this function checks if key is present in contrail. The data is then
2385
+ bundled and returned as a dictionary.
2386
+
2387
+ Parameters
2388
+ ----------
2389
+ contrail : VectorDataset
2390
+ Data from which uncertainty parameters are extracted
2391
+
2392
+ Returns
2393
+ -------
2394
+ dict[str, npt.NDArray[np.float64]]
2395
+ Dictionary of uncertainty parameters.
2396
+ """
2397
+ keys = (
2398
+ "rhi_adj",
2399
+ "rhi_boost_exponent",
2400
+ "sedimentation_impact_factor",
2401
+ "wind_shear_enhancement_exponent",
2402
+ )
2403
+ return {key: val for key in keys if (val := contrail.get(key)) is not None}
2404
+
2405
+
2406
+ _T = TypeVar("_T", np.float64, np.datetime64)
2407
+
2408
+
2409
+ def _check_coverage(
2410
+ met_array: npt.NDArray[_T], grid_array: npt.NDArray[_T], coord: str, name: str
2411
+ ) -> None:
2412
+ """Warn if the met data does not cover the entire source domain.
2413
+
2414
+ Parameters
2415
+ ----------
2416
+ met_array : np.ndarray
2417
+ Coordinate on met data
2418
+ grid_array : np.ndarray
2419
+ Coordinate on grid data
2420
+ coord : {"longitude", "latitude", "level", "time"}
2421
+ Name of coordinate. Only used for warning message.
2422
+ name : str
2423
+ Name of met dataset. Only used for warning message.
2424
+ """
2425
+ if met_array.min() > grid_array.min() or met_array.max() < grid_array.max():
2426
+ warnings.warn(
2427
+ f"Met data '{name}' does not cover the source domain along the {coord} axis. "
2428
+ "This causes some interpolated values to be nan, leading to meaningless results."
2429
+ )
2430
+
2431
+
2432
+ def _downselect_met(
2433
+ source: GeoVectorDataset | MetDataset, met: MetDataset, rad: MetDataset, params: dict[str, Any]
2434
+ ) -> tuple[MetDataset, MetDataset]:
2435
+ """Downselect met and rad to the bounding box of the source.
2436
+
2437
+ Implementation is nearly identical to the :meth:`Model.downselect_met` method. The
2438
+ key difference is that this method uses the "max_age" and "dt_integration" parameters
2439
+ to further buffer the bounding box in the time dimension.
2440
+
2441
+ .. versionchanged:: 0.25.0
2442
+
2443
+ Support :class:`MetDataset` ``source`` for use in :class:`CocipGrid`.
2444
+
2445
+ Parameters
2446
+ ----------
2447
+ source : GeoVectorDataset | MetDataset
2448
+ Model source
2449
+ met : MetDataset
2450
+ Model met
2451
+ rad : MetDataset
2452
+ Model rad
2453
+ params : dict[str, Any]
2454
+ Model parameters
2455
+
2456
+ Returns
2457
+ -------
2458
+ met : MetDataset
2459
+ MetDataset with met data copied within the bounding box of ``source``.
2460
+ rad : MetDataset
2461
+ MetDataset with rad data copied within the bounding box of ``source``.
2462
+
2463
+ See Also
2464
+ --------
2465
+ :meth:`Model.downselect_met`
2466
+ """
2467
+
2468
+ if not params["downselect_met"]:
2469
+ logger.debug("Avoiding downselecting met because params['downselect_met'] is False")
2470
+ return met, rad
2471
+
2472
+ logger.debug("Downselecting met domain to vector points")
2473
+
2474
+ # check params
2475
+ longitude_buffer = params["met_longitude_buffer"]
2476
+ latitude_buffer = params["met_latitude_buffer"]
2477
+ level_buffer = params["met_level_buffer"]
2478
+ time_buffer = params["met_time_buffer"]
2479
+
2480
+ # Down select met relative to min / max integration timesteps, not Flight
2481
+ t0 = time_buffer[0]
2482
+ t1 = time_buffer[1] + params["max_age"] + params["dt_integration"]
2483
+
2484
+ met = source.downselect_met(
2485
+ met,
2486
+ latitude_buffer=latitude_buffer,
2487
+ longitude_buffer=longitude_buffer,
2488
+ level_buffer=level_buffer,
2489
+ time_buffer=(t0, t1),
2490
+ copy=False,
2491
+ )
2492
+
2493
+ rad = source.downselect_met(
2494
+ rad,
2495
+ latitude_buffer=latitude_buffer,
2496
+ longitude_buffer=longitude_buffer,
2497
+ time_buffer=(t0, t1),
2498
+ copy=False,
2499
+ )
2500
+
2501
+ return met, rad
2502
+
2503
+
2504
+ def _is_segment_free_mode(vector: GeoVectorDataset) -> bool:
2505
+ """Determine if model is run in a segment-free mode."""
2506
+ return "longitude_head" not in vector
2507
+
2508
+
2509
+ def _check_met_rad_time(
2510
+ met: MetDataset,
2511
+ rad: MetDataset,
2512
+ tmin: pd.Timestamp,
2513
+ tmax: pd.Timestamp,
2514
+ ) -> None:
2515
+ """Warn if meteorology data doesn't cover a required time range.
2516
+
2517
+ Parameters
2518
+ ----------
2519
+ met : MetDataset
2520
+ Meteorology dataset
2521
+ rad : MetDataset
2522
+ Radiative flux dataset
2523
+ tmin: pd.Timestamp
2524
+ Start of required time range
2525
+ tmax:pd.Timestamp
2526
+ End of required time range
2527
+ """
2528
+ met_time = met.data["time"].values
2529
+ met_tmin = pd.to_datetime(met_time.min())
2530
+ met_tmax = pd.to_datetime(met_time.max())
2531
+ _check_start_time(met_tmin, tmin, "met")
2532
+ _check_end_time(met_tmax, tmax, "met")
2533
+
2534
+ rad_time = rad.data["time"].values
2535
+ rad_tmin = pd.to_datetime(rad_time.min())
2536
+ rad_tmax = pd.to_datetime(rad_time.max())
2537
+ note = "differencing reduces time coverage when providing accumulated radiative fluxes."
2538
+ _check_start_time(rad_tmin, tmin, "rad", note=note)
2539
+ _check_end_time(rad_tmax, tmax, "rad", note=note)
2540
+
2541
+
2542
+ def _check_start_time(
2543
+ met_start: pd.Timestamp,
2544
+ model_start: pd.Timestamp,
2545
+ name: str,
2546
+ *,
2547
+ note: str | None = None,
2548
+ ) -> None:
2549
+ if met_start > model_start:
2550
+ note = f" Note: {note}" if note else ""
2551
+ warnings.warn(
2552
+ f"Start time of parameter '{name}' ({met_start}) "
2553
+ f"is after model start time ({model_start}). "
2554
+ f"Include additional time at the start of '{name}'."
2555
+ f"{note}"
2556
+ )
2557
+
2558
+
2559
+ def _check_end_time(
2560
+ met_end: pd.Timestamp,
2561
+ model_end: pd.Timestamp,
2562
+ name: str,
2563
+ *,
2564
+ note: str | None = None,
2565
+ ) -> None:
2566
+ if met_end < model_end:
2567
+ note = f" Note: {note}" if note else ""
2568
+ warnings.warn(
2569
+ f"End time of parameter '{name}' ({met_end}) "
2570
+ f"is before model end time ({model_end}). "
2571
+ f"Include additional time at the end of '{name}' or reduce 'max_age' parameter."
2572
+ f"{note}"
2573
+ )