pycontrails 0.58.0__cp314-cp314-macosx_10_13_x86_64.whl

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

Potentially problematic release.


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

Files changed (122) hide show
  1. pycontrails/__init__.py +70 -0
  2. pycontrails/_version.py +34 -0
  3. pycontrails/core/__init__.py +30 -0
  4. pycontrails/core/aircraft_performance.py +679 -0
  5. pycontrails/core/airports.py +228 -0
  6. pycontrails/core/cache.py +889 -0
  7. pycontrails/core/coordinates.py +174 -0
  8. pycontrails/core/fleet.py +483 -0
  9. pycontrails/core/flight.py +2185 -0
  10. pycontrails/core/flightplan.py +228 -0
  11. pycontrails/core/fuel.py +140 -0
  12. pycontrails/core/interpolation.py +702 -0
  13. pycontrails/core/met.py +2931 -0
  14. pycontrails/core/met_var.py +387 -0
  15. pycontrails/core/models.py +1321 -0
  16. pycontrails/core/polygon.py +549 -0
  17. pycontrails/core/rgi_cython.cpython-314-darwin.so +0 -0
  18. pycontrails/core/vector.py +2249 -0
  19. pycontrails/datalib/__init__.py +12 -0
  20. pycontrails/datalib/_met_utils/metsource.py +746 -0
  21. pycontrails/datalib/ecmwf/__init__.py +73 -0
  22. pycontrails/datalib/ecmwf/arco_era5.py +345 -0
  23. pycontrails/datalib/ecmwf/common.py +114 -0
  24. pycontrails/datalib/ecmwf/era5.py +554 -0
  25. pycontrails/datalib/ecmwf/era5_model_level.py +490 -0
  26. pycontrails/datalib/ecmwf/hres.py +804 -0
  27. pycontrails/datalib/ecmwf/hres_model_level.py +466 -0
  28. pycontrails/datalib/ecmwf/ifs.py +287 -0
  29. pycontrails/datalib/ecmwf/model_levels.py +435 -0
  30. pycontrails/datalib/ecmwf/static/model_level_dataframe_v20240418.csv +139 -0
  31. pycontrails/datalib/ecmwf/variables.py +268 -0
  32. pycontrails/datalib/geo_utils.py +261 -0
  33. pycontrails/datalib/gfs/__init__.py +28 -0
  34. pycontrails/datalib/gfs/gfs.py +656 -0
  35. pycontrails/datalib/gfs/variables.py +104 -0
  36. pycontrails/datalib/goes.py +757 -0
  37. pycontrails/datalib/himawari/__init__.py +27 -0
  38. pycontrails/datalib/himawari/header_struct.py +266 -0
  39. pycontrails/datalib/himawari/himawari.py +667 -0
  40. pycontrails/datalib/landsat.py +589 -0
  41. pycontrails/datalib/leo_utils/__init__.py +5 -0
  42. pycontrails/datalib/leo_utils/correction.py +266 -0
  43. pycontrails/datalib/leo_utils/landsat_metadata.py +300 -0
  44. pycontrails/datalib/leo_utils/search.py +250 -0
  45. pycontrails/datalib/leo_utils/sentinel_metadata.py +748 -0
  46. pycontrails/datalib/leo_utils/static/bq_roi_query.sql +6 -0
  47. pycontrails/datalib/leo_utils/vis.py +59 -0
  48. pycontrails/datalib/sentinel.py +650 -0
  49. pycontrails/datalib/spire/__init__.py +5 -0
  50. pycontrails/datalib/spire/exceptions.py +62 -0
  51. pycontrails/datalib/spire/spire.py +604 -0
  52. pycontrails/ext/bada.py +42 -0
  53. pycontrails/ext/cirium.py +14 -0
  54. pycontrails/ext/empirical_grid.py +140 -0
  55. pycontrails/ext/synthetic_flight.py +431 -0
  56. pycontrails/models/__init__.py +1 -0
  57. pycontrails/models/accf.py +425 -0
  58. pycontrails/models/apcemm/__init__.py +8 -0
  59. pycontrails/models/apcemm/apcemm.py +983 -0
  60. pycontrails/models/apcemm/inputs.py +226 -0
  61. pycontrails/models/apcemm/static/apcemm_yaml_template.yaml +183 -0
  62. pycontrails/models/apcemm/utils.py +437 -0
  63. pycontrails/models/cocip/__init__.py +29 -0
  64. pycontrails/models/cocip/cocip.py +2742 -0
  65. pycontrails/models/cocip/cocip_params.py +305 -0
  66. pycontrails/models/cocip/cocip_uncertainty.py +291 -0
  67. pycontrails/models/cocip/contrail_properties.py +1530 -0
  68. pycontrails/models/cocip/output_formats.py +2270 -0
  69. pycontrails/models/cocip/radiative_forcing.py +1260 -0
  70. pycontrails/models/cocip/radiative_heating.py +520 -0
  71. pycontrails/models/cocip/unterstrasser_wake_vortex.py +508 -0
  72. pycontrails/models/cocip/wake_vortex.py +396 -0
  73. pycontrails/models/cocip/wind_shear.py +120 -0
  74. pycontrails/models/cocipgrid/__init__.py +9 -0
  75. pycontrails/models/cocipgrid/cocip_grid.py +2552 -0
  76. pycontrails/models/cocipgrid/cocip_grid_params.py +138 -0
  77. pycontrails/models/dry_advection.py +602 -0
  78. pycontrails/models/emissions/__init__.py +21 -0
  79. pycontrails/models/emissions/black_carbon.py +599 -0
  80. pycontrails/models/emissions/emissions.py +1353 -0
  81. pycontrails/models/emissions/ffm2.py +336 -0
  82. pycontrails/models/emissions/static/default-engine-uids.csv +239 -0
  83. pycontrails/models/emissions/static/edb-gaseous-v29b-engines.csv +596 -0
  84. pycontrails/models/emissions/static/edb-nvpm-v29b-engines.csv +215 -0
  85. pycontrails/models/extended_k15.py +1327 -0
  86. pycontrails/models/humidity_scaling/__init__.py +37 -0
  87. pycontrails/models/humidity_scaling/humidity_scaling.py +1075 -0
  88. pycontrails/models/humidity_scaling/quantiles/era5-model-level-quantiles.pq +0 -0
  89. pycontrails/models/humidity_scaling/quantiles/era5-pressure-level-quantiles.pq +0 -0
  90. pycontrails/models/issr.py +210 -0
  91. pycontrails/models/pcc.py +326 -0
  92. pycontrails/models/pcr.py +154 -0
  93. pycontrails/models/ps_model/__init__.py +18 -0
  94. pycontrails/models/ps_model/ps_aircraft_params.py +381 -0
  95. pycontrails/models/ps_model/ps_grid.py +701 -0
  96. pycontrails/models/ps_model/ps_model.py +1000 -0
  97. pycontrails/models/ps_model/ps_operational_limits.py +525 -0
  98. pycontrails/models/ps_model/static/ps-aircraft-params-20250328.csv +69 -0
  99. pycontrails/models/ps_model/static/ps-synonym-list-20250328.csv +104 -0
  100. pycontrails/models/sac.py +442 -0
  101. pycontrails/models/tau_cirrus.py +183 -0
  102. pycontrails/physics/__init__.py +1 -0
  103. pycontrails/physics/constants.py +117 -0
  104. pycontrails/physics/geo.py +1138 -0
  105. pycontrails/physics/jet.py +968 -0
  106. pycontrails/physics/static/iata-cargo-load-factors-20250221.csv +74 -0
  107. pycontrails/physics/static/iata-passenger-load-factors-20250221.csv +74 -0
  108. pycontrails/physics/thermo.py +551 -0
  109. pycontrails/physics/units.py +472 -0
  110. pycontrails/py.typed +0 -0
  111. pycontrails/utils/__init__.py +1 -0
  112. pycontrails/utils/dependencies.py +66 -0
  113. pycontrails/utils/iteration.py +13 -0
  114. pycontrails/utils/json.py +187 -0
  115. pycontrails/utils/temp.py +50 -0
  116. pycontrails/utils/types.py +163 -0
  117. pycontrails-0.58.0.dist-info/METADATA +180 -0
  118. pycontrails-0.58.0.dist-info/RECORD +122 -0
  119. pycontrails-0.58.0.dist-info/WHEEL +6 -0
  120. pycontrails-0.58.0.dist-info/licenses/LICENSE +178 -0
  121. pycontrails-0.58.0.dist-info/licenses/NOTICE +43 -0
  122. pycontrails-0.58.0.dist-info/top_level.txt +3 -0
