pycontrails 0.59.0__cp314-cp314-macosx_10_15_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 (123) 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 +2936 -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 +764 -0
  37. pycontrails/datalib/gruan.py +343 -0
  38. pycontrails/datalib/himawari/__init__.py +27 -0
  39. pycontrails/datalib/himawari/header_struct.py +266 -0
  40. pycontrails/datalib/himawari/himawari.py +671 -0
  41. pycontrails/datalib/landsat.py +589 -0
  42. pycontrails/datalib/leo_utils/__init__.py +5 -0
  43. pycontrails/datalib/leo_utils/correction.py +266 -0
  44. pycontrails/datalib/leo_utils/landsat_metadata.py +300 -0
  45. pycontrails/datalib/leo_utils/search.py +250 -0
  46. pycontrails/datalib/leo_utils/sentinel_metadata.py +748 -0
  47. pycontrails/datalib/leo_utils/static/bq_roi_query.sql +6 -0
  48. pycontrails/datalib/leo_utils/vis.py +59 -0
  49. pycontrails/datalib/sentinel.py +650 -0
  50. pycontrails/datalib/spire/__init__.py +5 -0
  51. pycontrails/datalib/spire/exceptions.py +62 -0
  52. pycontrails/datalib/spire/spire.py +604 -0
  53. pycontrails/ext/bada.py +42 -0
  54. pycontrails/ext/cirium.py +14 -0
  55. pycontrails/ext/empirical_grid.py +140 -0
  56. pycontrails/ext/synthetic_flight.py +431 -0
  57. pycontrails/models/__init__.py +1 -0
  58. pycontrails/models/accf.py +425 -0
  59. pycontrails/models/apcemm/__init__.py +8 -0
  60. pycontrails/models/apcemm/apcemm.py +983 -0
  61. pycontrails/models/apcemm/inputs.py +226 -0
  62. pycontrails/models/apcemm/static/apcemm_yaml_template.yaml +183 -0
  63. pycontrails/models/apcemm/utils.py +437 -0
  64. pycontrails/models/cocip/__init__.py +29 -0
  65. pycontrails/models/cocip/cocip.py +2742 -0
  66. pycontrails/models/cocip/cocip_params.py +305 -0
  67. pycontrails/models/cocip/cocip_uncertainty.py +291 -0
  68. pycontrails/models/cocip/contrail_properties.py +1530 -0
  69. pycontrails/models/cocip/output_formats.py +2270 -0
  70. pycontrails/models/cocip/radiative_forcing.py +1260 -0
  71. pycontrails/models/cocip/radiative_heating.py +520 -0
  72. pycontrails/models/cocip/unterstrasser_wake_vortex.py +508 -0
  73. pycontrails/models/cocip/wake_vortex.py +396 -0
  74. pycontrails/models/cocip/wind_shear.py +120 -0
  75. pycontrails/models/cocipgrid/__init__.py +9 -0
  76. pycontrails/models/cocipgrid/cocip_grid.py +2552 -0
  77. pycontrails/models/cocipgrid/cocip_grid_params.py +138 -0
  78. pycontrails/models/dry_advection.py +602 -0
  79. pycontrails/models/emissions/__init__.py +21 -0
  80. pycontrails/models/emissions/black_carbon.py +599 -0
  81. pycontrails/models/emissions/emissions.py +1353 -0
  82. pycontrails/models/emissions/ffm2.py +336 -0
  83. pycontrails/models/emissions/static/default-engine-uids.csv +239 -0
  84. pycontrails/models/emissions/static/edb-gaseous-v29b-engines.csv +596 -0
  85. pycontrails/models/emissions/static/edb-nvpm-v29b-engines.csv +215 -0
  86. pycontrails/models/extended_k15.py +1327 -0
  87. pycontrails/models/humidity_scaling/__init__.py +37 -0
  88. pycontrails/models/humidity_scaling/humidity_scaling.py +1075 -0
  89. pycontrails/models/humidity_scaling/quantiles/era5-model-level-quantiles.pq +0 -0
  90. pycontrails/models/humidity_scaling/quantiles/era5-pressure-level-quantiles.pq +0 -0
  91. pycontrails/models/issr.py +210 -0
  92. pycontrails/models/pcc.py +326 -0
  93. pycontrails/models/pcr.py +154 -0
  94. pycontrails/models/ps_model/__init__.py +18 -0
  95. pycontrails/models/ps_model/ps_aircraft_params.py +381 -0
  96. pycontrails/models/ps_model/ps_grid.py +701 -0
  97. pycontrails/models/ps_model/ps_model.py +1000 -0
  98. pycontrails/models/ps_model/ps_operational_limits.py +525 -0
  99. pycontrails/models/ps_model/static/ps-aircraft-params-20250328.csv +69 -0
  100. pycontrails/models/ps_model/static/ps-synonym-list-20250328.csv +104 -0
  101. pycontrails/models/sac.py +442 -0
  102. pycontrails/models/tau_cirrus.py +183 -0
  103. pycontrails/physics/__init__.py +1 -0
  104. pycontrails/physics/constants.py +117 -0
  105. pycontrails/physics/geo.py +1138 -0
  106. pycontrails/physics/jet.py +968 -0
  107. pycontrails/physics/static/iata-cargo-load-factors-20250221.csv +74 -0
  108. pycontrails/physics/static/iata-passenger-load-factors-20250221.csv +74 -0
  109. pycontrails/physics/thermo.py +551 -0
  110. pycontrails/physics/units.py +472 -0
  111. pycontrails/py.typed +0 -0
  112. pycontrails/utils/__init__.py +1 -0
  113. pycontrails/utils/dependencies.py +66 -0
  114. pycontrails/utils/iteration.py +13 -0
  115. pycontrails/utils/json.py +187 -0
  116. pycontrails/utils/temp.py +50 -0
  117. pycontrails/utils/types.py +163 -0
  118. pycontrails-0.59.0.dist-info/METADATA +179 -0
  119. pycontrails-0.59.0.dist-info/RECORD +123 -0
  120. pycontrails-0.59.0.dist-info/WHEEL +6 -0
  121. pycontrails-0.59.0.dist-info/licenses/LICENSE +178 -0
  122. pycontrails-0.59.0.dist-info/licenses/NOTICE +43 -0
  123. pycontrails-0.59.0.dist-info/top_level.txt +3 -0
