pycontrails 0.54.0__cp312-cp312-macosx_10_13_x86_64.whl

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

Potentially problematic release.


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

Files changed (109) hide show
  1. pycontrails/__init__.py +70 -0
  2. pycontrails/_version.py +16 -0
  3. pycontrails/core/__init__.py +30 -0
  4. pycontrails/core/aircraft_performance.py +641 -0
  5. pycontrails/core/airports.py +226 -0
  6. pycontrails/core/cache.py +881 -0
  7. pycontrails/core/coordinates.py +174 -0
  8. pycontrails/core/fleet.py +470 -0
  9. pycontrails/core/flight.py +2314 -0
  10. pycontrails/core/flightplan.py +220 -0
  11. pycontrails/core/fuel.py +140 -0
  12. pycontrails/core/interpolation.py +721 -0
  13. pycontrails/core/met.py +2833 -0
  14. pycontrails/core/met_var.py +307 -0
  15. pycontrails/core/models.py +1181 -0
  16. pycontrails/core/polygon.py +549 -0
  17. pycontrails/core/rgi_cython.cpython-312-darwin.so +0 -0
  18. pycontrails/core/vector.py +2190 -0
  19. pycontrails/datalib/__init__.py +12 -0
  20. pycontrails/datalib/_leo_utils/search.py +250 -0
  21. pycontrails/datalib/_leo_utils/static/bq_roi_query.sql +6 -0
  22. pycontrails/datalib/_leo_utils/vis.py +59 -0
  23. pycontrails/datalib/_met_utils/metsource.py +746 -0
  24. pycontrails/datalib/ecmwf/__init__.py +73 -0
  25. pycontrails/datalib/ecmwf/arco_era5.py +340 -0
  26. pycontrails/datalib/ecmwf/common.py +109 -0
  27. pycontrails/datalib/ecmwf/era5.py +550 -0
  28. pycontrails/datalib/ecmwf/era5_model_level.py +487 -0
  29. pycontrails/datalib/ecmwf/hres.py +782 -0
  30. pycontrails/datalib/ecmwf/hres_model_level.py +459 -0
  31. pycontrails/datalib/ecmwf/ifs.py +284 -0
  32. pycontrails/datalib/ecmwf/model_levels.py +434 -0
  33. pycontrails/datalib/ecmwf/static/model_level_dataframe_v20240418.csv +139 -0
  34. pycontrails/datalib/ecmwf/variables.py +267 -0
  35. pycontrails/datalib/gfs/__init__.py +28 -0
  36. pycontrails/datalib/gfs/gfs.py +646 -0
  37. pycontrails/datalib/gfs/variables.py +100 -0
  38. pycontrails/datalib/goes.py +772 -0
  39. pycontrails/datalib/landsat.py +569 -0
  40. pycontrails/datalib/sentinel.py +511 -0
  41. pycontrails/datalib/spire.py +739 -0
  42. pycontrails/ext/bada.py +41 -0
  43. pycontrails/ext/cirium.py +14 -0
  44. pycontrails/ext/empirical_grid.py +140 -0
  45. pycontrails/ext/synthetic_flight.py +430 -0
  46. pycontrails/models/__init__.py +1 -0
  47. pycontrails/models/accf.py +406 -0
  48. pycontrails/models/apcemm/__init__.py +8 -0
  49. pycontrails/models/apcemm/apcemm.py +982 -0
  50. pycontrails/models/apcemm/inputs.py +226 -0
  51. pycontrails/models/apcemm/static/apcemm_yaml_template.yaml +183 -0
  52. pycontrails/models/apcemm/utils.py +437 -0
  53. pycontrails/models/cocip/__init__.py +29 -0
  54. pycontrails/models/cocip/cocip.py +2616 -0
  55. pycontrails/models/cocip/cocip_params.py +299 -0
  56. pycontrails/models/cocip/cocip_uncertainty.py +285 -0
  57. pycontrails/models/cocip/contrail_properties.py +1517 -0
  58. pycontrails/models/cocip/output_formats.py +2261 -0
  59. pycontrails/models/cocip/radiative_forcing.py +1262 -0
  60. pycontrails/models/cocip/radiative_heating.py +520 -0
  61. pycontrails/models/cocip/unterstrasser_wake_vortex.py +403 -0
  62. pycontrails/models/cocip/wake_vortex.py +396 -0
  63. pycontrails/models/cocip/wind_shear.py +120 -0
  64. pycontrails/models/cocipgrid/__init__.py +9 -0
  65. pycontrails/models/cocipgrid/cocip_grid.py +2573 -0
  66. pycontrails/models/cocipgrid/cocip_grid_params.py +138 -0
  67. pycontrails/models/dry_advection.py +494 -0
  68. pycontrails/models/emissions/__init__.py +21 -0
  69. pycontrails/models/emissions/black_carbon.py +594 -0
  70. pycontrails/models/emissions/emissions.py +1353 -0
  71. pycontrails/models/emissions/ffm2.py +336 -0
  72. pycontrails/models/emissions/static/default-engine-uids.csv +239 -0
  73. pycontrails/models/emissions/static/edb-gaseous-v29b-engines.csv +596 -0
  74. pycontrails/models/emissions/static/edb-nvpm-v29b-engines.csv +215 -0
  75. pycontrails/models/humidity_scaling/__init__.py +37 -0
  76. pycontrails/models/humidity_scaling/humidity_scaling.py +1025 -0
  77. pycontrails/models/humidity_scaling/quantiles/era5-model-level-quantiles.pq +0 -0
  78. pycontrails/models/humidity_scaling/quantiles/era5-pressure-level-quantiles.pq +0 -0
  79. pycontrails/models/issr.py +210 -0
  80. pycontrails/models/pcc.py +327 -0
  81. pycontrails/models/pcr.py +154 -0
  82. pycontrails/models/ps_model/__init__.py +17 -0
  83. pycontrails/models/ps_model/ps_aircraft_params.py +376 -0
  84. pycontrails/models/ps_model/ps_grid.py +505 -0
  85. pycontrails/models/ps_model/ps_model.py +1017 -0
  86. pycontrails/models/ps_model/ps_operational_limits.py +540 -0
  87. pycontrails/models/ps_model/static/ps-aircraft-params-20240524.csv +68 -0
  88. pycontrails/models/ps_model/static/ps-synonym-list-20240524.csv +103 -0
  89. pycontrails/models/sac.py +459 -0
  90. pycontrails/models/tau_cirrus.py +168 -0
  91. pycontrails/physics/__init__.py +1 -0
  92. pycontrails/physics/constants.py +116 -0
  93. pycontrails/physics/geo.py +989 -0
  94. pycontrails/physics/jet.py +837 -0
  95. pycontrails/physics/thermo.py +451 -0
  96. pycontrails/physics/units.py +472 -0
  97. pycontrails/py.typed +0 -0
  98. pycontrails/utils/__init__.py +1 -0
  99. pycontrails/utils/dependencies.py +66 -0
  100. pycontrails/utils/iteration.py +13 -0
  101. pycontrails/utils/json.py +188 -0
  102. pycontrails/utils/temp.py +50 -0
  103. pycontrails/utils/types.py +165 -0
  104. pycontrails-0.54.0.dist-info/LICENSE +178 -0
  105. pycontrails-0.54.0.dist-info/METADATA +179 -0
  106. pycontrails-0.54.0.dist-info/NOTICE +43 -0
  107. pycontrails-0.54.0.dist-info/RECORD +109 -0
  108. pycontrails-0.54.0.dist-info/WHEEL +5 -0
  109. pycontrails-0.54.0.dist-info/top_level.txt +3 -0