@@ -0,0 +1,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.core.vector import GeoVectorDataset
32
+ from pycontrails.models.apcemm import utils
33
+ from pycontrails.models.apcemm.inputs import APCEMMInput
34
+ from pycontrails.models.dry_advection import DryAdvection
35
+ from pycontrails.models.emissions import Emissions
36
+ from pycontrails.models.humidity_scaling import HumidityScaling
37
+ from pycontrails.models.ps_model import PSFlight
38
+ from pycontrails.physics import constants, geo, thermo
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ #: Minimum altitude
43
+
44
+
45
+ @dataclasses.dataclass
46
+ class APCEMMParams(models.ModelParams):
47
+ """Default parameters for the pycontrails :class:`APCEMM` interface."""
48
+
49
+ #: Maximum contrail age
50
+ max_age: np.timedelta64 = np.timedelta64(20, "h")
51
+
52
+ #: Longitude buffer for Lagrangian trajectory calculation [WGS84]
53
+ met_longitude_buffer: tuple[float, float] = (10.0, 10.0)
54
+
55
+ #: Latitude buffer for Lagrangian trajectory calculation [WGS84]
56
+ met_latitude_buffer: tuple[float, float] = (10.0, 10.0)
57
+
58
+ #: Level buffer for Lagrangian trajectory calculation [:math:`hPa`]
59
+ met_level_buffer: tuple[float, float] = (40.0, 40.0)
60
+
61
+ #: Timestep for Lagrangian trajectory calculation
62
+ dt_lagrangian: np.timedelta64 = np.timedelta64(30, "m")
63
+
64
+ #: Sedimentation rate for Lagrangian trajectories [:math:`Pa/s`]
65
+ lagrangian_sedimentation_rate: float = 0.0
66
+
67
+ #: Time step of meteorology in generated APCEMM input file.
68
+ dt_input_met: np.timedelta64 = np.timedelta64(1, "h")
69
+
70
+ #: Altitude coordinates [:math:`m`] for meteorology in generated APCEMM input file.
71
+ #: If not provided, uses estimated altitudes for levels in input :class:`Metdataset`.
72
+ altitude_input_met: list[float] | None = None
73
+
74
+ #: Humidity scaling
75
+ humidity_scaling: HumidityScaling | None = None
76
+
77
+ #: Altitude difference for vertical derivative calculations [:math:`m`]
78
+ dz_m: float = 200.0
79
+
80
+ #: ICAO aircraft identifier
81
+ aircraft_type: str = "B738"
82
+
83
+ #: Engine UID. If not provided, uses the default engine UID
84
+ #: for the :attr:`aircraft_type`.
85
+ engine_uid: str | None = None
86
+
87
+ #: Aircraft performance model
88
+ aircraft_performance: AircraftPerformance = dataclasses.field(default_factory=PSFlight)
89
+
90
+ #: Fuel type
91
+ fuel: Fuel = dataclasses.field(default_factory=JetA)
92
+
93
+ #: List of flight waypoints to simulate in APCEMM.
94
+ #: By default, runs a simulation for every waypoint.
95
+ waypoints: list[int] | None = None
96
+
97
+ #: If defined, use to override ``input_background_conditions`` and
98
+ #: ``input_engine_emissions`` in :class:`APCEMMInput` assuming that
99
+ #: ``apcemm_root`` points to the root of the APCEMM git repository.
100
+ apcemm_root: pathlib.Path | str | None = None
101
+
102
+ #: If True, delete existing run directories before running APCEMM simulations.
103
+ #: If False (default), raise an exception if a run directory already exists.
104
+ overwrite: bool = False
105
+
106
+ #: Name of output directory within run directory
107
+ output_directory: str = "out"
108
+
109
+ #: Number of threads to use within individual APCEMM simulations.
110
+ apcemm_threads: int = 1
111
+
112
+ #: Number of individual APCEMM simulations to run in parallel.
113
+ n_jobs: int = 1
114
+
115
+
116
+ class APCEMM(models.Model):
117
+ """Run APCEMM as a pycontrails :class:`Model`.
118
+
119
+ This class acts as an adapter between the pycontrails :class:`Model` interface
120
+ (shared with other contrail models) and APCEMM's native interface.
121
+
122
+ `APCEMM <https://github.com/MIT-LAE/APCEMM>`__ was developed at the
123
+ `MIT Laboratory for Aviation and the Environment <https://lae.mit.edu/>`__
124
+ and is described in :cite:`fritzRolePlumescaleProcesses2020`.
125
+
126
+ Parameters
127
+ ----------
128
+ met : MetDataset
129
+ Pressure level dataset containing :attr:`met_variables` variables.
130
+ See *Notes* for variable names by data source.
131
+ apcemm_path : pathlib.Path | str
132
+ Path to APCEMM executable. See *Notes* for information about
133
+ acquiring and compiling APCEMM.
134
+ apcemm_root : pathlib.Path | str, optional
135
+ Path to APCEMM root directory, used to set ``input_background_conditions`` and
136
+ ``input_engine_emissions`` based on the structure of the
137
+ `APCEMM GitHub repository <https://github.com/MIT-LAE/APCEMM>`__.
138
+ If not provided, pycontrails will use the default paths defined in :class:`APCEMMInput`.
139
+ apcemm_input_params : APCEMMInput, optional
140
+ Value for APCEMM input parameters defined in :class:`APCEMMInput`. If provided, values
141
+ for ``input_background_condition`` or ``input_engine_emissions`` will override values
142
+ set based on ``apcemm_root``. Attempting to provide values for input parameters
143
+ that are determined automatically by this interface will result in an error.
144
+ See *Notes* for detailed information about YAML file generation.
145
+ cachestore : CacheStore, optional
146
+ :class:`CacheStore` used to store APCEMM run directories.
147
+ If not provided, uses a :class:`DiskCacheStore`.
148
+ See *Notes* for detailed information about the file structure for APCEMM
149
+ simulations.
150
+ params : dict[str,Any], optional
151
+ Override APCEMM model parameters with dictionary.
152
+ See :class:`APCEMMParams` for model parameters.
153
+ **params_kwargs : Any
154
+ Override Cocip model parameters with keyword arguments.
155
+ See :class:`APCEMMParams` for model parameters.
156
+
157
+ Notes
158
+ -----
159
+ **Meteorology**
160
+
161
+ APCEMM requires temperature, humidity, gepotential height, and winds.
162
+ Geopotential height is required because APCEMM expects meteorological fields
163
+ on height rather than pressure surfaces. See :attr:`met_variables` for the
164
+ list of required variables.
165
+
166
+ .. list-table:: Variable keys for pressure level data
167
+ :header-rows: 1
168
+
169
+ * - Parameter
170
+ - ECMWF
171
+ - GFS
172
+ * - Air Temperature
173
+ - ``air_temperature``
174
+ - ``air_temperature``
175
+ * - Specific Humidity
176
+ - ``specific_humidity``
177
+ - ``specific_humidity``
178
+ * - Geopotential/Geopotential Height
179
+ - ``geopotential``
180
+ - ``geopotential_height``
181
+ * - Eastward wind
182
+ - ``eastward_wind``
183
+ - ``eastward_wind``
184
+ * - Northward wind
185
+ - ``northward_wind``
186
+ - ``northward_wind``
187
+ * - Vertical velocity
188
+ - ``lagrangian_tendency_of_air_pressure``
189
+ - ``lagrangian_tendency_of_air_pressure``
190
+
191
+ **Acquiring and compiling APCEMM**
192
+
193
+ Users are responsible for acquiring and compiling APCEMM. The APCEMM source code is
194
+ available through `GitHub <https://github.com/MIT-LAE/APCEMM>`__, and instructions
195
+ for compiling APCEMM are available in the repository.
196
+
197
+ Note that APCEMM does not provide versioned releases, and future updates may break
198
+ this interface. To guarantee compatibility between this interface and APCEMM,
199
+ users should use commit
200
+ `9d8e1ee <https://github.com/MIT-LAE/APCEMM/commit/9d8e1eeaa61cbdee1b1d03c65b5b033ded9159e4>`__
201
+ from the APCEMM repository.
202
+
203
+ **Configuring APCEMM YAML files**
204
+
205
+ :class:`APCEMMInput` provides low-level control over the contents of YAML files used
206
+ as APCEMM input. YAML file contents can be controlled by passing custom parameters
207
+ in a dictionary through the ``apcemm_input_params`` parameter. Note, however, that
208
+ :class:`APCEMM` sets a number of APCEMM input parameters automatically, and attempting
209
+ to override any automatically-determined parameters using ``apcemm_input_params``
210
+ will result in an error. A list of automatically-determined parameters is available in
211
+ :attr:`dynamic_yaml_params`.
212
+
213
+ **Simulation initialization, execution, and postprocessing**
214
+
215
+ This interface initializes, runs, and postprocesses APCEMM simulations in four stages:
216
+
217
+ 1. A :class:`DryAdvection` model is used to generate trajectories for contrails
218
+ initialized at each flight waypoint. This is a necessary preprocessing step because
219
+ APCEMM is a Lagrangian model and does not explicitly track changes in plume
220
+ location over time. This step also provides time-dependent azimuths that define the
221
+ orientation of advected contrails, which is required to compute contrail-normal
222
+ wind shear from horizontal winds.
223
+ Results from the trajectory calculation are stored in :attr:`trajectories`.
224
+ 2. Model parameters and results from the trajectory calculation are used to generate
225
+ YAML files with APCEMM input parameters and netCDF files with meteorology data
226
+ used by APCEMM simulations. A separate pair of files is generated for each
227
+ waypoint processed by APCEMM. Files are saved as ``apcemm_waypoint_<i>/input.yaml``
228
+ and ``apcemm_waypoint_<i>/input.nc`` in the model :attr:`cachestore`,
229
+ where ``<i>`` is replaced by the index of each simulated flight waypoint.
230
+ 3. A separate APCEMM simulation is run in each run directory inside the model
231
+ :attr:`cachestore`. Simulations are independent and can be run in parallel
232
+ (controlled by the ``n_jobs`` parameter in :class:`APCEMMParams`). Standard output
233
+ and error streams from each simulation are saved in ``apcemm_waypoint_<i>/stdout.log``
234
+ and ``apcemm_waypoint_<i>/stderr.log``, and APCEMM output is saved
235
+ in a subdirectory specified by the ``output_directory`` model parameter ("out" by default).
236
+ 4. APCEMM simulation output is postprocessed. After postprocessing:
237
+
238
+ - A ``status`` column is attached to the ``Flight`` returned by :meth:`eval`.
239
+ This column contains ``"NoSimulation"`` for waypoints where no simulation
240
+ was run and the contents of the APCEMM ``status_case0`` output file for
241
+ other waypoints.
242
+ - A :class:`pd.DataFrame` is created and stored in :attr:`vortex`. This dataframe
243
+ contains time series output from the APCEMM "early plume model" of the aircraft
244
+ exhaust plume and downwash vortex, read from ``Micro_000000.out`` output files
245
+ saved by APCEMM.
246
+ - If APCEMM simulated at least one persistent contrail, A :class:`pd.DataFrame` is
247
+ created and stored in :attr:`contrail`. This dataframe contains paths to netCDF
248
+ files, saved at prescribed time intervals during the APCEMM simulation, and can be
249
+ used to open APCEMM output (e.g., using :func:`xr.open_dataset`) for further analysis.
250
+
251
+ **Numerics**
252
+
253
+ APCEMM simulations are initialized at flight waypoints and represent the evolution of the
254
+ cross-section of contrails formed at each waypoint. APCEMM does not explicitly model the length
255
+ of contrail segments and does not include any representation of deformation by divergent flow.
256
+ APCEMM output represents properties of cross-sections of contrails formed at flight waypoints,
257
+ not properties of contrail segments that form between flight waypoints. Unlike :class:`Cocip`,
258
+ output produced by this interface does not include trailing NaN values.
259
+
260
+ **Known limitations**
261
+
262
+ - Engine core exit temperature and bypass area are not provided as output by pycontrails
263
+ aircraft performance models and are currently set to static values in APCEMM input files.
264
+ These parameters will be computed dynamically in a future release.
265
+ - APCEMM does not compute contrail radiative forcing internally. Radiative forcing must be
266
+ computed offline by the user. Tools for radiative forcing calculations may be included
267
+ in a future version of the interface.
268
+ - APCEMM currently produces different results in simulations that do not read vertical
269
+ velocity data from netCDF input files and in simulations that read vertical velocities
270
+ that are set to 0 everywhere (see https://github.com/MIT-LAE/APCEMM/issues/17).
271
+ Reading of vertical velocity data from netCDF input files will be disabled in this
272
+ interface until this issue is resolved.
273
+
274
+ References
275
+ ----------
276
+ - :cite:`fritzRolePlumescaleProcesses2020`
277
+ """
278
+
279
+ __slots__ = (
280
+ "_trajectory_downsampling",
281
+ "apcemm_input_params",
282
+ "apcemm_path",
283
+ "cachestore",
284
+ "contrail",
285
+ "trajectories",
286
+ "vortex",
287
+ )
288
+
289
+ name = "apcemm"
290
+ long_name = "Interface to APCEMM plume model"
291
+ met_variables = (
292
+ AirTemperature,
293
+ SpecificHumidity,
294
+ (Geopotential, GeopotentialHeight),
295
+ EastwardWind,
296
+ NorthwardWind,
297
+ VerticalVelocity,
298
+ )
299
+ default_params = APCEMMParams
300
+
301
+ #: Met data is not optional
302
+ met: MetDataset
303
+ met_required = True
304
+
305
+ #: Path to APCEMM executable
306
+ apcemm_path: pathlib.Path
307
+
308
+ #: Overridden APCEMM input parameters
309
+ apcemm_input_params: dict[str, Any]
310
+
311
+ #: CacheStore for APCEMM run directories
312
+ cachestore: cache.CacheStore
313
+
314
+ #: Last flight processed in :meth:`eval`
315
+ source: Flight
316
+
317
+ #: Output from trajectory calculation
318
+ trajectories: GeoVectorDataset | None
319
+
320
+ #: Time series output from the APCEMM early plume model
321
+ vortex: pd.DataFrame | None
322
+
323
+ #: Paths to APCEMM netCDF output at prescribed time intervals
324
+ contrail: pd.DataFrame | None
325
+
326
+ #: Downsampling factor from trajectory time resolution to
327
+ #: APCEMM met input file resolution
328
+ _trajectory_downsampling: int
329
+
330
+ def __init__(
331
+ self,
332
+ met: MetDataset,
333
+ apcemm_path: pathlib.Path | str,
334
+ apcemm_root: pathlib.Path | str | None = None,
335
+ apcemm_input_params: dict[str, Any] | None = None,
336
+ cachestore: cache.CacheStore | None = None,
337
+ params: dict[str, Any] | None = None,
338
+ **params_kwargs: Any,
339
+ ) -> None:
340
+ super().__init__(met, params=params, **params_kwargs)
341
+ self._ensure_geopotential_height()
342
+
343
+ if isinstance(apcemm_path, str):
344
+ apcemm_path = pathlib.Path(apcemm_path)
345
+ self.apcemm_path = apcemm_path
346
+
347
+ if cachestore is None:
348
+ cache_root = cache._get_user_cache_dir()
349
+ cache_dir = f"{cache_root}/apcemm"
350
+ cachestore = cache.DiskCacheStore(cache_dir=cache_dir)
351
+ self.cachestore = cachestore
352
+
353
+ # Validate overridden input parameters
354
+ apcemm_input_params = apcemm_input_params or {}
355
+ if apcemm_root is not None:
356
+ apcemm_input_params = {
357
+ "input_background_conditions": os.path.join(apcemm_root, "input_data", "init.txt"),
358
+ "input_engine_emissions": os.path.join(apcemm_root, "input_data", "ENG_EI.txt"),
359
+ } | apcemm_input_params
360
+ cannot_override = set(apcemm_input_params.keys()).intersection(self.dynamic_yaml_params)
361
+ if len(cannot_override) > 0:
362
+ msg = (
363
+ f"Cannot override APCEMM input parameters {cannot_override}, "
364
+ "as these parameters are set automatically by the APCEMM interface."
365
+ )
366
+ raise ValueError(msg)
367
+ self.apcemm_input_params = apcemm_input_params
368
+
369
+ @overload
370
+ def eval(self, source: Flight, **params: Any) -> Flight: ...
371
+
372
+ @overload
373
+ def eval(self, source: None = ..., **params: Any) -> NoReturn: ...
374
+
375
+ def eval(self, source: Flight | None = None, **params: Any) -> Flight:
376
+ """Set up and run APCEMM simulations initialized at flight waypoints.
377
+
378
+ Simulates the formation and evolution of contrails from a Flight
379
+ using the APCEMM plume model described in Fritz et. al. (2020)
380
+ :cite:`fritzRolePlumescaleProcesses2020`.
381
+
382
+ Parameters
383
+ ----------
384
+ source : Flight | None
385
+ Input Flight to model.
386
+ **params : Any
387
+ Overwrite model parameters before eval.
388
+
389
+ Returns
390
+ -------
391
+ Flight | NoReturn
392
+ Flight with exit status of APCEMM simulations. Detailed APCEMM outputs are attached
393
+ to model :attr:`vortex` and :attr:`contrail` attributes (see :class:`APCEMM` notes
394
+ for details).
395
+
396
+ References
397
+ ----------
398
+ - :cite:`fritzRolePlumescaleProcesses2020`
399
+ """
400
+
401
+ self.update_params(params)
402
+ self.set_source(source)
403
+ self.source = self.require_source_type(Flight)
404
+
405
+ # Assign waypoints to flight if not already present
406
+ if "waypoint" not in self.source:
407
+ self.source["waypoint"] = np.arange(self.source.size)
408
+
409
+ logger.info("Attaching APCEMM initial conditions to source")
410
+ self.attach_apcemm_initial_conditions()
411
+
412
+ logger.info("Computing Lagrangian trajectories")
413
+ self.compute_lagrangian_trajectories()
414
+
415
+ # Select waypoints to run in APCEMM
416
+ # Defaults to all waypoints, but allows user to select a subset
417
+ waypoints = self.params["waypoints"]
418
+ if waypoints is None:
419
+ waypoints = list(self.source["waypoints"])
420
+
421
+ # Generate input files (serial)
422
+ logger.info("Generating APCEMM input files") # serial
423
+ for waypoint in waypoints:
424
+ rundir = self.apcemm_file(waypoint)
425
+ if self.cachestore.exists(rundir) and not self.params["overwrite"]:
426
+ msg = f"APCEMM run directory already exists at {rundir}"
427
+ raise ValueError(msg)
428
+ if self.cachestore.exists(rundir) and self.params["overwrite"]:
429
+ shutil.rmtree(rundir)
430
+ self.generate_apcemm_input(waypoint)
431
+
432
+ # Run APCEMM (parallelizable)
433
+ logger.info("Running APCEMM")
434
+ self.run_apcemm(waypoints)
435
+
436
+ # Process output (serial)
437
+ logger.info("Postprocessing APCEMM output")
438
+ self.process_apcemm_output()
439
+
440
+ return self.source
441
+
442
+ def attach_apcemm_initial_conditions(self) -> None:
443
+ """Compute fields required for APCEMM initial conditions and attach to :attr:`source`.
444
+
445
+ This modifies :attr:`source` by attaching quantities derived from meterology
446
+ data and aircraft performance calculations.
447
+ """
448
+
449
+ self._attach_apcemm_time()
450
+ self._attach_initial_met()
451
+ self._attach_aircraft_performance()
452
+
453
+ def compute_lagrangian_trajectories(self) -> None:
454
+ """Calculate Lagrangian trajectories using a :class:`DryAdvection` model.
455
+
456
+ Lagrangian trajectories provide the expected time-dependent location
457
+ (longitude, latitude, and altitude) and orientation (azimuth) of
458
+ contrails formed by the input source. This information is used to
459
+ extract time series of meteorological profiles at the contrail location
460
+ from input meteorology data, and to compute contrail-normal horizontal shear
461
+ from horizontal winds.
462
+
463
+ The length of Lagrangian trajectories is set by the ``max_age`` parameter,
464
+ and trajectories are integrated using a time step set by the ``dt_lagrangian``
465
+ parameter. Contrails are advected both horizontally and vertically, and a
466
+ fixed sedimentation velocity (set by the ``sedimentation_rate`` parameter)
467
+ can be included to represent contrail sedimentation.
468
+
469
+ Results of the trajectory calculation are attached to :attr:`trajectories`.
470
+ """
471
+
472
+ buffers = {
473
+ f"{coord}_buffer": self.params[f"met_{coord}_buffer"]
474
+ for coord in ("longitude", "latitude", "level")
475
+ }
476
+ buffers["time_buffer"] = (0, self.params["max_age"] + self.params["dt_lagrangian"])
477
+ met = self.source.downselect_met(self.met, **buffers)
478
+ model = DryAdvection(
479
+ met=met,
480
+ dt_integration=self.params["dt_lagrangian"],
481
+ max_age=self.params["max_age"],
482
+ sedimentation_rate=self.params["lagrangian_sedimentation_rate"],
483
+ )
484
+ self.trajectories = model.eval(self.source)
485
+
486
+ def generate_apcemm_input(self, waypoint: int) -> None:
487
+ """Generate APCEMM yaml and netCDF input files for a single waypoint.
488
+
489
+ For details about generated input files, see :class:`APCEMM` notes.
490
+
491
+ Parameters
492
+ ----------
493
+ waypoint : int
494
+ Waypoint for which to generate input files.
495
+ """
496
+
497
+ self._gen_apcemm_yaml(waypoint)
498
+ self._gen_apcemm_nc(waypoint)
499
+
500
+ def run_apcemm(self, waypoints: list[int]) -> None:
501
+ """Run APCEMM over multiple waypoints.
502
+
503
+ Multiple waypoints will be processed in parallel if the :class:`APCEMM`
504
+ ``n_jobs`` parameter is set to a value larger than 1.
505
+
506
+ Parameters
507
+ ----------
508
+ waypoints : list[int]
509
+ List of waypoints at which to initialize simulations.
510
+ """
511
+
512
+ # run in series
513
+ if self.params["n_jobs"] == 1:
514
+ for waypoint in waypoints:
515
+ utils.run(
516
+ apcemm_path=self.apcemm_path,
517
+ input_yaml=self.apcemm_file(waypoint, "input.yaml"),
518
+ rundir=self.apcemm_file(waypoint),
519
+ stdout_log=self.apcemm_file(waypoint, "stdout.log"),
520
+ stderr_log=self.apcemm_file(waypoint, "stderr.log"),
521
+ )
522
+
523
+ # run in parallel
524
+ else:
525
+ with mp.Pool(self.params["n_jobs"]) as p:
526
+ args = (
527
+ (
528
+ self.apcemm_path,
529
+ self.apcemm_file(waypoint, "input.yaml"),
530
+ self.apcemm_file(waypoint),
531
+ self.apcemm_file(waypoint, "stdout.log"),
532
+ self.apcemm_file(waypoint, "stderr.log"),
533
+ )
534
+ for waypoint in waypoints
535
+ )
536
+ p.starmap(utils.run, args)
537
+
538
+ def process_apcemm_output(self) -> None:
539
+ """Process APCEMM output.
540
+
541
+ After processing, a ``status`` column will be attached to
542
+ :attr:`source`, and additional output data will be attached
543
+ to :attr:`vortex` and :attr:`contrail`. For details about
544
+ contents of APCEMM output files, see :class:`APCEMM` notes.
545
+ """
546
+
547
+ output_directory = self.params["output_directory"]
548
+
549
+ statuses: list[str] = []
550
+ vortexes: list[pd.DataFrame] = []
551
+ contrails: list[pd.DataFrame] = []
552
+
553
+ for _, row in self.source.dataframe.iterrows():
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)
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