pycontrails 0.53.0__cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

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

Potentially problematic release.


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

Files changed (109) hide show
  1. pycontrails/__init__.py +70 -0
  2. pycontrails/_version.py +16 -0
  3. pycontrails/core/__init__.py +30 -0
  4. pycontrails/core/aircraft_performance.py +641 -0
  5. pycontrails/core/airports.py +226 -0
  6. pycontrails/core/cache.py +881 -0
  7. pycontrails/core/coordinates.py +174 -0
  8. pycontrails/core/fleet.py +470 -0
  9. pycontrails/core/flight.py +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.cpython-313-x86_64-linux-gnu.so +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 +6 -0
  109. pycontrails-0.53.0.dist-info/top_level.txt +3 -0
@@ -0,0 +1,983 @@
1
+ """Pycontrails :class:`Model` interface to APCEMM."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import glob
7
+ import logging
8
+ import multiprocessing as mp
9
+ import os
10
+ import pathlib
11
+ import shutil
12
+ from typing import Any, NoReturn, overload
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+
17
+ from pycontrails.core import cache, models
18
+ from pycontrails.core.aircraft_performance import AircraftPerformance
19
+ from pycontrails.core.flight import Flight
20
+ from pycontrails.core.fuel import Fuel, JetA
21
+ from pycontrails.core.met import MetDataset
22
+ from pycontrails.core.met_var import (
23
+ AirTemperature,
24
+ EastwardWind,
25
+ Geopotential,
26
+ GeopotentialHeight,
27
+ NorthwardWind,
28
+ SpecificHumidity,
29
+ VerticalVelocity,
30
+ )
31
+ from pycontrails.models.apcemm import utils
32
+ from pycontrails.models.apcemm.inputs import APCEMMInput
33
+ from pycontrails.models.dry_advection import DryAdvection
34
+ from pycontrails.models.emissions import Emissions
35
+ from pycontrails.models.humidity_scaling import HumidityScaling
36
+ from pycontrails.models.ps_model import PSFlight
37
+ from pycontrails.physics import constants, geo, thermo
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ #: Minimum altitude
42
+
43
+
44
+ @dataclasses.dataclass
45
+ class APCEMMParams(models.ModelParams):
46
+ """Default parameters for the pycontrails :class:`APCEMM` interface."""
47
+
48
+ #: Maximum contrail age
49
+ max_age: np.timedelta64 = np.timedelta64(20, "h")
50
+
51
+ #: Longitude buffer for Lagrangian trajectory calculation [WGS84]
52
+ met_longitude_buffer: tuple[float, float] = (10.0, 10.0)
53
+
54
+ #: Latitude buffer for Lagrangian trajectory calculation [WGS84]
55
+ met_latitude_buffer: tuple[float, float] = (10.0, 10.0)
56
+
57
+ #: Level buffer for Lagrangian trajectory calculation [:math:`hPa`]
58
+ met_level_buffer: tuple[float, float] = (40.0, 40.0)
59
+
60
+ #: Timestep for Lagrangian trajectory calculation
61
+ dt_lagrangian: np.timedelta64 = np.timedelta64(30, "m")
62
+
63
+ #: Sedimentation rate for Lagrangian trajectories [:math:`Pa/s`]
64
+ lagrangian_sedimentation_rate: float = 0.0
65
+
66
+ #: Time step of meteorology in generated APCEMM input file.
67
+ dt_input_met: np.timedelta64 = np.timedelta64(1, "h")
68
+
69
+ #: Altitude coordinates [:math:`m`] for meteorology in generated APCEMM input file.
70
+ #: If not provided, uses estimated altitudes for levels in input :class:`Metdataset`.
71
+ altitude_input_met: list[float] | None = None
72
+
73
+ #: Humidity scaling
74
+ humidity_scaling: HumidityScaling | None = None
75
+
76
+ #: Altitude difference for vertical derivative calculations [:math:`m`]
77
+ dz_m: float = 200.0
78
+
79
+ #: ICAO aircraft identifier
80
+ aircraft_type: str = "B738"
81
+
82
+ #: Engine UID. If not provided, uses the default engine UID
83
+ #: for the :attr:`aircraft_type`.
84
+ engine_uid: str | None = None
85
+
86
+ #: Aircraft performance model
87
+ aircraft_performance: AircraftPerformance = dataclasses.field(default_factory=PSFlight)
88
+
89
+ #: Fuel type
90
+ fuel: Fuel = dataclasses.field(default_factory=JetA)
91
+
92
+ #: List of flight waypoints to simulate in APCEMM.
93
+ #: By default, runs a simulation for every waypoint.
94
+ waypoints: list[int] | None = None
95
+
96
+ #: If defined, use to override ``input_background_conditions`` and
97
+ #: ``input_engine_emissions`` in :class:`APCEMMInput` assuming that
98
+ #: ``apcemm_root`` points to the root of the APCEMM git repository.
99
+ apcemm_root: pathlib.Path | str | None = None
100
+
101
+ #: If True, delete existing run directories before running APCEMM simulations.
102
+ #: If False (default), raise an exception if a run directory already exists.
103
+ overwrite: bool = False
104
+
105
+ #: Name of output directory within run directory
106
+ output_directory: str = "out"
107
+
108
+ #: Number of threads to use within individual APCEMM simulations.
109
+ apcemm_threads: int = 1
110
+
111
+ #: Number of individual APCEMM simulations to run in parallel.
112
+ n_jobs: int = 1
113
+
114
+
115
+ class APCEMM(models.Model):
116
+ """Run APCEMM as a pycontrails :class:`Model`.
117
+
118
+ This class acts as an adapter between the pycontrails :class:`Model` interface
119
+ (shared with other contrail models) and APCEMM's native interface.
120
+
121
+ `APCEMM <https://github.com/MIT-LAE/APCEMM>`__ was developed at the
122
+ `MIT Laboratory for Aviation and the Environment <https://lae.mit.edu/>`__
123
+ and is described in :cite:`fritzRolePlumescaleProcesses2020`.
124
+
125
+ Parameters
126
+ ----------
127
+ met : MetDataset
128
+ Pressure level dataset containing :attr:`met_variables` variables.
129
+ See *Notes* for variable names by data source.
130
+ apcemm_path : pathlib.Path | str
131
+ Path to APCEMM executable. See *Notes* for information about
132
+ acquiring and compiling APCEMM.
133
+ apcemm_root : pathlib.Path | str, optional
134
+ Path to APCEMM root directory, used to set ``input_background_conditions`` and
135
+ ``input_engine_emissions`` based on the structure of the
136
+ `APCEMM GitHub repository <https://github.com/MIT-LAE/APCEMM>`__.
137
+ If not provided, pycontrails will use the default paths defined in :class:`APCEMMInput`.
138
+ apcemm_input_params : APCEMMInput, optional
139
+ Value for APCEMM input parameters defined in :class:`APCEMMInput`. If provided, values
140
+ for ``input_background_condition`` or ``input_engine_emissions`` will override values
141
+ set based on ``apcemm_root``. Attempting to provide values for input parameters
142
+ that are determined automatically by this interface will result in an error.
143
+ See *Notes* for detailed information about YAML file generation.
144
+ cachestore : CacheStore, optional
145
+ :class:`CacheStore` used to store APCEMM run directories.
146
+ If not provided, uses a :class:`DiskCacheStore`.
147
+ See *Notes* for detailed information about the file structure for APCEMM
148
+ simulations.
149
+ params : dict[str,Any], optional
150
+ Override APCEMM model parameters with dictionary.
151
+ See :class:`APCEMMParams` for model parameters.
152
+ **params_kwargs : Any
153
+ Override Cocip model parameters with keyword arguments.
154
+ See :class:`APCEMMParams` for model parameters.
155
+
156
+ Notes
157
+ -----
158
+ **Meteorology**
159
+
160
+ APCEMM requires temperature, humidity, gepotential height, and winds.
161
+ Geopotential height is required because APCEMM expects meteorological fields
162
+ on height rather than pressure surfaces. See :attr:`met_variables` for the
163
+ list of required variables.
164
+
165
+ .. list-table:: Variable keys for pressure level data
166
+ :header-rows: 1
167
+
168
+ * - Parameter
169
+ - ECMWF
170
+ - GFS
171
+ * - Air Temperature
172
+ - ``air_temperature``
173
+ - ``air_temperature``
174
+ * - Specific Humidity
175
+ - ``specific_humidity``
176
+ - ``specific_humidity``
177
+ * - Geopotential/Geopotential Height
178
+ - ``geopotential``
179
+ - ``geopotential_height``
180
+ * - Eastward wind
181
+ - ``eastward_wind``
182
+ - ``eastward_wind``
183
+ * - Northward wind
184
+ - ``northward_wind``
185
+ - ``northward_wind``
186
+ * - Vertical velocity
187
+ - ``lagrangian_tendency_of_air_pressure``
188
+ - ``lagrangian_tendency_of_air_pressure``
189
+
190
+ **Acquiring and compiling APCEMM**
191
+
192
+ Users are responsible for acquiring and compiling APCEMM. The APCEMM source code is
193
+ available through `GitHub <https://github.com/MIT-LAE/APCEMM>`__, and instructions
194
+ for compiling APCEMM are available in the repository.
195
+
196
+ Note that APCEMM does not provide versioned releases, and future updates may break
197
+ this interface. To guarantee compatibility between this interface and APCEMM,
198
+ users should use commit
199
+ `9d8e1ee <https://github.com/MIT-LAE/APCEMM/commit/9d8e1eeaa61cbdee1b1d03c65b5b033ded9159e4>`__
200
+ from the APCEMM repository.
201
+
202
+ **Configuring APCEMM YAML files**
203
+
204
+ :class:`APCEMMInput` provides low-level control over the contents of YAML files used
205
+ as APCEMM input. YAML file contents can be controlled by passing custom parameters
206
+ in a dictionary through the ``apcemm_input_params`` parameter. Note, however, that
207
+ :class:`APCEMM` sets a number of APCEMM input parameters automatically, and attempting
208
+ to override any automatically-determined parameters using ``apcemm_input_params``
209
+ will result in an error. A list of automatically-determined parameters is available in
210
+ :attr:`dynamic_yaml_params`.
211
+
212
+ **Simulation initialization, execution, and postprocessing**
213
+
214
+ This interface initializes, runs, and postprocesses APCEMM simulations in four stages:
215
+
216
+ 1. A :class:`DryAdvection` model is used to generate trajectories for contrails
217
+ initialized at each flight waypoint. This is a necessary preprocessing step because
218
+ APCEMM is a Lagrangian model and does not explicitly track changes in plume
219
+ location over time. This step also provides time-dependent azimuths that define the
220
+ orientation of advected contrails, which is required to compute contrail-normal
221
+ wind shear from horizontal winds.
222
+ Results from the trajectory calculation are stored in :attr:`trajectories`.
223
+ 2. Model parameters and results from the trajectory calculation are used to generate
224
+ YAML files with APCEMM input parameters and netCDF files with meteorology data
225
+ used by APCEMM simulations. A separate pair of files is generated for each
226
+ waypoint processed by APCEMM. Files are saved as ``apcemm_waypoint_<i>/input.yaml``
227
+ and ``apcemm_waypoint_<i>/input.nc`` in the model :attr:`cachestore`,
228
+ where ``<i>`` is replaced by the index of each simulated flight waypoint.
229
+ 3. A separate APCEMM simulation is run in each run directory inside the model
230
+ :attr:`cachestore`. Simulations are independent and can be run in parallel
231
+ (controlled by the ``n_jobs`` parameter in :class:`APCEMMParams`). Standard output
232
+ and error streams from each simulation are saved in ``apcemm_waypoint_<i>/stdout.log``
233
+ and ``apcemm_waypoint_<i>/stderr.log``, and APCEMM output is saved
234
+ in a subdirectory specified by the ``output_directory`` model parameter ("out" by default).
235
+ 4. APCEMM simulation output is postprocessed. After postprocessing:
236
+
237
+ - A ``status`` column is attached to the ``Flight`` returned by :meth:`eval`.
238
+ This column contains ``"NoSimulation"`` for waypoints where no simulation
239
+ was run and the contents of the APCEMM ``status_case0`` output file for
240
+ other waypoints.
241
+ - A :class:`pd.DataFrame` is created and stored in :attr:`vortex`. This dataframe
242
+ contains time series output from the APCEMM "early plume model" of the aircraft
243
+ exhaust plume and downwash vortex, read from ``Micro_000000.out`` output files
244
+ saved by APCEMM.
245
+ - If APCEMM simulated at least one persistent contrail, A :class:`pd.DataFrame` is
246
+ created and stored in :attr:`contrail`. This dataframe contains paths to netCDF
247
+ files, saved at prescribed time intervals during the APCEMM simulation, and can be
248
+ used to open APCEMM output (e.g., using :func:`xr.open_dataset`) for further analysis.
249
+
250
+ **Numerics**
251
+
252
+ APCEMM simulations are initialized at flight waypoints and represent the evolution of the
253
+ cross-section of contrails formed at each waypoint. APCEMM does not explicitly model the length
254
+ of contrail segments and does not include any representation of deformation by divergent flow.
255
+ APCEMM output represents properties of cross-sections of contrails formed at flight waypoints,
256
+ not properties of contrail segments that form between flight waypoints. Unlike :class:`Cocip`,
257
+ output produced by this interface does not include trailing NaN values.
258
+
259
+ **Known limitations**
260
+
261
+ - Engine core exit temperature and bypass area are not provided as output by pycontrails
262
+ aircraft performance models and are currently set to static values in APCEMM input files.
263
+ These parameters will be computed dynamically in a future release.
264
+ - APCEMM does not compute contrail radiative forcing internally. Radiative forcing must be
265
+ computed offline by the user. Tools for radiative forcing calculations may be included
266
+ in a future version of the interface.
267
+ - APCEMM currently produces different results in simulations that do not read vertical
268
+ velocity data from netCDF input files and in simulations that read vertical velocities
269
+ that are set to 0 everywhere (see https://github.com/MIT-LAE/APCEMM/issues/17).
270
+ Reading of vertical velocity data from netCDF input files will be disabled in this
271
+ interface until this issue is resolved.
272
+
273
+ References
274
+ ----------
275
+ - :cite:`fritzRolePlumescaleProcesses2020`
276
+ """
277
+
278
+ __slots__ = (
279
+ "apcemm_path",
280
+ "apcemm_input_params",
281
+ "cachestore",
282
+ "trajectories",
283
+ "vortex",
284
+ "contrail",
285
+ "_trajectory_downsampling",
286
+ )
287
+
288
+ name = "apcemm"
289
+ long_name = "Interface to APCEMM plume model"
290
+ met_variables = (
291
+ AirTemperature,
292
+ SpecificHumidity,
293
+ (Geopotential, GeopotentialHeight),
294
+ EastwardWind,
295
+ NorthwardWind,
296
+ VerticalVelocity,
297
+ )
298
+ default_params = APCEMMParams
299
+
300
+ #: Met data is not optional
301
+ met: MetDataset
302
+ met_required = True
303
+
304
+ #: Path to APCEMM executable
305
+ apcemm_path: pathlib.Path
306
+
307
+ #: Overridden APCEMM input parameters
308
+ apcemm_input_params: dict[str, Any]
309
+
310
+ #: CacheStore for APCEMM run directories
311
+ cachestore: cache.CacheStore
312
+
313
+ #: Last flight processed in :meth:`eval`
314
+ source: Flight
315
+
316
+ #: Output from trajectory calculation
317
+ trajectories: Flight | None
318
+
319
+ #: Time series output from the APCEMM early plume model
320
+ vortex: pd.DataFrame | None
321
+
322
+ #: Paths to APCEMM netCDF output at prescribed time intervals
323
+ contrail: pd.DataFrame | None
324
+
325
+ #: Downsampling factor from trajectory time resolution to
326
+ #: APCEMM met input file resolution
327
+ _trajectory_downsampling: int
328
+
329
+ def __init__(
330
+ self,
331
+ met: MetDataset,
332
+ apcemm_path: pathlib.Path | str,
333
+ apcemm_root: pathlib.Path | str | None = None,
334
+ apcemm_input_params: dict[str, Any] | None = None,
335
+ cachestore: cache.CacheStore | None = None,
336
+ params: dict[str, Any] | None = None,
337
+ **params_kwargs: Any,
338
+ ) -> None:
339
+ super().__init__(met, params=params, **params_kwargs)
340
+ self._ensure_geopotential_height()
341
+
342
+ if isinstance(apcemm_path, str):
343
+ apcemm_path = pathlib.Path(apcemm_path)
344
+ self.apcemm_path = apcemm_path
345
+
346
+ if cachestore is None:
347
+ cache_root = cache._get_user_cache_dir()
348
+ cache_dir = f"{cache_root}/apcemm"
349
+ cachestore = cache.DiskCacheStore(cache_dir=cache_dir)
350
+ self.cachestore = cachestore
351
+
352
+ # Validate overridden input parameters
353
+ apcemm_input_params = apcemm_input_params or {}
354
+ if apcemm_root is not None:
355
+ apcemm_input_params = {
356
+ "input_background_conditions": os.path.join(apcemm_root, "input_data", "init.txt"),
357
+ "input_engine_emissions": os.path.join(apcemm_root, "input_data", "ENG_EI.txt"),
358
+ } | apcemm_input_params
359
+ cannot_override = set(apcemm_input_params.keys()).intersection(self.dynamic_yaml_params)
360
+ if len(cannot_override) > 0:
361
+ msg = (
362
+ f"Cannot override APCEMM input parameters {cannot_override}, "
363
+ "as these parameters are set automatically by the APCEMM interface."
364
+ )
365
+ raise ValueError(msg)
366
+ self.apcemm_input_params = apcemm_input_params
367
+
368
+ @overload
369
+ def eval(self, source: Flight, **params: Any) -> Flight: ...
370
+
371
+ @overload
372
+ def eval(self, source: None = ..., **params: Any) -> NoReturn: ...
373
+
374
+ def eval(self, source: Flight | None = None, **params: Any) -> Flight:
375
+ """Set up and run APCEMM simulations initialized at flight waypoints.
376
+
377
+ Simulates the formation and evolution of contrails from a Flight
378
+ using the APCEMM plume model described in Fritz et. al. (2020)
379
+ :cite:`fritzRolePlumescaleProcesses2020`.
380
+
381
+ Parameters
382
+ ----------
383
+ source : Flight | None
384
+ Input Flight to model.
385
+ **params : Any
386
+ Overwrite model parameters before eval.
387
+
388
+ Returns
389
+ -------
390
+ Flight | NoReturn
391
+ Flight with exit status of APCEMM simulations. Detailed APCEMM outputs are attached
392
+ to model :attr:`vortex` and :attr:`contrail` attributes (see :class:`APCEMM` notes
393
+ for details).
394
+
395
+ References
396
+ ----------
397
+ - :cite:`fritzRolePlumescaleProcesses2020`
398
+ """
399
+
400
+ self.update_params(params)
401
+ self.set_source(source)
402
+ self.source = self.require_source_type(Flight)
403
+
404
+ # Assign waypoints to flight if not already present
405
+ if "waypoint" not in self.source:
406
+ self.source["waypoint"] = np.arange(self.source.size)
407
+
408
+ logger.info("Attaching APCEMM initial conditions to source")
409
+ self.attach_apcemm_initial_conditions()
410
+
411
+ logger.info("Computing Lagrangian trajectories")
412
+ self.compute_lagrangian_trajectories()
413
+
414
+ # Select waypoints to run in APCEMM
415
+ # Defaults to all waypoints, but allows user to select a subset
416
+ waypoints = self.params["waypoints"]
417
+ if waypoints is None:
418
+ waypoints = list(self.source["waypoints"])
419
+
420
+ # Generate input files (serial)
421
+ logger.info("Generating APCEMM input files") # serial
422
+ for waypoint in waypoints:
423
+ rundir = self.apcemm_file(waypoint)
424
+ if self.cachestore.exists(rundir) and not self.params["overwrite"]:
425
+ msg = f"APCEMM run directory already exists at {rundir}"
426
+ raise ValueError(msg)
427
+ if self.cachestore.exists(rundir) and self.params["overwrite"]:
428
+ shutil.rmtree(rundir)
429
+ self.generate_apcemm_input(waypoint)
430
+
431
+ # Run APCEMM (parallelizable)
432
+ logger.info("Running APCEMM")
433
+ self.run_apcemm(waypoints)
434
+
435
+ # Process output (serial)
436
+ logger.info("Postprocessing APCEMM output")
437
+ self.process_apcemm_output()
438
+
439
+ return self.source
440
+
441
+ def attach_apcemm_initial_conditions(self) -> None:
442
+ """Compute fields required for APCEMM initial conditions and attach to :attr:`source`.
443
+
444
+ This modifies :attr:`source` by attaching quantities derived from meterology
445
+ data and aircraft performance calculations.
446
+ """
447
+
448
+ self._attach_apcemm_time()
449
+ self._attach_initial_met()
450
+ self._attach_aircraft_performance()
451
+
452
+ def compute_lagrangian_trajectories(self) -> None:
453
+ """Calculate Lagrangian trajectories using a :class:`DryAdvection` model.
454
+
455
+ Lagrangian trajectories provide the expected time-dependent location
456
+ (longitude, latitude, and altitude) and orientation (azimuth) of
457
+ contrails formed by the input source. This information is used to
458
+ extract time series of meteorological profiles at the contrail location
459
+ from input meteorology data, and to compute contrail-normal horizontal shear
460
+ from horizontal winds.
461
+
462
+ The length of Lagrangian trajectories is set by the ``max_age`` parameter,
463
+ and trajectories are integrated using a time step set by the ``dt_lagrangian``
464
+ parameter. Contrails are advected both horizontally and vertically, and a
465
+ fixed sedimentation velocity (set by the ``sedimentation_rate`` parameter)
466
+ can be included to represent contrail sedimentation.
467
+
468
+ Results of the trajectory calculation are attached to :attr:`trajectories`.
469
+ """
470
+
471
+ buffers = {
472
+ f"{coord}_buffer": self.params[f"met_{coord}_buffer"]
473
+ for coord in ("longitude", "latitude", "level")
474
+ }
475
+ buffers["time_buffer"] = (0, self.params["max_age"] + self.params["dt_lagrangian"])
476
+ met = self.source.downselect_met(self.met, **buffers, copy=False)
477
+ model = DryAdvection(
478
+ met=met,
479
+ dt_integration=self.params["dt_lagrangian"],
480
+ max_age=self.params["max_age"],
481
+ sedimentation_rate=self.params["lagrangian_sedimentation_rate"],
482
+ )
483
+ self.trajectories = model.eval(self.source)
484
+
485
+ def generate_apcemm_input(self, waypoint: int) -> None:
486
+ """Generate APCEMM yaml and netCDF input files for a single waypoint.
487
+
488
+ For details about generated input files, see :class:`APCEMM` notes.
489
+
490
+ Parameters
491
+ ----------
492
+ waypoint : int
493
+ Waypoint for which to generate input files.
494
+ """
495
+
496
+ self._gen_apcemm_yaml(waypoint)
497
+ self._gen_apcemm_nc(waypoint)
498
+
499
+ def run_apcemm(self, waypoints: list[int]) -> None:
500
+ """Run APCEMM over multiple waypoints.
501
+
502
+ Multiple waypoints will be processed in parallel if the :class:`APCEMM`
503
+ ``n_jobs`` parameter is set to a value larger than 1.
504
+
505
+ Parameters
506
+ ----------
507
+ waypoints : list[int]
508
+ List of waypoints at which to initialize simulations.
509
+ """
510
+
511
+ # run in series
512
+ if self.params["n_jobs"] == 1:
513
+ for waypoint in waypoints:
514
+ utils.run(
515
+ apcemm_path=self.apcemm_path,
516
+ input_yaml=self.apcemm_file(waypoint, "input.yaml"),
517
+ rundir=self.apcemm_file(waypoint),
518
+ stdout_log=self.apcemm_file(waypoint, "stdout.log"),
519
+ stderr_log=self.apcemm_file(waypoint, "stderr.log"),
520
+ )
521
+
522
+ # run in parallel
523
+ else:
524
+ with mp.Pool(self.params["n_jobs"]) as p:
525
+ args = (
526
+ (
527
+ self.apcemm_path,
528
+ self.apcemm_file(waypoint, "input.yaml"),
529
+ self.apcemm_file(waypoint),
530
+ self.apcemm_file(waypoint, "stdout.log"),
531
+ self.apcemm_file(waypoint, "stderr.log"),
532
+ )
533
+ for waypoint in waypoints
534
+ )
535
+ p.starmap(utils.run, args)
536
+
537
+ def process_apcemm_output(self) -> None:
538
+ """Process APCEMM output.
539
+
540
+ After processing, a ``status`` column will be attached to
541
+ :attr:`source`, and additional output data will be attached
542
+ to :attr:`vortex` and :attr:`contrail`. For details about
543
+ contents of APCEMM output files, see :class:`APCEMM` notes.
544
+ """
545
+
546
+ output_directory = self.params["output_directory"]
547
+
548
+ statuses: list[str] = []
549
+ vortexes: list[pd.DataFrame] = []
550
+ contrails: list[pd.DataFrame] = []
551
+
552
+ for _, row in self.source.dataframe.iterrows():
553
+
554
+ waypoint = row["waypoint"]
555
+
556
+ # Mark waypoint as skipped if no APCEMM simulation ran
557
+ if waypoint not in self.params["waypoints"]:
558
+ statuses.append("NoSimulation")
559
+ continue
560
+
561
+ # Otherwise, record status of APCEMM simulation
562
+ with open(
563
+ self.apcemm_file(waypoint, os.path.join(output_directory, "status_case0"))
564
+ ) as f:
565
+ status = f.read().strip()
566
+ statuses.append(status)
567
+
568
+ # Get waypoint initialization time
569
+ base_time = row["time"]
570
+
571
+ # Convert contents of wake vortex output to pandas dataframe
572
+ # with elapsed times converted to absolute times
573
+ vortex = pd.read_csv(
574
+ self.apcemm_file(waypoint, os.path.join(output_directory, "Micro000000.out")),
575
+ skiprows=[1],
576
+ ).rename(columns=lambda x: x.strip())
577
+ time = (base_time + pd.to_timedelta(vortex["Time [s]"], unit="s")).rename("time")
578
+ waypoint_col = pd.Series(np.full((len(vortex),), waypoint), name="waypoint")
579
+ vortex = pd.concat(
580
+ (waypoint_col, time, vortex.drop(columns="Time [s]")), axis="columns"
581
+ )
582
+ vortexes.append(vortex)
583
+
584
+ # Record paths to contrail output (netCDF files) in pandas dataframe
585
+ # get paths to contrail output
586
+ files = sorted(
587
+ glob.glob(
588
+ self.apcemm_file(
589
+ waypoint, os.path.join(output_directory, "ts_aerosol_case0_*.nc")
590
+ )
591
+ )
592
+ )
593
+ if len(files) == 0:
594
+ continue
595
+ time = []
596
+ path = []
597
+ for file in files:
598
+ elapsed_hours = pd.to_timedelta(file[-7:-5] + "h")
599
+ elapsed_minutes = pd.to_timedelta(file[-5:-3] + "m")
600
+ elapsed_time = elapsed_hours + elapsed_minutes
601
+ time.append(base_time + elapsed_time)
602
+ path.append(file)
603
+ waypoint_col = pd.Series(np.full((len(time),), waypoint), name="waypoint")
604
+ contrail = pd.DataFrame.from_dict(
605
+ {
606
+ "waypoint": waypoint_col,
607
+ "time": time,
608
+ "path": path,
609
+ }
610
+ )
611
+ contrails.append(contrail)
612
+
613
+ # Attach status to self
614
+ self.source["status"] = statuses
615
+
616
+ # Attach wake vortex and contrail outputs to model
617
+ self.vortex = pd.concat(vortexes, axis="index", ignore_index=True)
618
+ if len(contrails) > 0: # only present if APCEMM simulates persistent contrails
619
+ self.contrail = pd.concat(contrails, axis="index", ignore_index=True)
620
+
621
+ @property
622
+ def dynamic_yaml_params(self) -> set[str]:
623
+ """Set of :class:`APCEMMInput` attributes set dynamically by this model.
624
+
625
+ Other :class:`APCEMMInput` attributes can be set statically by passing
626
+ parameters in ``apcemm_input_params`` to the :class:`APCEMM` constructor.
627
+ """
628
+ return {
629
+ "max_age",
630
+ "day_of_year",
631
+ "hour_of_day",
632
+ "longitude",
633
+ "latitude",
634
+ "air_pressure",
635
+ "air_temperature",
636
+ "rhw",
637
+ "normal_shear",
638
+ "brunt_vaisala_frequency",
639
+ "dt_input_met",
640
+ "nox_ei",
641
+ "co_ei",
642
+ "hc_ei",
643
+ "so2_ei",
644
+ "nvpm_ei_m",
645
+ "soot_radius",
646
+ "fuel_flow",
647
+ "aircraft_mass",
648
+ "true_airspeed",
649
+ "n_engine",
650
+ "wingspan",
651
+ "output_directory",
652
+ }
653
+
654
+ def apcemm_file(self, waypoint: int, name: str | None = None) -> str:
655
+ """Get path to file from an APCEMM simulation initialized at a specific waypoint.
656
+
657
+ Parameters
658
+ ----------
659
+ waypoint : int
660
+ Segment index
661
+ name : str, optional
662
+ If provided, the path to the file relative to the APCEMM simulation
663
+ root directory.
664
+
665
+ Returns
666
+ -------
667
+ str
668
+ Path to a file in the APCEMM simulation root directory, if ``name``
669
+ is provided, or the path to the APCEMM simulation root directory otherwise.
670
+ """
671
+ rpath = f"apcemm_waypoint_{waypoint}"
672
+ if name is not None:
673
+ rpath = os.path.join(rpath, name)
674
+ return self.cachestore.path(rpath)
675
+
676
+ def _ensure_geopotential_height(self) -> None:
677
+ """Ensure that :attr:`self.met` contains geopotential height."""
678
+ geopotential = Geopotential.standard_name
679
+ geopotential_height = GeopotentialHeight.standard_name
680
+
681
+ if geopotential not in self.met and geopotential_height not in self.met:
682
+ msg = f"APCEMM MetDataset must contain either {geopotential} or {geopotential_height}."
683
+ raise ValueError(msg)
684
+
685
+ if geopotential_height not in self.met:
686
+ self.met.update({geopotential_height: self.met[geopotential].data / constants.g})
687
+
688
+ def _attach_apcemm_time(self) -> None:
689
+ """Attach day of year and fractional hour of day.
690
+
691
+ Mutates :attr:`self.source` by adding the following keys if not already present:
692
+ - ``day_of_year``
693
+ - ``hour_of_day``
694
+ """
695
+
696
+ self.source.setdefault(
697
+ "day_of_year",
698
+ # APCEMM doesn't accept 366 on leap years
699
+ self.source.dataframe["time"].dt.dayofyear.clip(upper=365),
700
+ )
701
+ self.source.setdefault(
702
+ "hour_of_day",
703
+ self.source.dataframe["time"].dt.hour
704
+ + self.source.dataframe["time"].dt.minute / 60
705
+ + self.source.dataframe["time"].dt.second / 3600,
706
+ )
707
+
708
+ def _attach_initial_met(self) -> None:
709
+ """Attach meteorological fields for APCEMM initialization.
710
+
711
+ Mutates :attr:`source` by adding the following keys if not already present:
712
+ - ``air_temperature``
713
+ - ``eastward_wind``
714
+ - ``northward_wind``
715
+ - ``specific_humidity``
716
+ - ``air_temperature_lower``
717
+ - ``eastward_wind_lower``
718
+ - ``northward_wind_lower``
719
+ - ``rhw``
720
+ - ``brunt_vaisala_frequency``
721
+ - ``normal_shear``
722
+ """
723
+ humidity_scaling = self.params["humidity_scaling"]
724
+ scale_humidity = humidity_scaling is not None and "specific_humidity" not in self.source
725
+
726
+ # Downselect met before interpolation.
727
+ # Need buffer in downward direction for calculation of vertical derivatives,
728
+ # but not in any other directions.
729
+ level_buffer = 0, self.params["met_level_buffer"][1]
730
+ met = self.source.downselect_met(self.met, level_buffer=level_buffer)
731
+
732
+ # Interpolate meteorology data onto vector
733
+ for met_key in ("air_temperature", "eastward_wind", "northward_wind", "specific_humidity"):
734
+ models.interpolate_met(met, self.source, met_key, **self.interp_kwargs)
735
+
736
+ # Interpolate fields at lower levels for vertical derivative calculation
737
+ air_pressure_lower = thermo.pressure_dz(
738
+ self.source["air_temperature"], self.source.air_pressure, self.params["dz_m"]
739
+ )
740
+ lower_level = air_pressure_lower / 100.0
741
+ for met_key in ("air_temperature", "eastward_wind", "northward_wind"):
742
+ source_key = f"{met_key}_lower"
743
+ models.interpolate_met(
744
+ met, self.source, met_key, source_key, **self.interp_kwargs, level=lower_level
745
+ )
746
+
747
+ # Apply humidity scaling
748
+ if scale_humidity:
749
+ humidity_scaling.eval(self.source, copy_source=False)
750
+
751
+ # Compute RH over liquid water
752
+ self.source.setdefault(
753
+ "rhw",
754
+ thermo.rh(
755
+ self.source["specific_humidity"],
756
+ self.source["air_temperature"],
757
+ self.source.air_pressure,
758
+ ),
759
+ )
760
+
761
+ # Compute Brunt-Vaisala frequency
762
+ dT_dz = thermo.T_potential_gradient(
763
+ self.source["air_temperature"],
764
+ self.source.air_pressure,
765
+ self.source["air_temperature_lower"],
766
+ air_pressure_lower,
767
+ self.params["dz_m"],
768
+ )
769
+ self.source.setdefault(
770
+ "brunt_vaisala_frequency",
771
+ thermo.brunt_vaisala_frequency(
772
+ self.source.air_pressure, self.source["air_temperature"], dT_dz
773
+ ),
774
+ )
775
+
776
+ # Compute azimuth
777
+ # Use forward and backward differences for first and last waypoints
778
+ # and centered differences elsewhere
779
+ ileft = [0, *range(self.source.size - 1)]
780
+ iright = [*range(1, self.source.size), self.source.size - 1]
781
+ lon0 = self.source["longitude"][ileft]
782
+ lat0 = self.source["latitude"][ileft]
783
+ lon1 = self.source["longitude"][iright]
784
+ lat1 = self.source["latitude"][iright]
785
+ self.source.setdefault("azimuth", geo.azimuth(lon0, lat0, lon1, lat1))
786
+
787
+ # Compute normal shear
788
+ self.source.setdefault(
789
+ "normal_shear",
790
+ utils.normal_wind_shear(
791
+ self.source["eastward_wind"],
792
+ self.source["eastward_wind_lower"],
793
+ self.source["northward_wind"],
794
+ self.source["northward_wind_lower"],
795
+ self.source["azimuth"],
796
+ self.params["dz_m"],
797
+ ),
798
+ )
799
+
800
+ def _attach_aircraft_performance(self) -> None:
801
+ """Attach aircraft performance and emissions parameters.
802
+
803
+ Mutates :attr:`source evaluating the aircraft performance model provided by
804
+ the ``aircraft_performance`` model parameter and a :class:`Emissions` models. In addition:
805
+ - MetDatasetutates :attr:`source` by adding the following keys if not already present:
806
+ - ``soot_radius``
807
+ - Mutates :attr:`source.attrs` by adding the following keys if not already present:
808
+ - ``so2_ei``
809
+ """
810
+
811
+ ap_model = self.params["aircraft_performance"]
812
+ emissions = Emissions()
813
+ humidity_scaling = self.params["humidity_scaling"]
814
+ scale_humidity = humidity_scaling is not None and "specific_humidity" not in self.source
815
+
816
+ # Ensure required met data is present.
817
+ # No buffers needed for interpolation!
818
+ vars = ap_model.met_variables + ap_model.optional_met_variables + emissions.met_variables
819
+ met = self.source.downselect_met(self.met, copy=False)
820
+ met.ensure_vars(vars)
821
+ met.standardize_variables(vars)
822
+ for var in vars:
823
+ models.interpolate_met(met, self.source, var.standard_name, **self.interp_kwargs)
824
+
825
+ # Apply humidity scaling
826
+ if scale_humidity:
827
+ humidity_scaling.eval(self.source, copy_source=False)
828
+
829
+ # Ensure flight has aircraft type, fuel, and engine UID if defined
830
+ self.source.attrs.setdefault("aircraft_type", self.params["aircraft_type"])
831
+ self.source.attrs.setdefault("fuel", self.params["fuel"])
832
+ if self.params["engine_uid"]:
833
+ self.source.attrs.setdefault("engine_uid", self.params["engine_uid"])
834
+
835
+ # Run performance and emissions calculations
836
+ ap_model.eval(self.source, copy_source=False)
837
+ emissions.eval(self.source, copy_source=False)
838
+
839
+ # Attach additional required quantities
840
+ soot_radius = utils.soot_radius(self.source["nvpm_ei_m"], self.source["nvpm_ei_n"])
841
+ self.source.setdefault("soot_radius", soot_radius)
842
+ self.source.attrs.setdefault("so2_ei", self.source.attrs["fuel"].ei_so2)
843
+
844
+ def _gen_apcemm_yaml(self, waypoint: int) -> None:
845
+ """Generate APCEMM yaml file.
846
+
847
+ Parameters
848
+ ----------
849
+ waypoint : int
850
+ Waypoint for which to generate the yaml file.
851
+ """
852
+
853
+ # Collect parameters determined by this interface
854
+ dyn_params = _combine_prioritized(
855
+ self.dynamic_yaml_params,
856
+ [
857
+ self.source.dataframe.loc[waypoint], # flight waypoint
858
+ self.source.attrs, # flight attributes
859
+ self.params, # class parameters
860
+ ],
861
+ )
862
+
863
+ # Combine with other overridden parameters
864
+ params = self.apcemm_input_params | dyn_params
865
+
866
+ # We should be setting these parameters based on aircraft data,
867
+ # but we don't currently have an easy way to do this.
868
+ # For now, stubbing in static values.
869
+ params = params | {"core_exit_temp": 550.0, "core_exit_area": 1.0}
870
+
871
+ # Generate and write YAML file
872
+ yaml = APCEMMInput(**params)
873
+ yaml_contents = utils.generate_apcemm_input_yaml(yaml)
874
+ path = self.apcemm_file(waypoint, "input.yaml")
875
+ with open(path, "w") as f:
876
+ f.write(yaml_contents)
877
+
878
+ def _gen_apcemm_nc(self, waypoint: int) -> None:
879
+ """Generate APCEMM meteorology netCDF file.
880
+
881
+ Parameters
882
+ ----------
883
+ waypoint : int
884
+ Waypoint for which to generate the meteorology file.
885
+ """
886
+ # Extract trajectories of advected contrails, include initial position
887
+ columns = ["longitude", "latitude", "time", "azimuth"]
888
+ if self.trajectories is None:
889
+ msg = (
890
+ "APCEMM meteorology input generation requires precomputed trajectories. "
891
+ "To compute trajectories, call `compute_lagrangian_trajectories`."
892
+ )
893
+ raise ValueError(msg)
894
+ tail = self.trajectories.dataframe
895
+ tail = tail[tail["waypoint"] == waypoint][columns]
896
+ head = self.source.dataframe
897
+ head = head[head["waypoint"] == waypoint][columns]
898
+ traj = pd.concat((head, tail), axis="index").reset_index()
899
+
900
+ # APCEMM requires atmospheric profiles at even time intervals,
901
+ # but the time coordinates of the initial position plus subsequent
902
+ # trajectory may not be evenly spaced. To fix this, we interpolate
903
+ # horizontal location and azimuth to an evenly-spaced set of time
904
+ # coordinates.
905
+ time = traj["time"].values
906
+ n_profiles = int(self.params["max_age"] / self.params["dt_input_met"]) + 1
907
+ tick = np.timedelta64(1, "s")
908
+ target_elapsed = np.linspace(
909
+ 0, (n_profiles - 1) * self.params["dt_input_met"] / tick, n_profiles
910
+ )
911
+ target_time = time[0] + target_elapsed * tick
912
+ elapsed = (traj["time"] - traj["time"][0]) / tick
913
+
914
+ # Need to deal with antimeridian crossing.
915
+ # Detecting antimeridian crossing follows Flight.resample_and_fill,
916
+ # but rather than applying a variable shift we just convert longitudes to
917
+ # [0, 360) before interpolating flights that cross the antimeridian,
918
+ # and un-convert any longitudes above 180 degree after interpolation.
919
+ lon = traj["longitude"].values
920
+ min_pos = np.min(lon[lon > 0], initial=np.inf)
921
+ max_neg = np.max(lon[lon < 0], initial=-np.inf)
922
+ if (180 - min_pos) + (180 + max_neg) < 180 and min_pos < np.inf and max_neg > -np.inf:
923
+ lon = np.where(lon < 0, lon + 360, lon)
924
+ interp_lon = np.interp(target_elapsed, elapsed, lon)
925
+ interp_lon = np.where(interp_lon > 180, interp_lon - 360, interp_lon)
926
+
927
+ interp_lat = np.interp(target_elapsed, elapsed, traj["latitude"].values)
928
+ interp_az = np.interp(target_elapsed, elapsed, traj["azimuth"].values)
929
+
930
+ if self.params["altitude_input_met"] is None:
931
+ altitude = self.met["altitude"].values
932
+ else:
933
+ altitude = np.array(self.params["altitude_input_met"])
934
+
935
+ ds = utils.generate_apcemm_input_met(
936
+ time=target_time,
937
+ longitude=interp_lon,
938
+ latitude=interp_lat,
939
+ azimuth=interp_az,
940
+ altitude=altitude,
941
+ met=self.met,
942
+ humidity_scaling=self.params["humidity_scaling"],
943
+ dz_m=self.params["dz_m"],
944
+ interp_kwargs=self.interp_kwargs,
945
+ )
946
+
947
+ path = self.apcemm_file(waypoint, "input.nc")
948
+ ds.to_netcdf(path)
949
+
950
+
951
+ def _combine_prioritized(keys: set[str], sources: list[dict[str, Any]]) -> dict[str, Any]:
952
+ """Combine dictionary keys from prioritized list of source dictionaries.
953
+
954
+ Parameters
955
+ ----------
956
+ keys : set[str]
957
+ Set of keys to attempt to extract from source dictionary.
958
+ sources : list[dict[str, Any]]
959
+ List of dictionaries from which to attempt to extract key-value pairs.
960
+ If the key is in the first dictionary, it will be set in the returned dictionary
961
+ with the corresponding value. Otherwise, the method will fall on the remaining
962
+ dictionaries in the order provided.
963
+
964
+ Returns
965
+ -------
966
+ dict[str, Any]
967
+ Dictionary containing key-value pairs from :param:`sources`.
968
+
969
+ Raises
970
+ ------
971
+ ValueError
972
+ Any key is not found in any dictionary in ``sources``.
973
+ """
974
+ dest = {}
975
+ for key in keys:
976
+ for source in sources:
977
+ if key in source:
978
+ dest[key] = source[key]
979
+ break
980
+ else:
981
+ msg = f"Key {key} not found in any source dictionary."
982
+ raise ValueError(msg)
983
+ return dest