@@ -0,0 +1,1321 @@
1
+ """Physical model data structures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import functools
7
+ import hashlib
8
+ import json
9
+ import logging
10
+ import warnings
11
+ from abc import ABC, abstractmethod
12
+ from collections.abc import Sequence
13
+ from dataclasses import dataclass, fields
14
+ from typing import Any, NoReturn, TypeVar, overload
15
+
16
+ import numpy as np
17
+ import numpy.typing as npt
18
+ import pandas as pd
19
+ import scipy.interpolate
20
+ import xarray as xr
21
+
22
+ from pycontrails.core.fleet import Fleet
23
+ from pycontrails.core.flight import Flight
24
+ from pycontrails.core.met import MetDataArray, MetDataset, MetVariable, originates_from_ecmwf
25
+ from pycontrails.core.met_var import MET_VARIABLES, SpecificHumidity
26
+ from pycontrails.core.vector import GeoVectorDataset
27
+ from pycontrails.datalib.ecmwf import ECMWF_VARIABLES
28
+ from pycontrails.datalib.gfs import GFS_VARIABLES
29
+ from pycontrails.utils.json import NumpyEncoder
30
+ from pycontrails.utils.types import type_guard
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ #: Model input source types
35
+ ModelInput = MetDataset | GeoVectorDataset | Flight | Sequence[Flight] | None
36
+
37
+ #: Model output source types
38
+ ModelOutput = MetDataArray | MetDataset | GeoVectorDataset | Flight | list[Flight]
39
+
40
+ #: Model attribute source types
41
+ SourceType = MetDataset | GeoVectorDataset | Flight | Fleet
42
+
43
+ _Source = TypeVar("_Source")
44
+
45
+ # ------------
46
+ # Model Params
47
+ # ------------
48
+
49
+
50
+ @dataclass
51
+ class ModelParams:
52
+ """Class for constructing model parameters.
53
+
54
+ Implementing classes must still use the ``@dataclass`` operator.
55
+ """
56
+
57
+ #: Copy input ``source`` data on eval
58
+ copy_source: bool = True
59
+
60
+ # -----------
61
+ # Interpolate
62
+ # -----------
63
+
64
+ #: Interpolation method. Supported methods include "linear", "nearest", "slinear",
65
+ #: "cubic", and "quintic". See :class:`scipy.interpolate.RegularGridInterpolator`
66
+ #: for the description of each method. Not all methods are supported by all
67
+ #: met grids. For example, the "cubic" method requires at least 4 points per
68
+ #: dimension.
69
+ interpolation_method: str = "linear"
70
+
71
+ #: If True, points lying outside interpolation will raise an error
72
+ interpolation_bounds_error: bool = False
73
+
74
+ #: Used for outside interpolation value if :attr:`interpolation_bounds_error` is False
75
+ interpolation_fill_value: float = np.nan
76
+
77
+ #: Experimental. See :mod:`pycontrails.core.interpolation`.
78
+ interpolation_localize: bool = False
79
+
80
+ #: Experimental. See :mod:`pycontrails.core.interpolation`.
81
+ interpolation_use_indices: bool = False
82
+
83
+ #: Experimental. Alternative interpolation method to account for specific humidity
84
+ #: lapse rate bias. Must be one of ``None``, ``"cubic-spline"``, or ``"log-q-log-p"``.
85
+ #: If ``None``, no special interpolation is used for specific humidity.
86
+ #: The ``"cubic-spline"`` method applies a custom stretching of the met interpolation
87
+ #: table to account for the specific humidity lapse rate bias. The ``"log-q-log-p"``
88
+ #: method interpolates in the log of specific humidity and pressure, then converts
89
+ #: back to specific humidity.
90
+ #: Only used by models calling to :func:`interpolate_met`.
91
+ interpolation_q_method: str | None = None
92
+
93
+ # -----------
94
+ # Meteorology
95
+ # -----------
96
+
97
+ #: Call :meth:`_verify_met` on model instantiation.
98
+ verify_met: bool = True
99
+
100
+ #: Downselect input :class:`MetDataset`` to region around ``source``.
101
+ downselect_met: bool = True
102
+
103
+ #: Met longitude buffer for input to :meth:`Flight.downselect_met`,
104
+ #: in WGS84 coordinates.
105
+ #: Only applies when :attr:`downselect_met` is True.
106
+ met_longitude_buffer: tuple[float, float] = (0.0, 0.0)
107
+
108
+ #: Met latitude buffer for input to :meth:`Flight.downselect_met`,
109
+ #: in WGS84 coordinates.
110
+ #: Only applies when :attr:`downselect_met` is True.
111
+ met_latitude_buffer: tuple[float, float] = (0.0, 0.0)
112
+
113
+ #: Met level buffer for input to :meth:`Flight.downselect_met`,
114
+ #: in [:math:`hPa`].
115
+ #: Only applies when :attr:`downselect_met` is True.
116
+ met_level_buffer: tuple[float, float] = (0.0, 0.0)
117
+
118
+ #: Met time buffer for input to :meth:`Flight.downselect_met`
119
+ #: Only applies when :attr:`downselect_met` is True.
120
+ met_time_buffer: tuple[np.timedelta64, np.timedelta64] = (
121
+ np.timedelta64(0, "h"),
122
+ np.timedelta64(0, "h"),
123
+ )
124
+
125
+ def as_dict(self) -> dict[str, Any]:
126
+ """Convert object to dictionary.
127
+
128
+ We use this method instead of `dataclasses.asdict`
129
+ to use a shallow/unrecursive copy.
130
+ This will return values as Any instead of dict.
131
+
132
+ Returns
133
+ -------
134
+ dict[str, Any]
135
+ Dictionary version of self.
136
+ """
137
+ return {(name := field.name): getattr(self, name) for field in fields(self)}
138
+
139
+
140
+ @dataclass
141
+ class AdvectionBuffers(ModelParams):
142
+ """Override buffers in :class:`ModelParams` for advection models."""
143
+
144
+ #: Met longitude [WGS84] buffer for evolution by advection.
145
+ met_longitude_buffer: tuple[float, float] = (10.0, 10.0)
146
+
147
+ #: Met latitude buffer [WGS84] for evolution by advection.
148
+ met_latitude_buffer: tuple[float, float] = (10.0, 10.0)
149
+
150
+ #: Met level buffer [:math:`hPa`] for evolution by advection.
151
+ met_level_buffer: tuple[float, float] = (40.0, 40.0)
152
+
153
+
154
+ # ------
155
+ # Models
156
+ # ------
157
+
158
+
159
+ class Model(ABC):
160
+ """Base class for physical models.
161
+
162
+ Implementing classes must implement the :meth:`eval` method
163
+ """
164
+
165
+ __slots__ = ("met", "params", "source")
166
+
167
+ #: Default model parameter dataclass
168
+ default_params: type[ModelParams] = ModelParams
169
+
170
+ #: Instantiated model parameters, in dictionary form
171
+ params: dict[str, Any]
172
+
173
+ #: Data evaluated in model
174
+ source: SourceType
175
+
176
+ #: Meteorology data
177
+ met: MetDataset | None
178
+
179
+ #: Require meteorology is not None on __init__()
180
+ met_required: bool = False
181
+
182
+ #: Required meteorology pressure level variables.
183
+ #: Each element in the list is a :class:`MetVariable` or a ``tuple[MetVariable]``.
184
+ #: If element is a ``tuple[MetVariable]``, the variable depends on the data source
185
+ #: and the tuple must include entries for a model-agnostic variable,
186
+ #: an ECMWF-specific variable, and a GFS-specific variable.
187
+ #: Only one of the three variable in the tuple is required for model evaluation.
188
+ met_variables: tuple[MetVariable | tuple[MetVariable, ...], ...]
189
+
190
+ #: Set of required parameters if processing already complete on ``met`` input.
191
+ processed_met_variables: tuple[MetVariable, ...]
192
+
193
+ #: Optional meteorology variables
194
+ optional_met_variables: tuple[MetVariable | tuple[MetVariable, ...], ...]
195
+
196
+ def __init__(
197
+ self,
198
+ met: MetDataset | None = None,
199
+ params: ModelParams | dict[str, Any] | None = None,
200
+ **params_kwargs: Any,
201
+ ) -> None:
202
+ # Load base params, override default and user params
203
+ self._load_params(params, **params_kwargs)
204
+
205
+ # Do *not* copy met on input
206
+ self.met = met
207
+
208
+ # require met inputs
209
+ if self.met_required:
210
+ self.require_met()
211
+
212
+ # verify met variables
213
+ if self.params["verify_met"]:
214
+ self._verify_met()
215
+
216
+ # Warn if humidity_scaling param is NOT present for ECMWF met data
217
+ humidity_scaling = self.params.get("humidity_scaling")
218
+
219
+ if (
220
+ humidity_scaling is None
221
+ and self.met is not None
222
+ and SpecificHumidity in getattr(self, "met_variables", ())
223
+ and originates_from_ecmwf(self.met)
224
+ ):
225
+ warnings.warn(
226
+ "\nMet data appears to have originated from ECMWF and no humidity "
227
+ "scaling is enabled. For ECMWF data, consider using one of: \n"
228
+ " - 'ConstantHumidityScaling'\n"
229
+ " - 'ExponentialBoostHumidityScaling'\n"
230
+ " - 'ExponentialBoostLatitudeCorrectionHumidityScaling'\n"
231
+ " - 'HistogramMatching'\n"
232
+ "For example: \n"
233
+ ">>> from pycontrails.models.humidity_scaling import ConstantHumidityScaling\n"
234
+ f">>> {type(self).__name__}(met=met, ..., humidity_scaling=ConstantHumidityScaling(rhi_adj=0.99))" # noqa: E501
235
+ )
236
+
237
+ # Ensure humidity_scaling q_method matches parent model
238
+ elif humidity_scaling is not None:
239
+ # Some humidity scaling models use the interpolation_q_method parameter to determine
240
+ # which parameters to use for scaling. Ensure that both models are consistent.
241
+ parent_q = self.params["interpolation_q_method"]
242
+ if humidity_scaling.params["interpolation_q_method"] != parent_q:
243
+ warnings.warn(
244
+ f"Model {type(self).__name__} uses interpolation_q_method={parent_q} but "
245
+ f"humidity_scaling model {type(humidity_scaling).__name__} uses "
246
+ f"interpolation_q_method={humidity_scaling.params['interpolation_q_method']}. "
247
+ "Overriding humidity_scaling interpolation_q_method to match parent model."
248
+ )
249
+ humidity_scaling.params["interpolation_q_method"] = parent_q
250
+
251
+ def __repr__(self) -> str:
252
+ params = getattr(self, "params", {})
253
+ return f"{type(self).__name__} model\n\t{self.long_name}\n\tParams: {params}\n"
254
+
255
+ @property
256
+ @abstractmethod
257
+ def name(self) -> str:
258
+ """Get model name for use as a data key in :class:`xr.DataArray` or :class`Flight`."""
259
+
260
+ @property
261
+ @abstractmethod
262
+ def long_name(self) -> str:
263
+ """Get long name descriptor, annotated on :class:`xr.DataArray` outputs."""
264
+
265
+ @property
266
+ def hash(self) -> str:
267
+ """Generate a unique hash for model instance.
268
+
269
+ Returns
270
+ -------
271
+ str
272
+ Unique hash for model instance (sha1)
273
+ """
274
+ params = json.dumps(self.params, sort_keys=True, cls=NumpyEncoder)
275
+ _hash = self.name + params
276
+ if self.met is not None:
277
+ _hash += self.met.hash
278
+ if hasattr(self, "source"):
279
+ _hash += self.source.hash
280
+
281
+ return hashlib.sha1(bytes(_hash, "utf-8")).hexdigest()
282
+
283
+ @classmethod
284
+ def generic_met_variables(cls) -> tuple[MetVariable, ...]:
285
+ """Return a model-agnostic list of required meteorology variables.
286
+
287
+ Returns
288
+ -------
289
+ tuple[MetVariable]
290
+ List of model-agnostic variants of required variables
291
+ """
292
+ available = set(MET_VARIABLES)
293
+ return tuple(_find_match(required, available) for required in cls.met_variables)
294
+
295
+ @classmethod
296
+ def ecmwf_met_variables(cls) -> tuple[MetVariable, ...]:
297
+ """Return an ECMWF-specific list of required meteorology variables.
298
+
299
+ Returns
300
+ -------
301
+ tuple[MetVariable]
302
+ List of ECMWF-specific variants of required variables
303
+ """
304
+ available = set(ECMWF_VARIABLES)
305
+ return tuple(_find_match(required, available) for required in cls.met_variables)
306
+
307
+ @classmethod
308
+ def gfs_met_variables(cls) -> tuple[MetVariable, ...]:
309
+ """Return a GFS-specific list of required meteorology variables.
310
+
311
+ Returns
312
+ -------
313
+ tuple[MetVariable]
314
+ List of GFS-specific variants of required variables
315
+ """
316
+ available = set(GFS_VARIABLES)
317
+ return tuple(_find_match(required, available) for required in cls.met_variables)
318
+
319
+ def _verify_met(self) -> None:
320
+ """Verify integrity of :attr:`met`.
321
+
322
+ This method confirms that :attr:`met` contains each variable in
323
+ :attr:`met_variables`. If this check fails, and :attr:`processed_met_variables`
324
+ is defined, confirm :attr:`met` contains each variable there.
325
+
326
+ Does not raise errors if :attr:`met` is None.
327
+
328
+ Raises
329
+ ------
330
+ KeyError
331
+ Raises KeyError if data does not contain variables :attr:`met_variables`
332
+ """
333
+ if self.met is None:
334
+ return
335
+
336
+ if not hasattr(self, "met_variables"):
337
+ return
338
+
339
+ # Try to verify met_variables
340
+ try:
341
+ self.met.ensure_vars(self.met_variables)
342
+ except KeyError as e1:
343
+ # If that fails, try to verify processed_met_variables
344
+ if hasattr(self, "processed_met_variables"):
345
+ try:
346
+ self.met.ensure_vars(self.processed_met_variables)
347
+ except KeyError as e2:
348
+ raise e2 from e1
349
+ else:
350
+ raise
351
+
352
+ def _load_params(
353
+ self, params: ModelParams | dict[str, Any] | None = None, **params_kwargs: Any
354
+ ) -> None:
355
+ """Load parameters to model :attr:`params`.
356
+
357
+ Load order:
358
+
359
+ 1. If ``params`` is a :attr:`default_params` instance, use as is. Otherwise
360
+ instantiate as :attr:`default_params`.
361
+ 2. ``params`` input dict
362
+ 3. ``params_kwargs`` override keys in params
363
+
364
+ Parameters
365
+ ----------
366
+ params : dict[str, Any], optional
367
+ Model parameter dictionary or :attr:`default_params` instance.
368
+ Defaults to {}
369
+ **params_kwargs : Any
370
+ Override keys in ``params`` with keyword arguments.
371
+
372
+ Raises
373
+ ------
374
+ KeyError
375
+ Unknown parameter passed into model
376
+ """
377
+ if isinstance(params, self.default_params):
378
+ base_params = params
379
+ params = None
380
+ elif isinstance(params, ModelParams):
381
+ msg = f"Model parameters must be of type {self.default_params.__name__} or dict"
382
+ raise TypeError(msg)
383
+ else:
384
+ base_params = self.default_params()
385
+
386
+ self.params = base_params.as_dict()
387
+ self.update_params(params, **params_kwargs)
388
+
389
+ @abstractmethod
390
+ def eval(self, source: Any = None, **params: Any) -> ModelOutput:
391
+ """Abstract method to handle evaluation.
392
+
393
+ Implementing classes should override call signature to overload ``source`` inputs
394
+ and model outputs.
395
+
396
+ Parameters
397
+ ----------
398
+ source : ModelInput, optional
399
+ Dataset defining coordinates to evaluate model.
400
+ Defined by implementing class, but must be a subset of ModelInput.
401
+ If None, :attr:`met` is assumed to be evaluation points.
402
+ **params : Any
403
+ Overwrite model parameters before evaluation.
404
+
405
+ Returns
406
+ -------
407
+ ModelOutput
408
+ Return type depends on implementing model
409
+ """
410
+
411
+ # ---------
412
+ # Utilities
413
+ # ---------
414
+
415
+ @property
416
+ def interp_kwargs(self) -> dict[str, Any]:
417
+ """Shortcut to create interpolation arguments from :attr:`params`.
418
+
419
+ The output of this is useful for passing to :func:`interpolate_met`.
420
+
421
+ Returns
422
+ -------
423
+ dict[str, Any]
424
+ Dictionary with keys
425
+
426
+ - "method"
427
+ - "bounds_error"
428
+ - "fill_value"
429
+ - "localize"
430
+ - "use_indices"
431
+ - "q_method"
432
+
433
+ as determined by :attr:`params`.
434
+ """
435
+ params = self.params
436
+ return {
437
+ "method": params["interpolation_method"],
438
+ "bounds_error": params["interpolation_bounds_error"],
439
+ "fill_value": params["interpolation_fill_value"],
440
+ "localize": params["interpolation_localize"],
441
+ "use_indices": params["interpolation_use_indices"],
442
+ "q_method": params["interpolation_q_method"],
443
+ }
444
+
445
+ def require_met(self) -> MetDataset:
446
+ """Ensure that :attr:`met` is a MetDataset.
447
+
448
+ Returns
449
+ -------
450
+ MetDataset
451
+ Returns reference to :attr:`met`.
452
+ This is helpful for type narrowing :attr:`met` when meteorology is required.
453
+
454
+ Raises
455
+ ------
456
+ ValueError
457
+ Raises when :attr:`met` is None.
458
+ """
459
+ return type_guard(
460
+ self.met,
461
+ MetDataset,
462
+ f"Meteorology is required for this model. Specify with {type(self).__name__}(met=...) ",
463
+ )
464
+
465
+ def require_source_type(self, type_: type[_Source] | tuple[type[_Source], ...]) -> _Source:
466
+ """Ensure that :attr:`source` is ``type_``.
467
+
468
+ Returns
469
+ -------
470
+ _Source
471
+ Returns reference to :attr:`source`.
472
+ This is helpful for type narrowing :attr:`source` to specific type(s).
473
+
474
+ Raises
475
+ ------
476
+ ValueError
477
+ Raises when :attr:`source` is not ``_type_``.
478
+ """
479
+ return type_guard(getattr(self, "source", None), type_, f"Source must be of type {type_}")
480
+
481
+ @overload
482
+ def _get_source(self, source: MetDataset | None) -> MetDataset: ...
483
+
484
+ @overload
485
+ def _get_source(self, source: GeoVectorDataset) -> GeoVectorDataset: ...
486
+
487
+ @overload
488
+ def _get_source(self, source: Sequence[Flight]) -> Fleet: ...
489
+
490
+ def _get_source(self, source: ModelInput) -> SourceType:
491
+ """Construct :attr:`source` from ``source`` parameter."""
492
+
493
+ # Fallback to met coordinates if source is None
494
+ if source is None:
495
+ self.met = self.require_met()
496
+
497
+ # Return dataset with the same coords as self.met, but empty data_vars
498
+ return MetDataset._from_fastpath(xr.Dataset(coords=self.met.data.coords))
499
+
500
+ copy_source = self.params["copy_source"]
501
+
502
+ # Turn Sequence into Fleet
503
+ if isinstance(source, Sequence):
504
+ if not copy_source:
505
+ msg = "Parameter copy_source=False is not supported for Sequence[Flight] source"
506
+ raise ValueError(msg)
507
+ return Fleet.from_seq(source)
508
+
509
+ # Raise error if source is not a MetDataset or GeoVectorDataset
510
+ if not isinstance(source, MetDataset | GeoVectorDataset):
511
+ msg = f"Unknown source type: {type(source)}"
512
+ raise TypeError(msg)
513
+
514
+ if copy_source:
515
+ source = source.copy()
516
+
517
+ if not isinstance(source, Flight):
518
+ return source
519
+
520
+ # Ensure flight_id is present on Flight instances
521
+ # Either broadcast from attrs or add as 0
522
+ if "flight_id" not in source:
523
+ if "flight_id" in source.attrs:
524
+ source.broadcast_attrs("flight_id")
525
+
526
+ else:
527
+ warnings.warn(
528
+ "Source flight does not contain `flight_id` data or attr. "
529
+ "Adding `flight_id` of 0"
530
+ )
531
+ source["flight_id"] = np.zeros(len(source), dtype=int)
532
+
533
+ return source
534
+
535
+ def set_source(self, source: ModelInput = None) -> None:
536
+ """Attach original or copy of input ``source`` to :attr:`source`.
537
+
538
+ Parameters
539
+ ----------
540
+ source : MetDataset | GeoVectorDataset | Flight | Iterable[Flight] | None
541
+ Parameter ``source`` passed in :meth:`eval`.
542
+ If None, an empty MetDataset with coordinates like :attr:`met` is set to :attr:`source`.
543
+
544
+ See Also
545
+ --------
546
+ eval
547
+ """
548
+ self.source = self._get_source(source)
549
+
550
+ def update_params(self, params: dict[str, Any] | None = None, **params_kwargs: Any) -> None:
551
+ """Update model parameters on :attr:`params`.
552
+
553
+ Parameters
554
+ ----------
555
+ params : dict[str, Any], optional
556
+ Model parameters to update, as dictionary.
557
+ Defaults to {}
558
+ **params_kwargs : Any
559
+ Override keys in ``params`` with keyword arguments.
560
+ """
561
+ update_param_dict(self.params, params or {})
562
+ update_param_dict(self.params, params_kwargs)
563
+
564
+ def downselect_met(self) -> None:
565
+ """Downselect :attr:`met` domain to the max/min bounds of :attr:`source`.
566
+
567
+ Override this method if special handling is needed in met down-selection.
568
+
569
+ - :attr:`source` must be defined before calling :meth:`downselect_met`.
570
+ - This method copies and re-assigns :attr:`met` using :meth:`met.copy()`
571
+ to avoid side-effects.
572
+
573
+ Raises
574
+ ------
575
+ ValueError
576
+ Raised if :attr:`source` is not defined.
577
+ Raised if :attr:`source` is not a :class:`GeoVectorDataset`.
578
+ TypeError
579
+ Raised if :attr:`met` is not a :class:`MetDataset`.
580
+ """
581
+ try:
582
+ source = self.source
583
+ except AttributeError as exc:
584
+ msg = "Attribute 'source' must be defined before calling 'downselect_met'."
585
+ raise AttributeError(msg) from exc
586
+
587
+ # TODO: This could be generalized for a MetDataset source
588
+ if not isinstance(source, GeoVectorDataset):
589
+ msg = "Attribute 'source' must be a GeoVectorDataset"
590
+ raise TypeError(msg)
591
+
592
+ if self.met is None:
593
+ return
594
+
595
+ # return if downselect_met is False
596
+ if not self.params["downselect_met"]:
597
+ logger.debug("Avoiding downselecting met because params['downselect_met'] is False")
598
+ return
599
+
600
+ logger.debug("Downselecting met in model %s", self.name)
601
+
602
+ # get buffers from params
603
+ buffers = {
604
+ "longitude_buffer": self.params.get("met_longitude_buffer"),
605
+ "latitude_buffer": self.params.get("met_latitude_buffer"),
606
+ "level_buffer": self.params.get("met_level_buffer"),
607
+ "time_buffer": self.params.get("met_time_buffer"),
608
+ }
609
+ kwargs = {k: v for k, v in buffers.items() if v is not None}
610
+
611
+ self.met = source.downselect_met(self.met, **kwargs)
612
+
613
+ def set_source_met(
614
+ self,
615
+ optional: bool = False,
616
+ variable: MetVariable | Sequence[MetVariable] | None = None,
617
+ ) -> None:
618
+ """Ensure or interpolate each required :attr:`met_variables` on :attr:`source` .
619
+
620
+ For each variable in :attr:`met_variables`, check :attr:`source` for data variable
621
+ with the same name.
622
+
623
+ For :class:`GeoVectorDataset` sources, try to interpolate :attr:`met`
624
+ if variable does not exist.
625
+
626
+ For :class:`MetDataset` sources, try to get data from :attr:`met`
627
+ if variable does not exist.
628
+
629
+ Parameters
630
+ ----------
631
+ optional : bool, optional
632
+ Include :attr:`optional_met_variables`
633
+ variable : MetVariable | Sequence[MetVariable] | None, optional
634
+ MetVariable to set, from :attr:`met_variables`.
635
+ If None, set all variables in :attr:`met_variables`
636
+ and :attr:`optional_met_variables` if ``optional`` is True.
637
+
638
+ Raises
639
+ ------
640
+ ValueError
641
+ Variable does not exist and :attr:`source` is a MetDataset.
642
+ KeyError
643
+ Variable not found in :attr:`source` or :attr:`met`.
644
+ """
645
+ variables = self._determine_relevant_variables(optional, variable)
646
+
647
+ q_method = self.params["interpolation_q_method"]
648
+
649
+ for var in variables:
650
+ # If var is a tuple of options, check if at least one of them exists in source
651
+ if isinstance(var, tuple):
652
+ for v in var:
653
+ if v.standard_name in self.source:
654
+ continue
655
+
656
+ # Check if var exists in source
657
+ elif var.standard_name in self.source:
658
+ continue
659
+
660
+ # Otherwise, interpolate / set from met
661
+ if not isinstance(self.met, MetDataset):
662
+ _raise_missing_met_var(var)
663
+
664
+ # take the first var name output from ensure_vars
665
+ met_key = self.met.ensure_vars(var)[0]
666
+
667
+ # interpolate GeoVectorDataset
668
+ if isinstance(self.source, GeoVectorDataset):
669
+ interpolate_met(self.met, self.source, met_key, **self.interp_kwargs)
670
+ continue
671
+
672
+ if not isinstance(self.source, MetDataset):
673
+ msg = f"Unknown source type: {type(self.source)}"
674
+ raise TypeError(msg)
675
+
676
+ da = self.met.data[met_key].reset_coords(drop=True)
677
+ try:
678
+ # This case is when self.source is a subgrid of self.met
679
+ # The call to .sel will raise a KeyError if this is not the case
680
+
681
+ # XXX: Sometimes this hangs when using dask!
682
+ # This issue is somewhat similar to
683
+ # https://github.com/pydata/xarray/issues/4406
684
+ self.source[met_key] = da.sel(self.source.coords)
685
+
686
+ except KeyError:
687
+ self.source[met_key] = _interp_grid_to_grid(
688
+ met_key, da, self.source, self.params, q_method
689
+ )
690
+
691
+ def _determine_relevant_variables(
692
+ self,
693
+ optional: bool,
694
+ variable: MetVariable | Sequence[MetVariable] | None,
695
+ ) -> Sequence[MetVariable | tuple[MetVariable, ...]]:
696
+ """Determine the relevant variables used in :meth:`set_source_met`."""
697
+ if variable is None:
698
+ if optional:
699
+ return (*self.met_variables, *self.optional_met_variables)
700
+ return self.met_variables
701
+ if isinstance(variable, MetVariable):
702
+ return (variable,)
703
+ return variable
704
+
705
+ # Following python implementation
706
+ # https://github.com/python/cpython/blob/618b7a8260bb40290d6551f24885931077309590/Lib/collections/__init__.py#L231
707
+ __marker = object()
708
+
709
+ def get_data_param(
710
+ self, other: SourceType, key: str, default: Any = __marker, *, set_attr: bool = True
711
+ ) -> Any:
712
+ """Get data from other source-compatible object with default set by model parameter key.
713
+
714
+ Retrieves data with the following hierarchy:
715
+
716
+ 1. :attr:`other.data[key]`. Returns ``np.ndarray | xr.DataArray``.
717
+ 2. :attr:`other.attrs[key]`
718
+ 3. :attr:`params[key]`
719
+ 4. ``default``
720
+
721
+ In case 3., the value of :attr:`params[key]` is attached to :attr:`other.attrs[key]`
722
+ unless ``set_attr`` is set to False.
723
+
724
+ Parameters
725
+ ----------
726
+ key : str
727
+ Key to retrieve
728
+ default : Any, optional
729
+ Default value if key is not found.
730
+ set_attr : bool, optional
731
+ If True (default), set :attr:`source.attrs[key]` to :attr:`params[key]` if found.
732
+ This allows for better post model evaluation tracking.
733
+
734
+ Returns
735
+ -------
736
+ Any
737
+ Value(s) found for key in ``other`` data, ``other`` attrs, or model params
738
+
739
+ Raises
740
+ ------
741
+ KeyError
742
+ Raises KeyError if key is not found in any location and ``default`` is not provided.
743
+
744
+
745
+ See Also
746
+ --------
747
+ get_source_param
748
+ pycontrails.core.vector.GeoVectorDataset.get_data_or_attr
749
+ """
750
+ marker = self.__marker
751
+
752
+ out = other.data.get(key, marker)
753
+ if out is not marker:
754
+ return out
755
+
756
+ out = other.attrs.get(key, marker)
757
+ if out is not marker:
758
+ return out
759
+
760
+ out = self.params.get(key, marker)
761
+ if out is not marker:
762
+ if set_attr:
763
+ other.attrs[key] = out
764
+
765
+ return out
766
+
767
+ if default is not marker:
768
+ return default
769
+
770
+ msg = f"Key '{key}' not found in source data, attrs, or model params"
771
+ raise KeyError(msg)
772
+
773
+ def get_source_param(self, key: str, default: Any = __marker, *, set_attr: bool = True) -> Any:
774
+ """Get source data with default set by parameter key.
775
+
776
+ Retrieves data with the following hierarchy:
777
+
778
+ 1. :attr:`source.data[key]`. Returns ``np.ndarray | xr.DataArray``.
779
+ 2. :attr:`source.attrs[key]`
780
+ 3. :attr:`params[key]`
781
+ 4. ``default``
782
+
783
+ In case 3., the value of :attr:`params[key]` is attached to :attr:`source.attrs[key]`
784
+ unless ``set_attr`` is set to False.
785
+
786
+ Parameters
787
+ ----------
788
+ key : str
789
+ Key to retrieve
790
+ default : Any, optional
791
+ Default value if key is not found.
792
+ set_attr : bool, optional
793
+ If True (default), set :attr:`source.attrs[key]` to :attr:`params[key]` if found.
794
+ This allows for better post model evaluation tracking.
795
+
796
+ Returns
797
+ -------
798
+ Any
799
+ Value(s) found for key in source data, source attrs, or model params
800
+
801
+ Raises
802
+ ------
803
+ KeyError
804
+ Raises KeyError if key is not found in any location and ``default`` is not provided.
805
+
806
+ See Also
807
+ --------
808
+ get_data_param
809
+ pycontrails.core.vector.GeoVectorDataset.get_data_or_attr
810
+ """
811
+ return self.get_data_param(self.source, key, default, set_attr=set_attr)
812
+
813
+ def _cleanup_indices(self) -> None:
814
+ """Cleanup indices artifacts if ``params["interpolation_use_indices"]`` is True."""
815
+ if self.params["interpolation_use_indices"] and isinstance(self.source, GeoVectorDataset):
816
+ self.source._invalidate_indices()
817
+
818
+ def transfer_met_source_attrs(self, source: SourceType | None = None) -> None:
819
+ """Transfer met source metadata from :attr:`met` to ``source``."""
820
+
821
+ if self.met is None:
822
+ return
823
+
824
+ source = source or self.source
825
+ with contextlib.suppress(KeyError):
826
+ source.attrs["met_source_provider"] = self.met.provider_attr
827
+
828
+ with contextlib.suppress(KeyError):
829
+ source.attrs["met_source_dataset"] = self.met.dataset_attr
830
+
831
+ with contextlib.suppress(KeyError):
832
+ source.attrs["met_source_product"] = self.met.product_attr
833
+
834
+ with contextlib.suppress(KeyError):
835
+ source.attrs["met_source_forecast_time"] = self.met.attrs["forecast_time"]
836
+
837
+
838
+ def _interp_grid_to_grid(
839
+ met_key: str,
840
+ da: xr.DataArray,
841
+ source: MetDataset,
842
+ params: dict[str, Any],
843
+ q_method: str,
844
+ ) -> xr.DataArray:
845
+ # This call to DataArray.interp was added in pycontrails 0.28.1
846
+ # For arbitrary grids, use xr.DataArray.interp
847
+ # Extract certain parameters to pass into interp
848
+ interp_kwargs = {
849
+ "method": params["interpolation_method"],
850
+ "kwargs": {
851
+ "bounds_error": params["interpolation_bounds_error"],
852
+ "fill_value": params["interpolation_fill_value"],
853
+ },
854
+ "assume_sorted": True,
855
+ }
856
+ # Correct dtype if promoted
857
+ # Somewhat of a pain: dask believes the dtype is float32, but
858
+ # when it is actually computed, it comes out as float64
859
+ # Call load() here to smooth over this issue
860
+ # https://github.com/pydata/xarray/issues/4770
861
+ # There is also an issue in which xarray assumes non-singleton
862
+ # dimensions. This causes issues when the ``da`` variable has
863
+ # a scalar dimension, or the ``self.source`` variable coincides
864
+ # with an edge of the ``da`` variable. For now, we try an additional
865
+ # sel over just the time dimension, which is the most common case.
866
+ # This stuff isn't so well unit tested in pycontrails, and the xarray
867
+ # and scipy interpolate conventions are always changing, so more
868
+ # issues may arise here in the future.
869
+ coords = source.coords
870
+ try:
871
+ da = da.sel(time=coords["time"])
872
+ except KeyError:
873
+ pass
874
+ else:
875
+ del coords["time"]
876
+
877
+ if q_method is None or met_key not in ("q", "specific_humidity"):
878
+ return da.interp(coords, **interp_kwargs).load().astype(da.dtype, copy=False)
879
+
880
+ if q_method == "cubic-spline":
881
+ ppoly = _load_spline()
882
+
883
+ da = da.assign_coords(level=ppoly(da["level"]))
884
+ level0 = coords.pop("level")
885
+ coords["level"] = ppoly(level0)
886
+ interped = da.interp(coords, **interp_kwargs).load().astype(da.dtype, copy=False)
887
+ return interped.assign_coords(level=level0)
888
+
889
+ msg = f"Unsupported q_method: {q_method}"
890
+ raise NotImplementedError(msg)
891
+
892
+
893
+ def _find_match(
894
+ required: MetVariable | Sequence[MetVariable], available: set[MetVariable]
895
+ ) -> MetVariable:
896
+ """Find match for required met variable in list of data-source-specific met variables.
897
+
898
+ Parameters
899
+ ----------
900
+ required : MetVariable | Sequence[MetVariable]
901
+ Required met variable
902
+
903
+ available : Sequence[MetVariable]
904
+ Collection of data-source-specific met variables
905
+
906
+ Returns
907
+ -------
908
+ MetVariable
909
+ Match for required met variable in collection of data-source-specific met variables
910
+
911
+ Raises
912
+ ------
913
+ KeyError
914
+ Raised if not match is found
915
+ """
916
+ if isinstance(required, MetVariable):
917
+ return required
918
+
919
+ for var in required:
920
+ if var in available:
921
+ return var
922
+
923
+ required_keys = [v.standard_name for v in required]
924
+ available_keys = [v.standard_name for v in available]
925
+ msg = f"None of {required_keys} match variable in {available_keys}"
926
+ raise KeyError(msg)
927
+
928
+
929
+ def _raise_missing_met_var(var: MetVariable | Sequence[MetVariable]) -> NoReturn:
930
+ """Raise KeyError on missing met variable.
931
+
932
+ Parameters
933
+ ----------
934
+ var : MetVariable | list[MetVariable]
935
+ Met variable
936
+
937
+ Raises
938
+ ------
939
+ KeyError
940
+ """
941
+ if isinstance(var, MetVariable):
942
+ msg = (
943
+ f"Variable `{var.standard_name}` not found. Either pass parameter `met`"
944
+ f"in model constructor, or define `{var.standard_name}` data on input data."
945
+ )
946
+ raise KeyError(msg)
947
+ missing_keys = [v.standard_name for v in var]
948
+ msg = (
949
+ f"One of `{missing_keys}` is required. Either pass parameter `met`"
950
+ f"in model constructor, or define one of `{missing_keys}` data on input data."
951
+ )
952
+ raise KeyError(msg)
953
+
954
+
955
+ def interpolate_met(
956
+ met: MetDataset | None,
957
+ vector: GeoVectorDataset,
958
+ met_key: str,
959
+ vector_key: str | None = None,
960
+ *,
961
+ q_method: str | None = None,
962
+ **interp_kwargs: Any,
963
+ ) -> npt.NDArray[np.floating]:
964
+ """Interpolate ``vector`` against ``met`` gridded data.
965
+
966
+ If ``vector_key`` (=``met_key`` by default) already exists,
967
+ return values at ``vector_key``.
968
+
969
+ Mutates parameter ``vector`` in place by attaching new key
970
+ and returns values.
971
+
972
+ Parameters
973
+ ----------
974
+ met : MetDataset | None
975
+ Met data to interpolate against
976
+ vector : GeoVectorDataset
977
+ Flight or GeoVectorDataset instance
978
+ met_key : str
979
+ Key of met variable in ``met``.
980
+ vector_key : str, optional
981
+ Key of variable to attach to ``vector``.
982
+ By default, use ``met_key``.
983
+ q_method : str, optional
984
+ Experimental method to use for interpolating specific humidity. See
985
+ :class:`ModelParams` for more information.
986
+ **interp_kwargs : Any,
987
+ Additional keyword only arguments passed to :meth:`GeoVectorDataset.intersect_met`.
988
+ For example, ``level=[...]``.
989
+
990
+ Returns
991
+ -------
992
+ npt.NDArray[np.floating]
993
+ Interpolated values.
994
+
995
+ Raises
996
+ ------
997
+ KeyError
998
+ Parameter ``met_key`` not found in ``met``.
999
+ """
1000
+ vector_key = vector_key or met_key
1001
+
1002
+ if (out := vector.get(vector_key, None)) is not None:
1003
+ return out
1004
+
1005
+ if met is None:
1006
+ msg = f"No variable key '{vector_key}' in 'vector' and 'met' is None"
1007
+ raise KeyError(msg)
1008
+
1009
+ if met_key in ("q", "specific_humidity") and q_method is not None:
1010
+ mda, log_applied = _extract_q(met, met_key, q_method)
1011
+ out = interpolate_gridded_specific_humidity(
1012
+ mda, vector, q_method, log_applied, **interp_kwargs
1013
+ )
1014
+
1015
+ else:
1016
+ try:
1017
+ mda = met[met_key]
1018
+ except KeyError as exc:
1019
+ msg = f"No variable key '{met_key}' in 'met'."
1020
+ raise KeyError(msg) from exc
1021
+
1022
+ out = vector.intersect_met(mda, **interp_kwargs)
1023
+
1024
+ vector[vector_key] = out
1025
+ return out
1026
+
1027
+
1028
+ def _extract_q(met: MetDataset, met_key: str, q_method: str) -> tuple[MetDataArray, bool]:
1029
+ """Extract specific humidity from ``met`` :class:`MetDataset`.
1030
+
1031
+ Parameters
1032
+ ----------
1033
+ met : MetDataset
1034
+ Met data
1035
+ met_key : str
1036
+ Key of specific humidity in ``met``. Typically either ``"q"`` or ``"specific_humidity"``.
1037
+ q_method : str
1038
+ Method to use for interpolating specific humidity.
1039
+
1040
+ Returns
1041
+ -------
1042
+ mda : MetDataArray
1043
+ Specific humidity data
1044
+ log_applied : bool
1045
+ Whether a log transform was applied to ``mda``.
1046
+ """
1047
+ if q_method != "log-q-log-p":
1048
+ try:
1049
+ return met[met_key], False
1050
+ except KeyError as exc:
1051
+ msg = f"No variable key '{met_key}' in 'met'."
1052
+ raise KeyError(msg) from exc
1053
+
1054
+ try:
1055
+ return met["log_specific_humidity"], True
1056
+ except KeyError:
1057
+ warnings.warn(
1058
+ "No variable key 'log_specific_humidity' in 'met'. "
1059
+ "Falling back to 'specific_humidity'. "
1060
+ "Computation will be faster if 'log_specific_humidity' is provided."
1061
+ )
1062
+
1063
+ try:
1064
+ return met[met_key], False
1065
+ except KeyError as exc:
1066
+ msg = f"No variable key '{met_key}' in 'met'."
1067
+ raise KeyError(msg) from exc
1068
+
1069
+
1070
+ def _prepare_q(
1071
+ mda: MetDataArray, level: npt.NDArray[np.floating], q_method: str, log_applied: bool
1072
+ ) -> tuple[MetDataArray, npt.NDArray[np.floating]]:
1073
+ """Prepare specific humidity for interpolation with experimental ``q_method``.
1074
+
1075
+ Parameters
1076
+ ----------
1077
+ mda : MetDataArray
1078
+ MetDataArray of specific humidity.
1079
+ level : npt.NDArray[np.floating]
1080
+ Levels to interpolate to, [:math:`hPa`].
1081
+ q_method : str
1082
+ One of ``"log-q-log-p"`` or ``"cubic-spline"``.
1083
+ log_applied : bool
1084
+ Whether a log transform was applied to ``mda``.
1085
+
1086
+ Returns
1087
+ -------
1088
+ mda : MetDataArray
1089
+ MetDataArray of specific humidity transformed for interpolation.
1090
+ level : npt.NDArray[np.floating]
1091
+ Transformed levels for interpolation.
1092
+ """
1093
+ da = mda.data
1094
+ if not da._in_memory:
1095
+ # XXX: It's unclear where this should go. If we wait too long to load,
1096
+ # we may need to reload into memory on each call to intersect_met.
1097
+ # If we load here, we only load once, but we may load data that is
1098
+ # never used. For now, we load here.
1099
+ da.load()
1100
+
1101
+ if q_method == "log-q-log-p":
1102
+ return _prepare_q_log_q_log_p(da, level, log_applied)
1103
+
1104
+ assert not log_applied, "Log transform should not be applied for cubic spline interpolation"
1105
+
1106
+ if q_method == "cubic-spline":
1107
+ return _prepare_q_cubic_spline(da, level)
1108
+
1109
+ raise_invalid_q_method_error(q_method)
1110
+ return None
1111
+
1112
+
1113
+ def _prepare_q_log_q_log_p(
1114
+ da: xr.DataArray, level: npt.NDArray[np.floating], log_applied: bool
1115
+ ) -> tuple[MetDataArray, npt.NDArray[np.floating]]:
1116
+ da = da.assign_coords(level=np.log(da["level"]))
1117
+
1118
+ if not log_applied:
1119
+ # ERA5 specific humidity can have negative values
1120
+ # These will get converted to NaNs
1121
+ # Ignore the xarray warning
1122
+ with warnings.catch_warnings():
1123
+ warnings.filterwarnings("ignore", message="invalid value encountered in log")
1124
+ da = np.log(da) # type: ignore[assignment]
1125
+
1126
+ mda = MetDataArray(da, copy=False)
1127
+
1128
+ level = np.log(level)
1129
+ return mda, level
1130
+
1131
+
1132
+ def _prepare_q_cubic_spline(
1133
+ da: xr.DataArray, level: npt.NDArray[np.floating]
1134
+ ) -> tuple[MetDataArray, npt.NDArray[np.floating]]:
1135
+ if da["level"][0] < 50.0 or da["level"][-1] > 1000.0:
1136
+ msg = "Cubic spline interpolation requires data to span 50-1000 hPa."
1137
+ raise ValueError(msg)
1138
+ ppoly = _load_spline()
1139
+
1140
+ da = da.assign_coords(level=ppoly(da["level"]))
1141
+ mda = MetDataArray(da, copy=False)
1142
+
1143
+ level = ppoly(level)
1144
+
1145
+ return mda, level
1146
+
1147
+
1148
+ def interpolate_gridded_specific_humidity(
1149
+ mda: MetDataArray,
1150
+ vector: GeoVectorDataset,
1151
+ q_method: str | None,
1152
+ log_applied: bool,
1153
+ **interp_kwargs: Any,
1154
+ ) -> np.ndarray:
1155
+ """Interpolate specific humidity against ``vector`` with experimental ``q_method``.
1156
+
1157
+ Parameters
1158
+ ----------
1159
+ mda : MetDataArray
1160
+ MetDataArray of specific humidity.
1161
+ vector : GeoVectorDataset
1162
+ Flight or GeoVectorDataset instance
1163
+ q_method : {None, "cubic-spline", "log-q-log-p"}
1164
+ Experimental method to use for interpolating specific humidity.
1165
+ log_applied : bool
1166
+ Whether or not a log transform was applied to specific humidity.
1167
+ **interp_kwargs : Any,
1168
+ Additional keyword only arguments passed to `intersect_met`.
1169
+
1170
+ Returns
1171
+ -------
1172
+ np.ndarray
1173
+ Interpolated values.
1174
+ """
1175
+ if q_method is None:
1176
+ return vector.intersect_met(mda, **interp_kwargs)
1177
+
1178
+ level = interp_kwargs.get("level", vector.level)
1179
+ mda, level = _prepare_q(mda, level, q_method, log_applied)
1180
+ interp_kwargs = {**interp_kwargs, "level": level}
1181
+
1182
+ out = vector.intersect_met(mda, **interp_kwargs)
1183
+ if q_method == "log-q-log-p":
1184
+ out = np.exp(out)
1185
+
1186
+ return out
1187
+
1188
+
1189
+ def raise_invalid_q_method_error(q_method: str) -> NoReturn:
1190
+ """Raise error for invalid ``q_method``.
1191
+
1192
+ Parameters
1193
+ ----------
1194
+ q_method : str
1195
+ ``q_method`` to raise error for.
1196
+
1197
+ Raises
1198
+ ------
1199
+ ValueError
1200
+ ``q_method`` is not one of ``None``, ``"log-q-log-p"``, or ``"cubic-spline"``.
1201
+ """
1202
+ available = None, "log-q-log-p", "cubic-spline"
1203
+ msg = f"Invalid 'q_method' value '{q_method}'. Must be one of {available}."
1204
+ raise ValueError(msg)
1205
+
1206
+
1207
+ @functools.cache
1208
+ def _load_spline() -> scipy.interpolate.PchipInterpolator:
1209
+ """Load spline interpolator estimating the specific humidity vertical profile (ie, lapse rate).
1210
+
1211
+ Data computed from historic ERA5 reanalysis data for 2019.
1212
+
1213
+ The first data point ``(50.0, 1.8550577e-06)`` is added to the spline to
1214
+ ensure that the spline is monotonic for high altitudes. It was chosen
1215
+ so that the resulting spline has a continuous second derivative at 100 hPa.
1216
+
1217
+ Returns
1218
+ -------
1219
+ scipy.interpolate.PchipInterpolator
1220
+ Spline interpolator.
1221
+ """
1222
+
1223
+ level = [
1224
+ 50.0,
1225
+ 100.0,
1226
+ 125.0,
1227
+ 150.0,
1228
+ 175.0,
1229
+ 200.0,
1230
+ 225.0,
1231
+ 250.0,
1232
+ 300.0,
1233
+ 350.0,
1234
+ 400.0,
1235
+ 450.0,
1236
+ 500.0,
1237
+ 550.0,
1238
+ 600.0,
1239
+ 650.0,
1240
+ 700.0,
1241
+ 750.0,
1242
+ 775.0,
1243
+ 800.0,
1244
+ 825.0,
1245
+ 850.0,
1246
+ 875.0,
1247
+ 900.0,
1248
+ 925.0,
1249
+ 950.0,
1250
+ 975.0,
1251
+ 1000.0,
1252
+ ]
1253
+ q = [
1254
+ 1.8550577e-06,
1255
+ 2.6863474e-06,
1256
+ 3.4371210e-06,
1257
+ 5.6529648e-06,
1258
+ 1.0849595e-05,
1259
+ 2.0879523e-05,
1260
+ 3.7430935e-05,
1261
+ 6.1511033e-05,
1262
+ 1.3460252e-04,
1263
+ 2.4769874e-04,
1264
+ 4.0938452e-04,
1265
+ 6.2360929e-04,
1266
+ 8.9822523e-04,
1267
+ 1.2304801e-03,
1268
+ 1.5927359e-03,
1269
+ 2.0140875e-03,
1270
+ 2.5222234e-03,
1271
+ 3.1251940e-03,
1272
+ 3.4660504e-03,
1273
+ 3.8333545e-03,
1274
+ 4.2424337e-03,
1275
+ 4.7023618e-03,
1276
+ 5.1869694e-03,
1277
+ 5.6702676e-03,
1278
+ 6.1630723e-03,
1279
+ 6.6630659e-03,
1280
+ 7.0036170e-03,
1281
+ 7.1794386e-03,
1282
+ ]
1283
+
1284
+ return scipy.interpolate.PchipInterpolator(level, q, extrapolate=False)
1285
+
1286
+
1287
+ def update_param_dict(param_dict: dict[str, Any], new_params: dict[str, Any]) -> None:
1288
+ """Update parameter dictionary in place.
1289
+
1290
+ Parameters
1291
+ ----------
1292
+ param_dict : dict[str, Any]
1293
+ Active model parameter dictionary
1294
+ new_params : dict[str, Any]
1295
+ Model parameters to update, as a dictionary
1296
+
1297
+ Raises
1298
+ ------
1299
+ KeyError
1300
+ Raises when ``new_params`` key is not found in ``param_dict``
1301
+
1302
+ """
1303
+ for param, value in new_params.items():
1304
+ try:
1305
+ old_value = param_dict[param]
1306
+ except KeyError:
1307
+ msg = (
1308
+ f"Unknown parameter '{param}' passed into model. Possible "
1309
+ f"parameters include {', '.join(param_dict)}."
1310
+ )
1311
+ raise KeyError(msg) from None
1312
+
1313
+ # Convenience: convert timedelta64-like params
1314
+ if (
1315
+ isinstance(old_value, np.timedelta64)
1316
+ and not isinstance(value, np.timedelta64)
1317
+ and value is not None
1318
+ ):
1319
+ value = pd.to_timedelta(value).to_numpy()
1320
+
1321
+ param_dict[param] = value