@@ -0,0 +1,982 @@
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
+ waypoint = row["waypoint"]
554
+
555
+ # Mark waypoint as skipped if no APCEMM simulation ran
556
+ if waypoint not in self.params["waypoints"]:
557
+ statuses.append("NoSimulation")
558
+ continue
559
+
560
+ # Otherwise, record status of APCEMM simulation
561
+ with open(
562
+ self.apcemm_file(waypoint, os.path.join(output_directory, "status_case0"))
563
+ ) as f:
564
+ status = f.read().strip()
565
+ statuses.append(status)
566
+
567
+ # Get waypoint initialization time
568
+ base_time = row["time"]
569
+
570
+ # Convert contents of wake vortex output to pandas dataframe
571
+ # with elapsed times converted to absolute times
572
+ vortex = pd.read_csv(
573
+ self.apcemm_file(waypoint, os.path.join(output_directory, "Micro000000.out")),
574
+ skiprows=[1],
575
+ ).rename(columns=lambda x: x.strip())
576
+ time = (base_time + pd.to_timedelta(vortex["Time [s]"], unit="s")).rename("time")
577
+ waypoint_col = pd.Series(np.full((len(vortex),), waypoint), name="waypoint")
578
+ vortex = pd.concat(
579
+ (waypoint_col, time, vortex.drop(columns="Time [s]")), axis="columns"
580
+ )
581
+ vortexes.append(vortex)
582
+
583
+ # Record paths to contrail output (netCDF files) in pandas dataframe
584
+ # get paths to contrail output
585
+ files = sorted(
586
+ glob.glob(
587
+ self.apcemm_file(
588
+ waypoint, os.path.join(output_directory, "ts_aerosol_case0_*.nc")
589
+ )
590
+ )
591
+ )
592
+ if len(files) == 0:
593
+ continue
594
+ time = []
595
+ path = []
596
+ for file in files:
597
+ elapsed_hours = pd.to_timedelta(file[-7:-5] + "h")
598
+ elapsed_minutes = pd.to_timedelta(file[-5:-3] + "m")
599
+ elapsed_time = elapsed_hours + elapsed_minutes
600
+ time.append(base_time + elapsed_time)
601
+ path.append(file)
602
+ waypoint_col = pd.Series(np.full((len(time),), waypoint), name="waypoint")
603
+ contrail = pd.DataFrame.from_dict(
604
+ {
605
+ "waypoint": waypoint_col,
606
+ "time": time,
607
+ "path": path,
608
+ }
609
+ )
610
+ contrails.append(contrail)
611
+
612
+ # Attach status to self
613
+ self.source["status"] = statuses
614
+
615
+ # Attach wake vortex and contrail outputs to model
616
+ self.vortex = pd.concat(vortexes, axis="index", ignore_index=True)
617
+ if len(contrails) > 0: # only present if APCEMM simulates persistent contrails
618
+ self.contrail = pd.concat(contrails, axis="index", ignore_index=True)
619
+
620
+ @property
621
+ def dynamic_yaml_params(self) -> set[str]:
622
+ """Set of :class:`APCEMMInput` attributes set dynamically by this model.
623
+
624
+ Other :class:`APCEMMInput` attributes can be set statically by passing
625
+ parameters in ``apcemm_input_params`` to the :class:`APCEMM` constructor.
626
+ """
627
+ return {
628
+ "max_age",
629
+ "day_of_year",
630
+ "hour_of_day",
631
+ "longitude",
632
+ "latitude",
633
+ "air_pressure",
634
+ "air_temperature",
635
+ "rhw",
636
+ "normal_shear",
637
+ "brunt_vaisala_frequency",
638
+ "dt_input_met",
639
+ "nox_ei",
640
+ "co_ei",
641
+ "hc_ei",
642
+ "so2_ei",
643
+ "nvpm_ei_m",
644
+ "soot_radius",
645
+ "fuel_flow",
646
+ "aircraft_mass",
647
+ "true_airspeed",
648
+ "n_engine",
649
+ "wingspan",
650
+ "output_directory",
651
+ }
652
+
653
+ def apcemm_file(self, waypoint: int, name: str | None = None) -> str:
654
+ """Get path to file from an APCEMM simulation initialized at a specific waypoint.
655
+
656
+ Parameters
657
+ ----------
658
+ waypoint : int
659
+ Segment index
660
+ name : str, optional
661
+ If provided, the path to the file relative to the APCEMM simulation
662
+ root directory.
663
+
664
+ Returns
665
+ -------
666
+ str
667
+ Path to a file in the APCEMM simulation root directory, if ``name``
668
+ is provided, or the path to the APCEMM simulation root directory otherwise.
669
+ """
670
+ rpath = f"apcemm_waypoint_{waypoint}"
671
+ if name is not None:
672
+ rpath = os.path.join(rpath, name)
673
+ return self.cachestore.path(rpath)
674
+
675
+ def _ensure_geopotential_height(self) -> None:
676
+ """Ensure that :attr:`self.met` contains geopotential height."""
677
+ geopotential = Geopotential.standard_name
678
+ geopotential_height = GeopotentialHeight.standard_name
679
+
680
+ if geopotential not in self.met and geopotential_height not in self.met:
681
+ msg = f"APCEMM MetDataset must contain either {geopotential} or {geopotential_height}."
682
+ raise ValueError(msg)
683
+
684
+ if geopotential_height not in self.met:
685
+ self.met.update({geopotential_height: self.met[geopotential].data / constants.g})
686
+
687
+ def _attach_apcemm_time(self) -> None:
688
+ """Attach day of year and fractional hour of day.
689
+
690
+ Mutates :attr:`self.source` by adding the following keys if not already present:
691
+ - ``day_of_year``
692
+ - ``hour_of_day``
693
+ """
694
+
695
+ self.source.setdefault(
696
+ "day_of_year",
697
+ # APCEMM doesn't accept 366 on leap years
698
+ self.source.dataframe["time"].dt.dayofyear.clip(upper=365),
699
+ )
700
+ self.source.setdefault(
701
+ "hour_of_day",
702
+ self.source.dataframe["time"].dt.hour
703
+ + self.source.dataframe["time"].dt.minute / 60
704
+ + self.source.dataframe["time"].dt.second / 3600,
705
+ )
706
+
707
+ def _attach_initial_met(self) -> None:
708
+ """Attach meteorological fields for APCEMM initialization.
709
+
710
+ Mutates :attr:`source` by adding the following keys if not already present:
711
+ - ``air_temperature``
712
+ - ``eastward_wind``
713
+ - ``northward_wind``
714
+ - ``specific_humidity``
715
+ - ``air_temperature_lower``
716
+ - ``eastward_wind_lower``
717
+ - ``northward_wind_lower``
718
+ - ``rhw``
719
+ - ``brunt_vaisala_frequency``
720
+ - ``normal_shear``
721
+ """
722
+ humidity_scaling = self.params["humidity_scaling"]
723
+ scale_humidity = humidity_scaling is not None and "specific_humidity" not in self.source
724
+
725
+ # Downselect met before interpolation.
726
+ # Need buffer in downward direction for calculation of vertical derivatives,
727
+ # but not in any other directions.
728
+ level_buffer = 0, self.params["met_level_buffer"][1]
729
+ met = self.source.downselect_met(self.met, level_buffer=level_buffer)
730
+
731
+ # Interpolate meteorology data onto vector
732
+ for met_key in ("air_temperature", "eastward_wind", "northward_wind", "specific_humidity"):
733
+ models.interpolate_met(met, self.source, met_key, **self.interp_kwargs)
734
+
735
+ # Interpolate fields at lower levels for vertical derivative calculation
736
+ air_pressure_lower = thermo.pressure_dz(
737
+ self.source["air_temperature"], self.source.air_pressure, self.params["dz_m"]
738
+ )
739
+ lower_level = air_pressure_lower / 100.0
740
+ for met_key in ("air_temperature", "eastward_wind", "northward_wind"):
741
+ source_key = f"{met_key}_lower"
742
+ models.interpolate_met(
743
+ met, self.source, met_key, source_key, **self.interp_kwargs, level=lower_level
744
+ )
745
+
746
+ # Apply humidity scaling
747
+ if scale_humidity:
748
+ humidity_scaling.eval(self.source, copy_source=False)
749
+
750
+ # Compute RH over liquid water
751
+ self.source.setdefault(
752
+ "rhw",
753
+ thermo.rh(
754
+ self.source["specific_humidity"],
755
+ self.source["air_temperature"],
756
+ self.source.air_pressure,
757
+ ),
758
+ )
759
+
760
+ # Compute Brunt-Vaisala frequency
761
+ dT_dz = thermo.T_potential_gradient(
762
+ self.source["air_temperature"],
763
+ self.source.air_pressure,
764
+ self.source["air_temperature_lower"],
765
+ air_pressure_lower,
766
+ self.params["dz_m"],
767
+ )
768
+ self.source.setdefault(
769
+ "brunt_vaisala_frequency",
770
+ thermo.brunt_vaisala_frequency(
771
+ self.source.air_pressure, self.source["air_temperature"], dT_dz
772
+ ),
773
+ )
774
+
775
+ # Compute azimuth
776
+ # Use forward and backward differences for first and last waypoints
777
+ # and centered differences elsewhere
778
+ ileft = [0, *range(self.source.size - 1)]
779
+ iright = [*range(1, self.source.size), self.source.size - 1]
780
+ lon0 = self.source["longitude"][ileft]
781
+ lat0 = self.source["latitude"][ileft]
782
+ lon1 = self.source["longitude"][iright]
783
+ lat1 = self.source["latitude"][iright]
784
+ self.source.setdefault("azimuth", geo.azimuth(lon0, lat0, lon1, lat1))
785
+
786
+ # Compute normal shear
787
+ self.source.setdefault(
788
+ "normal_shear",
789
+ utils.normal_wind_shear(
790
+ self.source["eastward_wind"],
791
+ self.source["eastward_wind_lower"],
792
+ self.source["northward_wind"],
793
+ self.source["northward_wind_lower"],
794
+ self.source["azimuth"],
795
+ self.params["dz_m"],
796
+ ),
797
+ )
798
+
799
+ def _attach_aircraft_performance(self) -> None:
800
+ """Attach aircraft performance and emissions parameters.
801
+
802
+ Mutates :attr:`source evaluating the aircraft performance model provided by
803
+ the ``aircraft_performance`` model parameter and a :class:`Emissions` models. In addition:
804
+ - MetDatasetutates :attr:`source` by adding the following keys if not already present:
805
+ - ``soot_radius``
806
+ - Mutates :attr:`source.attrs` by adding the following keys if not already present:
807
+ - ``so2_ei``
808
+ """
809
+
810
+ ap_model = self.params["aircraft_performance"]
811
+ emissions = Emissions()
812
+ humidity_scaling = self.params["humidity_scaling"]
813
+ scale_humidity = humidity_scaling is not None and "specific_humidity" not in self.source
814
+
815
+ # Ensure required met data is present.
816
+ # No buffers needed for interpolation!
817
+ vars = ap_model.met_variables + ap_model.optional_met_variables + emissions.met_variables
818
+ met = self.source.downselect_met(self.met, copy=False)
819
+ met.ensure_vars(vars)
820
+ met.standardize_variables(vars)
821
+ for var in vars:
822
+ models.interpolate_met(met, self.source, var.standard_name, **self.interp_kwargs)
823
+
824
+ # Apply humidity scaling
825
+ if scale_humidity:
826
+ humidity_scaling.eval(self.source, copy_source=False)
827
+
828
+ # Ensure flight has aircraft type, fuel, and engine UID if defined
829
+ self.source.attrs.setdefault("aircraft_type", self.params["aircraft_type"])
830
+ self.source.attrs.setdefault("fuel", self.params["fuel"])
831
+ if self.params["engine_uid"]:
832
+ self.source.attrs.setdefault("engine_uid", self.params["engine_uid"])
833
+
834
+ # Run performance and emissions calculations
835
+ ap_model.eval(self.source, copy_source=False)
836
+ emissions.eval(self.source, copy_source=False)
837
+
838
+ # Attach additional required quantities
839
+ soot_radius = utils.soot_radius(self.source["nvpm_ei_m"], self.source["nvpm_ei_n"])
840
+ self.source.setdefault("soot_radius", soot_radius)
841
+ self.source.attrs.setdefault("so2_ei", self.source.attrs["fuel"].ei_so2)
842
+
843
+ def _gen_apcemm_yaml(self, waypoint: int) -> None:
844
+ """Generate APCEMM yaml file.
845
+
846
+ Parameters
847
+ ----------
848
+ waypoint : int
849
+ Waypoint for which to generate the yaml file.
850
+ """
851
+
852
+ # Collect parameters determined by this interface
853
+ dyn_params = _combine_prioritized(
854
+ self.dynamic_yaml_params,
855
+ [
856
+ self.source.dataframe.loc[waypoint], # flight waypoint
857
+ self.source.attrs, # flight attributes
858
+ self.params, # class parameters
859
+ ],
860
+ )
861
+
862
+ # Combine with other overridden parameters
863
+ params = self.apcemm_input_params | dyn_params
864
+
865
+ # We should be setting these parameters based on aircraft data,
866
+ # but we don't currently have an easy way to do this.
867
+ # For now, stubbing in static values.
868
+ params = params | {"core_exit_temp": 550.0, "core_exit_area": 1.0}
869
+
870
+ # Generate and write YAML file
871
+ yaml = APCEMMInput(**params)
872
+ yaml_contents = utils.generate_apcemm_input_yaml(yaml)
873
+ path = self.apcemm_file(waypoint, "input.yaml")
874
+ with open(path, "w") as f:
875
+ f.write(yaml_contents)
876
+
877
+ def _gen_apcemm_nc(self, waypoint: int) -> None:
878
+ """Generate APCEMM meteorology netCDF file.
879
+
880
+ Parameters
881
+ ----------
882
+ waypoint : int
883
+ Waypoint for which to generate the meteorology file.
884
+ """
885
+ # Extract trajectories of advected contrails, include initial position
886
+ columns = ["longitude", "latitude", "time", "azimuth"]
887
+ if self.trajectories is None:
888
+ msg = (
889
+ "APCEMM meteorology input generation requires precomputed trajectories. "
890
+ "To compute trajectories, call `compute_lagrangian_trajectories`."
891
+ )
892
+ raise ValueError(msg)
893
+ tail = self.trajectories.dataframe
894
+ tail = tail[tail["waypoint"] == waypoint][columns]
895
+ head = self.source.dataframe
896
+ head = head[head["waypoint"] == waypoint][columns]
897
+ traj = pd.concat((head, tail), axis="index").reset_index()
898
+
899
+ # APCEMM requires atmospheric profiles at even time intervals,
900
+ # but the time coordinates of the initial position plus subsequent
901
+ # trajectory may not be evenly spaced. To fix this, we interpolate
902
+ # horizontal location and azimuth to an evenly-spaced set of time
903
+ # coordinates.
904
+ time = traj["time"].values
905
+ n_profiles = int(self.params["max_age"] / self.params["dt_input_met"]) + 1
906
+ tick = np.timedelta64(1, "s")
907
+ target_elapsed = np.linspace(
908
+ 0, (n_profiles - 1) * self.params["dt_input_met"] / tick, n_profiles
909
+ )
910
+ target_time = time[0] + target_elapsed * tick
911
+ elapsed = (traj["time"] - traj["time"][0]) / tick
912
+
913
+ # Need to deal with antimeridian crossing.
914
+ # Detecting antimeridian crossing follows Flight.resample_and_fill,
915
+ # but rather than applying a variable shift we just convert longitudes to
916
+ # [0, 360) before interpolating flights that cross the antimeridian,
917
+ # and un-convert any longitudes above 180 degree after interpolation.
918
+ lon = traj["longitude"].values
919
+ min_pos = np.min(lon[lon > 0], initial=np.inf)
920
+ max_neg = np.max(lon[lon < 0], initial=-np.inf)
921
+ if (180 - min_pos) + (180 + max_neg) < 180 and min_pos < np.inf and max_neg > -np.inf:
922
+ lon = np.where(lon < 0, lon + 360, lon)
923
+ interp_lon = np.interp(target_elapsed, elapsed, lon)
924
+ interp_lon = np.where(interp_lon > 180, interp_lon - 360, interp_lon)
925
+
926
+ interp_lat = np.interp(target_elapsed, elapsed, traj["latitude"].values)
927
+ interp_az = np.interp(target_elapsed, elapsed, traj["azimuth"].values)
928
+
929
+ if self.params["altitude_input_met"] is None:
930
+ altitude = self.met["altitude"].values
931
+ else:
932
+ altitude = np.array(self.params["altitude_input_met"])
933
+
934
+ ds = utils.generate_apcemm_input_met(
935
+ time=target_time,
936
+ longitude=interp_lon,
937
+ latitude=interp_lat,
938
+ azimuth=interp_az,
939
+ altitude=altitude,
940
+ met=self.met,
941
+ humidity_scaling=self.params["humidity_scaling"],
942
+ dz_m=self.params["dz_m"],
943
+ interp_kwargs=self.interp_kwargs,
944
+ )
945
+
946
+ path = self.apcemm_file(waypoint, "input.nc")
947
+ ds.to_netcdf(path)
948
+
949
+
950
+ def _combine_prioritized(keys: set[str], sources: list[dict[str, Any]]) -> dict[str, Any]:
951
+ """Combine dictionary keys from prioritized list of source dictionaries.
952
+
953
+ Parameters
954
+ ----------
955
+ keys : set[str]
956
+ Set of keys to attempt to extract from source dictionary.
957
+ sources : list[dict[str, Any]]
958
+ List of dictionaries from which to attempt to extract key-value pairs.
959
+ If the key is in the first dictionary, it will be set in the returned dictionary
960
+ with the corresponding value. Otherwise, the method will fall on the remaining
961
+ dictionaries in the order provided.
962
+
963
+ Returns
964
+ -------
965
+ dict[str, Any]
966
+ Dictionary containing key-value pairs from :param:`sources`.
967
+
968
+ Raises
969
+ ------
970
+ ValueError
971
+ Any key is not found in any dictionary in ``sources``.
972
+ """
973
+ dest = {}
974
+ for key in keys:
975
+ for source in sources:
976
+ if key in source:
977
+ dest[key] = source[key]
978
+ break
979
+ else:
980
+ msg = f"Key {key} not found in any source dictionary."
981
+ raise ValueError(msg)
982
+ return dest